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.
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
deriveFieldKeys,
|
deriveFieldKeys,
|
||||||
escapeLike,
|
escapeLike,
|
||||||
pickMemberForEmail,
|
pickMemberForEmail,
|
||||||
|
deriveSubmitterEmail,
|
||||||
} from '@/lib/forms-logic'
|
} from '@/lib/forms-logic'
|
||||||
import { INVITE_ROLES, isInviteRole, canAssignInviteRole } from '@/lib/invite-logic'
|
import { INVITE_ROLES, isInviteRole, canAssignInviteRole } from '@/lib/invite-logic'
|
||||||
import { createInviteSchema, updateInviteSchema } from '@/lib/validations'
|
import { createInviteSchema, updateInviteSchema } from '@/lib/validations'
|
||||||
@@ -561,3 +562,26 @@ describe('pickMemberForEmail', () => {
|
|||||||
])).toBe('y')
|
])).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: '[email protected]' }, '[email protected]')).toBe('[email protected]')
|
||||||
|
})
|
||||||
|
it('falls back to an email-type field answer (the bug fix)', () => {
|
||||||
|
expect(deriveSubmitterEmail(fields, { name: 'Jo', contact: ' [email protected] ' }, null)).toBe('[email protected]')
|
||||||
|
})
|
||||||
|
it('falls back to any answer that is a valid email when no email field', () => {
|
||||||
|
expect(deriveSubmitterEmail([{ key: 'q1', type: 'short_text' }], { q1: '[email protected]' })).toBe('[email protected]')
|
||||||
|
})
|
||||||
|
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: '[email protected]' }, 'garbage')).toBe('[email protected]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+53
-10
@@ -31,6 +31,7 @@ import {
|
|||||||
shouldBumpFormVersion,
|
shouldBumpFormVersion,
|
||||||
escapeLike,
|
escapeLike,
|
||||||
pickMemberForEmail,
|
pickMemberForEmail,
|
||||||
|
deriveSubmitterEmail,
|
||||||
} from '@/lib/forms-logic'
|
} from '@/lib/forms-logic'
|
||||||
|
|
||||||
// Associate prior unlinked submissions in a space with a member by email
|
// Associate prior unlinked submissions in a space with a member by email
|
||||||
@@ -270,6 +271,8 @@ export async function relinkAllSubmissions() {
|
|||||||
const { member } = gate
|
const { member } = gate
|
||||||
|
|
||||||
const admin = createAdminClient()
|
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
|
const { data: members, error } = await admin
|
||||||
.from('space_members')
|
.from('space_members')
|
||||||
.select('id, email, joined_at')
|
.select('id, email, joined_at')
|
||||||
@@ -277,10 +280,40 @@ export async function relinkAllSubmissions() {
|
|||||||
.not('email', 'is', null)
|
.not('email', 'is', null)
|
||||||
.order('joined_at', { ascending: true })
|
.order('joined_at', { ascending: true })
|
||||||
if (error) return { error: error.message }
|
if (error) return { error: error.message }
|
||||||
|
const byEmail = new Map<string, string>()
|
||||||
|
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
|
let linked = 0
|
||||||
for (const m of members ?? []) {
|
for (const s of subs ?? []) {
|
||||||
linked += await linkSubmissionsByEmail(admin, member.space_id, m.id as string, m.email as string)
|
const email = deriveSubmitterEmail(
|
||||||
|
parseFormSchema(s.form_snapshot) as Array<{ key: string; type: string }>,
|
||||||
|
(s.answers ?? {}) as Record<string, unknown>,
|
||||||
|
s.submitter_email as string | null,
|
||||||
|
)
|
||||||
|
if (!email) continue
|
||||||
|
const memberId = byEmail.get(email)
|
||||||
|
if (!memberId) continue
|
||||||
|
const patch: Record<string, unknown> = { 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')
|
revalidatePath('/forms')
|
||||||
@@ -436,6 +469,24 @@ export async function submitForm(input: unknown) {
|
|||||||
}
|
}
|
||||||
// public_anon: no auth required; submitter_email is whatever was provided.
|
// 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
|
// Email-match association. PRODUCT DECISION (2026-05, owner-chosen): link a
|
||||||
// submission to a member whenever the submitter_email matches a member in
|
// submission to a member whenever the submitter_email matches a member in
|
||||||
// the space, INCLUDING raw anonymous public submissions where the email was
|
// 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 }>)
|
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 h = await headers()
|
||||||
const { error: insErr } = await admin.from('form_submissions').insert({
|
const { error: insErr } = await admin.from('form_submissions').insert({
|
||||||
form_id: form.id,
|
form_id: form.id,
|
||||||
|
|||||||
@@ -101,6 +101,36 @@ export function escapeLike(s: string): string {
|
|||||||
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
|
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<string, unknown> | 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
|
// 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
|
// added duplicate), associate deterministically with the earliest-joined one
|
||||||
// so the result is stable and reproducible.
|
// so the result is stable and reproducible.
|
||||||
|
|||||||
Reference in New Issue
Block a user