// Pure notification logic: dedupe-key construction, dues email rendering, // and the dispatcher's terminal-attempt rule. No I/O, no Supabase, no Stripe // imports so this stays unit-testable in isolation. The Stripe webhook builds // rows with these helpers; the dispatcher cron sends them. export type DuesNotificationType = | 'dues_renewed' | 'dues_payment_failed' | 'dues_lapsed' // After this many failed send attempts the dispatcher gives up and marks the // row 'failed' so it stops being retried every minute forever. export const MAX_NOTIFICATION_ATTEMPTS = 5 // A row is terminal once it has burned through its attempt budget. The // dispatcher uses this to decide 'failed' vs leaving it 'pending' for retry. export function isTerminalAttempt( attempts: number, max: number = MAX_NOTIFICATION_ATTEMPTS, ): boolean { return attempts >= max } // Deterministic dedupe key. The (space_id, dedupe_key) unique index makes the // webhook's enqueue idempotent: the same Stripe event replayed (different // event id, same invoice) collapses to one notification. Empty/nullish parts // are dropped so callers can pass optional ids without changing the shape. export function notificationDedupeKey( parts: Array, ): string { return parts .filter(p => p !== null && p !== undefined && String(p).length > 0) .map(String) .join(':') } // Renewal + payment-failed are keyed by the Stripe invoice (one mail per // invoice outcome). Lapse is keyed by member + the period it lapsed for, so a // member can lapse again next cycle but not be re-mailed for the same one. // periodEnd can be absent (canceled/deleted subscriptions carry no item // current_period_end); without a fallback every lapse would collapse to // `dues_lapsed:` and suppress every future lapse notice. Fall back to // the subscription id (a new comeback subscription has a new id, so distinct // lapses stay distinct). export function duesDedupeKey( type: DuesNotificationType, ref: { invoiceId?: string | null memberId?: string | null periodEnd?: string | null subscriptionId?: string | null }, ): string { if (type === 'dues_lapsed') { return notificationDedupeKey([ 'dues_lapsed', ref.memberId, ref.periodEnd || ref.subscriptionId, ]) } return notificationDedupeKey([type, ref.invoiceId]) } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } // renderShell auto-linkifies any https?:// in the body so the manageUrl line // becomes a clickable link. That same regex turns ANY URL embedded in a // user-controlled input (member display_name, equipment/class title, form // title, submitter label, space name) into a clickable link too, opening a // phishing vector wherever those fields land in an email to a different user // (notably form_submission_admin, which surfaces a submitter's display_name // to an admin). Scrub URL patterns from every user-controlled input before // rendering. Only manageUrl, which is server-built from NEXT_PUBLIC_APP_URL, // is allowed to reach the linkify regex. function stripUrls(s: string | null | undefined): string { if (!s) return '' return s.replace(/https?:\/\/\S+/gi, '[link]') } const CURRENCY_SYMBOL: Record = { USD: '$', CAD: '$', EUR: '€', GBP: '£' } // Stripe zero-decimal currencies: the smallest unit IS the major unit, so // amounts are NOT scaled by 100 and have no fractional display. Source: // Stripe "zero-decimal currencies" list. export const ZERO_DECIMAL_CURRENCIES = new Set([ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF', ]) export function isZeroDecimalCurrency(currency: string | null | undefined): boolean { return ZERO_DECIMAL_CURRENCIES.has((currency || 'USD').toUpperCase()) } // amount is in major units, matching how the payments ledger stores it. // Zero-decimal currencies render with no fractional part. export function formatMoney( amount: number | null | undefined, currency: string | null | undefined, ): string { if (amount === null || amount === undefined || Number.isNaN(amount)) return '' const code = (currency || 'USD').toUpperCase() const sym = CURRENCY_SYMBOL[code] const n = isZeroDecimalCurrency(code) ? String(Math.round(amount)) : amount.toFixed(2) return sym ? `${sym}${n} ${code}` : `${n} ${code}` } function formatDate(iso: string | null | undefined): string { if (!iso) return '' const d = new Date(iso) if (Number.isNaN(d.getTime())) return '' return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) } function formatDateTime(iso: string | null | undefined): string { if (!iso) return '' const d = new Date(iso) if (Number.isNaN(d.getTime())) return '' return d.toLocaleString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', }) } // "Friday, May 22, 2026, 3:00 PM to 5:00 PM" if both sides parse; else best // effort with whichever side is available. Used by booking + class renders. function formatRange( startIso: string | null | undefined, endIso: string | null | undefined, ): string { const start = formatDateTime(startIso) const end = formatDateTime(endIso) if (start && end) return `${start} to ${end}` return start || end || '' } function renderShell(lines: string[]): { html: string; text: string } { const text = lines.join('\n').trim() + '\n' const html = `
` + lines .map(l => { if (l === '') return '
' const safe = escapeHtml(l) const linked = safe.replace( /(https?:\/\/[^\s]+)/g, '$1', ) return `

