notifications(P4d): form submission submitter + admin alerts

submitForm now enqueues two kinds of notifications, gated per the design:

  - form_submission_received: ONLY when the submitter is authenticated
    (members or public_auth visibility). Recipient is the verified auth
    email (user.email), never the typed body.email. Anonymous public
    submissions skip the confirmation because confirming to a typed
    address is a victim-spam vector.

  - form_submission_admin: one row per member who holds forms.manage in
    the space (mirrors the forms-guard / forms RLS gate). Dedupe by
    (submission, admin) so a replay is a no-op.

Finding all permission holders without N round-trips needs a new SQL
helper. Migration 047 adds members_with_permission(sid, perm): the
inverted, set-returning form of user_has_permission, with the same
current/late status gate (046) and the same admin shortcut +
space_role_permissions / user_effective_roles fallback. Additive,
introduces no new tables or policies; existing callers unchanged.
Mirrored into schema.sql; types/database.ts updated.

renderFormEmail + formDedupeKey live in notifications-logic; 6 new
render/dedupe unit tests cover both types, fallback labels, escaping.

Suite 565, build clean.
This commit is contained in:
Moheeb Zara
2026-05-19 15:28:21 -07:00
parent 105b62f28a
commit a2ea7675e7
6 changed files with 320 additions and 14 deletions
+59
View File
@@ -278,6 +278,65 @@ export function renderClassEmail(input: ClassEmailInput): RenderedEmail {
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/<id>/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 = input.spaceName || 'your hackerspace'
const name = input.recipientName?.trim() || null
const greeting = name ? `Hi ${name},` : 'Hi,'
const form = 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 = (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'