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:
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
effectiveCapacity,
|
||||||
|
computeSignupStatus,
|
||||||
|
sessionTiming,
|
||||||
|
canSignUp,
|
||||||
|
pickPromotion,
|
||||||
|
} from '@/lib/classes-logic'
|
||||||
|
|
||||||
|
describe('effectiveCapacity', () => {
|
||||||
|
it('session override wins, else class, else unlimited', () => {
|
||||||
|
expect(effectiveCapacity(5, 10)).toBe(5)
|
||||||
|
expect(effectiveCapacity(null, 10)).toBe(10)
|
||||||
|
expect(effectiveCapacity(undefined, undefined)).toBeNull()
|
||||||
|
expect(effectiveCapacity(0 as unknown as number, 10)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeSignupStatus', () => {
|
||||||
|
it('unlimited capacity always registers', () => {
|
||||||
|
expect(computeSignupStatus(null, 999)).toBe('registered')
|
||||||
|
})
|
||||||
|
it('registers under capacity, waitlists at/over', () => {
|
||||||
|
expect(computeSignupStatus(3, 2)).toBe('registered')
|
||||||
|
expect(computeSignupStatus(3, 3)).toBe('waitlisted')
|
||||||
|
expect(computeSignupStatus(3, 4)).toBe('waitlisted')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sessionTiming', () => {
|
||||||
|
const now = '2026-05-16T12:00:00.000Z'
|
||||||
|
it('classifies relative to now', () => {
|
||||||
|
expect(sessionTiming('2026-05-17T00:00:00.000Z', null, now)).toBe('upcoming')
|
||||||
|
expect(sessionTiming('2026-05-15T00:00:00.000Z', '2026-05-15T01:00:00.000Z', now)).toBe('past')
|
||||||
|
expect(sessionTiming('2026-05-16T11:00:00.000Z', '2026-05-16T13:00:00.000Z', now)).toBe('in_progress')
|
||||||
|
})
|
||||||
|
it('with no end, a started session is past once now exceeds start', () => {
|
||||||
|
expect(sessionTiming('2026-05-16T11:59:59.000Z', null, now)).toBe('past')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('canSignUp', () => {
|
||||||
|
const now = '2026-05-16T12:00:00.000Z'
|
||||||
|
it('rejects cancelled/completed/past', () => {
|
||||||
|
expect(canSignUp({ sessionStatus: 'cancelled', startsAt: '2026-06-01T00:00:00Z', now }).ok).toBe(false)
|
||||||
|
expect(canSignUp({ sessionStatus: 'completed', startsAt: '2026-06-01T00:00:00Z', now }).ok).toBe(false)
|
||||||
|
expect(canSignUp({ sessionStatus: 'scheduled', startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-05-01T01:00:00Z', now }).ok).toBe(false)
|
||||||
|
})
|
||||||
|
it('allows an upcoming scheduled session', () => {
|
||||||
|
expect(canSignUp({ sessionStatus: 'scheduled', startsAt: '2026-06-01T00:00:00Z', now }).ok).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pickPromotion', () => {
|
||||||
|
it('promotes the earliest waitlisted when a seat is free', () => {
|
||||||
|
const s = [
|
||||||
|
{ id: 'r1', status: 'registered', signed_up_at: '2026-01-01T00:00:00Z' },
|
||||||
|
{ id: 'w2', status: 'waitlisted', signed_up_at: '2026-01-03T00:00:00Z' },
|
||||||
|
{ id: 'w1', status: 'waitlisted', signed_up_at: '2026-01-02T00:00:00Z' },
|
||||||
|
]
|
||||||
|
expect(pickPromotion(s, 3)).toBe('w1')
|
||||||
|
})
|
||||||
|
it('returns null when still at capacity', () => {
|
||||||
|
const s = [
|
||||||
|
{ id: 'r1', status: 'registered', signed_up_at: '2026-01-01T00:00:00Z' },
|
||||||
|
{ id: 'r2', status: 'registered', signed_up_at: '2026-01-01T00:00:00Z' },
|
||||||
|
{ id: 'w1', status: 'waitlisted', signed_up_at: '2026-01-02T00:00:00Z' },
|
||||||
|
]
|
||||||
|
expect(pickPromotion(s, 2)).toBeNull()
|
||||||
|
})
|
||||||
|
it('returns null when nobody is waiting', () => {
|
||||||
|
expect(pickPromotion([{ id: 'r1', status: 'registered', signed_up_at: 'x' }], 5)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -741,6 +741,94 @@ export const listMemberCertificationsSchema = z.object({
|
|||||||
memberId: z.string().uuid('Invalid member ID'),
|
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 ──────────────────────────────────────────────────────
|
// ─── Generic ID schemas ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const uuidSchema = z.string().uuid('Invalid ID format')
|
export const uuidSchema = z.string().uuid('Invalid ID format')
|
||||||
|
|||||||
Reference in New Issue
Block a user