From f3b05afe1446c8cf1a3afa10756e3c71000753bb Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 21:41:01 -0700 Subject: [PATCH] feat(classes): optional required-form gate at signup (waiver-on-file) Pure signupFormEligibility (manager override bypasses) + 4 tests. signUpForClass now: classes.manage override + sign-up-on-behalf (targetMemberId), and a hard form gate -- if the class has a required_form_id the member must have a form_submissions row on file (checked via service client, since a class manager need not hold forms.manage). create/updateClass validate the form is in-space and published. listUpcomingSessions exposes required_form {title,url, satisfied}. Manage UI: published-form picker on create/edit + a 'requires form' badge. Member UI: shows the requirement, links to the form, and replaces Sign up with 'Complete required form' until it is on file. Mirrors the equipment cert-gate pattern. --- __tests__/classes-logic.test.ts | 18 ++ app/(app)/classes/classes-browse-client.tsx | 30 ++++ .../classes/manage/classes-manage-client.tsx | 32 +++- app/(app)/classes/manage/page.tsx | 11 +- lib/actions/classes.ts | 164 ++++++++++++++++-- lib/classes-logic.ts | 15 ++ lib/validations.ts | 5 + 7 files changed, 259 insertions(+), 16 deletions(-) diff --git a/__tests__/classes-logic.test.ts b/__tests__/classes-logic.test.ts index cde6353..1149ddc 100644 --- a/__tests__/classes-logic.test.ts +++ b/__tests__/classes-logic.test.ts @@ -4,6 +4,7 @@ import { computeSignupStatus, sessionTiming, canSignUp, + signupFormEligibility, pickPromotion, } from '@/lib/classes-logic' @@ -72,3 +73,20 @@ describe('pickPromotion', () => { expect(pickPromotion([{ id: 'r1', status: 'registered', signed_up_at: 'x' }], 5)).toBeNull() }) }) + +describe('signupFormEligibility', () => { + it('passes when no form is required', () => { + expect(signupFormEligibility({ requiresForm: false, memberHasForm: false, managerOverride: false }).ok).toBe(true) + }) + it('passes when the form is on file', () => { + expect(signupFormEligibility({ requiresForm: true, memberHasForm: true, managerOverride: false }).ok).toBe(true) + }) + it('blocks when required and not on file', () => { + const r = signupFormEligibility({ requiresForm: true, memberHasForm: false, managerOverride: false }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toMatch(/requires a form/i) + }) + it('a manager override bypasses the form requirement', () => { + expect(signupFormEligibility({ requiresForm: true, memberHasForm: false, managerOverride: true }).ok).toBe(true) + }) +}) diff --git a/app/(app)/classes/classes-browse-client.tsx b/app/(app)/classes/classes-browse-client.tsx index b94fa81..587f688 100644 --- a/app/(app)/classes/classes-browse-client.tsx +++ b/app/(app)/classes/classes-browse-client.tsx @@ -22,6 +22,7 @@ type Row = { classes: { title: string; description: string | null; payment_link: string | null; capacity: number | null } | Array<{ title: string; description: string | null; payment_link: string | null; capacity: number | null }> | null registered_count: number my_status: string | null + required_form: { title: string; url: string | null; satisfied: boolean } | null } function classOf(r: Row) { @@ -97,6 +98,7 @@ export function ClassesBrowseClient({ const cap = effectiveCapacity(r.capacity, c?.capacity ?? null) const spots = cap == null ? null : Math.max(0, cap - r.registered_count) const full = cap != null && r.registered_count >= cap + const formBlocked = !r.my_status && !!r.required_form && !r.required_form.satisfied return (
  • @@ -116,6 +118,21 @@ export function ClassesBrowseClient({ {r.location ? ` · ${r.location}` : ''} {cap == null ? ' · open' : ` · ${spots} of ${cap} spots left`}

    + {r.required_form && ( +

    + {r.required_form.satisfied + ? `Required form on file: ${r.required_form.title}` + : `Requires the "${r.required_form.title}" form before signup`} + {!r.required_form.satisfied && r.required_form.url && ( + <> + {' · '} + + complete it + + + )} +

    + )} {r.notes &&

    {r.notes}

    } {c?.payment_link && ( Cancel signup + ) : formBlocked ? ( + r.required_form?.url ? ( + + Complete required form + + ) : ( + Form required + ) ) : (
    + {forms.length === 0 && ( +

    + Tip: publish a form under Forms to be able to require it (e.g. a liability waiver) before signup. +

    + )} )} @@ -297,6 +319,11 @@ export function ClassesManageClient({ {certs.map(x => )} +
    @@ -313,6 +340,9 @@ export function ClassesManageClient({ {certName(c.grants_certification_id) && ( grants {certName(c.grants_certification_id)} )} + {formName(c.required_form_id) && ( + requires form: {formName(c.required_form_id)} + )}
    {c.description &&

    {c.description}

    } {c.payment_link && ( diff --git a/app/(app)/classes/manage/page.tsx b/app/(app)/classes/manage/page.tsx index a2157eb..e57d621 100644 --- a/app/(app)/classes/manage/page.tsx +++ b/app/(app)/classes/manage/page.tsx @@ -6,10 +6,10 @@ export const dynamic = 'force-dynamic' export default async function ClassesManagePage() { const { supabase, member } = await requireClassesManagerPage() - const [{ data: classes }, { data: sessions }, { data: certs }] = await Promise.all([ + const [{ data: classes }, { data: sessions }, { data: certs }, { data: forms }] = await Promise.all([ supabase .from('classes') - .select('id, title, description, payment_link, capacity, is_active, grants_certification_id, created_at, updated_at') + .select('id, title, description, payment_link, capacity, is_active, grants_certification_id, required_form_id, created_at, updated_at') .eq('space_id', member.space_id) .order('is_active', { ascending: false }) .order('title', { ascending: true }), @@ -24,6 +24,12 @@ export default async function ClassesManagePage() { .eq('space_id', member.space_id) .eq('is_active', true) .order('name', { ascending: true }), + supabase + .from('forms') + .select('id, title, kind') + .eq('space_id', member.space_id) + .eq('status', 'published') + .order('title', { ascending: true }), ]) return ( @@ -31,6 +37,7 @@ export default async function ClassesManagePage() { initialClasses={classes ?? []} initialSessions={sessions ?? []} certs={certs ?? []} + forms={forms ?? []} /> ) } diff --git a/lib/actions/classes.ts b/lib/actions/classes.ts index 575c436..7c3a744 100644 --- a/lib/actions/classes.ts +++ b/lib/actions/classes.ts @@ -26,10 +26,50 @@ import { effectiveCapacity, computeSignupStatus, canSignUp, + signupFormEligibility, pickPromotion, } from '@/lib/classes-logic' import { grantCertification } from './certifications' +// A class manager need not hold forms.manage, so form lookups + the +// submission gate use the service client (scoped by space_id) and never +// return form answers -- only existence/metadata. +async function findSpaceForm( + admin: ReturnType, + spaceId: string, + formId: string, +): Promise<{ id: string; status: string; title: string; slug: string } | null> { + const { data } = await admin + .from('forms') + .select('id, status, title, slug') + .eq('id', formId) + .eq('space_id', spaceId) + .maybeSingle() + return data + ? { + id: data.id as string, + status: data.status as string, + title: data.title as string, + slug: data.slug as string, + } + : null +} + +async function hasFormSubmission( + admin: ReturnType, + spaceId: string, + memberId: string, + formId: string, +): Promise { + const { count } = await admin + .from('form_submissions') + .select('id', { count: 'exact', head: true }) + .eq('space_id', spaceId) + .eq('form_id', formId) + .eq('member_id', memberId) + return (count ?? 0) > 0 +} + type Gate = | { ok: true; supabase: ServerSupabase; member: Member } | { ok: false; error: string } @@ -84,6 +124,12 @@ export async function createClass(input: unknown) { if (!cert) return { error: 'The selected certification was not found in this space.' } } + if (c.required_form_id) { + const form = await findSpaceForm(createAdminClient(), member.space_id, c.required_form_id) + if (!form) return { error: 'The selected form was not found in this space.' } + if (form.status !== 'published') return { error: 'The required form must be published before it can gate signups.' } + } + const { data, error } = await supabase .from('classes') .insert({ @@ -93,6 +139,7 @@ export async function createClass(input: unknown) { payment_link: c.payment_link ?? null, capacity: c.capacity ?? null, grants_certification_id: c.grants_certification_id ?? null, + required_form_id: c.required_form_id ?? null, created_by: member.id, }) .select('id') @@ -123,12 +170,19 @@ export async function updateClass(input: unknown) { if (!cert) return { error: 'The selected certification was not found in this space.' } } + if (u.required_form_id) { + const form = await findSpaceForm(createAdminClient(), member.space_id, u.required_form_id) + if (!form) return { error: 'The selected form was not found in this space.' } + if (form.status !== 'published') return { error: 'The required form must be published before it can gate signups.' } + } + const patch: Record = {} if (u.title !== undefined) patch.title = u.title if (u.description !== undefined) patch.description = u.description ?? null if (u.payment_link !== undefined) patch.payment_link = u.payment_link ?? null if (u.capacity !== undefined) patch.capacity = u.capacity ?? null if (u.grants_certification_id !== undefined) patch.grants_certification_id = u.grants_certification_id ?? null + if (u.required_form_id !== undefined) patch.required_form_id = u.required_form_id ?? null if (u.is_active !== undefined) patch.is_active = u.is_active if (Object.keys(patch).length === 0) return { data: { id: u.classId } } @@ -179,7 +233,7 @@ export async function listClasses() { const { data, error } = await supabase .from('classes') - .select('id, title, description, payment_link, capacity, is_active, grants_certification_id, created_at, updated_at') + .select('id, title, description, payment_link, capacity, is_active, grants_certification_id, required_form_id, created_at, updated_at') .eq('space_id', member.space_id) .order('is_active', { ascending: false }) .order('title', { ascending: true }) @@ -187,6 +241,7 @@ export async function listClasses() { return { data: data ?? [] } } + // ─── Sessions (classes.manage) ─────────────────────────────────────────────── export async function createSession(input: unknown) { @@ -303,7 +358,7 @@ export async function listUpcomingSessions() { const nowIso = new Date().toISOString() const { data: sessions, error } = await supabase .from('class_sessions') - .select('id, class_id, starts_at, ends_at, location, capacity, status, notes, classes(title, description, payment_link, capacity, is_active, grants_certification_id)') + .select('id, class_id, starts_at, ends_at, location, capacity, status, notes, classes(title, description, payment_link, capacity, is_active, grants_certification_id, required_form_id)') .eq('space_id', member.space_id) .neq('status', 'cancelled') .gte('starts_at', nowIso) @@ -317,6 +372,10 @@ export async function listUpcomingSessions() { const counts: Record = {} const mine: Record = {} + // required_form_id -> { slug, title } and whether THIS member has it on file. + const formMeta: Record = {} + const mySatisfiedForms = new Set() + let spaceSlug: string | null = null if (ids.length > 0) { const admin = createAdminClient() const { data: agg } = await admin @@ -338,14 +397,57 @@ export async function listUpcomingSessions() { for (const r of my ?? []) { mine[(r as { session_id: string }).session_id] = (r as { status: string }).status } + + const formIds = Array.from( + new Set( + rows + .map((s: { classes?: { required_form_id?: string | null } | null }) => s.classes?.required_form_id) + .filter((x): x is string => !!x), + ), + ) + if (formIds.length > 0) { + const { data: forms } = await admin + .from('forms') + .select('id, slug, title') + .in('id', formIds) + for (const f of forms ?? []) { + formMeta[f.id as string] = { slug: f.slug as string, title: f.title as string } + } + 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 sub of subs ?? []) mySatisfiedForms.add(sub.form_id as string) + const { data: space } = await admin + .from('spaces') + .select('slug') + .eq('id', member.space_id) + .maybeSingle() + spaceSlug = (space?.slug as string | undefined) ?? null + } } return { - data: rows.map((s: Record) => ({ - ...s, - registered_count: counts[s.id as string] ?? 0, - my_status: mine[s.id as string] ?? null, - })), + data: rows.map((s: Record) => { + const cls = s.classes as { required_form_id?: string | null } | null + const rfid = cls?.required_form_id ?? null + const meta = rfid ? formMeta[rfid] ?? null : null + return { + ...s, + registered_count: counts[s.id as string] ?? 0, + my_status: mine[s.id as string] ?? null, + required_form: + rfid && meta + ? { + title: meta.title, + url: spaceSlug ? `/f/${spaceSlug}/${meta.slug}` : null, + satisfied: mySatisfiedForms.has(rfid), + } + : null, + } + }), } } @@ -362,14 +464,37 @@ export async function signUpForClass(input: unknown) { const { data: session } = await supabase .from('class_sessions') - .select('id, space_id, status, starts_at, ends_at, capacity, classes(capacity, is_active)') + .select('id, space_id, status, starts_at, ends_at, capacity, classes(capacity, is_active, required_form_id)') .eq('id', v.data.sessionId) .eq('space_id', member.space_id) .maybeSingle() if (!session) return { error: 'Session not found' } - const cls = (session as { classes?: { capacity: number | null; is_active: boolean } | null }).classes + const cls = (session as { + classes?: { capacity: number | null; is_active: boolean; required_form_id: string | null } | null + }).classes if (cls && cls.is_active === false) return { error: 'This class is no longer available.' } + const { data: isMgr } = await supabase.rpc('user_has_permission', { + uid: member.user_id as string, + sid: member.space_id, + perm: 'classes.manage', + }) + const managerOverride = !!isMgr + // Only a manager may sign someone else up. + const targetMemberId = managerOverride && v.data.memberId ? v.data.memberId : member.id + + const admin = createAdminClient() + + if (targetMemberId !== member.id) { + const { data: tgt } = await admin + .from('space_members') + .select('id') + .eq('id', targetMemberId) + .eq('space_id', member.space_id) + .maybeSingle() + if (!tgt) return { error: 'That member was not found in this space.' } + } + const eligible = canSignUp({ sessionStatus: session.status as string, startsAt: session.starts_at as string, @@ -377,17 +502,30 @@ export async function signUpForClass(input: unknown) { }) if (!eligible.ok) return { error: eligible.reason } - const admin = createAdminClient() + // Optional per-class form gate (waiver-on-file). A manager override + // bypasses it, mirroring the equipment required-cert gate. + const requiredFormId = cls?.required_form_id ?? null + const memberHasForm = requiredFormId + ? await hasFormSubmission(admin, member.space_id, targetMemberId, requiredFormId) + : false + const formOk = signupFormEligibility({ + requiresForm: !!requiredFormId, + memberHasForm, + managerOverride, + }) + if (!formOk.ok) return { error: formOk.reason } // Already signed up (non-cancelled)? const { data: existing } = await admin .from('class_signups') .select('id, status') .eq('session_id', v.data.sessionId) - .eq('member_id', member.id) + .eq('member_id', targetMemberId) .neq('status', 'cancelled') .maybeSingle() - if (existing) return { error: 'You are already signed up for this session.' } + if (existing) { + return { error: targetMemberId === member.id ? 'You are already signed up for this session.' : 'That member is already signed up for this session.' } + } const cap = effectiveCapacity( session.capacity as number | null, @@ -405,7 +543,7 @@ export async function signUpForClass(input: unknown) { .insert({ session_id: v.data.sessionId, space_id: member.space_id, - member_id: member.id, + member_id: targetMemberId, status, }) .select('id') diff --git a/lib/classes-logic.ts b/lib/classes-logic.ts index 6b028b5..8fe2a37 100644 --- a/lib/classes-logic.ts +++ b/lib/classes-logic.ts @@ -63,6 +63,21 @@ export function canSignUp(input: { return { ok: true } } +// A class may require a form (waiver/intake) on file. Mirrors the equipment +// required-certification gate: the requirement is bypassed for a manager +// acting with override (e.g. signing a member up on their behalf). "On file" +// means the member has at least one completed submission of that form. +export function signupFormEligibility(input: { + requiresForm: boolean + memberHasForm: boolean + managerOverride: boolean +}): SignupEligibility { + if (input.requiresForm && !input.memberHasForm && !input.managerOverride) { + return { ok: false, reason: 'This class requires a form to be completed before you can sign up.' } + } + return { ok: true } +} + // When a registered member cancels and a seat frees, the earliest-signed-up // waitlisted member is promoted. Returns that signup id, or null if there is // no free seat or no one waiting. diff --git a/lib/validations.ts b/lib/validations.ts index 615cf36..dc9f462 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -773,6 +773,7 @@ export const createClassSchema = z.object({ payment_link: paymentLink, capacity: classCapacity, grants_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), + required_form_id: z.string().uuid('Invalid form ID').optional().nullable(), }) export const updateClassSchema = z.object({ @@ -782,6 +783,7 @@ export const updateClassSchema = z.object({ payment_link: paymentLink, capacity: classCapacity, grants_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), + required_form_id: z.string().uuid('Invalid form ID').optional().nullable(), is_active: z.boolean().optional(), }) @@ -814,6 +816,9 @@ export const sessionIdSchema = z.object({ export const signUpForClassSchema = z.object({ sessionId: z.string().uuid('Invalid session ID'), + // Manager-only: sign another member up on their behalf (also bypasses the + // required-form gate). Ignored for non-managers. + memberId: z.string().uuid('Invalid member ID').optional().nullable(), }) export const cancelSignupSchema = z.object({