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({