feat(forms): verified-email retro-link of anonymous submissions (Phase 5)

- 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.
This commit is contained in:
Moheeb Zara
2026-05-16 16:29:32 -07:00
parent da25950988
commit 40d4cdad98
6 changed files with 83 additions and 14 deletions
+1 -1
View File
@@ -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).
+8 -5
View File
@@ -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
+12 -5
View File
@@ -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.
---
+35
View File
@@ -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.
*
+12 -2
View File
@@ -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 }
}
+15 -1
View File
@@ -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