176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
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<AppointmentStatus, AppointmentStatus[]> = {
|
|
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<Awaited<ReturnType<typeof getAppointmentById>>> }
|
|
| { success: false; validation: ValidationResult };
|
|
|
|
async function transitionStatus(
|
|
prisma: PrismaClient,
|
|
appointmentId: string,
|
|
to: AppointmentStatus,
|
|
): Promise<TransitionResult> {
|
|
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;
|
|
}
|