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