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 |
|
| 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) |
|
| 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) |
|
| 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,
|
submitFormSchema,
|
||||||
formSchemaArray,
|
formSchemaArray,
|
||||||
formFieldSchema,
|
formFieldSchema,
|
||||||
|
onboardingStepTypeSchema,
|
||||||
|
createOnboardingStepSchema,
|
||||||
} from '@/lib/validations'
|
} from '@/lib/validations'
|
||||||
import { parseFormSchema, validateAnswers, type FormField } from '@/lib/forms-schema'
|
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', () => {
|
describe('parseFormSchema', () => {
|
||||||
it('returns [] for malformed stored schema', () => {
|
it('returns [] for malformed stored schema', () => {
|
||||||
expect(parseFormSchema('not an array')).toEqual([])
|
expect(parseFormSchema('not an array')).toEqual([])
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AreasPanel } from './panels/areas-panel'
|
|||||||
import { AreaLeadsPanel } from './panels/area-leads-panel'
|
import { AreaLeadsPanel } from './panels/area-leads-panel'
|
||||||
import { InvitesPanel } from './panels/invites-panel'
|
import { InvitesPanel } from './panels/invites-panel'
|
||||||
import { OnboardingPanel } from './panels/onboarding-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'
|
type Section = 'roles' | 'permissions' | 'tiers' | 'areas' | 'area-leads' | 'invites' | 'onboarding'
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ interface Props {
|
|||||||
tiers: Tier[]
|
tiers: Tier[]
|
||||||
invites: Invite[]
|
invites: Invite[]
|
||||||
onboardingSteps: Step[]
|
onboardingSteps: Step[]
|
||||||
|
forms: FormOption[]
|
||||||
areas: Area[]
|
areas: Area[]
|
||||||
rolePerms: Array<{ subject: string; permission: string }>
|
rolePerms: Array<{ subject: string; permission: string }>
|
||||||
areaLeads: AreaLead[]
|
areaLeads: AreaLead[]
|
||||||
@@ -78,7 +79,7 @@ export function CustomizeClient(props: Props) {
|
|||||||
{section === 'areas' && <AreasPanel isAdmin={props.isAdmin} areas={props.areas} />}
|
{section === 'areas' && <AreasPanel isAdmin={props.isAdmin} areas={props.areas} />}
|
||||||
{section === 'area-leads' && <AreaLeadsPanel isAdmin={props.isAdmin} areaLeads={props.areaLeads} members={props.members} />}
|
{section === 'area-leads' && <AreaLeadsPanel isAdmin={props.isAdmin} areaLeads={props.areaLeads} members={props.members} />}
|
||||||
{section === 'invites' && <InvitesPanel isAdmin={props.isAdmin} invites={props.invites} />}
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default async function CustomizePage() {
|
|||||||
{ data: tiers },
|
{ data: tiers },
|
||||||
{ data: invites },
|
{ data: invites },
|
||||||
{ data: onboardingSteps },
|
{ data: onboardingSteps },
|
||||||
|
{ data: forms },
|
||||||
{ data: areas },
|
{ data: areas },
|
||||||
{ data: rolePerms },
|
{ data: rolePerms },
|
||||||
{ data: areaLeads },
|
{ 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_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_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('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_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('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'),
|
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 }>}
|
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 }>}
|
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 }>}
|
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 }>}
|
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 }>}
|
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 }>}
|
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 { Card } from './card'
|
||||||
import { useConfirm } from '@/components/ui/confirm'
|
import { useConfirm } from '@/components/ui/confirm'
|
||||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
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 confirm = useConfirm()
|
||||||
const [steps, setSteps] = useState<Step[]>(initial)
|
const [steps, setSteps] = useState<Step[]>(initial)
|
||||||
|
const publishedForms = forms.filter(f => f.status === 'published')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title="New member onboarding"
|
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."
|
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 ? (
|
action={isAdmin ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
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 })
|
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
|
+ Custom step
|
||||||
</button>
|
</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}
|
) : undefined}
|
||||||
>
|
>
|
||||||
{steps.length === 0 && (
|
{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" />
|
<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>}
|
{s.is_system && <span className="font-mono text-[10px] text-amber-600">built-in</span>}
|
||||||
</div>
|
</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" />
|
<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' && (
|
{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" />
|
<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
|
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 {
|
export interface Step {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -5,14 +5,25 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { BrandMark } from '@/components/brand-mark'
|
import { BrandMark } from '@/components/brand-mark'
|
||||||
import { SafeMarkdown } from '@/components/safe-markdown'
|
import { SafeMarkdown } from '@/components/safe-markdown'
|
||||||
|
import { FormRenderer } from '@/components/forms/form-renderer'
|
||||||
|
import type { FormField } from '@/lib/forms-schema'
|
||||||
import {
|
import {
|
||||||
markOnboardingStepDone,
|
markOnboardingStepDone,
|
||||||
finishOnboarding,
|
finishOnboarding,
|
||||||
skipOnboarding,
|
skipOnboarding,
|
||||||
updateMyProfile,
|
updateMyProfile,
|
||||||
|
submitForm,
|
||||||
} from '@/lib/actions'
|
} 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 {
|
interface Step {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,6 +32,8 @@ interface Step {
|
|||||||
body: string | null
|
body: string | null
|
||||||
config: Record<string, unknown>
|
config: Record<string, unknown>
|
||||||
is_required: boolean
|
is_required: boolean
|
||||||
|
formDef?: StepFormDef | null
|
||||||
|
alreadySubmitted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
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
|
// Controlled raw text for the skills field so edits survive navigating
|
||||||
// Back/forward and the user sees exactly what they typed (no normalize).
|
// Back/forward and the user sees exactly what they typed (no normalize).
|
||||||
const [skillsText, setSkillsText] = useState(initialProfile.skills.join(', '))
|
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 step = steps[index]
|
||||||
const isLast = index === steps.length - 1
|
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 ackLabel = (step.config?.ack_label as string) || 'I have read and agree'
|
||||||
const ackOk = !requireAck || acked[step.id]
|
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() {
|
async function advance() {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
await markOnboardingStepDone(step.id)
|
await markOnboardingStepDone(step.id)
|
||||||
@@ -95,6 +116,28 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
|
|||||||
else setIndex(i => i + 1)
|
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)
|
const progressPct = Math.round(((index) / steps.length) * 100)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -193,6 +236,48 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="space-y-4">
|
||||||
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
|
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
|
||||||
@@ -233,11 +318,27 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={step.step_type === 'profile' ? saveProfileAndAdvance : advance}
|
onClick={
|
||||||
disabled={busy || !ackOk}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+65
-3
@@ -1,5 +1,7 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
|
import { parseFormSchema } from '@/lib/forms-schema'
|
||||||
import { OnboardingFlow } from './onboarding-flow'
|
import { OnboardingFlow } from './onboarding-flow'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -40,17 +42,77 @@ export default async function OnboardingPage() {
|
|||||||
const canSkip = !steps.some(s => s.is_required)
|
const canSkip = !steps.some(s => s.is_required)
|
||||||
const spaceName = (member.spaces as { name?: string } | null)?.name ?? 'your space'
|
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 (
|
return (
|
||||||
<OnboardingFlow
|
<OnboardingFlow
|
||||||
spaceName={spaceName}
|
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,
|
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,
|
title: s.title,
|
||||||
body: s.body,
|
body: s.body,
|
||||||
config: (s.config ?? {}) as Record<string, unknown>,
|
config: (s.config ?? {}) as Record<string, unknown>,
|
||||||
is_required: s.is_required,
|
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}
|
canSkip={canSkip}
|
||||||
profile={{
|
profile={{
|
||||||
display_name: member.display_name ?? '',
|
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).
|
- `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.
|
- `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 & 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
|
are done (schema + RLS; server actions in `lib/actions/forms.ts`; the builder
|
||||||
UI under `/forms` + the public `/f/[slug]` page). Onboarding integration and
|
UI under `/forms` + the public `/f/[slug]` page; onboarding `form` step type
|
||||||
automatic retro-linking are Phases 4-5. Security shape:
|
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
|
all submissions (anonymous, public-authenticated, or members) are written by a
|
||||||
single validated server action using the service client AFTER server-side
|
single validated server action using the service client AFTER server-side
|
||||||
schema validation and snapshotting (same pattern as `finishOnboarding`); the
|
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
|
`scripts/schema.sql` is the canonical idempotent schema; each numbered
|
||||||
migration is mirrored as a section in it. Tables/columns added:
|
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) |
|
| 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` |
|
| 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) |
|
| 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 |
|
| 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`.
|
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.
|
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.
|
- 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).
|
- 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
|
### 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.
|
- **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.
|
||||||
- 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).
|
- 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).
|
||||||
- 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`.
|
- 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 -----------------------------------------------------
|
// --- Admin: manage steps -----------------------------------------------------
|
||||||
|
|
||||||
export async function createOnboardingStep(input: {
|
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
|
title: string
|
||||||
body?: string | null
|
body?: string | null
|
||||||
config?: Record<string, unknown>
|
config?: Record<string, unknown>
|
||||||
@@ -157,7 +157,7 @@ export async function finishOnboarding() {
|
|||||||
|
|
||||||
const { data: steps } = await supabase
|
const { data: steps } = await supabase
|
||||||
.from('space_onboarding_steps')
|
.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('space_id', member.space_id)
|
||||||
.eq('is_enabled', true)
|
.eq('is_enabled', true)
|
||||||
|
|
||||||
@@ -169,10 +169,40 @@ export async function finishOnboarding() {
|
|||||||
|
|
||||||
const progress = (row?.onboarding_progress ?? {}) as { completed_step_ids?: string[] }
|
const progress = (row?.onboarding_progress ?? {}) as { completed_step_ids?: string[] }
|
||||||
const done = new Set(progress.completed_step_ids ?? [])
|
const done = new Set(progress.completed_step_ids ?? [])
|
||||||
const requiredMissing = (steps ?? []).filter(s => s.is_required && !done.has(s.id))
|
const candidates = (steps ?? []).filter(s => s.is_required && !done.has(s.id))
|
||||||
if (requiredMissing.length > 0) {
|
|
||||||
|
// 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.' }
|
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
|
// onboarding_completed_at is blocked by the self-change trigger (migration
|
||||||
// 024) so a member cannot skip required steps via a raw PostgREST call.
|
// 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 ──────────────────────────────────────────────────────────────
|
// ─── 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({
|
export const createOnboardingStepSchema = z.object({
|
||||||
step_type: onboardingStepTypeSchema,
|
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(),
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
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_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),
|
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
|
||||||
body text,
|
body text,
|
||||||
config jsonb NOT NULL DEFAULT '{}',
|
config jsonb NOT NULL DEFAULT '{}',
|
||||||
|
|||||||
Reference in New Issue
Block a user