notifications(P4a): shared enqueue helper, refactor stripe webhook to use it

Extracts resolveMemberContact, enqueueNotification, getSpaceName, and
buildManageUrl into lib/notifications/enqueue.ts. The Stripe webhook's
enqueueDues now composes them instead of inlining the member lookup +
upsert. Zero behavior change (same recipient resolution, same upsert
shape, same best-effort wrap, same idempotency via (space_id,
dedupe_key)). Prepares P4b-P4d (equipment/classes/forms enqueue) which
would otherwise copy-paste the same plumbing four times.

12 new unit tests cover the helpers: member lookup hit/miss, upsert
shape, recipient skip on missing-@, best-effort no-throw on error or
exception, URL fallbacks. Suite 544, build clean.
This commit is contained in:
Moheeb Zara
2026-05-19 15:17:15 -07:00
parent 792534ee7b
commit 84d31ee74b
3 changed files with 307 additions and 60 deletions
+158
View File
@@ -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<string, unknown>
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<typeof vi.fn>
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: '[email protected]', display_name: 'Mia' }, error: null },
})
const r = await resolveMemberContact(admin as never, 'space-1', 'member-1')
expect(r).toEqual({ email: '[email protected]', 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: '[email protected]',
subject: 'Reserved',
bodyHtml: '<p>ok</p>',
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: '[email protected]',
subject: 'Reserved',
body_html: '<p>ok</p>',
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('')
})
})
+24 -42
View File
@@ -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<string> {
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<void> {
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()
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: (mem?.display_name as string | null) ?? null,
memberName: contact?.displayName ?? 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,
await enqueueNotification(admin, {
spaceId,
memberId: args.memberId,
type,
channel: 'email',
recipient,
subject,
body_html: html,
body_text: text,
status: 'pending',
dedupe_key: duesDedupeKey(type, {
bodyHtml: html,
bodyText: text,
dedupeKey: 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,
)
}
})
}
// Resolve our member: prefer metadata (set on the subscription + session),
+107
View File
@@ -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<typeof createAdminClient>
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<MemberContact | null> {
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<void> {
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<string> {
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`
}