Booking Engine

Architecture: Six focused services with the BookingService/SchedulingService overlap resolved by giving SchedulingService exactly one job, the transactional double-booking guard , while BookingService owns the create/reschedule/cancel workflow around it.

59 unit tests, 24 integration tests
This commit is contained in:
Stefan Lazic committed 2026-08-02 18:36:39 +01:00
1 parent 090646fdfd
commit 8b63cc66a9
28 files changed
+4853 -15

No files matched your search

@@ -0,0 +1,125 @@
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);
}