From 9f0ee189a653ff7fea5f633f12297849df4c8895 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sun, 17 May 2026 00:31:33 -0700 Subject: [PATCH] fix(forms): link by an email collected as a form field, not only submitter_email Root cause: association only ever looked at the dedicated submitter_email (public 'your email' box / auth email). A form that asks for email as an ordinary field stored it in answers, so submitter_email stayed null and nothing matched -- 'Re-link' reported 0 ('already linked') and the submission never appeared under the member. New pure deriveSubmitterEmail (explicit > email-type field answer > any email-looking answer; trimmed lowercase) + 5 tests. submitForm now derives + persists submitter_email from answers; relinkAllSubmissions re-derives per submission from its form_snapshot and backfills member_id + submitter_email for existing rows. Suite 469. --- __tests__/forms.test.ts | 24 ++++++++++++++++ lib/actions/forms.ts | 63 ++++++++++++++++++++++++++++++++++------- lib/forms-logic.ts | 30 ++++++++++++++++++++ 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/__tests__/forms.test.ts b/__tests__/forms.test.ts index 8488d7b..686b5f7 100644 --- a/__tests__/forms.test.ts +++ b/__tests__/forms.test.ts @@ -24,6 +24,7 @@ import { deriveFieldKeys, escapeLike, pickMemberForEmail, + deriveSubmitterEmail, } from '@/lib/forms-logic' import { INVITE_ROLES, isInviteRole, canAssignInviteRole } from '@/lib/invite-logic' import { createInviteSchema, updateInviteSchema } from '@/lib/validations' @@ -561,3 +562,26 @@ describe('pickMemberForEmail', () => { ])).toBe('y') }) }) + +describe('deriveSubmitterEmail', () => { + const fields = [ + { key: 'name', type: 'short_text' }, + { key: 'contact', type: 'email' }, + ] + it('prefers the explicit submitter email', () => { + expect(deriveSubmitterEmail(fields, { contact: 'a@b.com' }, 'EXPLICIT@X.com')).toBe('explicit@x.com') + }) + it('falls back to an email-type field answer (the bug fix)', () => { + expect(deriveSubmitterEmail(fields, { name: 'Jo', contact: ' Jo@Foo.COM ' }, null)).toBe('jo@foo.com') + }) + it('falls back to any answer that is a valid email when no email field', () => { + expect(deriveSubmitterEmail([{ key: 'q1', type: 'short_text' }], { q1: 'who@there.io' })).toBe('who@there.io') + }) + it('returns null when nothing looks like an email', () => { + expect(deriveSubmitterEmail(fields, { name: 'Jo', contact: 'not-an-email' }, '')).toBeNull() + expect(deriveSubmitterEmail([], {}, null)).toBeNull() + }) + it('ignores a non-email explicit value and still finds the field', () => { + expect(deriveSubmitterEmail(fields, { contact: 'real@x.com' }, 'garbage')).toBe('real@x.com') + }) +}) diff --git a/lib/actions/forms.ts b/lib/actions/forms.ts index fbde2d5..d1652fe 100644 --- a/lib/actions/forms.ts +++ b/lib/actions/forms.ts @@ -31,6 +31,7 @@ import { shouldBumpFormVersion, escapeLike, pickMemberForEmail, + deriveSubmitterEmail, } from '@/lib/forms-logic' // Associate prior unlinked submissions in a space with a member by email @@ -270,6 +271,8 @@ export async function relinkAllSubmissions() { const { member } = gate const admin = createAdminClient() + // Email -> earliest-joined member id (first occurrence wins; members are + // ordered by joined_at so this matches pickMemberForEmail's determinism). const { data: members, error } = await admin .from('space_members') .select('id, email, joined_at') @@ -277,10 +280,40 @@ export async function relinkAllSubmissions() { .not('email', 'is', null) .order('joined_at', { ascending: true }) if (error) return { error: error.message } + const byEmail = new Map() + for (const m of members ?? []) { + const e = (m.email as string | null)?.trim().toLowerCase() + if (e && !byEmail.has(e)) byEmail.set(e, m.id as string) + } + + // Every unlinked submission: match on submitter_email OR an email found in + // the answers (forms that collect email as an ordinary field). Backfill + // submitter_email when we derived it so future paths stay consistent. + const { data: subs, error: subErr } = await admin + .from('form_submissions') + .select('id, submitter_email, answers, form_snapshot') + .eq('space_id', member.space_id) + .is('member_id', null) + if (subErr) return { error: subErr.message } let linked = 0 - for (const m of members ?? []) { - linked += await linkSubmissionsByEmail(admin, member.space_id, m.id as string, m.email as string) + for (const s of subs ?? []) { + const email = deriveSubmitterEmail( + parseFormSchema(s.form_snapshot) as Array<{ key: string; type: string }>, + (s.answers ?? {}) as Record, + s.submitter_email as string | null, + ) + if (!email) continue + const memberId = byEmail.get(email) + if (!memberId) continue + const patch: Record = { member_id: memberId } + if (!s.submitter_email) patch.submitter_email = email + const { error: upErr } = await admin + .from('form_submissions') + .update(patch) + .eq('id', s.id as string) + .eq('space_id', member.space_id) + if (!upErr) linked++ } revalidatePath('/forms') @@ -436,6 +469,24 @@ export async function submitForm(input: unknown) { } // public_anon: no auth required; submitter_email is whatever was provided. + if (form.kind === 'waiver' && body.consent !== true) { + return { error: 'You must agree to the waiver to continue' } + } + + const fields = parseFormSchema(form.schema) + const answers = validateAnswers(fields, body.answers) + if (!answers.ok) return { error: answers.error } + + // If no dedicated submitter email, derive one from an email-type answer so + // a form that collects email as an ordinary field still links + persists + // it (otherwise submitter_email stays null and nothing ever matches). + if (!submitterEmail) { + submitterEmail = deriveSubmitterEmail( + fields as Array<{ key: string; type: string }>, + answers.value, + ) + } + // 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 @@ -452,14 +503,6 @@ export async function submitForm(input: unknown) { 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' } - } - - const fields = parseFormSchema(form.schema) - const answers = validateAnswers(fields, body.answers) - if (!answers.ok) return { error: answers.error } - const h = await headers() const { error: insErr } = await admin.from('form_submissions').insert({ form_id: form.id, diff --git a/lib/forms-logic.ts b/lib/forms-logic.ts index 1d76044..15ee5a7 100644 --- a/lib/forms-logic.ts +++ b/lib/forms-logic.ts @@ -101,6 +101,36 @@ export function escapeLike(s: string): string { return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') } +const FORMS_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +// The email used to associate a submission with a member. The dedicated +// `submitter_email` (the public "your email" box, or the signed-in user's +// address) wins; otherwise fall back to the answer of an email-type field, +// then any answer that is itself a valid email. Without this, a form that +// collects email as an ordinary field never populates submitter_email and +// so never links. Normalized to trimmed lowercase. +export function deriveSubmitterEmail( + fields: Array<{ key: string; type: string }>, + answers: Record | null | undefined, + explicit?: string | null, +): string | null { + const norm = (v: unknown) => (typeof v === 'string' ? v.trim().toLowerCase() : '') + const e = norm(explicit) + if (e && FORMS_EMAIL_RE.test(e)) return e + const a = answers ?? {} + for (const f of fields) { + if (f.type === 'email') { + const v = norm(a[f.key]) + if (FORMS_EMAIL_RE.test(v)) return v + } + } + for (const k of Object.keys(a)) { + const v = norm(a[k]) + if (FORMS_EMAIL_RE.test(v)) return v + } + return null +} + // 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.