diff --git a/__tests__/equipment-logic.test.ts b/__tests__/equipment-logic.test.ts new file mode 100644 index 0000000..e209ec2 --- /dev/null +++ b/__tests__/equipment-logic.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { + intervalsOverlap, + hasConflict, + reservationEligibility, +} from '@/lib/equipment-logic' + +describe('intervalsOverlap', () => { + it('detects overlap and treats touching as non-overlap', () => { + expect(intervalsOverlap('2026-01-01T10:00Z', '2026-01-01T12:00Z', '2026-01-01T11:00Z', '2026-01-01T13:00Z')).toBe(true) + expect(intervalsOverlap('2026-01-01T10:00Z', '2026-01-01T12:00Z', '2026-01-01T12:00Z', '2026-01-01T13:00Z')).toBe(false) + expect(intervalsOverlap('2026-01-01T10:00Z', '2026-01-01T12:00Z', '2026-01-01T08:00Z', '2026-01-01T09:00Z')).toBe(false) + }) + it('is false on unparseable input', () => { + expect(intervalsOverlap('x', 'y', 'a', 'b')).toBe(false) + }) +}) + +describe('hasConflict', () => { + const req = ['2026-02-01T10:00Z', '2026-02-01T12:00Z'] as const + it('only reserved rows block', () => { + expect(hasConflict(req[0], req[1], [{ starts_at: '2026-02-01T11:00Z', ends_at: '2026-02-01T13:00Z', status: 'reserved' }])).toBe(true) + expect(hasConflict(req[0], req[1], [{ starts_at: '2026-02-01T11:00Z', ends_at: '2026-02-01T13:00Z', status: 'cancelled' }])).toBe(false) + expect(hasConflict(req[0], req[1], [{ starts_at: '2026-02-01T11:00Z', ends_at: '2026-02-01T13:00Z', status: 'completed' }])).toBe(false) + }) + it('no conflict when disjoint', () => { + expect(hasConflict(req[0], req[1], [{ starts_at: '2026-02-01T12:00Z', ends_at: '2026-02-01T14:00Z', status: 'reserved' }])).toBe(false) + }) +}) + +describe('reservationEligibility', () => { + const base = { + equipmentStatus: 'available', + equipmentActive: true, + startsAt: '2026-06-01T10:00:00Z', + endsAt: '2026-06-01T12:00:00Z', + now: '2026-05-16T00:00:00Z', + conflict: false, + requiresCert: false, + memberHasCert: false, + managerOverride: false, + } + it('allows a clean future reservation', () => { + expect(reservationEligibility(base).ok).toBe(true) + }) + it('blocks archived/retired/maintenance', () => { + expect(reservationEligibility({ ...base, equipmentActive: false }).ok).toBe(false) + expect(reservationEligibility({ ...base, equipmentStatus: 'retired' }).ok).toBe(false) + expect(reservationEligibility({ ...base, equipmentStatus: 'maintenance' }).ok).toBe(false) + }) + it('blocks past start and end<=start', () => { + expect(reservationEligibility({ ...base, startsAt: '2026-05-01T10:00:00Z' }).ok).toBe(false) + expect(reservationEligibility({ ...base, endsAt: '2026-06-01T10:00:00Z' }).ok).toBe(false) + }) + it('blocks on conflict', () => { + expect(reservationEligibility({ ...base, conflict: true }).ok).toBe(false) + }) + it('enforces the cert gate with manager override', () => { + expect(reservationEligibility({ ...base, requiresCert: true, memberHasCert: false }).ok).toBe(false) + expect(reservationEligibility({ ...base, requiresCert: true, memberHasCert: true }).ok).toBe(true) + expect(reservationEligibility({ ...base, requiresCert: true, memberHasCert: false, managerOverride: true }).ok).toBe(true) + }) +}) diff --git a/lib/equipment-logic.ts b/lib/equipment-logic.ts new file mode 100644 index 0000000..9f37c16 --- /dev/null +++ b/lib/equipment-logic.ts @@ -0,0 +1,91 @@ +// Pure, dependency-free decision logic for the equipment feature. No +// Supabase, no React, no Next. Unit-tested directly so the overlap and +// reservation-eligibility rules cannot silently drift. + +function ms(value: string): number | null { + const t = new Date(value).getTime() + return Number.isNaN(t) ? null : t +} + +// Half-open [start, end): two windows overlap iff each starts before the +// other ends. Touching end-to-start (a.end === b.start) does NOT overlap. +export function intervalsOverlap( + aStart: string, + aEnd: string, + bStart: string, + bEnd: string, +): boolean { + const as = ms(aStart) + const ae = ms(aEnd) + const bs = ms(bStart) + const be = ms(bEnd) + if (as == null || ae == null || bs == null || be == null) return false + return as < be && bs < ae +} + +// Does a requested window conflict with any blocking reservation? Only +// 'reserved' rows block; 'cancelled' and 'completed' never do. +export function hasConflict( + reqStart: string, + reqEnd: string, + existing: Array<{ starts_at: string; ends_at: string; status: string }>, +): boolean { + return existing.some( + r => + r.status === 'reserved' && + intervalsOverlap(reqStart, reqEnd, r.starts_at, r.ends_at), + ) +} + +export type ReservationEligibility = { ok: true } | { ok: false; reason: string } + +// Whether a reservation may be created. managerOverride bypasses only the +// required-certification gate (and is also how a manager books on someone +// else's behalf); it does NOT override an operational status block. +export function reservationEligibility(input: { + equipmentStatus: string + equipmentActive: boolean + startsAt: string + endsAt: string + now?: Date | string + conflict: boolean + requiresCert: boolean + memberHasCert: boolean + managerOverride: boolean +}): ReservationEligibility { + if (!input.equipmentActive) return { ok: false, reason: 'This equipment is archived.' } + if (input.equipmentStatus === 'retired') return { ok: false, reason: 'This equipment is retired.' } + if (input.equipmentStatus === 'maintenance') { + return { ok: false, reason: 'This equipment is under maintenance and cannot be reserved.' } + } + if (input.equipmentStatus !== 'available') { + return { ok: false, reason: 'This equipment is not available.' } + } + + const start = ms(input.startsAt) + const end = ms(input.endsAt) + if (start == null || end == null) return { ok: false, reason: 'Invalid reservation time.' } + if (end <= start) return { ok: false, reason: 'The end time must be after the start time.' } + const t = input.now == null ? Date.now() : new Date(input.now).getTime() + if (start < t) return { ok: false, reason: 'The reservation cannot start in the past.' } + + if (input.conflict) { + return { ok: false, reason: 'That time overlaps an existing reservation for this equipment.' } + } + if (input.requiresCert && !input.memberHasCert && !input.managerOverride) { + return { ok: false, reason: 'You are not certified to reserve this equipment.' } + } + return { ok: true } +} + +export const EQUIPMENT_STATUS_LABEL: Record = { + available: 'Available', + maintenance: 'Under maintenance', + retired: 'Retired', +} + +export const RESERVATION_STATUS_LABEL: Record = { + reserved: 'Reserved', + cancelled: 'Cancelled', + completed: 'Completed', +} diff --git a/lib/validations.ts b/lib/validations.ts index 9b57461..34853cf 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -829,6 +829,51 @@ export const listSessionSignupsSchema = z.object({ sessionId: z.string().uuid('Invalid session ID'), }) +// ─── Equipment ─────────────────────────────────────────────────────────────── + +const equipmentStatus = z.enum(['available', 'maintenance', 'retired']) + +export const createEquipmentSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), + description: z.string().max(5000).optional().nullable(), + location: z.string().max(300).optional().nullable(), + status: equipmentStatus.optional().default('available'), + required_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), + asset_tag: z.string().max(120).optional().nullable(), +}) + +export const updateEquipmentSchema = z.object({ + equipmentId: z.string().uuid('Invalid equipment ID'), + name: z.string().min(1).max(200).optional(), + description: z.string().max(5000).optional().nullable(), + location: z.string().max(300).optional().nullable(), + status: equipmentStatus.optional(), + required_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), + asset_tag: z.string().max(120).optional().nullable(), + is_active: z.boolean().optional(), +}) + +export const equipmentIdSchema = z.object({ + equipmentId: z.string().uuid('Invalid equipment ID'), +}) + +export const reserveEquipmentSchema = z.object({ + equipmentId: z.string().uuid('Invalid equipment ID'), + starts_at: z.string().min(1, 'A start time is required').max(40), + ends_at: z.string().min(1, 'An end time is required').max(40), + notes: z.string().max(2000).optional().nullable(), + // Manager-only: book on another member's behalf. Ignored for non-managers. + memberId: z.string().uuid('Invalid member ID').optional().nullable(), +}) + +export const cancelReservationSchema = z.object({ + reservationId: z.string().uuid('Invalid reservation ID'), +}) + +export const listEquipmentReservationsSchema = z.object({ + equipmentId: z.string().uuid('Invalid equipment ID'), +}) + // ─── Generic ID schemas ────────────────────────────────────────────────────── export const uuidSchema = z.string().uuid('Invalid ID format')