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:
@@ -10,6 +10,8 @@ import {
|
|||||||
renderBookingEmail,
|
renderBookingEmail,
|
||||||
classDedupeKey,
|
classDedupeKey,
|
||||||
renderClassEmail,
|
renderClassEmail,
|
||||||
|
formDedupeKey,
|
||||||
|
renderFormEmail,
|
||||||
} from '@/lib/notifications-logic'
|
} from '@/lib/notifications-logic'
|
||||||
|
|
||||||
describe('isTerminalAttempt', () => {
|
describe('isTerminalAttempt', () => {
|
||||||
@@ -309,3 +311,75 @@ describe('renderClassEmail', () => {
|
|||||||
expect(r.html).toContain('<b>x</b>')
|
expect(r.html).toContain('<b>x</b>')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('formDedupeKey', () => {
|
||||||
|
it('submitter confirmation is one row per submission', () => {
|
||||||
|
expect(formDedupeKey('form_submission_received', { submissionId: 's1' })).toBe(
|
||||||
|
'form_submission_received:s1',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('admin alert fans out one row per (submission, admin)', () => {
|
||||||
|
expect(
|
||||||
|
formDedupeKey('form_submission_admin', { submissionId: 's1', adminMemberId: 'a1' }),
|
||||||
|
).toBe('form_submission_admin:s1:a1')
|
||||||
|
expect(
|
||||||
|
formDedupeKey('form_submission_admin', { submissionId: 's1', adminMemberId: 'a1' }),
|
||||||
|
).not.toBe(
|
||||||
|
formDedupeKey('form_submission_admin', { submissionId: 's1', adminMemberId: 'a2' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('renderFormEmail', () => {
|
||||||
|
it('submitter received: thank-you subject + body', () => {
|
||||||
|
const r = renderFormEmail({
|
||||||
|
type: 'form_submission_received',
|
||||||
|
spaceName: 'Acme',
|
||||||
|
recipientName: 'Ada',
|
||||||
|
formTitle: 'Liability Waiver',
|
||||||
|
manageUrl: 'https://x.test/me',
|
||||||
|
})
|
||||||
|
expect(r.subject).toBe('Received: Liability Waiver at Acme')
|
||||||
|
expect(r.text).toContain('Hi Ada,')
|
||||||
|
expect(r.text).toContain('received your submission to Liability Waiver at Acme')
|
||||||
|
expect(r.text).toContain('https://x.test/me')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin alert: subject names the form; body names the submitter and the link goes to the results URL', () => {
|
||||||
|
const r = renderFormEmail({
|
||||||
|
type: 'form_submission_admin',
|
||||||
|
spaceName: 'Acme',
|
||||||
|
recipientName: 'Boss',
|
||||||
|
formTitle: 'Liability Waiver',
|
||||||
|
manageUrl: 'https://x.test/forms/abc/results',
|
||||||
|
submitterLabel: 'Ada Lovelace',
|
||||||
|
})
|
||||||
|
expect(r.subject).toBe('New submission: Liability Waiver at Acme')
|
||||||
|
expect(r.text).toContain('Ada Lovelace submitted Liability Waiver at Acme')
|
||||||
|
expect(r.text).toContain('https://x.test/forms/abc/results')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin alert: falls back to "someone" when submitterLabel is missing', () => {
|
||||||
|
const r = renderFormEmail({
|
||||||
|
type: 'form_submission_admin',
|
||||||
|
spaceName: 'Acme',
|
||||||
|
formTitle: 'Liability Waiver',
|
||||||
|
manageUrl: 'https://x.test/forms/abc/results',
|
||||||
|
})
|
||||||
|
expect(r.text.toLowerCase()).toContain('someone submitted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes HTML in form title and submitter label', () => {
|
||||||
|
const r = renderFormEmail({
|
||||||
|
type: 'form_submission_admin',
|
||||||
|
spaceName: 'Acme',
|
||||||
|
formTitle: '<script>',
|
||||||
|
manageUrl: 'https://x.test/forms/abc/results',
|
||||||
|
submitterLabel: '<img>',
|
||||||
|
})
|
||||||
|
expect(r.html).not.toContain('<script>')
|
||||||
|
expect(r.html).not.toContain('<img>')
|
||||||
|
expect(r.html).toContain('<script>')
|
||||||
|
expect(r.html).toContain('<img>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+123
-14
@@ -34,6 +34,13 @@ import {
|
|||||||
pickMemberForEmail,
|
pickMemberForEmail,
|
||||||
deriveSubmitterEmail,
|
deriveSubmitterEmail,
|
||||||
} from '@/lib/forms-logic'
|
} from '@/lib/forms-logic'
|
||||||
|
import { renderFormEmail, formDedupeKey } from '@/lib/notifications-logic'
|
||||||
|
import {
|
||||||
|
enqueueNotification,
|
||||||
|
resolveMemberContact,
|
||||||
|
getSpaceName,
|
||||||
|
buildManageUrl,
|
||||||
|
} from '@/lib/notifications/enqueue'
|
||||||
|
|
||||||
// Associate prior unlinked submissions in a space with a member by email
|
// Associate prior unlinked submissions in a space with a member by email
|
||||||
// (case-insensitive, ILIKE-escaped so `_`/`%` in an address are literal).
|
// (case-insensitive, ILIKE-escaped so `_`/`%` in an address are literal).
|
||||||
@@ -439,7 +446,7 @@ export async function submitForm(input: unknown) {
|
|||||||
const admin = createAdminClient()
|
const admin = createAdminClient()
|
||||||
const { data: form } = await admin
|
const { data: form } = await admin
|
||||||
.from('forms')
|
.from('forms')
|
||||||
.select('id, space_id, slug, kind, visibility, status, schema, legal_text, version')
|
.select('id, space_id, slug, title, kind, visibility, status, schema, legal_text, version')
|
||||||
.eq('id', body.formId)
|
.eq('id', body.formId)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
if (!form) return { error: 'Form not found' }
|
if (!form) return { error: 'Form not found' }
|
||||||
@@ -516,19 +523,23 @@ export async function submitForm(input: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const h = await headers()
|
const h = await headers()
|
||||||
const { error: insErr } = await admin.from('form_submissions').insert({
|
const { data: submission, error: insErr } = await admin
|
||||||
form_id: form.id,
|
.from('form_submissions')
|
||||||
space_id: form.space_id,
|
.insert({
|
||||||
member_id: linkedMemberId,
|
form_id: form.id,
|
||||||
submitter_email: submitterEmail,
|
space_id: form.space_id,
|
||||||
answers: answers.value,
|
member_id: linkedMemberId,
|
||||||
form_snapshot: form.schema,
|
submitter_email: submitterEmail,
|
||||||
legal_text_snapshot: form.legal_text ?? null,
|
answers: answers.value,
|
||||||
form_version: form.version,
|
form_snapshot: form.schema,
|
||||||
ip: parseClientIp(h.get('x-forwarded-for'), h.get('x-real-ip')),
|
legal_text_snapshot: form.legal_text ?? null,
|
||||||
user_agent: h.get('user-agent'),
|
form_version: form.version,
|
||||||
})
|
ip: parseClientIp(h.get('x-forwarded-for'), h.get('x-real-ip')),
|
||||||
if (insErr) return { error: insErr.message }
|
user_agent: h.get('user-agent'),
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single()
|
||||||
|
if (insErr || !submission) return { error: insErr?.message ?? 'Submission failed' }
|
||||||
|
|
||||||
// Advisory audit. No member context for anon submitters, so write directly.
|
// Advisory audit. No member context for anon submitters, so write directly.
|
||||||
await admin.from('activity_log').insert({
|
await admin.from('activity_log').insert({
|
||||||
@@ -541,9 +552,107 @@ export async function submitForm(input: unknown) {
|
|||||||
details: form.slug,
|
details: form.slug,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Notifications (best-effort, never throws into this action):
|
||||||
|
// 1. Submitter confirmation: ONLY when the submitter is authenticated
|
||||||
|
// (members or public_auth). Recipient is the verified auth email, not
|
||||||
|
// body.email. Anonymous public submissions skip the confirmation, since
|
||||||
|
// a typed email could belong to anyone and confirming to it would be a
|
||||||
|
// spam vector.
|
||||||
|
// 2. Admin alert: one row per member who holds forms.manage in the space
|
||||||
|
// (the same gate forms-guard / forms RLS use). Dedupe by (submission,
|
||||||
|
// admin) so a replay is a no-op.
|
||||||
|
try {
|
||||||
|
const submissionId = submission.id as string
|
||||||
|
const spaceName = await getSpaceName(admin, form.space_id)
|
||||||
|
const formTitle = (form.title as string | null) ?? form.slug ?? ''
|
||||||
|
|
||||||
|
if (user?.email) {
|
||||||
|
const recipientEmail = user.email.toLowerCase()
|
||||||
|
const recipientName = linkedMemberId
|
||||||
|
? (await resolveMemberContact(admin, form.space_id, linkedMemberId))?.displayName ?? null
|
||||||
|
: null
|
||||||
|
const { subject, html, text } = renderFormEmail({
|
||||||
|
type: 'form_submission_received',
|
||||||
|
spaceName,
|
||||||
|
recipientName,
|
||||||
|
formTitle,
|
||||||
|
manageUrl: buildManageUrl(null),
|
||||||
|
})
|
||||||
|
await enqueueNotification(admin, {
|
||||||
|
spaceId: form.space_id,
|
||||||
|
memberId: linkedMemberId,
|
||||||
|
type: 'form_submission_received',
|
||||||
|
recipient: recipientEmail,
|
||||||
|
subject,
|
||||||
|
bodyHtml: html,
|
||||||
|
bodyText: text,
|
||||||
|
dedupeKey: formDedupeKey('form_submission_received', { submissionId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: admins } = await admin.rpc('members_with_permission', {
|
||||||
|
sid: form.space_id,
|
||||||
|
perm: 'forms.manage',
|
||||||
|
})
|
||||||
|
const adminMemberIds = Array.from(
|
||||||
|
new Set(((admins ?? []) as Array<{ member_id: string }>).map(a => a.member_id)),
|
||||||
|
)
|
||||||
|
if (adminMemberIds.length > 0) {
|
||||||
|
const resultsUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh'}/forms/${form.id}/results`
|
||||||
|
const submitterLabel = await deriveSubmitterLabel(admin, form.space_id, {
|
||||||
|
linkedMemberId,
|
||||||
|
submitterEmail,
|
||||||
|
userEmail: user?.email ?? null,
|
||||||
|
})
|
||||||
|
for (const adminMemberId of adminMemberIds) {
|
||||||
|
const contact = await resolveMemberContact(admin, form.space_id, adminMemberId)
|
||||||
|
if (!contact?.email) continue
|
||||||
|
const { subject, html, text } = renderFormEmail({
|
||||||
|
type: 'form_submission_admin',
|
||||||
|
spaceName,
|
||||||
|
recipientName: contact.displayName,
|
||||||
|
formTitle,
|
||||||
|
manageUrl: resultsUrl,
|
||||||
|
submitterLabel,
|
||||||
|
})
|
||||||
|
await enqueueNotification(admin, {
|
||||||
|
spaceId: form.space_id,
|
||||||
|
memberId: adminMemberId,
|
||||||
|
type: 'form_submission_admin',
|
||||||
|
recipient: contact.email,
|
||||||
|
subject,
|
||||||
|
bodyHtml: html,
|
||||||
|
bodyText: text,
|
||||||
|
dedupeKey: formDedupeKey('form_submission_admin', {
|
||||||
|
submissionId,
|
||||||
|
adminMemberId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[submitForm] notifications fan-out failed:', e instanceof Error ? e.message : e)
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true as const }
|
return { success: true as const }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: how to label the submitter in an admin alert. Prefer the linked
|
||||||
|
// member's display name (most informative); fall back to the typed/auth
|
||||||
|
// email; fall back to "someone" for fully anonymous submissions where the
|
||||||
|
// typed email is also missing. Best-effort: never throws.
|
||||||
|
async function deriveSubmitterLabel(
|
||||||
|
admin: ReturnType<typeof createAdminClient>,
|
||||||
|
spaceId: string,
|
||||||
|
ids: { linkedMemberId: string | null; submitterEmail: string | null; userEmail: string | null },
|
||||||
|
): Promise<string> {
|
||||||
|
if (ids.linkedMemberId) {
|
||||||
|
const contact = await resolveMemberContact(admin, spaceId, ids.linkedMemberId)
|
||||||
|
if (contact?.displayName) return contact.displayName
|
||||||
|
}
|
||||||
|
return ids.userEmail || ids.submitterEmail || 'someone'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public read for the /f/[slug] page. Service client (anon has no grant on
|
* Public read for the /f/[slug] page. Service client (anon has no grant on
|
||||||
* `forms`). Returns only a published form and only public-safe columns.
|
* `forms`). Returns only a published form and only public-safe columns.
|
||||||
|
|||||||
@@ -278,6 +278,65 @@ export function renderClassEmail(input: ClassEmailInput): RenderedEmail {
|
|||||||
return { subject, html, text }
|
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) ─────────────────────────────────────────
|
// ─── Booking (equipment reservation) ─────────────────────────────────────────
|
||||||
|
|
||||||
export type BookingNotificationType = 'booking_confirmed' | 'booking_cancelled'
|
export type BookingNotificationType = 'booking_confirmed' | 'booking_cancelled'
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 047: members_with_permission helper (Product spine Phase 4 notifications)
|
||||||
|
-- =============================================================================
|
||||||
|
-- The dues outbox is member-keyed: one recipient per row. The new form-
|
||||||
|
-- submission "admin alert" fans out: one outbox row per recipient member who
|
||||||
|
-- holds a given permission (e.g. forms.manage). Today the only available
|
||||||
|
-- function is user_has_permission(uid, sid, perm): a per-user check. Iterating
|
||||||
|
-- it across every member of a space would be N round-trips for every form
|
||||||
|
-- submit -- bad latency, bad scaling.
|
||||||
|
--
|
||||||
|
-- members_with_permission(sid, perm) is the inverted, set-returning version of
|
||||||
|
-- user_has_permission. Same membership-status gate (current/late only, mirrors
|
||||||
|
-- migration 046), same admin-shortcut, same space_role_permissions +
|
||||||
|
-- user_effective_roles fallback. Additive: it adds nothing to RLS surface,
|
||||||
|
-- introduces no new tables/policies, and existing callers continue to use
|
||||||
|
-- user_has_permission unchanged.
|
||||||
|
--
|
||||||
|
-- Idempotent: CREATE OR REPLACE. Apply as-is (Supabase SQL editor / psql);
|
||||||
|
-- re-runs are no-ops.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.members_with_permission(sid uuid, perm text)
|
||||||
|
RETURNS TABLE(member_id uuid) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT sm.id
|
||||||
|
FROM public.space_members sm
|
||||||
|
WHERE sm.space_id = sid
|
||||||
|
AND sm.status IN ('current','late') -- privilege-eligible only, mirrors 046
|
||||||
|
AND (
|
||||||
|
sm.role = 'admin' -- admins always pass (matches user_has_permission)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.space_role_permissions p
|
||||||
|
WHERE p.space_id = sid AND p.permission = perm
|
||||||
|
AND p.subject IN (SELECT public.user_effective_roles(sm.user_id, sid))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
@@ -1769,6 +1769,29 @@ AS $$
|
|||||||
);
|
);
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
-- Inverted, set-returning form of user_has_permission: which members in this
|
||||||
|
-- space hold the named permission? Same membership-status gate (current/late,
|
||||||
|
-- mirrors 046), same admin-shortcut, same space_role_permissions +
|
||||||
|
-- user_effective_roles fallback. Used by notification fan-outs (e.g. the
|
||||||
|
-- forms.manage admin alert) where N user_has_permission calls would be N
|
||||||
|
-- round-trips. Equivalent to scripts/047_members_with_permission.sql.
|
||||||
|
CREATE OR REPLACE FUNCTION public.members_with_permission(sid uuid, perm text)
|
||||||
|
RETURNS TABLE(member_id uuid) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT sm.id
|
||||||
|
FROM public.space_members sm
|
||||||
|
WHERE sm.space_id = sid
|
||||||
|
AND sm.status IN ('current','late')
|
||||||
|
AND (
|
||||||
|
sm.role = 'admin'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.space_role_permissions p
|
||||||
|
WHERE p.space_id = sid AND p.permission = perm
|
||||||
|
AND p.subject IN (SELECT public.user_effective_roles(sm.user_id, sid))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;
|
||||||
DROP POLICY IF EXISTS role_perms_select ON public.space_role_permissions;
|
DROP POLICY IF EXISTS role_perms_select ON public.space_role_permissions;
|
||||||
|
|||||||
@@ -1674,6 +1674,10 @@ export type Database = {
|
|||||||
Args: { uid: string; sid: string; perm: string }
|
Args: { uid: string; sid: string; perm: string }
|
||||||
Returns: boolean
|
Returns: boolean
|
||||||
}
|
}
|
||||||
|
members_with_permission: {
|
||||||
|
Args: { sid: string; perm: string }
|
||||||
|
Returns: { member_id: string }[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
area_lead_status: "active" | "vacant" | "handoff"
|
area_lead_status: "active" | "vacant" | "handoff"
|
||||||
|
|||||||
Reference in New Issue
Block a user