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:
@@ -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`) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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' && <AreasPanel isAdmin={props.isAdmin} areas={props.areas} />}
|
||||
{section === 'area-leads' && <AreaLeadsPanel isAdmin={props.isAdmin} areaLeads={props.areaLeads} members={props.members} />}
|
||||
{section === 'invites' && <InvitesPanel isAdmin={props.isAdmin} invites={props.invites} />}
|
||||
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} />}
|
||||
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} forms={props.forms} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, unknown>; 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<string, unknown>; 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 }>}
|
||||
|
||||
@@ -7,17 +7,27 @@ 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<Step[]>(initial)
|
||||
const publishedForms = forms.filter(f => f.status === 'published')
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="New member onboarding"
|
||||
blurb="These steps run, in order, the first time a new member opens the app. Reorder with the number. Disable to hide. Built-in steps cannot be deleted, only disabled. Body supports markdown and a safe subset of HTML."
|
||||
action={isAdmin ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
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 })
|
||||
@@ -30,6 +40,19 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean;
|
||||
>
|
||||
+ Custom step
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const result = await createOnboardingStep({ step_type: 'form', title: 'Sign a form or waiver', body: 'Pick which form members fill here.', 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('Form step added — pick a form below')
|
||||
const id = (result as { id: string }).id
|
||||
setSteps(prev => [...prev, { id, step_key: `custom-${id.slice(0, 12)}`, step_type: 'form', title: 'Sign a form or waiver', body: 'Pick which form members fill here.', 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"
|
||||
>
|
||||
+ Form step
|
||||
</button>
|
||||
</div>
|
||||
) : undefined}
|
||||
>
|
||||
{steps.length === 0 && (
|
||||
@@ -50,9 +73,36 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean;
|
||||
<input type="text" defaultValue={s.title} disabled={!isAdmin} onBlur={async e => { 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 && <span className="font-mono text-[10px] text-amber-600">built-in</span>}
|
||||
</div>
|
||||
{(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') && (
|
||||
<textarea defaultValue={s.body ?? ''} disabled={!isAdmin} rows={4} maxLength={50000} onBlur={async e => { const v = e.target.value; if (v === (s.body ?? '')) return; const res = await updateOnboardingStep(s.id, { body: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, body: v } : x)) }} placeholder="Markdown / safe HTML" className="w-full bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 focus:outline-none focus:border-primary mb-3" />
|
||||
)}
|
||||
{s.step_type === 'form' && (
|
||||
<div className="mb-3">
|
||||
{publishedForms.length === 0 ? (
|
||||
<p className="font-sans text-xs text-muted-foreground">No published forms yet. Create and publish one under Forms & waivers, then pick it here.</p>
|
||||
) : (
|
||||
<select
|
||||
defaultValue={(s.config?.form_id as string) ?? ''}
|
||||
disabled={!isAdmin}
|
||||
onChange={async e => {
|
||||
const v = e.target.value
|
||||
const res = await updateOnboardingStep(s.id, { config: { ...s.config, form_id: v } })
|
||||
if (res.error) { toast.error(res.error); return }
|
||||
setSteps(prev => prev.map(x => x.id === s.id ? { ...x, config: { ...x.config, form_id: v } } : x))
|
||||
toast.success('Form linked')
|
||||
}}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-2 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Select a form…</option>
|
||||
{publishedForms.map(f => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.title}{f.kind === 'waiver' ? ' (waiver)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{s.step_type === 'payment' && (
|
||||
<input type="url" defaultValue={(s.config?.payment_url as string) ?? ''} disabled={!isAdmin} placeholder="Payment link (https://...)" onBlur={async e => { const v = e.target.value.trim(); if (v === ((s.config?.payment_url as string) ?? '')) return; const res = await updateOnboardingStep(s.id, { config: { ...s.config, payment_url: v } }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, config: { ...x.config, payment_url: v } } : x)) }} className="w-full bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 focus:outline-none focus:border-primary mb-3" />
|
||||
)}
|
||||
|
||||
@@ -40,7 +40,14 @@ export interface Invite {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type OnboardingStepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
|
||||
export type OnboardingStepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form'
|
||||
|
||||
export interface FormOption {
|
||||
id: string
|
||||
title: string
|
||||
kind: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
id: string
|
||||
|
||||
@@ -5,14 +5,25 @@ import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { BrandMark } from '@/components/brand-mark'
|
||||
import { SafeMarkdown } from '@/components/safe-markdown'
|
||||
import { FormRenderer } from '@/components/forms/form-renderer'
|
||||
import type { FormField } from '@/lib/forms-schema'
|
||||
import {
|
||||
markOnboardingStepDone,
|
||||
finishOnboarding,
|
||||
skipOnboarding,
|
||||
updateMyProfile,
|
||||
submitForm,
|
||||
} from '@/lib/actions'
|
||||
|
||||
type StepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
|
||||
type StepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form'
|
||||
|
||||
interface StepFormDef {
|
||||
id: string
|
||||
title: string
|
||||
kind: string
|
||||
legalText: string | null
|
||||
fields: FormField[]
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: string
|
||||
@@ -21,6 +32,8 @@ interface Step {
|
||||
body: string | null
|
||||
config: Record<string, unknown>
|
||||
is_required: boolean
|
||||
formDef?: StepFormDef | null
|
||||
alreadySubmitted?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -45,6 +58,8 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
|
||||
// Controlled raw text for the skills field so edits survive navigating
|
||||
// Back/forward and the user sees exactly what they typed (no normalize).
|
||||
const [skillsText, setSkillsText] = useState(initialProfile.skills.join(', '))
|
||||
const [formAnswers, setFormAnswers] = useState<Record<string, Record<string, unknown>>>({})
|
||||
const [formConsent, setFormConsent] = useState<Record<string, boolean>>({})
|
||||
|
||||
const step = steps[index]
|
||||
const isLast = index === steps.length - 1
|
||||
@@ -52,6 +67,12 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
|
||||
const ackLabel = (step.config?.ack_label as string) || 'I have read and agree'
|
||||
const ackOk = !requireAck || acked[step.id]
|
||||
|
||||
const isFormStep = step.step_type === 'form'
|
||||
const formDef = step.formDef ?? null
|
||||
const formActionable = isFormStep && formDef && !step.alreadySubmitted
|
||||
const isWaiver = formDef?.kind === 'waiver'
|
||||
const consentOk = !formActionable || !isWaiver || formConsent[step.id] === true
|
||||
|
||||
async function advance() {
|
||||
setBusy(true)
|
||||
await markOnboardingStepDone(step.id)
|
||||
@@ -95,6 +116,28 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
|
||||
else setIndex(i => i + 1)
|
||||
}
|
||||
|
||||
async function submitFormStep() {
|
||||
setBusy(true)
|
||||
// No form to fill (already done, or misconfigured/unpublished): treat as a
|
||||
// plain non-blocking step so a member is never trapped.
|
||||
if (formActionable && formDef) {
|
||||
const res = await submitForm({
|
||||
formId: formDef.id,
|
||||
answers: formAnswers[step.id] ?? {},
|
||||
consent: isWaiver ? formConsent[step.id] === true : undefined,
|
||||
})
|
||||
if ('error' in res && res.error) {
|
||||
setBusy(false)
|
||||
toast.error(res.error)
|
||||
return
|
||||
}
|
||||
}
|
||||
await markOnboardingStepDone(step.id)
|
||||
setBusy(false)
|
||||
if (isLast) await finish()
|
||||
else setIndex(i => i + 1)
|
||||
}
|
||||
|
||||
const progressPct = Math.round(((index) / steps.length) * 100)
|
||||
|
||||
return (
|
||||
@@ -193,6 +236,48 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : step.step_type === 'form' ? (
|
||||
<div className="space-y-4">
|
||||
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
|
||||
{!formDef ? (
|
||||
<p className="font-sans text-sm text-muted-foreground">
|
||||
This form is not available right now. You can continue.
|
||||
</p>
|
||||
) : step.alreadySubmitted ? (
|
||||
<p className="font-sans text-sm text-muted-foreground">
|
||||
You have already completed this. You can continue.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{isWaiver && formDef.legalText && (
|
||||
<div className="max-h-72 overflow-y-auto whitespace-pre-wrap rounded border border-border bg-muted/30 p-3 font-sans text-sm">
|
||||
{formDef.legalText}
|
||||
</div>
|
||||
)}
|
||||
<FormRenderer
|
||||
fields={formDef.fields}
|
||||
values={formAnswers[step.id] ?? {}}
|
||||
onChange={(k, v) =>
|
||||
setFormAnswers(p => ({ ...p, [step.id]: { ...(p[step.id] ?? {}), [k]: v } }))
|
||||
}
|
||||
idPrefix={`ob-${step.id}`}
|
||||
/>
|
||||
{isWaiver && (
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formConsent[step.id] === true}
|
||||
onChange={e => setFormConsent(p => ({ ...p, [step.id]: e.target.checked }))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="font-sans text-sm text-foreground">
|
||||
I have read and agree to the terms above.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
|
||||
@@ -233,11 +318,27 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={step.step_type === 'profile' ? saveProfileAndAdvance : advance}
|
||||
disabled={busy || !ackOk}
|
||||
onClick={
|
||||
step.step_type === 'profile'
|
||||
? saveProfileAndAdvance
|
||||
: step.step_type === 'form'
|
||||
? submitFormStep
|
||||
: advance
|
||||
}
|
||||
disabled={busy || !ackOk || !consentOk}
|
||||
className="bg-primary text-white text-sm font-sans px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Saving...' : isLast ? 'Finish' : step.step_type === 'payment' ? 'I have set this up' : 'Continue'}
|
||||
{busy
|
||||
? 'Saving...'
|
||||
: isLast
|
||||
? 'Finish'
|
||||
: step.step_type === 'payment'
|
||||
? 'I have set this up'
|
||||
: formActionable
|
||||
? isWaiver
|
||||
? 'Sign & continue'
|
||||
: 'Submit & continue'
|
||||
: 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+65
-3
@@ -1,5 +1,7 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { parseFormSchema } from '@/lib/forms-schema'
|
||||
import { OnboardingFlow } from './onboarding-flow'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -40,17 +42,77 @@ export default async function OnboardingPage() {
|
||||
const canSkip = !steps.some(s => s.is_required)
|
||||
const spaceName = (member.spaces as { name?: string } | null)?.name ?? 'your space'
|
||||
|
||||
// Enrich form steps with the referenced (published) form and whether this
|
||||
// member has already submitted it. Unpublished/missing forms resolve to no
|
||||
// formDef; the flow then shows a non-blocking notice (mirrors the
|
||||
// fail-open in finishOnboarding).
|
||||
const formIds = Array.from(
|
||||
new Set(
|
||||
steps
|
||||
.filter(s => s.step_type === 'form')
|
||||
.map(s => (s.config as { form_id?: string } | null)?.form_id)
|
||||
.filter((x): x is string => typeof x === 'string'),
|
||||
),
|
||||
)
|
||||
|
||||
const formDefs = new Map<
|
||||
string,
|
||||
{ id: string; title: string; kind: string; legal_text: string | null; fields: ReturnType<typeof parseFormSchema> }
|
||||
>()
|
||||
const submittedFormIds = new Set<string>()
|
||||
|
||||
if (formIds.length > 0) {
|
||||
const { data: formRows } = await supabase
|
||||
.from('forms')
|
||||
.select('id, title, kind, legal_text, schema, status')
|
||||
.eq('space_id', member.space_id)
|
||||
.in('id', formIds)
|
||||
.eq('status', 'published')
|
||||
for (const f of formRows ?? []) {
|
||||
formDefs.set(f.id, {
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
kind: f.kind,
|
||||
legal_text: f.legal_text,
|
||||
fields: parseFormSchema(f.schema),
|
||||
})
|
||||
}
|
||||
// Submissions are forms.manage-only under RLS; probe with the service client.
|
||||
const admin = createAdminClient()
|
||||
const { data: subs } = await admin
|
||||
.from('form_submissions')
|
||||
.select('form_id')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('member_id', member.id)
|
||||
.in('form_id', formIds)
|
||||
for (const r of subs ?? []) submittedFormIds.add(r.form_id as string)
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingFlow
|
||||
spaceName={spaceName}
|
||||
steps={steps.map(s => ({
|
||||
steps={steps.map(s => {
|
||||
const formId = (s.config as { form_id?: string } | null)?.form_id
|
||||
const def = formId ? formDefs.get(formId) : undefined
|
||||
return {
|
||||
id: s.id,
|
||||
step_type: s.step_type as 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content',
|
||||
step_type: s.step_type as 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form',
|
||||
title: s.title,
|
||||
body: s.body,
|
||||
config: (s.config ?? {}) as Record<string, unknown>,
|
||||
is_required: s.is_required,
|
||||
}))}
|
||||
formDef: def
|
||||
? {
|
||||
id: def.id,
|
||||
title: def.title,
|
||||
kind: def.kind,
|
||||
legalText: def.legal_text,
|
||||
fields: def.fields,
|
||||
}
|
||||
: null,
|
||||
alreadySubmitted: formId ? submittedFormIds.has(formId) : false,
|
||||
}
|
||||
})}
|
||||
canSkip={canSkip}
|
||||
profile={{
|
||||
display_name: member.display_name ?? '',
|
||||
|
||||
@@ -894,4 +894,6 @@ Management actions are gated by `forms.manage` (checked via the `user_has_permis
|
||||
- `submitForm(input)` — public entry point (anonymous, signed-in non-member, or member by form `visibility`). Reads the form with the service client (anon has no `forms` grant), enforces visibility + waiver consent, validates `answers` against the stored field schema (`lib/forms-schema.ts`, unknown keys discarded), snapshots schema/legal-text/version, captures IP + user-agent, and writes the row with the service client (`form_submissions` has no write policy, so this is the only path; rows are immutable).
|
||||
- `linkSubmissionsForMember(input)` — `forms.manage` admin manual-link of prior anonymous submissions by email. The automatic verified-email retro-link is Phase 5.
|
||||
|
||||
Not implemented yet (planned): builder UI, public `/f/[slug]` page, onboarding form-step integration, automatic retro-link on email verification.
|
||||
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.
|
||||
|
||||
@@ -422,10 +422,11 @@ forms + waivers).
|
||||
|
||||
### Forms & waivers (migration 026 — Phase 1: schema + RLS)
|
||||
|
||||
`forms` and `form_submissions` back a custom form/waiver builder. Phases 1-3
|
||||
`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 integration and
|
||||
automatic retro-linking are Phases 4-5. Security shape:
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -686,7 +686,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at);
|
||||
|
||||
---
|
||||
|
||||
## Migrations 014-026 (additions since the 13-table baseline)
|
||||
## Migrations 014-027 (additions since the 13-table baseline)
|
||||
|
||||
`scripts/schema.sql` is the canonical idempotent schema; each numbered
|
||||
migration is mirrored as a section in it. Tables/columns added:
|
||||
@@ -705,6 +705,7 @@ migration is mirrored as a section in it. Tables/columns added:
|
||||
| 023 | `space_role_permissions`, `ops_acl`; `user_effective_roles()`, `user_has_permission()` (SECURITY DEFINER); `secrets`/`knowledge_base` SELECT rewritten as `existing-rule OR ops_acl-match` (additive) |
|
||||
| 024 | `prevent_member_self_role_change()` extended to also block self-change of `tier_id` and `onboarding_completed_at` |
|
||||
| 025 | Re-asserts the hardened `incidents_insert` policy verbatim (production convergence; access-neutral, no schema change) |
|
||||
| 027 | Adds `'form'` to the `space_onboarding_steps.step_type` CHECK so an onboarding step can embed a custom form/waiver (referenced form id stored in the step's existing `config` jsonb; no new column) |
|
||||
| 026 | `forms`, `form_submissions` (custom forms + waivers). Globally-unique form slug; jsonb field schema; per-submission snapshot of schema/legal-text/version for immutable waiver records. RLS additive + default-deny: forms SELECT = `forms.manage` holders (all) or members (published only), write = `user_has_permission(..., 'forms.manage')`; form_submissions SELECT = `forms.manage` only, NO write policy (every submission is written by one validated service-client server action, and submissions are immutable). New `forms.manage` permission seeded to board + backfilled |
|
||||
|
||||
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
|
||||
|
||||
+14
-4
@@ -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-3 (schema/RLS + server actions + builder UI), LOCAL, awaiting deploy approval
|
||||
## 2026-05-16 (pass 18) — Forms + waivers Phases 1-4 (schema/RLS + actions + builder UI + onboarding step), LOCAL, awaiting deploy approval
|
||||
|
||||
Branch: `main`. Migration: `026` (not yet deployed). `pnpm build` + `pnpm exec vitest run` gate pending in this entry's session.
|
||||
|
||||
@@ -47,11 +47,21 @@ Audited the migration before continuing. Three fixes applied: dropped redundant
|
||||
- Gate: `pnpm exec vitest run` 285/285; `pnpm build` exit 0; `/forms`, `/forms/[id]`, `/forms/new`, `/f/[slug]` all compiled.
|
||||
- NOT browser-verified (no live browser this session): smoke-test the builder (add/reorder/remove fields, live preview, create + edit + publish + close + delete), the results CSV download, and a public submission for each visibility (anon, public_auth signed-out gate, waiver consent).
|
||||
|
||||
### Forms + waivers Phase 4 (this session) — onboarding form step, LOCAL
|
||||
|
||||
- `scripts/027_onboarding_form_step.sql` (idempotent constraint replace) + `schema.sql` Section 14 inline CHECK now allows `step_type = 'form'`. Referenced form id lives in the step's existing `config.form_id` (no new column). validations `onboardingStepTypeSchema` + onboarding action union + customize types/casts include `'form'`.
|
||||
- `finishOnboarding`: a required `form` step is satisfied if a submission for `config.form_id` by this member exists (any version — non-blocking re-sign / auto-satisfy). Fails open if the form is missing or unpublished so a misconfig cannot trap a member out of their space. Submission probe uses the service client (submissions are forms.manage-only under RLS).
|
||||
- `app/onboarding/page.tsx` enriches `form` steps with the published form def + a prior-submission flag; `onboarding-flow.tsx` renders `FormRenderer` + waiver legal text/consent and submits via `submitForm` then `markOnboardingStepDone`; already-submitted or unavailable forms show a non-blocking notice.
|
||||
- Admin: `customize/page.tsx` fetches the space's forms; `onboarding-panel.tsx` gets a "+ Form step" button and a form `<select>` (writes `config.form_id`); empty-state hint when no published forms.
|
||||
- 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.
|
||||
|
||||
### Open / next
|
||||
|
||||
- **Awaiting user approval to deploy** (push to `main` = prod). Phases 1-3 are all LOCAL (commits `3a2d3ca`, `66dd9b7`, Phase 2, Phase 3); do NOT push without explicit go. Migration `026` ships with the first push.
|
||||
- Phases 4-5 not started: onboarding form-step + required-waiver enforcement + auto-satisfy when a matching signed waiver exists; automatic retro-link hooks (join/signup/addMember/import + email verification) — `linkSubmissionsForMember` primitive already exists. Phase 3 is code-complete but NOT browser-verified (see Phase 3 smoke-test list above).
|
||||
- After an approved push: watch the Actions run, then smoke-test that `026` applied (`_migrations_applied` row) and a space's board has `forms.manage`.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,10 +169,40 @@ 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) {
|
||||
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
|
||||
// 024) so a member cannot skip required steps via a raw PostgREST call.
|
||||
|
||||
+1
-1
@@ -331,7 +331,7 @@ export const updateMyProfileSchema = z.object({
|
||||
|
||||
// ─── Onboarding ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const onboardingStepTypeSchema = z.enum(['welcome', 'code_of_conduct', 'profile', 'payment', 'content'])
|
||||
export const onboardingStepTypeSchema = z.enum(['welcome', 'code_of_conduct', 'profile', 'payment', 'content', 'form'])
|
||||
|
||||
export const createOnboardingStepSchema = z.object({
|
||||
step_type: onboardingStepTypeSchema,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- =============================================================================
|
||||
-- 027: Allow an onboarding step to embed a custom form / waiver
|
||||
-- =============================================================================
|
||||
-- Adds 'form' to the space_onboarding_steps.step_type CHECK so a space can
|
||||
-- require a custom form or waiver as an onboarding step. The referenced form
|
||||
-- is stored in the step's existing `config` jsonb as { "form_id": "<uuid>" };
|
||||
-- no new column is needed.
|
||||
--
|
||||
-- step_type is a TEXT column with a column-level CHECK, not a Postgres enum,
|
||||
-- so this is a constraint replacement (the only safe path for a CHECK), done
|
||||
-- by the auto-generated constraint name. Idempotent: DROP CONSTRAINT IF EXISTS
|
||||
-- then ADD.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.space_onboarding_steps
|
||||
DROP CONSTRAINT IF EXISTS space_onboarding_steps_step_type_check;
|
||||
|
||||
ALTER TABLE public.space_onboarding_steps
|
||||
ADD CONSTRAINT space_onboarding_steps_step_type_check
|
||||
CHECK (step_type IN ('welcome','code_of_conduct','profile','payment','content','form'));
|
||||
+1
-1
@@ -1628,7 +1628,7 @@ CREATE TABLE IF NOT EXISTS public.space_onboarding_steps (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
step_key text NOT NULL CHECK (char_length(step_key) BETWEEN 1 AND 60),
|
||||
step_type text NOT NULL CHECK (step_type IN ('welcome','code_of_conduct','profile','payment','content')),
|
||||
step_type text NOT NULL CHECK (step_type IN ('welcome','code_of_conduct','profile','payment','content','form')),
|
||||
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
|
||||
body text,
|
||||
config jsonb NOT NULL DEFAULT '{}',
|
||||
|
||||
Reference in New Issue
Block a user