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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user