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:
Moheeb Zara
2026-05-16 23:44:03 -07:00
parent a364d152ca
commit ea521de342
3 changed files with 98 additions and 4 deletions
+32
View File
@@ -22,6 +22,8 @@ import {
evaluateRequiredFormStep, evaluateRequiredFormStep,
slugify, slugify,
deriveFieldKeys, deriveFieldKeys,
escapeLike,
pickMemberForEmail,
} 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'
@@ -529,3 +531,33 @@ describe('slugify / deriveFieldKeys', () => {
expect(out[0]).toMatchObject({ label: 'Q', type: 'short_text', required: true, key: 'q' }) 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('[email protected]')).toBe('a\\[email protected]')
expect(escapeLike('100%@x.com')).toBe('100\\%@x.com')
expect(escapeLike('[email protected]')).toBe('[email protected]')
})
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')
})
})
+45 -4
View File
@@ -26,8 +26,33 @@ import {
csvCell, csvCell,
parseClientIp, parseClientIp,
shouldBumpFormVersion, shouldBumpFormVersion,
escapeLike,
pickMemberForEmail,
} from '@/lib/forms-logic' } 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 const SUBMISSIONS_CAP = 5000
type Manager = { ok: true; supabase: ServerSupabase; member: Member } | { ok: false; error: string } 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. // 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) { if (form.kind === 'waiver' && body.consent !== true) {
return { error: 'You must agree to the waiver to continue' } 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. * Retro-link prior anonymous submissions to a member by matching email.
* *
* Phase 2 exposes this as the forms.manage "admin manual-link" tool. The * The forms.manage "admin manual-link" tool. Automatic email-match linking
* automatic path (link on email verification) is Phase 5 and must only run * also runs at submit time and on member create/email-change/backfill (see
* with a verified email; this function trusts its caller to have established * linkSubmissionsByEmail and the submitForm note) per the 2026-05 product
* that the email belongs to the member. * decision; this manual tool remains for explicit re-linking.
*/ */
export async function linkSubmissionsForMember(input: unknown) { export async function linkSubmissionsForMember(input: unknown) {
const gate = await requireFormsManager() const gate = await requireFormsManager()
+21
View File
@@ -91,3 +91,24 @@ export function deriveFieldKeys<T extends { label: string }>(
return { ...f, key } 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
}