diff --git a/__tests__/classes-logic.test.ts b/__tests__/classes-logic.test.ts new file mode 100644 index 0000000..cde6353 --- /dev/null +++ b/__tests__/classes-logic.test.ts @@ -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() + }) +}) diff --git a/lib/classes-logic.ts b/lib/classes-logic.ts new file mode 100644 index 0000000..6b028b5 --- /dev/null +++ b/lib/classes-logic.ts @@ -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 = { + scheduled: 'Scheduled', + cancelled: 'Cancelled', + completed: 'Completed', +} + +export const SIGNUP_STATUS_LABEL: Record = { + registered: 'Registered', + waitlisted: 'Waitlisted', + cancelled: 'Cancelled', +} diff --git a/lib/validations.ts b/lib/validations.ts index 918664d..9b57461 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -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')