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:
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user