diff --git a/__tests__/notifications-enqueue.test.ts b/__tests__/notifications-enqueue.test.ts new file mode 100644 index 0000000..14a8fb6 --- /dev/null +++ b/__tests__/notifications-enqueue.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + resolveMemberContact, + enqueueNotification, + buildManageUrl, + getSpaceName, +} from '@/lib/notifications/enqueue' + +// Minimal hand-rolled mock of the supabase admin client. The helpers only use +// two query shapes: a chained select for member lookup and an upsert for the +// outbox write. Keeping it bare so the contract is obvious to a future reader. +type Row = Record + +function mockAdmin(opts: { + selectResult?: { data: Row | null; error?: { message: string } | null } + upsertResult?: { error: { message: string } | null } + throwOnUpsert?: boolean +}) { + const upsert = vi.fn(async () => { + if (opts.throwOnUpsert) throw new Error('boom') + return opts.upsertResult ?? { error: null } + }) + + const builder = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn(async () => + opts.selectResult ?? { data: null, error: null }, + ), + upsert, + } + const from = vi.fn(() => builder) + return { from, builder, upsert } as unknown as { + from: ReturnType + builder: typeof builder + upsert: typeof upsert + } +} + +describe('resolveMemberContact', () => { + it('returns email + display_name when the member exists in the space', async () => { + const admin = mockAdmin({ + selectResult: { data: { email: 'm@x.test', display_name: 'Mia' }, error: null }, + }) + const r = await resolveMemberContact(admin as never, 'space-1', 'member-1') + expect(r).toEqual({ email: 'm@x.test', displayName: 'Mia' }) + expect(admin.from).toHaveBeenCalledWith('space_members') + expect(admin.builder.eq).toHaveBeenCalledWith('id', 'member-1') + expect(admin.builder.eq).toHaveBeenCalledWith('space_id', 'space-1') + }) + + it('returns null when the (member, space) pair has no row', async () => { + const admin = mockAdmin({ selectResult: { data: null, error: null } }) + const r = await resolveMemberContact(admin as never, 'space-1', 'ghost') + expect(r).toBeNull() + }) + + it('coerces missing fields to null', async () => { + const admin = mockAdmin({ + selectResult: { data: { email: null, display_name: null }, error: null }, + }) + const r = await resolveMemberContact(admin as never, 'space-1', 'member-1') + expect(r).toEqual({ email: null, displayName: null }) + }) +}) + +describe('enqueueNotification', () => { + const params = { + spaceId: 'space-1', + memberId: 'member-1', + type: 'booking_confirmed' as const, + recipient: 'm@x.test', + subject: 'Reserved', + bodyHtml: '

ok

', + bodyText: 'ok', + dedupeKey: 'booking_confirmed:r1', + } + + it('upserts with onConflict + ignoreDuplicates so a replay is a no-op', async () => { + const admin = mockAdmin({ upsertResult: { error: null } }) + await enqueueNotification(admin as never, params) + expect(admin.from).toHaveBeenCalledWith('notifications') + const [row, opts] = admin.upsert.mock.calls[0] as [Row, Row] + expect(row).toMatchObject({ + space_id: 'space-1', + member_id: 'member-1', + type: 'booking_confirmed', + channel: 'email', + recipient: 'm@x.test', + subject: 'Reserved', + body_html: '

ok

