From 40d4cdad9835cad75d4c292aadd86f5794913900 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 16:29:32 -0700 Subject: [PATCH] feat(forms): verified-email retro-link of anonymous submissions (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claimMyAnonymousSubmissions(): exported but safe — no trusted params, resolves member + email from the caller's session, gated on email_confirmed_at, idempotent. Links the caller's own prior anonymous submissions to their member row - hooked best-effort from joinSpace (member insert now returns id; links prior anon submissions for the verified email) and finishOnboarding - addMember/importMembers deliberately NOT auto-linked: those emails are admin-asserted, not verified; the locked decision forbids auto-link there, so the forms.manage linkSubmissionsForMember stays the admin manual-link path - also repairs a build break committed in 295d220: finishOnboarding declared `const admin` twice (Phase 4 added a second). The probe and the completion update now share one admin client - docs: API_REFERENCE / ARCHITECTURE / HANDOFF 287/287 tests, build compiles clean (verified Compiled successfully). No deploy. --- docs/API_REFERENCE.md | 2 +- docs/ARCHITECTURE.md | 13 ++++++++----- docs/HANDOFF.md | 17 ++++++++++++----- lib/actions/forms.ts | 35 +++++++++++++++++++++++++++++++++++ lib/actions/onboarding.ts | 14 ++++++++++++-- lib/auth-actions.ts | 16 +++++++++++++++- 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 994c159..31d4a81 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -896,4 +896,4 @@ Management actions are gated by `forms.manage` (checked via the `user_has_permis Onboarding integration (Phase 4, migration 027): a `space_onboarding_steps` step can have `step_type = 'form'` with `config.form_id`. The onboarding flow renders the form/waiver and submits via `submitForm`; `finishOnboarding` treats a required form step as satisfied if a submission for that form by the member already exists (any version — re-sign is non-blocking), and fails open if the configured form is missing/unpublished so a misconfiguration cannot trap members. -Not implemented yet (planned): automatic retro-link on email verification + join/signup/addMember/import hooks (Phase 5). The `linkSubmissionsForMember` primitive exists. +Retro-link (Phase 5): `claimMyAnonymousSubmissions()` is a safe, self-only, verified-email-gated action — it resolves the member/email from the caller's session (no trusted params), so a client can only ever link its own anonymous submissions, and only with a confirmed email (the locked decision). It is invoked best-effort from `joinSpace` (new membership, verified email) and `finishOnboarding`. `addMember`/`importMembers` are deliberately NOT auto-linked: those emails are admin-asserted, not verified, so auto-linking would violate the verified-email rule and risk mis-attributing a waiver; the admin uses the forms.manage `linkSubmissionsForMember` for those (the "admin manual-link" path). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0100b58..c20bc6f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -422,11 +422,14 @@ forms + waivers). ### Forms & waivers (migration 026 — Phase 1: schema + RLS) -`forms` and `form_submissions` back a custom form/waiver builder. Phases 1-4 -are done (schema + RLS; server actions in `lib/actions/forms.ts`; the builder -UI under `/forms` + the public `/f/[slug]` page; onboarding `form` step type -with required-waiver enforcement + auto-satisfy, migration 027). Automatic -retro-linking on email verification is Phase 5. Security shape: +`forms` and `form_submissions` back a custom form/waiver builder. The feature +is complete (Phases 1-5): schema + RLS; server actions in +`lib/actions/forms.ts`; the builder UI under `/forms` + the public `/f/[slug]` +page; onboarding `form` step type with required-waiver enforcement + +auto-satisfy (migration 027); and verified-email retro-link of anonymous +submissions (`claimMyAnonymousSubmissions`, hooked from `joinSpace` and +`finishOnboarding`; admin manual-link for unverified/admin-added members). +Security shape: all submissions (anonymous, public-authenticated, or members) are written by a single validated server action using the service client AFTER server-side schema validation and snapshotting (same pattern as `finishOnboarding`); the diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index e7f96d9..82c0429 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,7 +4,7 @@ Append-only. Newest entries on top. Keep each entry to one screen. --- -## 2026-05-16 (pass 18) — Forms + waivers Phases 1-4 (schema/RLS + actions + builder UI + onboarding step), LOCAL, awaiting deploy approval +## 2026-05-16 (pass 18) — Forms + waivers Phases 1-5 COMPLETE (schema/RLS + actions + builder UI + onboarding step + retro-link), LOCAL, awaiting deploy approval Branch: `main`. Migration: `026` (not yet deployed). `pnpm build` + `pnpm exec vitest run` gate pending in this entry's session. @@ -56,12 +56,19 @@ Audited the migration before continuing. Three fixes applied: dropped redundant - Tests: `__tests__/forms.test.ts` +2 (onboarding form-step contract). Gate: `pnpm exec vitest run` 287/287; `pnpm build` exit 0; onboarding + customize compiled. NOT unit-tested: `finishOnboarding` enforcement/auto-satisfy is DB-bound (no mocked test); covered by reasoning + manual. - NOT browser-verified: admin adds a form step + picks a form; member onboarding renders/sumbits the form/waiver; required-form blocks finish until submitted; auto-satisfy when a prior submission exists; fail-open on unpublished form. +### Phase 4 self-audit + Phase 5 (this session) — LOCAL + +- Phase 4 audit: verified migration `022` uses an inline unnamed column CHECK, so the auto name `space_onboarding_steps_step_type_check` is correct and `027`'s DROP targets it (no fix needed). Fixed a real bug: `onboarding-flow` could double-insert a form/waiver submission on Back-then-Forward — added session-local `submittedNow` tracking so a step is not re-submitted (commit is the small `harden(forms)` one). +- Phase 5 (retro-link): `claimMyAnonymousSubmissions()` in `forms.ts` — exported but safe (no trusted params; resolves member + email from the caller's session; gated on `email_confirmed_at`; idempotent). Hooked best-effort from `joinSpace` (member insert now returns id; links prior anon submissions for the verified email) and `finishOnboarding`. `addMember`/`importMembers` are deliberately NOT auto-linked (admin-asserted emails are unverified — the locked decision forbids auto-link there); the forms.manage `linkSubmissionsForMember` remains the admin manual-link. **Interpretation flagged for the user:** the brief listed addMember/import as hook points, but the locked verified-email rule wins; auto-linking unverified emails would risk mis-attributing a waiver. +- Gate: `pnpm exec vitest run` 287/287; `pnpm build` exit 0. Retro-link logic is DB-bound — not unit-tested (no mocked test); covered by reasoning + manual. + ### Open / next -- **Awaiting user approval to deploy** (push to `main` = prod). Phases 1-4 are all LOCAL; do NOT push without explicit go. Migrations `026` + `027` ship with the first push. -- Phase 5 not started: automatic retro-link on email verification + join/signup/addMember/import hooks — `linkSubmissionsForMember` primitive already exists (currently exposed as the forms.manage manual-link). -- Phases 3-4 are code-complete but NOT browser-verified (see the smoke-test lists in each subsection). No live browser this session. -- After an approved push: watch the Actions run; smoke-test that `026`+`027` applied (`_migrations_applied`), a space's board has `forms.manage`, and the builder/public page/onboarding form step work end to end. +- **Forms + waivers feature is code-complete (Phases 1-5).** All work is LOCAL; do NOT push without explicit go. Migrations `026` + `027` ship with the first push. +- NOT browser-verified (no live browser this session): the full builder/public-page/onboarding-form-step/retro-link paths. Smoke-test lists are in each phase subsection above. +- Retro-link not browser-verified: sign an anon public waiver, then join the space (or finish onboarding) with that verified email → the submission should attribute to the member; addMember with the same email should NOT auto-link (admin uses linkSubmissionsForMember). +- After an approved push: watch the Actions run; confirm `026`+`027` applied (`_migrations_applied`), a space's board has `forms.manage`, and exercise builder → publish → public submit → onboarding form step → retro-link end to end. +- Unrelated, still open: the production incident-filing RLS bug — not retried since `025`; do not touch the permissions/RLS surface without a prod retry + diagnosis. --- diff --git a/lib/actions/forms.ts b/lib/actions/forms.ts index d87e8af..bef1179 100644 --- a/lib/actions/forms.ts +++ b/lib/actions/forms.ts @@ -417,6 +417,41 @@ export async function getPublicForm(input: unknown) { return { data: form } } +/** + * Self-service retro-link (Phase 5). Links the caller's prior ANONYMOUS + * submissions to their own member row, but only when their email is verified + * (the locked decision: retro-link to an account only on a verified email). + * + * Safe to expose: it takes no trusted parameters. It resolves the member and + * email strictly from the caller's authenticated session, so a client can + * only ever link submissions to their own account, and only their own + * verified email. Idempotent (already-linked rows have a non-null member_id + * and are excluded). This is the concrete "email-verification" hook; it is + * also invoked best-effort from joinSpace and finishOnboarding. + */ +export async function claimMyAnonymousSubmissions() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return { error: 'Not authenticated' } + if (!user.email || !user.email_confirmed_at) return { data: { linked: 0 } } + + const member = await getAuthMember(supabase) + if (!member) return { data: { linked: 0 } } + + const admin = createAdminClient() + const { data: linked } = await admin + .from('form_submissions') + .update({ member_id: member.id }) + .eq('space_id', member.space_id) + .is('member_id', null) + .eq('submitter_email', user.email.toLowerCase()) + .select('id') + + return { data: { linked: linked?.length ?? 0 } } +} + /** * Retro-link prior anonymous submissions to a member by matching email. * diff --git a/lib/actions/onboarding.ts b/lib/actions/onboarding.ts index c61a935..2539492 100644 --- a/lib/actions/onboarding.ts +++ b/lib/actions/onboarding.ts @@ -9,6 +9,7 @@ import { updateOnboardingStepSchema, uuidSchema, } from '@/lib/validations' +import { claimMyAnonymousSubmissions } from './forms' const ONBOARDING_ADMIN_ROLES = ['admin', 'board'] as const @@ -207,8 +208,7 @@ export async function finishOnboarding() { // onboarding_completed_at is blocked by the self-change trigger (migration // 024) so a member cannot skip required steps via a raw PostgREST call. // Set it via the service client AFTER the required-steps check above, scoped - // to this member's own id. - const admin = createAdminClient() + // to this member's own id. Reuses the `admin` client created above. const { error } = await admin .from('space_members') .update({ onboarding_completed_at: new Date().toISOString() }) @@ -216,6 +216,16 @@ export async function finishOnboarding() { .eq('space_id', member.space_id) if (error) return { error: error.message } + + // Best-effort: pull in any prior anonymous submissions for this now-verified + // member (e.g., a waiver signed before they had an account). Never blocks + // finishing. + try { + await claimMyAnonymousSubmissions() + } catch { + /* advisory */ + } + revalidatePath('/dashboard') return { success: true as const } } diff --git a/lib/auth-actions.ts b/lib/auth-actions.ts index b4e42e1..77d928d 100644 --- a/lib/auth-actions.ts +++ b/lib/auth-actions.ts @@ -149,7 +149,7 @@ export async function joinSpace(formData: { if (lookupErr || !space) return { error: 'Invalid invite code' } - const { error: memberErr } = await admin + const { data: newMember, error: memberErr } = await admin .from('space_members') .insert({ space_id: space.id, @@ -161,9 +161,23 @@ export async function joinSpace(formData: { status: space.require_approval ? 'unverified' : 'current', approved: true, }) + .select('id') + .single() if (memberErr) return { error: memberErr.message } + // Retro-link prior anonymous form/waiver submissions in this space to the + // new member, but only for a verified email (locked decision). Best-effort: + // a linking hiccup must never fail the join. + if (newMember && user.email && user.email_confirmed_at) { + await admin + .from('form_submissions') + .update({ member_id: newMember.id }) + .eq('space_id', space.id) + .is('member_id', null) + .eq('submitter_email', user.email.toLowerCase()) + } + // Increment invite usage counter for the multi-code path. if (invite) { await admin