feat(classes): pure capacity/waitlist logic + Zod schemas

classes-logic.ts: effectiveCapacity (session override > class > unlimited),
computeSignupStatus (registered vs waitlisted), sessionTiming, canSignUp
eligibility, pickPromotion (earliest waitlisted when a seat frees).
Dependency-free, 10 unit tests (suite 372 -> 382).

validations.ts: class/session/signup/attendance schemas. payment_link is
a generic http(s) link only (empty -> null); capacity is a positive int;
no live payment integration.
This commit is contained in:
Moheeb Zara
2026-05-16 19:29:08 -07:00
parent 0c028f52cc
commit dc23e9261f
3 changed files with 261 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
// Pure, dependency-free decision logic for the classes feature. No Supabase,
// no React, no Next. Unit-tested directly so capacity/waitlist/eligibility
// rules cannot silently drift.
export type SignupOutcome = 'registered' | 'waitlisted'
export type SessionTiming = 'upcoming' | 'in_progress' | 'past'
// A session-level capacity overrides the class default; null at both levels
// means unlimited.
export function effectiveCapacity(
sessionCapacity: number | null | undefined,
classCapacity: number | null | undefined,
): number | null {
if (sessionCapacity != null) return sessionCapacity
if (classCapacity != null) return classCapacity
return null
}
// Where a new signup lands given the effective capacity and how many are
// already registered (not counting waitlisted/cancelled).
export function computeSignupStatus(
capacity: number | null,
currentRegistered: number,
): SignupOutcome {
if (capacity == null) return 'registered'
return currentRegistered < capacity ? 'registered' : 'waitlisted'
}
function ms(value: string | null | undefined): number | null {
if (!value) return null
const t = new Date(value).getTime()
return Number.isNaN(t) ? null : t
}
export function sessionTiming(
startsAt: string,
endsAt: string | null | undefined,
now?: Date | string,
): SessionTiming {
const t = now == null ? Date.now() : new Date(now).getTime()
const start = ms(startsAt)
if (start == null) return 'upcoming'
const end = ms(endsAt) ?? start
if (t < start) return 'upcoming'
if (t > end) return 'past'
return 'in_progress'
}
export type SignupEligibility = { ok: true } | { ok: false; reason: string }
// Whether a member may newly sign up for a session.
export function canSignUp(input: {
sessionStatus: string
startsAt: string
endsAt?: string | null
now?: Date | string
}): SignupEligibility {
if (input.sessionStatus === 'cancelled') return { ok: false, reason: 'This session was cancelled.' }
if (input.sessionStatus === 'completed') return { ok: false, reason: 'This session is already completed.' }
if (sessionTiming(input.startsAt, input.endsAt, input.now) === 'past') {
return { ok: false, reason: 'This session has already ended.' }
}
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.
export function pickPromotion(
signups: Array<{ id: string; status: string; signed_up_at: string }>,
capacity: number | null,
): string | null {
if (capacity == null) {
// Unlimited: anyone waitlisted (shouldn't normally happen) can move up;
// promote the earliest to be safe.
const waiting = signups
.filter(s => s.status === 'waitlisted')
.sort((a, b) => a.signed_up_at.localeCompare(b.signed_up_at))
return waiting[0]?.id ?? null
}
const registered = signups.filter(s => s.status === 'registered').length
if (registered >= capacity) return null
const waiting = signups
.filter(s => s.status === 'waitlisted')
.sort((a, b) => a.signed_up_at.localeCompare(b.signed_up_at))
return waiting[0]?.id ?? null
}
export const SESSION_STATUS_LABEL: Record<string, string> = {
scheduled: 'Scheduled',
cancelled: 'Cancelled',
completed: 'Completed',
}
export const SIGNUP_STATUS_LABEL: Record<string, string> = {
registered: 'Registered',
waitlisted: 'Waitlisted',
cancelled: 'Cancelled',
}
+88
View File
@@ -741,6 +741,94 @@ export const listMemberCertificationsSchema = z.object({
memberId: z.string().uuid('Invalid member ID'),
})
// ─── Classes ─────────────────────────────────────────────────────────────────
// Generic manual payment link only (no live payment integration). Empty
// string -> null so a cleared field is stored as "no link".
const paymentLink = z
.string()
.trim()
.max(2000)
.optional()
.nullable()
.transform(v => (v && v !== '' ? v : null))
.refine(
v => v === null || /^https?:\/\/.+/i.test(v),
'Payment link must be an http(s) URL',
)
const classCapacity = z
.number()
.int('Capacity must be a whole number')
.positive('Capacity must be at least 1')
.max(100000)
.optional()
.nullable()
const dateTimeString = z.string().min(1, 'A date and time is required').max(40)
export const createClassSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(5000).optional().nullable(),
payment_link: paymentLink,
capacity: classCapacity,
grants_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(),
})
export const updateClassSchema = z.object({
classId: z.string().uuid('Invalid class ID'),
title: z.string().min(1).max(200).optional(),
description: z.string().max(5000).optional().nullable(),
payment_link: paymentLink,
capacity: classCapacity,
grants_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(),
is_active: z.boolean().optional(),
})
export const classIdSchema = z.object({
classId: z.string().uuid('Invalid class ID'),
})
export const createSessionSchema = z.object({
classId: z.string().uuid('Invalid class ID'),
starts_at: dateTimeString,
ends_at: z.string().max(40).optional().nullable().transform(v => (v && v !== '' ? v : null)),
location: z.string().max(300).optional().nullable(),
capacity: classCapacity,
notes: z.string().max(5000).optional().nullable(),
})
export const updateSessionSchema = z.object({
sessionId: z.string().uuid('Invalid session ID'),
starts_at: dateTimeString.optional(),
ends_at: z.string().max(40).optional().nullable().transform(v => (v && v !== '' ? v : null)),
location: z.string().max(300).optional().nullable(),
capacity: classCapacity,
notes: z.string().max(5000).optional().nullable(),
status: z.enum(['scheduled', 'cancelled', 'completed']).optional(),
})
export const sessionIdSchema = z.object({
sessionId: z.string().uuid('Invalid session ID'),
})
export const signUpForClassSchema = z.object({
sessionId: z.string().uuid('Invalid session ID'),
})
export const cancelSignupSchema = z.object({
sessionId: z.string().uuid('Invalid session ID'),
})
export const markAttendanceSchema = z.object({
signupId: z.string().uuid('Invalid signup ID'),
attended: z.boolean(),
})
export const listSessionSignupsSchema = z.object({
sessionId: z.string().uuid('Invalid session ID'),
})
// ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format')