import type { PrismaClient } from "@/generated/prisma/client"; import { AppointmentStatus } from "@/generated/prisma/enums"; import { endOfBusinessDay, startOfBusinessDay } from "@/lib/time/business-time"; import type { CalendarDate } from "@/lib/time/business-time"; import { fail, ok } from "./types"; import type { ValidationResult } from "./types"; /** * Day-of-operations concerns: retrieving appointments and moving them * through their status lifecycle (check-in, start, complete, no-show). * Deliberately has nothing to do with booking/rescheduling/availability — * see booking.service.ts for that. An appointment already exists by the * time anything in this file touches it. */ const TRANSITIONS: Record = { PENDING: [AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELLED], CONFIRMED: [AppointmentStatus.CHECKED_IN, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW], CHECKED_IN: [AppointmentStatus.IN_PROGRESS, AppointmentStatus.CANCELLED], IN_PROGRESS: [AppointmentStatus.COMPLETED], COMPLETED: [], CANCELLED: [], NO_SHOW: [], }; export function canTransition(from: AppointmentStatus, to: AppointmentStatus): boolean { return TRANSITIONS[from].includes(to); } export function validateTransition( from: AppointmentStatus, to: AppointmentStatus, ): ValidationResult { if (!canTransition(from, to)) { return fail({ code: "INVALID_STATUS_TRANSITION", message: `An appointment can't move from ${from} to ${to}.`, }); } return ok(); } const APPOINTMENT_INCLUDE = { services: { include: { service: true } }, staff: { include: { user: true } }, customer: true, } as const; export async function getAppointmentById(prisma: PrismaClient, id: string) { return prisma.appointment.findUnique({ where: { id }, include: APPOINTMENT_INCLUDE }); } export interface ListAppointmentsParams { businessId?: string; staffId?: string; customerId?: string; statuses?: AppointmentStatus[]; fromDate?: CalendarDate; toDate?: CalendarDate; businessTimezone?: string; // required if fromDate/toDate given } export async function listAppointments(prisma: PrismaClient, params: ListAppointmentsParams) { const dateFilter = params.fromDate && params.toDate && params.businessTimezone ? { startAt: { gte: startOfBusinessDay(params.fromDate, params.businessTimezone), lt: endOfBusinessDay(params.toDate, params.businessTimezone), }, } : {}; return prisma.appointment.findMany({ where: { ...(params.businessId ? { businessId: params.businessId } : {}), ...(params.staffId ? { staffId: params.staffId } : {}), ...(params.customerId ? { customerId: params.customerId } : {}), ...(params.statuses ? { status: { in: params.statuses } } : {}), ...dateFilter, }, include: APPOINTMENT_INCLUDE, orderBy: { startAt: "asc" }, }); } export type TransitionResult = | { success: true; appointment: NonNullable>> } | { success: false; validation: ValidationResult }; async function transitionStatus( prisma: PrismaClient, appointmentId: string, to: AppointmentStatus, ): Promise { return prisma.$transaction(async (tx) => { const appointment = await tx.appointment.findUnique({ where: { id: appointmentId } }); if (!appointment) { return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }), }; } const check = validateTransition(appointment.status, to); if (!check.valid) return { success: false, validation: check }; const updated = await tx.appointment.update({ where: { id: appointmentId }, data: { status: to }, include: APPOINTMENT_INCLUDE, }); return { success: true, appointment: updated }; }); } export function checkIn(prisma: PrismaClient, appointmentId: string) { return transitionStatus(prisma, appointmentId, AppointmentStatus.CHECKED_IN); } export function start(prisma: PrismaClient, appointmentId: string) { return transitionStatus(prisma, appointmentId, AppointmentStatus.IN_PROGRESS); } export function complete(prisma: PrismaClient, appointmentId: string) { return transitionStatus(prisma, appointmentId, AppointmentStatus.COMPLETED); } export function markNoShow(prisma: PrismaClient, appointmentId: string) { return transitionStatus(prisma, appointmentId, AppointmentStatus.NO_SHOW); } export function confirm(prisma: PrismaClient, appointmentId: string) { return transitionStatus(prisma, appointmentId, AppointmentStatus.CONFIRMED); } const CURRENT_APPOINTMENT_ACTIVE_STATUSES: AppointmentStatus[] = [ AppointmentStatus.PENDING, AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN, AppointmentStatus.IN_PROGRESS, ]; /** * Which of a staff member's appointments is "the current one" for the * Current Appointment screen — a pure function over already-fetched * appointments (the page fetches today's list via listAppointments; this * just picks the most relevant one), so it's cheap to unit test every edge * case without a database. Priority, per the Phase 5 brief: * 1. IN_PROGRESS * 2. CHECKED_IN * 3. Scheduled right now (startAt <= now < endAt) and not yet terminal * 4. The next upcoming one today * 5. null — the page shows its own empty state */ export function selectCurrentAppointment< T extends { status: AppointmentStatus; startAt: Date; endAt: Date }, >(appointments: T[], now: Date): T | null { const inProgress = appointments.find((a) => a.status === AppointmentStatus.IN_PROGRESS); if (inProgress) return inProgress; const checkedIn = appointments.find((a) => a.status === AppointmentStatus.CHECKED_IN); if (checkedIn) return checkedIn; const scheduledNow = appointments.find( (a) => CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt <= now && a.endAt > now, ); if (scheduledNow) return scheduledNow; const upcoming = appointments .filter((a) => CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt > now) .sort((a, b) => a.startAt.getTime() - b.startAt.getTime()); return upcoming[0] ?? null; }