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.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<li key={r.id} className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
@@ -116,6 +118,21 @@ export function ClassesBrowseClient({
|
||||
{r.location ? ` · ${r.location}` : ''}
|
||||
{cap == null ? ' · open' : ` · ${spots} of ${cap} spots left`}
|
||||
</p>
|
||||
{r.required_form && (
|
||||
<p className={`font-mono text-[10px] mt-1 ${r.required_form.satisfied ? 'text-muted-foreground' : 'text-amber-600'}`}>
|
||||
{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 && (
|
||||
<>
|
||||
{' · '}
|
||||
<a href={r.required_form.url} target="_blank" rel="noopener noreferrer" className="text-primary underline">
|
||||
complete it
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{r.notes && <p className="font-sans text-xs text-muted-foreground mt-1">{r.notes}</p>}
|
||||
{c?.payment_link && (
|
||||
<a href={c.payment_link} target="_blank" rel="noopener noreferrer"
|
||||
@@ -131,6 +148,19 @@ export function ClassesBrowseClient({
|
||||
>
|
||||
Cancel signup
|
||||
</button>
|
||||
) : formBlocked ? (
|
||||
r.required_form?.url ? (
|
||||
<a
|
||||
href={r.required_form.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-[10px] border border-amber-500 text-amber-600 px-3 py-2 rounded hover:bg-amber-50 transition"
|
||||
>
|
||||
Complete required form
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-mono text-[10px] text-amber-600 px-3 py-2">Form required</span>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onSignUp(r)}
|
||||
|
||||
@@ -26,6 +26,7 @@ type Cls = {
|
||||
capacity: number | null
|
||||
is_active: boolean
|
||||
grants_certification_id: string | null
|
||||
required_form_id: string | null
|
||||
}
|
||||
type Session = {
|
||||
id: string
|
||||
@@ -38,12 +39,13 @@ type Session = {
|
||||
notes: string | null
|
||||
}
|
||||
type Cert = { id: string; name: string; is_active: boolean }
|
||||
type Form = { id: string; title: string; kind: string }
|
||||
|
||||
const inputCls =
|
||||
'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary'
|
||||
|
||||
function emptyClassDraft() {
|
||||
return { title: '', description: '', payment_link: '', capacity: '', grants_certification_id: '' }
|
||||
return { title: '', description: '', payment_link: '', capacity: '', grants_certification_id: '', required_form_id: '' }
|
||||
}
|
||||
function emptySessionDraft() {
|
||||
return { starts_at: '', ends_at: '', location: '', capacity: '', notes: '' }
|
||||
@@ -59,10 +61,12 @@ export function ClassesManageClient({
|
||||
initialClasses,
|
||||
initialSessions,
|
||||
certs,
|
||||
forms,
|
||||
}: {
|
||||
initialClasses: Cls[]
|
||||
initialSessions: Session[]
|
||||
certs: Cert[]
|
||||
forms: Form[]
|
||||
}) {
|
||||
const confirm = useConfirm()
|
||||
const [classes, setClasses] = useState<Cls[]>(initialClasses)
|
||||
@@ -78,6 +82,9 @@ export function ClassesManageClient({
|
||||
function certName(id: string | null) {
|
||||
return id ? certs.find(c => c.id === id)?.name ?? null : null
|
||||
}
|
||||
function formName(id: string | null) {
|
||||
return id ? forms.find(f => f.id === id)?.title ?? null : null
|
||||
}
|
||||
|
||||
async function onCreate(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -89,6 +96,7 @@ export function ClassesManageClient({
|
||||
payment_link: draft.payment_link.trim() || null,
|
||||
capacity: num(draft.capacity),
|
||||
grants_certification_id: draft.grants_certification_id || null,
|
||||
required_form_id: draft.required_form_id || null,
|
||||
})
|
||||
setBusy(false)
|
||||
if ('error' in res && res.error) return toast.error(res.error)
|
||||
@@ -102,6 +110,7 @@ export function ClassesManageClient({
|
||||
capacity: num(draft.capacity),
|
||||
is_active: true,
|
||||
grants_certification_id: draft.grants_certification_id || null,
|
||||
required_form_id: draft.required_form_id || null,
|
||||
},
|
||||
...prev,
|
||||
])
|
||||
@@ -118,6 +127,7 @@ export function ClassesManageClient({
|
||||
payment_link: c.payment_link ?? '',
|
||||
capacity: c.capacity == null ? '' : String(c.capacity),
|
||||
grants_certification_id: c.grants_certification_id ?? '',
|
||||
required_form_id: c.required_form_id ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,6 +141,7 @@ export function ClassesManageClient({
|
||||
payment_link: editDraft.payment_link.trim() || null,
|
||||
capacity: num(editDraft.capacity),
|
||||
grants_certification_id: editDraft.grants_certification_id || null,
|
||||
required_form_id: editDraft.required_form_id || null,
|
||||
})
|
||||
setBusy(false)
|
||||
if ('error' in res && res.error) return toast.error(res.error)
|
||||
@@ -144,6 +155,7 @@ export function ClassesManageClient({
|
||||
payment_link: editDraft.payment_link.trim() || null,
|
||||
capacity: num(editDraft.capacity),
|
||||
grants_certification_id: editDraft.grants_certification_id || null,
|
||||
required_form_id: editDraft.required_form_id || null,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
@@ -260,7 +272,17 @@ export function ClassesManageClient({
|
||||
<option value="">Grants no certification</option>
|
||||
{certs.map(c => <option key={c.id} value={c.id}>Grants: {c.name}</option>)}
|
||||
</select>
|
||||
<select className={inputCls} value={draft.required_form_id}
|
||||
onChange={e => setDraft({ ...draft, required_form_id: e.target.value })}>
|
||||
<option value="">No required form</option>
|
||||
{forms.map(f => <option key={f.id} value={f.id}>Requires: {f.title}{f.kind === 'waiver' ? ' (waiver)' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{forms.length === 0 && (
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
Tip: publish a form under Forms to be able to require it (e.g. a liability waiver) before signup.
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" size="sm" disabled={busy}>Create class</Button>
|
||||
</form>
|
||||
)}
|
||||
@@ -297,6 +319,11 @@ export function ClassesManageClient({
|
||||
<option value="">Grants no certification</option>
|
||||
{certs.map(x => <option key={x.id} value={x.id}>Grants: {x.name}</option>)}
|
||||
</select>
|
||||
<select className={inputCls} value={editDraft.required_form_id}
|
||||
onChange={e => setEditDraft({ ...editDraft, required_form_id: e.target.value })}>
|
||||
<option value="">No required form</option>
|
||||
{forms.map(f => <option key={f.id} value={f.id}>Requires: {f.title}{f.kind === 'waiver' ? ' (waiver)' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={busy} onClick={() => onSaveEdit(c.id)}>Save</Button>
|
||||
@@ -313,6 +340,9 @@ export function ClassesManageClient({
|
||||
{certName(c.grants_certification_id) && (
|
||||
<span className="font-mono text-[10px] text-primary">grants {certName(c.grants_certification_id)}</span>
|
||||
)}
|
||||
{formName(c.required_form_id) && (
|
||||
<span className="font-mono text-[10px] text-amber-600">requires form: {formName(c.required_form_id)}</span>
|
||||
)}
|
||||
</div>
|
||||
{c.description && <p className="font-sans text-sm text-muted-foreground mt-1">{c.description}</p>}
|
||||
{c.payment_link && (
|
||||
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+151
-13
@@ -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<typeof createAdminClient>,
|
||||
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<typeof createAdminClient>,
|
||||
spaceId: string,
|
||||
memberId: string,
|
||||
formId: string,
|
||||
): Promise<boolean> {
|
||||
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<string, unknown> = {}
|
||||
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<string, number> = {}
|
||||
const mine: Record<string, string> = {}
|
||||
// required_form_id -> { slug, title } and whether THIS member has it on file.
|
||||
const formMeta: Record<string, { slug: string; title: string }> = {}
|
||||
const mySatisfiedForms = new Set<string>()
|
||||
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<string, unknown>) => ({
|
||||
...s,
|
||||
registered_count: counts[s.id as string] ?? 0,
|
||||
my_status: mine[s.id as string] ?? null,
|
||||
})),
|
||||
data: rows.map((s: Record<string, unknown>) => {
|
||||
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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user