feat(notifications): P2a outbox table + pure dues-email logic

Migration 041 + schema.sql section 26: notifications outbox (service-only
writer, admin/board/treasurer SELECT, member self-view via validated
service action like member_billing; (space_id, dedupe_key) unique makes
the webhook enqueue idempotent). lib/notifications-logic.ts is pure:
deterministic dedupe keys, dues email rendering (HTML-escaped, brand-
neutral), terminal-attempt rule. 13 tests. Inert until P2c wires it.
This commit is contained in:
Moheeb Zara
2026-05-17 04:49:03 -07:00
parent dc76a86949
commit ddb790abb9
5 changed files with 378 additions and 0 deletions
+127
View File
@@ -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('<a href="https://hackerspace.sh/me"')
})
it('payment_failed: action-needed subject and update-method copy', () => {
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: '<script>evil</script>',
memberName: 'a<b>c',
manageUrl: 'https://x.test/me',
})
expect(r.html).not.toContain('<script>evil</script>')
expect(r.html).toContain('&lt;script&gt;')
expect(r.html).toContain('a&lt;b&gt;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,')
})
})
+148
View File
@@ -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 | number | null | undefined>,
): 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
const CURRENCY_SYMBOL: Record<string, string> = { 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 =
`<div style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:14px;line-height:1.6;color:#111">` +
lines
.map(l => {
if (l === '') return '<br/>'
const safe = escapeHtml(l)
const linked = safe.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1" style="color:#2563eb">$1</a>',
)
return `<p style="margin:0 0 8px">${linked}</p>`
})
.join('') +
`</div>`
return { subject, html, text }
}
+52
View File
@@ -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();
+45
View File
@@ -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();
+6
View File
@@ -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