feat(forms): associate submissions with members by email match
submitForm now links member_id whenever submitter_email matches a space member (including raw anonymous public submissions) -- owner-chosen product decision, with the attribution tradeoff documented inline so a future audit does not revert it. New pure escapeLike (ILIKE-safe email match; '_' in addresses is literal) + pickMemberForEmail (deterministic earliest-joined on duplicates) + 5 tests (suite 464). Shared linkSubmissionsByEmail helper for reuse; updated the now-superseded verified-only comment.
This commit is contained in:
+45
-4
@@ -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<typeof createAdminClient>,
|
||||
spaceId: string,
|
||||
memberId: string,
|
||||
email: string | null | undefined,
|
||||
): Promise<number> {
|
||||
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()
|
||||
|
||||
@@ -91,3 +91,24 @@ export function deriveFieldKeys<T extends { label: string }>(
|
||||
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, `[email protected]` would also match `[email protected]`. 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user