', + body_text: 'ok', + status: 'pending', + dedupe_key: 'booking_confirmed:r1', + }) + expect(opts).toEqual({ onConflict: 'space_id,dedupe_key', ignoreDuplicates: true }) + }) + + it('skips when the recipient is empty or missing an @', async () => { + const admin = mockAdmin({}) + await enqueueNotification(admin as never, { ...params, recipient: '' }) + await enqueueNotification(admin as never, { ...params, recipient: 'not-an-email' }) + expect(admin.from).not.toHaveBeenCalled() + }) + + it('never throws when the upsert errors (best-effort)', async () => { + const admin = mockAdmin({ upsertResult: { error: { message: 'rls denied' } } }) + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect(enqueueNotification(admin as never, params)).resolves.toBeUndefined() + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('never throws when the upsert call itself throws (best-effort)', async () => { + const admin = mockAdmin({ throwOnUpsert: true }) + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect(enqueueNotification(admin as never, params)).resolves.toBeUndefined() + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) +}) + +describe('buildManageUrl', () => { + const OLD = { ...process.env } + beforeEach(() => { + delete process.env.NEXT_PUBLIC_APP_URL + }) + afterEach(() => { + process.env = { ...OLD } + }) + + it('uses the provided host + proto when given', () => { + expect(buildManageUrl('example.test', 'https')).toBe('https://example.test/me') + expect(buildManageUrl('localhost:3000', 'http')).toBe('http://localhost:3000/me') + }) + + it('defaults proto to https when host is given without one', () => { + expect(buildManageUrl('example.test')).toBe('https://example.test/me') + }) + + it('falls back to NEXT_PUBLIC_APP_URL then to the prod default', () => { + process.env.NEXT_PUBLIC_APP_URL = 'https://my.test' + expect(buildManageUrl(null)).toBe('https://my.test/me') + delete process.env.NEXT_PUBLIC_APP_URL + expect(buildManageUrl(null)).toBe('https://hackerspace.sh/me') + }) +}) + +describe('getSpaceName', () => { + it('returns the name when present', async () => { + const admin = mockAdmin({ selectResult: { data: { name: 'Acme Space' }, error: null } }) + expect(await getSpaceName(admin as never, 'space-1')).toBe('Acme Space') + }) + it('returns empty string on miss', async () => { + const admin = mockAdmin({ selectResult: { data: null, error: null } }) + expect(await getSpaceName(admin as never, 'ghost')).toBe('') + }) +}) diff --git a/app/api/stripe/webhook/[space]/route.ts b/app/api/stripe/webhook/[space]/route.ts index 1d29c61..0548650 100644 --- a/app/api/stripe/webhook/[space]/route.ts +++ b/app/api/stripe/webhook/[space]/route.ts @@ -28,6 +28,12 @@ import { duesDedupeKey, type DuesNotificationType, } from '@/lib/notifications-logic' +import { + enqueueNotification, + resolveMemberContact, + getSpaceName as readSpaceName, + buildManageUrl, +} from '@/lib/notifications/enqueue' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -77,29 +83,23 @@ export async function POST( // Stripe sets Host to the configured webhook URL's host; fall back to the // app URL. Used only to build the "manage billing" link in the email. - const hookHost = req.headers.get('x-forwarded-host') ?? req.headers.get('host') - const hookProto = req.headers.get('x-forwarded-proto') ?? 'https' - const manageUrl = `${ - hookHost ? `${hookProto}://${hookHost}` : process.env.NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh' - }/me` + const manageUrl = buildManageUrl( + req.headers.get('x-forwarded-host') ?? req.headers.get('host'), + req.headers.get('x-forwarded-proto') ?? 'https', + ) let spaceNameMemo: string | null = null async function getSpaceName(): Promise { if (spaceNameMemo !== null) return spaceNameMemo - const { data } = await admin.from('spaces').select('name').eq('id', spaceId).maybeSingle() - spaceNameMemo = (data?.name as string | null) ?? '' + spaceNameMemo = await readSpaceName(admin, spaceId) return spaceNameMemo } // Enqueue a dues-lifecycle email into the notifications outbox. NEVER sends // inline (keeps this money path fast + retry-safe); the dispatcher cron - // sends. ignoreDuplicates + the (space_id, dedupe_key) unique index make a - // Stripe event replay (new event id, same invoice/period) a no-op. - // - // BEST-EFFORT: the entire body is wrapped so a notifications-table or - // render failure can NEVER throw into the money path. The Stripe ledger / - // member_billing / status writes must finalize even if email infra is - // down; a missed enqueue is acceptable, a wedged money path is not. + // sends. Goes through the shared outbox helper: best-effort wrapped, so a + // notifications-table or render failure can never throw into the money path. + // Idempotent via (space_id, dedupe_key) so a Stripe event replay collapses. async function enqueueDues( type: DuesNotificationType, args: { @@ -112,52 +112,34 @@ export async function POST( subscriptionId?: string | null }, ): Promise { - try { - if (!args.memberId) return - const { data: mem } = await admin - .from('space_members') - .select('email, display_name') - .eq('id', args.memberId) - .eq('space_id', spaceId) - .maybeSingle() - const recipient = ((mem?.email as string | null) || args.fallbackEmail || '').trim() - if (!recipient) return - const { subject, html, text } = renderDuesEmail({ - type, - spaceName: await getSpaceName(), - memberName: (mem?.display_name as string | null) ?? null, - amount: args.amount ?? null, - currency: args.currency ?? null, - periodEnd: args.periodEnd ?? null, - manageUrl, - }) - const { error: enqErr } = await admin.from('notifications').upsert( - { - space_id: spaceId, - member_id: args.memberId, - type, - channel: 'email', - recipient, - subject, - body_html: html, - body_text: text, - status: 'pending', - dedupe_key: duesDedupeKey(type, { - invoiceId: args.invoiceId, - memberId: args.memberId, - periodEnd: args.periodEnd, - subscriptionId: args.subscriptionId, - }), - }, - { onConflict: 'space_id,dedupe_key', ignoreDuplicates: true }, - ) - if (enqErr) console.error(`[stripe webhook] enqueue ${type} failed:`, enqErr.message) - } catch (e) { - console.error( - `[stripe webhook] enqueue ${type} threw:`, - e instanceof Error ? e.message : e, - ) - } + if (!args.memberId) return + const contact = await resolveMemberContact(admin, spaceId, args.memberId) + const recipient = ((contact?.email ?? null) || args.fallbackEmail || '').trim() + if (!recipient) return + const { subject, html, text } = renderDuesEmail({ + type, + spaceName: await getSpaceName(), + memberName: contact?.displayName ?? null, + amount: args.amount ?? null, + currency: args.currency ?? null, + periodEnd: args.periodEnd ?? null, + manageUrl, + }) + await enqueueNotification(admin, { + spaceId, + memberId: args.memberId, + type, + recipient, + subject, + bodyHtml: html, + bodyText: text, + dedupeKey: duesDedupeKey(type, { + invoiceId: args.invoiceId, + memberId: args.memberId, + periodEnd: args.periodEnd, + subscriptionId: args.subscriptionId, + }), + }) } // Resolve our member: prefer metadata (set on the subscription + session), diff --git a/lib/notifications/enqueue.ts b/lib/notifications/enqueue.ts new file mode 100644 index 0000000..0f21dd8 --- /dev/null +++ b/lib/notifications/enqueue.ts @@ -0,0 +1,107 @@ +// Shared notifications-outbox helpers used by every enqueue site (the Stripe +// webhook, equipment/classes/forms server actions). Resolving the recipient +// from a member id and writing the idempotent outbox row are the only two +// things every caller needs to do; rendering and dedupe-key construction stay +// in lib/notifications-logic.ts (pure, unit-tested). +// +// enqueueNotification is BEST-EFFORT: it never throws into the caller. The +// money path can write its ledger and finalize status even if the +// notifications table or email infra is wedged; a missed enqueue is +// acceptable, a wedged calling path is not. The (space_id, dedupe_key) +// unique index makes duplicate enqueues a no-op via ignoreDuplicates. + +import type { createAdminClient } from '@/lib/supabase/admin' + +type AdminClient = ReturnType + +export type MemberContact = { + email: string | null + displayName: string | null +} + +// Look up a member's email + display_name, scoped to the given space. Used +// by every enqueue site to derive a recipient from a member id. Returns null +// if the member is not in that space (cross-tenant safety: the (id, space_id) +// filter pins the lookup). +export async function resolveMemberContact( + admin: AdminClient, + spaceId: string, + memberId: string, +): Promise { + const { data } = await admin + .from('space_members') + .select('email, display_name') + .eq('id', memberId) + .eq('space_id', spaceId) + .maybeSingle() + if (!data) return null + return { + email: (data.email as string | null) ?? null, + displayName: (data.display_name as string | null) ?? null, + } +} + +export type EnqueueParams = { + spaceId: string + // null when the notification is not tied to a single member (e.g. a future + // admin broadcast). For member-personal notifications this is the affected + // member; for fan-outs (one row per recipient admin) it is the admin's id. + memberId: string | null + type: string + recipient: string + subject: string + bodyHtml: string + bodyText: string + dedupeKey: string +} + +// Idempotent best-effort outbox write. Never throws. The (space_id, +// dedupe_key) unique index collapses replays. +export async function enqueueNotification( + admin: AdminClient, + params: EnqueueParams, +): Promise { + try { + if (!params.recipient || !params.recipient.includes('@')) return + const { error } = await admin.from('notifications').upsert( + { + space_id: params.spaceId, + member_id: params.memberId, + type: params.type, + channel: 'email', + recipient: params.recipient, + subject: params.subject, + body_html: params.bodyHtml, + body_text: params.bodyText, + status: 'pending', + dedupe_key: params.dedupeKey, + }, + { onConflict: 'space_id,dedupe_key', ignoreDuplicates: true }, + ) + if (error) { + console.error(`[notifications] enqueue ${params.type} failed:`, error.message) + } + } catch (e) { + console.error( + `[notifications] enqueue ${params.type} threw:`, + e instanceof Error ? e.message : e, + ) + } +} + +// Read the space's name once per caller. Used for brand-neutral copy that +// injects the space name (this is a generic multi-space platform; never +// hard-code a tenant). Returns empty string on miss so renders still work. +export async function getSpaceName(admin: AdminClient, spaceId: string): Promise { + const { data } = await admin.from('spaces').select('name').eq('id', spaceId).maybeSingle() + return (data?.name as string | null) ?? '' +} + +// Build the member portal URL the way every enqueue site does. Header host +// takes precedence (lets a request to a non-canonical host link back to +// itself); otherwise falls back to NEXT_PUBLIC_APP_URL and finally the prod +// default. Same shape as the existing dues path. +export function buildManageUrl(host?: string | null, proto?: string | null): string { + if (host) return `${proto || 'https'}://${host}/me` + return `${process.env.NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh'}/me` +}