From a2ea7675e7c6da93c1d8001b1c8050f34d473282 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Tue, 19 May 2026 15:28:21 -0700 Subject: [PATCH] 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. --- __tests__/notifications-logic.test.ts | 74 +++++++++++++ lib/actions/forms.ts | 137 +++++++++++++++++++++--- lib/notifications-logic.ts | 59 ++++++++++ scripts/047_members_with_permission.sql | 37 +++++++ scripts/schema.sql | 23 ++++ types/database.ts | 4 + 6 files changed, 320 insertions(+), 14 deletions(-) create mode 100644 scripts/047_members_with_permission.sql diff --git a/__tests__/notifications-logic.test.ts b/__tests__/notifications-logic.test.ts index e3a7117..16d0228 100644 --- a/__tests__/notifications-logic.test.ts +++ b/__tests__/notifications-logic.test.ts @@ -10,6 +10,8 @@ import { renderBookingEmail, classDedupeKey, renderClassEmail, + formDedupeKey, + renderFormEmail, } from '@/lib/notifications-logic' describe('isTerminalAttempt', () => { @@ -309,3 +311,75 @@ describe('renderClassEmail', () => { 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: '