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:
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);
|
||||
}
|
||||
Reference in new issue
Block a user