diff --git a/__tests__/forms.test.ts b/__tests__/forms.test.ts index 61bea32..8488d7b 100644 --- a/__tests__/forms.test.ts +++ b/__tests__/forms.test.ts @@ -22,6 +22,8 @@ import { evaluateRequiredFormStep, slugify, deriveFieldKeys, + escapeLike, + pickMemberForEmail, } from '@/lib/forms-logic' import { INVITE_ROLES, isInviteRole, canAssignInviteRole } from '@/lib/invite-logic' import { createInviteSchema, updateInviteSchema } from '@/lib/validations' @@ -529,3 +531,33 @@ describe('slugify / deriveFieldKeys', () => { expect(out[0]).toMatchObject({ label: 'Q', type: 'short_text', required: true, key: 'q' }) }) }) + +describe('escapeLike', () => { + it('escapes ILIKE wildcards so an email matches exactly', () => { + expect(escapeLike('a_b@x.com')).toBe('a\\_b@x.com') + expect(escapeLike('100%@x.com')).toBe('100\\%@x.com') + expect(escapeLike('plain@x.com')).toBe('plain@x.com') + }) + it('escapes the backslash first (no double-unescape)', () => { + expect(escapeLike('a\\_b')).toBe('a\\\\\\_b') + }) +}) + +describe('pickMemberForEmail', () => { + it('returns null when no member matches', () => { + expect(pickMemberForEmail([])).toBeNull() + }) + it('picks the earliest-joined member deterministically', () => { + expect(pickMemberForEmail([ + { id: 'b', joined_at: '2026-02-01T00:00:00Z' }, + { id: 'a', joined_at: '2026-01-01T00:00:00Z' }, + { id: 'c', joined_at: '2026-03-01T00:00:00Z' }, + ])).toBe('a') + }) + it('treats a null joined_at as earliest (stable)', () => { + expect(pickMemberForEmail([ + { id: 'x', joined_at: '2026-01-01T00:00:00Z' }, + { id: 'y', joined_at: null }, + ])).toBe('y') + }) +}) diff --git a/lib/actions/forms.ts b/lib/actions/forms.ts index 95354a6..b47ac7f 100644 --- a/lib/actions/forms.ts +++ b/lib/actions/forms.ts @@ -26,8 +26,33 @@ import { csvCell, parseClientIp, shouldBumpFormVersion, + escapeLike, + pickMemberForEmail, } from '@/lib/forms-logic' +// Associate prior unlinked submissions in a space with a member by email +// (case-insensitive, ILIKE-escaped so `_`/`%` in an address are literal). +// Only fills NULL member_id (never re-points an existing link). Shared by +// member create/email-change and submit-time linking. The caller is trusted +// to have established the email belongs to the member; see the note on the +// anon submit path in submitForm. +export async function linkSubmissionsByEmail( + admin: ReturnType, + spaceId: string, + memberId: string, + email: string | null | undefined, +): Promise { + if (!email) return 0 + const { data } = await admin + .from('form_submissions') + .update({ member_id: memberId }) + .eq('space_id', spaceId) + .is('member_id', null) + .ilike('submitter_email', escapeLike(email.toLowerCase())) + .select('id') + return data?.length ?? 0 +} + const SUBMISSIONS_CAP = 5000 type Manager = { ok: true; supabase: ServerSupabase; member: Member } | { ok: false; error: string } @@ -349,6 +374,22 @@ export async function submitForm(input: unknown) { } // public_anon: no auth required; submitter_email is whatever was provided. + // Email-match association. PRODUCT DECISION (2026-05, owner-chosen): link a + // submission to a member whenever the submitter_email matches a member in + // the space, INCLUDING raw anonymous public submissions where the email was + // simply typed. Tradeoff understood and accepted: someone could get a + // submission attributed to another member by typing that member's email + // (attribution only -- it grants no access). Do NOT "harden" this back to + // verified-only without an explicit decision; it is intentional, not a bug. + if (!linkedMemberId && submitterEmail) { + const { data: matches } = await admin + .from('space_members') + .select('id, joined_at') + .eq('space_id', form.space_id) + .ilike('email', escapeLike(submitterEmail)) + linkedMemberId = pickMemberForEmail((matches ?? []) as Array<{ id: string; joined_at: string | null }>) + } + if (form.kind === 'waiver' && body.consent !== true) { return { error: 'You must agree to the waiver to continue' } } @@ -455,10 +496,10 @@ export async function claimMyAnonymousSubmissions() { /** * Retro-link prior anonymous submissions to a member by matching email. * - * Phase 2 exposes this as the forms.manage "admin manual-link" tool. The - * automatic path (link on email verification) is Phase 5 and must only run - * with a verified email; this function trusts its caller to have established - * that the email belongs to the member. + * The forms.manage "admin manual-link" tool. Automatic email-match linking + * also runs at submit time and on member create/email-change/backfill (see + * linkSubmissionsByEmail and the submitForm note) per the 2026-05 product + * decision; this manual tool remains for explicit re-linking. */ export async function linkSubmissionsForMember(input: unknown) { const gate = await requireFormsManager() diff --git a/lib/forms-logic.ts b/lib/forms-logic.ts index 812fa71..1d76044 100644 --- a/lib/forms-logic.ts +++ b/lib/forms-logic.ts @@ -91,3 +91,24 @@ export function deriveFieldKeys( return { ...f, key } }) } + +// Escape a literal so it can be used as a PostgREST/SQL ILIKE *value* and +// match exactly (case-insensitively) rather than as a pattern. Email local +// parts legitimately contain `_`, which is a single-char ILIKE wildcard -- +// without escaping, `a_b@x.com` would also match `aXb@x.com`. Order matters: +// escape the escape char first. +export function escapeLike(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') +} + +// When an email matches more than one member row in a space (e.g. a manually +// added duplicate), associate deterministically with the earliest-joined one +// so the result is stable and reproducible. +export function pickMemberForEmail( + rows: Array<{ id: string; joined_at: string | null }>, +): string | null { + if (rows.length === 0) return null + return [...rows].sort((a, b) => + (a.joined_at ?? '').localeCompare(b.joined_at ?? ''), + )[0].id +}