diff --git a/__tests__/notifications-logic.test.ts b/__tests__/notifications-logic.test.ts new file mode 100644 index 0000000..5b4ee0d --- /dev/null +++ b/__tests__/notifications-logic.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest' +import { + MAX_NOTIFICATION_ATTEMPTS, + isTerminalAttempt, + notificationDedupeKey, + duesDedupeKey, + formatMoney, + renderDuesEmail, +} from '@/lib/notifications-logic' + +describe('isTerminalAttempt', () => { + it('is terminal only once the attempt budget is exhausted', () => { + expect(isTerminalAttempt(0)).toBe(false) + expect(isTerminalAttempt(MAX_NOTIFICATION_ATTEMPTS - 1)).toBe(false) + expect(isTerminalAttempt(MAX_NOTIFICATION_ATTEMPTS)).toBe(true) + expect(isTerminalAttempt(MAX_NOTIFICATION_ATTEMPTS + 3)).toBe(true) + }) + it('respects a custom max', () => { + expect(isTerminalAttempt(2, 3)).toBe(false) + expect(isTerminalAttempt(3, 3)).toBe(true) + }) +}) + +describe('notificationDedupeKey', () => { + it('joins non-empty parts and drops nullish/empty', () => { + expect(notificationDedupeKey(['a', 'b', 'c'])).toBe('a:b:c') + expect(notificationDedupeKey(['a', null, undefined, '', 'd'])).toBe('a:d') + expect(notificationDedupeKey([1, 'x'])).toBe('1:x') + }) + it('is deterministic for the same input', () => { + expect(notificationDedupeKey(['dues_renewed', 'in_1'])).toBe( + notificationDedupeKey(['dues_renewed', 'in_1']), + ) + }) +}) + +describe('duesDedupeKey', () => { + it('renewed/failed key by invoice; a Stripe event-id replay collapses', () => { + expect(duesDedupeKey('dues_renewed', { invoiceId: 'in_1' })).toBe('dues_renewed:in_1') + expect(duesDedupeKey('dues_payment_failed', { invoiceId: 'in_9' })).toBe( + 'dues_payment_failed:in_9', + ) + }) + it('lapse keys by member + period so next cycle can lapse again', () => { + const a = duesDedupeKey('dues_lapsed', { memberId: 'm1', periodEnd: '2026-06-01' }) + const b = duesDedupeKey('dues_lapsed', { memberId: 'm1', periodEnd: '2026-07-01' }) + expect(a).toBe('dues_lapsed:m1:2026-06-01') + expect(a).not.toBe(b) + }) +}) + +describe('formatMoney', () => { + it('formats known currencies with a symbol, others with the code', () => { + expect(formatMoney(12, 'usd')).toBe('$12.00 USD') + expect(formatMoney(12, 'eur')).toBe('€12.00 EUR') + expect(formatMoney(5.5, 'AUD')).toBe('5.50 AUD') + }) + it('returns empty string when amount is missing', () => { + expect(formatMoney(null, 'usd')).toBe('') + expect(formatMoney(undefined, 'usd')).toBe('') + expect(formatMoney(NaN, 'usd')).toBe('') + }) +}) + +describe('renderDuesEmail', () => { + it('renewed: subject + amount + manage link, both bodies', () => { + const r = renderDuesEmail({ + type: 'dues_renewed', + spaceName: 'HeatSync Labs', + memberName: 'Ada', + amount: 25, + currency: 'USD', + periodEnd: '2026-06-15', + manageUrl: 'https://hackerspace.sh/me', + }) + expect(r.subject).toBe('Your HeatSync Labs dues renewed') + expect(r.text).toContain('Hi Ada,') + expect(r.text).toContain('$25.00 USD') + expect(r.text).toContain('https://hackerspace.sh/me') + expect(r.html).toContain(' { + const r = renderDuesEmail({ + type: 'dues_payment_failed', + spaceName: 'Acme', + manageUrl: 'https://x.test/me', + }) + expect(r.subject).toBe('Action needed: Acme dues payment failed') + expect(r.text).toContain('Hi,') + expect(r.text.toLowerCase()).toContain('update your payment method') + }) + + it('lapsed: late subject and restore copy', () => { + const r = renderDuesEmail({ + type: 'dues_lapsed', + spaceName: 'Acme', + memberName: ' ', + manageUrl: 'https://x.test/me', + }) + expect(r.subject).toBe('Your Acme membership is now marked late') + expect(r.text).toContain('Hi,') + expect(r.text.toLowerCase()).toContain('marked late') + }) + + it('escapes HTML in injected names to prevent email markup injection', () => { + const r = renderDuesEmail({ + type: 'dues_renewed', + spaceName: '', + memberName: 'ac', + manageUrl: 'https://x.test/me', + }) + expect(r.html).not.toContain('') + expect(r.html).toContain('<script>') + expect(r.html).toContain('a<b>c') + }) + + it('falls back to a neutral space name and generic greeting', () => { + const r = renderDuesEmail({ + type: 'dues_renewed', + spaceName: '', + manageUrl: 'https://x.test/me', + }) + expect(r.subject).toBe('Your your hackerspace dues renewed') + expect(r.text).toContain('Hi,') + }) +}) diff --git a/lib/notifications-logic.ts b/lib/notifications-logic.ts new file mode 100644 index 0000000..01f8bac --- /dev/null +++ b/lib/notifications-logic.ts @@ -0,0 +1,148 @@ +// 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. +export function duesDedupeKey( + type: DuesNotificationType, + ref: { invoiceId?: string | null; memberId?: string | null; periodEnd?: string | null }, +): string { + if (type === 'dues_lapsed') { + return notificationDedupeKey(['dues_lapsed', ref.memberId, ref.periodEnd]) + } + return notificationDedupeKey([type, ref.invoiceId]) +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +const CURRENCY_SYMBOL: Record = { USD: '$', CAD: '$', EUR: '€', GBP: '£' } + +// amount is in major units (dollars), matching how the payments ledger stores +// it (Stripe minor units are divided by 100 by the caller). +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 = 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' }) +} + +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 = input.spaceName || 'your hackerspace' + const name = 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 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 { subject, html, text } +} diff --git a/scripts/041_notifications.sql b/scripts/041_notifications.sql new file mode 100644 index 0000000..1ebdba5 --- /dev/null +++ b/scripts/041_notifications.sql @@ -0,0 +1,52 @@ +-- ============================================================================= +-- 041: Transactional notifications outbox (Product spine Phase 2) +-- ============================================================================= +-- Dues-lifecycle emails (renewal receipt, payment-failed, lapse-to-late) are +-- enqueued by the Stripe webhook and sent by a separate dispatcher cron. The +-- webhook NEVER sends inline: it only writes an idempotent outbox row, so the +-- money path stays fast and safe to retry (a duplicate enqueue is a no-op via +-- the (space_id, dedupe_key) unique index). +-- +-- * notifications: one row per message. Service client (webhook enqueue + +-- dispatcher) is the only writer; SELECT = admin/board/treasurer (mirrors +-- member_billing / payments visibility). Member self-view of their own +-- notifications is a validated service-client action, not an RLS path +-- (same convention as getMyBilling). +-- +-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then +-- CREATE. Apply as-is (Supabase SQL editor / psql); re-runs are no-ops. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.notifications ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + member_id uuid REFERENCES public.space_members(id) ON DELETE CASCADE, + type text NOT NULL, + channel text NOT NULL DEFAULT 'email', + recipient text NOT NULL, + subject text NOT NULL, + body_html text NOT NULL, + body_text text NOT NULL, + status text NOT NULL DEFAULT 'pending', + attempts integer NOT NULL DEFAULT 0, + last_error text, + dedupe_key text NOT NULL, + sent_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_notifications_dedupe ON public.notifications (space_id, dedupe_key); +CREATE INDEX IF NOT EXISTS idx_notifications_pending ON public.notifications (created_at) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_notifications_member ON public.notifications (space_id, member_id); + +ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS notifications_select ON public.notifications; +CREATE POLICY notifications_select ON public.notifications FOR SELECT + USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); +-- No INSERT/UPDATE/DELETE policy: the validated service-client webhook enqueue +-- and dispatcher cron are the only writers. + +DROP TRIGGER IF EXISTS trg_notifications_touch ON public.notifications; +CREATE TRIGGER trg_notifications_touch + BEFORE UPDATE ON public.notifications + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); diff --git a/scripts/schema.sql b/scripts/schema.sql index c102937..9048a7d 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -2587,3 +2587,48 @@ DROP TRIGGER IF EXISTS trg_member_billing_touch ON public.member_billing; CREATE TRIGGER trg_member_billing_touch BEFORE UPDATE ON public.member_billing FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); + +-- ============================================================================= +-- 26. Transactional notifications outbox (Product spine Phase 2) +-- (equivalent to scripts/041_notifications.sql.) +-- +-- Dues-lifecycle emails are enqueued by the Stripe webhook and sent by a +-- separate dispatcher cron. The webhook only writes an idempotent outbox +-- row (never sends inline), so the money path stays fast and retry-safe; +-- a duplicate enqueue is a no-op via the (space_id, dedupe_key) unique +-- index. Service client (webhook enqueue + dispatcher) is the only +-- writer; SELECT = admin/board/treasurer (mirrors member_billing). Member +-- self-view is a validated service-client action, not an RLS path. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.notifications ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + member_id uuid REFERENCES public.space_members(id) ON DELETE CASCADE, + type text NOT NULL, + channel text NOT NULL DEFAULT 'email', + recipient text NOT NULL, + subject text NOT NULL, + body_html text NOT NULL, + body_text text NOT NULL, + status text NOT NULL DEFAULT 'pending', + attempts integer NOT NULL DEFAULT 0, + last_error text, + dedupe_key text NOT NULL, + sent_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_notifications_dedupe ON public.notifications (space_id, dedupe_key); +CREATE INDEX IF NOT EXISTS idx_notifications_pending ON public.notifications (created_at) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_notifications_member ON public.notifications (space_id, member_id); + +ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS notifications_select ON public.notifications; +CREATE POLICY notifications_select ON public.notifications FOR SELECT + USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); + +DROP TRIGGER IF EXISTS trg_notifications_touch ON public.notifications; +CREATE TRIGGER trg_notifications_touch + BEFORE UPDATE ON public.notifications + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); diff --git a/types/database.ts b/types/database.ts index 55422e3..b944bb1 100644 --- a/types/database.ts +++ b/types/database.ts @@ -190,6 +190,12 @@ export type Database = { Update: { event_id?: string; space_id?: string | null; type?: string | null; received_at?: string } Relationships: [] } + notifications: { + Row: { id: string; space_id: string; member_id: string | null; type: string; channel: string; recipient: string; subject: string; body_html: string; body_text: string; status: string; attempts: number; last_error: string | null; dedupe_key: string; sent_at: string | null; created_at: string; updated_at: string } + Insert: { id?: string; space_id: string; member_id?: string | null; type: string; channel?: string; recipient: string; subject: string; body_html: string; body_text: string; status?: string; attempts?: number; last_error?: string | null; dedupe_key: string; sent_at?: string | null; created_at?: string; updated_at?: string } + Update: { id?: string; space_id?: string; member_id?: string | null; type?: string; channel?: string; recipient?: string; subject?: string; body_html?: string; body_text?: string; status?: string; attempts?: number; last_error?: string | null; dedupe_key?: string; sent_at?: string | null; created_at?: string; updated_at?: string } + Relationships: [] + } activity_log: { Row: { action: string