${linked}

` }) .join('') + `
` return { html, text } } export type DuesEmailInput = { type: DuesNotificationType spaceName: string memberName?: string | null amount?: number | null currency?: string | null periodEnd?: string | null manageUrl: string } export type RenderedEmail = { subject: string; html: string; text: string } // Brand-neutral copy: this is a generic multi-space platform, the space name // is injected, never hard-coded. Returns both an HTML and a plain-text body // (Resend will also auto-generate text, but we send an explicit one). export function renderDuesEmail(input: DuesEmailInput): RenderedEmail { const space = stripUrls(input.spaceName) || 'your hackerspace' const name = stripUrls(input.memberName).trim() || null const greeting = name ? `Hi ${name},` : 'Hi,' const money = formatMoney(input.amount, input.currency) const renews = formatDate(input.periodEnd) let subject: string const lines: string[] = [greeting, ''] if (input.type === 'dues_renewed') { subject = `Your ${space} dues renewed` lines.push( `Your membership dues at ${space} were charged${money ? ` (${money})` : ''} and your membership is current.`, ) if (renews) lines.push(`Your next renewal is ${renews}.`) lines.push('', `Manage your billing: ${input.manageUrl}`) } else if (input.type === 'dues_payment_failed') { subject = `Action needed: ${space} dues payment failed` lines.push( `We could not process your latest membership dues payment at ${space}${money ? ` (${money})` : ''}.`, 'Please update your payment method so your membership stays active. There is a short grace period before your membership is marked late.', '', `Update your payment method: ${input.manageUrl}`, ) } else { subject = `Your ${space} membership is now marked late` lines.push( `Your membership dues at ${space} were not paid within the grace period, so your membership is now marked late.`, 'You can restore it at any time by paying your dues.', '', `Pay your dues: ${input.manageUrl}`, ) } const { html, text } = renderShell(lines) return { subject, html, text } } // ─── Class signup ──────────────────────────────────────────────────────────── export type ClassNotificationType = | 'class_signup_registered' | 'class_signup_waitlisted' | 'class_signup_promoted' | 'class_session_cancelled' // Signup-keyed events use the signup_id (which is stable across a // waitlist→registered promotion). class_session_cancelled fans out one row // per (session, member) so each affected member's email is independent. export function classDedupeKey( type: ClassNotificationType, ids: { signupId?: string | null; sessionId?: string | null; memberId?: string | null }, ): string { if (type === 'class_session_cancelled') { return notificationDedupeKey([type, ids.sessionId, ids.memberId]) } return notificationDedupeKey([type, ids.signupId]) } export type ClassEmailInput = { type: ClassNotificationType spaceName: string memberName?: string | null className: string location?: string | null startsAt: string | null endsAt?: string | null manageUrl: string } export function renderClassEmail(input: ClassEmailInput): RenderedEmail { const space = stripUrls(input.spaceName) || 'your hackerspace' const name = stripUrls(input.memberName).trim() || null const greeting = name ? `Hi ${name},` : 'Hi,' const cls = stripUrls(input.className) || 'the class' const range = formatRange(input.startsAt, input.endsAt) const location = stripUrls(input.location).trim() let subject: string const lines: string[] = [greeting, ''] if (input.type === 'class_signup_registered') { subject = `Signed up for ${cls} at ${space}` lines.push(`You are registered for ${cls} at ${space}${range ? ` on ${range}` : ''}.`) if (location) lines.push(`Location: ${location}.`) lines.push('', `View your signups: ${input.manageUrl}`) } else if (input.type === 'class_signup_waitlisted') { subject = `Waitlisted for ${cls} at ${space}` lines.push( `You are on the waitlist for ${cls} at ${space}${range ? ` on ${range}` : ''}.`, 'You will be moved into the session by email if a spot opens up.', ) if (location) lines.push(`Location: ${location}.`) lines.push('', `View your signups: ${input.manageUrl}`) } else if (input.type === 'class_signup_promoted') { subject = `You're in: ${cls} at ${space}` lines.push( `A spot opened up and you have been moved from the waitlist into ${cls} at ${space}${range ? ` on ${range}` : ''}.`, ) if (location) lines.push(`Location: ${location}.`) lines.push('', `View your signups: ${input.manageUrl}`) } else { subject = `Cancelled: ${cls} at ${space}` lines.push( `The session of ${cls} at ${space}${range ? ` scheduled for ${range}` : ''} has been cancelled.`, '', `View your signups: ${input.manageUrl}`, ) } const { html, text } = renderShell(lines) return { subject, html, text } } // ─── Form submission ───────────────────────────────────────────────────────── export type FormNotificationType = 'form_submission_received' | 'form_submission_admin' // Submitter confirmation is one row per submission. Admin fan-out is one row // per (submission, admin) so each admin's email is independent. export function formDedupeKey( type: FormNotificationType, ids: { submissionId: string; adminMemberId?: string | null }, ): string { if (type === 'form_submission_admin') { return notificationDedupeKey([type, ids.submissionId, ids.adminMemberId]) } return notificationDedupeKey([type, ids.submissionId]) } export type FormEmailInput = { type: FormNotificationType spaceName: string recipientName?: string | null formTitle: string // For the submitter confirmation, the link goes to /me. For admin alerts, // pass the /forms//results URL so the admin lands on the result. manageUrl: string // Admin-only: name (or generic) of the person who submitted, to give // context. For submitter confirmation this is unused. Always optional. submitterLabel?: string | null } export function renderFormEmail(input: FormEmailInput): RenderedEmail { const space = stripUrls(input.spaceName) || 'your hackerspace' const name = stripUrls(input.recipientName).trim() || null const greeting = name ? `Hi ${name},` : 'Hi,' const form = stripUrls(input.formTitle) || 'the form' let subject: string const lines: string[] = [greeting, ''] if (input.type === 'form_submission_received') { subject = `Received: ${form} at ${space}` lines.push( `We received your submission to ${form} at ${space}. Thanks.`, '', `Manage your account: ${input.manageUrl}`, ) } else { subject = `New submission: ${form} at ${space}` const who = stripUrls(input.submitterLabel).trim() || 'someone' lines.push( `${who} submitted ${form} at ${space}.`, '', `View the submission: ${input.manageUrl}`, ) } const { html, text } = renderShell(lines) return { subject, html, text } } // ─── Booking (equipment reservation) ───────────────────────────────────────── export type BookingNotificationType = 'booking_confirmed' | 'booking_cancelled' // One row per reservation per event. A cancel-then-re-reserve produces a new // reservation_id, so confirmation + cancel of the same reservation each fire // once and never collide. export function bookingDedupeKey( type: BookingNotificationType, reservationId: string, ): string { return notificationDedupeKey([type, reservationId]) } export type BookingEmailInput = { type: BookingNotificationType spaceName: string memberName?: string | null equipmentName: string location?: string | null startsAt: string endsAt: string manageUrl: string } export function renderBookingEmail(input: BookingEmailInput): RenderedEmail { const space = stripUrls(input.spaceName) || 'your hackerspace' const name = stripUrls(input.memberName).trim() || null const greeting = name ? `Hi ${name},` : 'Hi,' const equipment = stripUrls(input.equipmentName) || 'the equipment' const range = formatRange(input.startsAt, input.endsAt) const location = stripUrls(input.location).trim() let subject: string const lines: string[] = [greeting, ''] if (input.type === 'booking_confirmed') { subject = `Reservation confirmed: ${equipment} at ${space}` lines.push( `Your reservation for ${equipment} at ${space} is confirmed${range ? ` for ${range}` : ''}.`, ) if (location) lines.push(`Location: ${location}.`) lines.push('', `View your reservations: ${input.manageUrl}`) } else { subject = `Reservation cancelled: ${equipment} at ${space}` lines.push( `Your reservation for ${equipment} at ${space}${range ? ` for ${range}` : ''} was cancelled.`, ) lines.push('', `View your reservations: ${input.manageUrl}`) } const { html, text } = renderShell(lines) return { subject, html, text } }