feat(forms): onboarding form/waiver step with required enforcement (Phase 4)

- scripts/027 (idempotent CHECK replace) + schema.sql Section 14: allow
  space_onboarding_steps.step_type = 'form'; referenced form id lives in
  the step's existing config.form_id (no new column)
- finishOnboarding: a required form step is satisfied when a submission
  for config.form_id by the member exists (any version — re-sign is
  non-blocking / auto-satisfy); fails open if the form is missing or
  unpublished so a misconfig cannot trap a member. Probe uses the
  service client (submissions are forms.manage-only under RLS)
- onboarding page enriches form steps with the published form def +
  prior-submission flag; onboarding-flow renders FormRenderer + waiver
  legal text/consent and submits via submitForm then marks the step done
- customize: forms fetched and threaded to the onboarding panel; new
  "+ Form step" button + form picker writing config.form_id
- validations/types/casts include 'form'; forms.test.ts +2
- docs: DATABASE_SCHEMA/DB_SCHEMA_MAP/API_REFERENCE/ARCHITECTURE/HANDOFF

287/287 tests, build exit 0. No deploy.
This commit is contained in:
Moheeb Zara
2026-05-16 15:15:17 -07:00
parent 01b924b2f5
commit 295d220849
16 changed files with 355 additions and 47 deletions
+35 -5
View File
@@ -15,7 +15,7 @@ const ONBOARDING_ADMIN_ROLES = ['admin', 'board'] as const
// --- Admin: manage steps -----------------------------------------------------
export async function createOnboardingStep(input: {
step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form'
title: string
body?: string | null
config?: Record<string, unknown>
@@ -157,7 +157,7 @@ export async function finishOnboarding() {
const { data: steps } = await supabase
.from('space_onboarding_steps')
.select('id, is_required, is_enabled')
.select('id, is_required, is_enabled, step_type, config')
.eq('space_id', member.space_id)
.eq('is_enabled', true)
@@ -169,9 +169,39 @@ export async function finishOnboarding() {
const progress = (row?.onboarding_progress ?? {}) as { completed_step_ids?: string[] }
const done = new Set(progress.completed_step_ids ?? [])
const requiredMissing = (steps ?? []).filter(s => s.is_required && !done.has(s.id))
if (requiredMissing.length > 0) {
return { error: 'Please complete the required steps before finishing.' }
const candidates = (steps ?? []).filter(s => s.is_required && !done.has(s.id))
// A required 'form' step is also satisfied if a submission for the
// configured form already exists for this member (any version — re-sign is
// non-blocking). If the form is missing or not published, the step is
// treated as non-blocking: a misconfiguration must not trap members out of
// their own space. Submissions are forms.manage-only under RLS, so the
// existence probe uses the service client.
const admin = createAdminClient()
for (const s of candidates) {
if (s.step_type !== 'form') {
return { error: 'Please complete the required steps before finishing.' }
}
const formId = (s.config as { form_id?: string } | null)?.form_id
if (!formId) continue
const { data: form } = await supabase
.from('forms')
.select('id, status')
.eq('id', formId)
.eq('space_id', member.space_id)
.maybeSingle()
if (!form || form.status !== 'published') continue
const { count } = await admin
.from('form_submissions')
.select('id', { count: 'exact', head: true })
.eq('form_id', formId)
.eq('space_id', member.space_id)
.eq('member_id', member.id)
if ((count ?? 0) === 0) {
return { error: 'Please complete the required form before finishing.' }
}
}
// onboarding_completed_at is blocked by the self-change trigger (migration