From 295d220849f4d9ef1e74004bb894ef2a055e4bb5 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 15:15:17 -0700 Subject: [PATCH] feat(forms): onboarding form/waiver step with required enforcement (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- DB_SCHEMA_MAP.md | 1 + __tests__/forms.test.ts | 19 +++ app/(app)/customize/customize-client.tsx | 5 +- app/(app)/customize/page.tsx | 5 +- .../customize/panels/onboarding-panel.tsx | 80 ++++++++++--- app/(app)/customize/panels/types.ts | 9 +- app/onboarding/onboarding-flow.tsx | 109 +++++++++++++++++- app/onboarding/page.tsx | 78 +++++++++++-- docs/API_REFERENCE.md | 4 +- docs/ARCHITECTURE.md | 7 +- docs/DATABASE_SCHEMA.md | 3 +- docs/HANDOFF.md | 18 ++- lib/actions/onboarding.ts | 40 ++++++- lib/validations.ts | 2 +- scripts/027_onboarding_form_step.sql | 20 ++++ scripts/schema.sql | 2 +- 16 files changed, 355 insertions(+), 47 deletions(-) create mode 100644 scripts/027_onboarding_form_step.sql diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index d24eb8c..6a6f79f 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -392,6 +392,7 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 013 | Added `space_members.last_paid_at` timestamptz — required by importMembers and linkPaymentToMember | | 014-025 | See `docs/DATABASE_SCHEMA.md` migrations table (governance kernel, areas, forum/tiers/roles/invites, onboarding, permissions/ACLs, self-change hardening, incidents_insert re-assert) | | 026 | `forms`, `form_submissions` + `forms.manage` permission (additive, default-deny RLS; submissions immutable + service-client-only) | +| 027 | `space_onboarding_steps.step_type` CHECK adds `'form'` (onboarding can embed a form/waiver; form id in step `config.form_id`) | --- diff --git a/__tests__/forms.test.ts b/__tests__/forms.test.ts index c860ad9..20d94ed 100644 --- a/__tests__/forms.test.ts +++ b/__tests__/forms.test.ts @@ -5,6 +5,8 @@ import { submitFormSchema, formSchemaArray, formFieldSchema, + onboardingStepTypeSchema, + createOnboardingStepSchema, } from '@/lib/validations' import { parseFormSchema, validateAnswers, type FormField } from '@/lib/forms-schema' @@ -110,6 +112,23 @@ describe('validateAnswers', () => { }) }) +describe('onboarding form step (Phase 4)', () => { + it('onboardingStepTypeSchema accepts "form" and rejects unknown', () => { + expect(onboardingStepTypeSchema.safeParse('form').success).toBe(true) + expect(onboardingStepTypeSchema.safeParse('content').success).toBe(true) + expect(onboardingStepTypeSchema.safeParse('quiz').success).toBe(false) + }) + + it('createOnboardingStepSchema accepts a form step with a form_id config', () => { + const r = createOnboardingStepSchema.safeParse({ + step_type: 'form', + title: 'Sign the liability waiver', + config: { form_id: '11111111-1111-1111-1111-111111111111' }, + }) + expect(r.success).toBe(true) + }) +}) + describe('parseFormSchema', () => { it('returns [] for malformed stored schema', () => { expect(parseFormSchema('not an array')).toEqual([]) diff --git a/app/(app)/customize/customize-client.tsx b/app/(app)/customize/customize-client.tsx index cbdcb5f..0bed4e5 100644 --- a/app/(app)/customize/customize-client.tsx +++ b/app/(app)/customize/customize-client.tsx @@ -8,7 +8,7 @@ import { AreasPanel } from './panels/areas-panel' import { AreaLeadsPanel } from './panels/area-leads-panel' import { InvitesPanel } from './panels/invites-panel' import { OnboardingPanel } from './panels/onboarding-panel' -import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area } from './panels/types' +import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area, FormOption } from './panels/types' type Section = 'roles' | 'permissions' | 'tiers' | 'areas' | 'area-leads' | 'invites' | 'onboarding' @@ -38,6 +38,7 @@ interface Props { tiers: Tier[] invites: Invite[] onboardingSteps: Step[] + forms: FormOption[] areas: Area[] rolePerms: Array<{ subject: string; permission: string }> areaLeads: AreaLead[] @@ -78,7 +79,7 @@ export function CustomizeClient(props: Props) { {section === 'areas' && } {section === 'area-leads' && } {section === 'invites' && } - {section === 'onboarding' && } + {section === 'onboarding' && } diff --git a/app/(app)/customize/page.tsx b/app/(app)/customize/page.tsx index 6286611..d7fb234 100644 --- a/app/(app)/customize/page.tsx +++ b/app/(app)/customize/page.tsx @@ -27,6 +27,7 @@ export default async function CustomizePage() { { data: tiers }, { data: invites }, { data: onboardingSteps }, + { data: forms }, { data: areas }, { data: rolePerms }, { data: areaLeads }, @@ -37,6 +38,7 @@ export default async function CustomizePage() { supabase.from('space_tiers').select('id, slug, name, description, monthly_price_cents, billing_cadence, is_system, is_archived, sort_order').eq('space_id', member.space_id).order('sort_order'), supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }), supabase.from('space_onboarding_steps').select('id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order').eq('space_id', member.space_id).order('sort_order'), + supabase.from('forms').select('id, title, kind, status').eq('space_id', member.space_id).order('created_at', { ascending: false }), supabase.from('space_areas').select('id, code, name, icon, sort_order, is_archived').eq('space_id', member.space_id).order('sort_order').order('name'), supabase.from('space_role_permissions').select('subject, permission').eq('space_id', member.space_id), supabase.from('area_leads').select('id, area_code, area_name, lead_id, lead_handle, status').eq('space_id', member.space_id).order('area_name'), @@ -50,7 +52,8 @@ export default async function CustomizePage() { customRoles={(customRoles ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; color: string | null; sort_order: number }>} tiers={(tiers ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; monthly_price_cents: number; billing_cadence: string; is_system: boolean; is_archived: boolean; sort_order: number }>} invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_at: string }>} - onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'; title: string; body: string | null; config: Record; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>} + onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form'; title: string; body: string | null; config: Record; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>} + forms={(forms ?? []) as Array<{ id: string; title: string; kind: string; status: string }>} areas={(areas ?? []) as Array<{ id: string; code: string; name: string; icon: string | null; sort_order: number; is_archived: boolean }>} rolePerms={(rolePerms ?? []) as Array<{ subject: string; permission: string }>} areaLeads={(areaLeads ?? []) as Array<{ id: string; area_code: string | null; area_name: string; lead_id: string | null; lead_handle: string | null; status: string }>} diff --git a/app/(app)/customize/panels/onboarding-panel.tsx b/app/(app)/customize/panels/onboarding-panel.tsx index d498243..27c99f7 100644 --- a/app/(app)/customize/panels/onboarding-panel.tsx +++ b/app/(app)/customize/panels/onboarding-panel.tsx @@ -7,29 +7,52 @@ import { createOnboardingStep, updateOnboardingStep, deleteOnboardingStep } from import { Card } from './card' import { useConfirm } from '@/components/ui/confirm' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' -import type { Step } from './types' +import type { Step, FormOption } from './types' -export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean; steps: Step[] }) { +export function OnboardingPanel({ + isAdmin, + steps: initial, + forms, +}: { + isAdmin: boolean + steps: Step[] + forms: FormOption[] +}) { const confirm = useConfirm() const [steps, setSteps] = useState(initial) + const publishedForms = forms.filter(f => f.status === 'published') return ( { - const result = await createOnboardingStep({ step_type: 'content', title: 'New step', body: 'Edit this content in Customize, Onboarding.', sort_order: steps.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 }) - if ('error' in result && result.error) { toast.error(result.error); return } - toast.success('Step added') - const id = (result as { id: string }).id - setSteps(prev => [...prev, { id, step_key: `custom-${id.slice(0, 12)}`, step_type: 'content', title: 'New step', body: 'Edit this content in Customize, Onboarding.', config: {}, is_enabled: true, is_required: false, is_system: false, sort_order: prev.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 }]) - }} - className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap" - > - + Custom step - +
+ + +
) : undefined} > {steps.length === 0 && ( @@ -50,9 +73,36 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean; { const v = e.target.value.trim(); if (!v || v === s.title) return; const res = await updateOnboardingStep(s.id, { title: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: v } : x)) }} className="flex-1 min-w-[160px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" /> {s.is_system && built-in} - {(s.step_type === 'welcome' || s.step_type === 'code_of_conduct' || s.step_type === 'payment' || s.step_type === 'content') && ( + {(s.step_type === 'welcome' || s.step_type === 'code_of_conduct' || s.step_type === 'payment' || s.step_type === 'content' || s.step_type === 'form') && (