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,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { SchedulingTestClient } from "./scheduling-test-client";
|
||||
|
||||
export const metadata: Metadata = { title: "Scheduling Engine — Dev Test" };
|
||||
// Always reflect live DB state — this is a manual test harness, not a page that should serve a build-time snapshot.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Internal-only harness for manually exercising the booking engine
|
||||
* (Phase 3) — not a customer-facing screen, not linked from any nav. Uses
|
||||
* the seeded demo business/staff/customer from prisma/seed.ts. See
|
||||
* docs/phase-3-summary.md.
|
||||
*/
|
||||
export default async function SchedulingTestPage() {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { slug: "blake-heritage-barbershop" },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
});
|
||||
if (!business) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [services, staff, customer] = await Promise.all([
|
||||
prisma.service.findMany({
|
||||
where: { businessId: business.id, isActive: true },
|
||||
orderBy: { displayOrder: "asc" },
|
||||
}),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: business.id },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
prisma.customerProfile.findFirst({
|
||||
where: { email: "alex.chen@example.com" },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<div className="p-8 text-sm text-red-500">
|
||||
No seeded customer found — run <code>npm run prisma:seed</code> first.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SchedulingTestClient
|
||||
business={business}
|
||||
services={services.map((s) => ({ id: s.id, name: s.name, durationMinutes: s.durationMinutes, price: s.price.toString() }))}
|
||||
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff member" }))}
|
||||
customerId={customer.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import {
|
||||
getAvailableDatesAction,
|
||||
getAvailableTimeSlotsAction,
|
||||
} from "@/features/booking/actions/availability";
|
||||
import {
|
||||
cancelBookingAction,
|
||||
createBookingAction,
|
||||
rescheduleBookingAction,
|
||||
} from "@/features/booking/actions/booking";
|
||||
import {
|
||||
checkInAction,
|
||||
completeAppointmentAction,
|
||||
listAppointmentsAction,
|
||||
markNoShowAction,
|
||||
startAppointmentAction,
|
||||
} from "@/features/booking/actions/appointment";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { MonthCalendar } from "@/components/ui/month-calendar";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
durationMinutes: number;
|
||||
price: string;
|
||||
}
|
||||
interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface TimeSlot {
|
||||
startMinutes: number;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
staffOptions: string[];
|
||||
}
|
||||
|
||||
function toYMD(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const EMPTY_SET: Set<string> = new Set();
|
||||
const EMPTY_SLOTS: TimeSlot[] = [];
|
||||
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
PENDING: "pending_approval",
|
||||
CONFIRMED: "confirmed",
|
||||
CHECKED_IN: "arrived",
|
||||
IN_PROGRESS: "in_progress",
|
||||
COMPLETED: "completed",
|
||||
CANCELLED: "cancelled",
|
||||
NO_SHOW: "no_show",
|
||||
};
|
||||
|
||||
export function SchedulingTestClient(props: {
|
||||
business: { id: string; name: string; timezone: string };
|
||||
services: Service[];
|
||||
staff: Staff[];
|
||||
customerId: string;
|
||||
}) {
|
||||
const { business, services, staff, customerId } = props;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>(services[0] ? [services[0].id] : []);
|
||||
const [selectedStaffId, setSelectedStaffId] = useState<string>("any");
|
||||
const [month, setMonth] = useState(() => new Date());
|
||||
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
|
||||
const [slots, setSlots] = useState<TimeSlot[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [appointments, setAppointments] = useState<any[]>([]);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const refreshAppointments = () => {
|
||||
listAppointmentsAction({ businessId: business.id, businessTimezone: business.timezone }).then(setAppointments);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshAppointments();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Available dates for the visible month. When nothing is selected there's
|
||||
// nothing to fetch — `effectiveAvailableDates` below derives the empty
|
||||
// case at render time rather than this effect resetting state
|
||||
// synchronously (which cascades an extra render for no reason).
|
||||
useEffect(() => {
|
||||
if (selectedServiceIds.length === 0) return;
|
||||
const from = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const to = new Date(month.getFullYear(), month.getMonth() + 1, 0);
|
||||
getAvailableDatesAction({
|
||||
businessId: business.id,
|
||||
serviceIds: selectedServiceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
fromDate: toYMD(from),
|
||||
toDate: toYMD(to),
|
||||
}).then((res) => setAvailableDates(new Set(res.dates)));
|
||||
}, [business.id, month, selectedServiceIds, selectedStaffId]);
|
||||
const effectiveAvailableDates = selectedServiceIds.length === 0 ? EMPTY_SET : availableDates;
|
||||
|
||||
// Time slots for the selected date — same derive-at-render-time pattern.
|
||||
useEffect(() => {
|
||||
if (!selectedDate || selectedServiceIds.length === 0) return;
|
||||
getAvailableTimeSlotsAction({
|
||||
businessId: business.id,
|
||||
serviceIds: selectedServiceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
date: toYMD(selectedDate),
|
||||
}).then((res) => setSlots(res.slots));
|
||||
}, [business.id, selectedDate, selectedServiceIds, selectedStaffId]);
|
||||
const effectiveSlots = !selectedDate || selectedServiceIds.length === 0 ? EMPTY_SLOTS : slots;
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
setSelectedServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const book = (slot: TimeSlot) => {
|
||||
startTransition(async () => {
|
||||
const result = await createBookingAction({
|
||||
businessId: business.id,
|
||||
customerId,
|
||||
serviceIds: selectedServiceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
startAt: slot.startAt,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage(`Booked: ${result.appointment.id}`);
|
||||
setNotes("");
|
||||
setSelectedDate(undefined);
|
||||
refreshAppointments();
|
||||
} else {
|
||||
setMessage(`Failed: ${result.validation.issues.map((i) => `${i.code} — ${i.message}`).join("; ")}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cancel = (appointmentId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await cancelBookingAction({ appointmentId, cancelledByUserId: customerId, reason: "Dev test" });
|
||||
setMessage(result.success ? "Cancelled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
|
||||
refreshAppointments();
|
||||
});
|
||||
};
|
||||
|
||||
const reschedule = (appointmentId: string) => {
|
||||
const input = window.prompt("New start time (ISO, e.g. 2026-08-20T10:00:00.000Z):");
|
||||
if (!input) return;
|
||||
startTransition(async () => {
|
||||
const result = await rescheduleBookingAction({ appointmentId, startAt: input });
|
||||
setMessage(result.success ? "Rescheduled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
|
||||
refreshAppointments();
|
||||
});
|
||||
};
|
||||
|
||||
const runTransition = (fn: (id: string) => Promise<{ success: boolean }>, appointmentId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await fn(appointmentId);
|
||||
setMessage(result.success ? "Status updated" : "Transition rejected");
|
||||
refreshAppointments();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 px-6 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Scheduling Engine — Dev Test</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{business.name} · {business.timezone} · not a real feature page, see docs/phase-3-summary.md
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{message && (
|
||||
<div className="rounded-xl border border-line bg-white p-3 text-sm" role="status">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="space-y-4 p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">1. Services</h2>
|
||||
<div className="space-y-2">
|
||||
{services.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
label={`${s.name} (${s.durationMinutes}m, £${s.price})`}
|
||||
checked={selectedServiceIds.includes(s.id)}
|
||||
onChange={() => toggleService(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="pt-2 text-xs font-bold uppercase tracking-widest text-muted">2. Barber</h2>
|
||||
<Select value={selectedStaffId} onChange={(e) => setSelectedStaffId(e.target.value)}>
|
||||
<option value="any">Anyone available</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="mb-4 text-xs font-bold uppercase tracking-widest text-muted">3. Date</h2>
|
||||
<MonthCalendar
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
selectedDate={selectedDate}
|
||||
onSelectDate={setSelectedDate}
|
||||
isDateDisabled={(date) => !effectiveAvailableDates.has(toYMD(date))}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-muted">Selectable dates are ones the engine reports as having openings.</p>
|
||||
</Card>
|
||||
|
||||
{selectedDate && (
|
||||
<Card className="space-y-4 p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">4. Time</h2>
|
||||
{effectiveSlots.length === 0 ? (
|
||||
<p className="text-sm text-muted">No available slots this day for this selection.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{effectiveSlots.map((slot) => (
|
||||
<button
|
||||
key={slot.startAt}
|
||||
onClick={() => book(slot)}
|
||||
disabled={isPending}
|
||||
className="rounded-lg border border-line px-4 py-2 text-sm font-medium hover:border-ink disabled:opacity-50"
|
||||
>
|
||||
{new Date(slot.startAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })}
|
||||
<span className="ml-1 text-[10px] text-muted">({slot.staffOptions.length} avail.)</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Notes (optional)"
|
||||
className="w-full rounded-lg border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="space-y-3 p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">Appointments</h2>
|
||||
{appointments.length === 0 && <p className="text-sm text-muted">None yet.</p>}
|
||||
{appointments.map((a) => (
|
||||
<div key={a.id} className="rounded-xl border border-line p-4 text-sm">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<StatusPill status={STATUS_MAP[a.status] ?? "confirmed"} />
|
||||
<span className="text-xs text-muted">{new Date(a.startAt).toLocaleString("en-GB", { timeZone: business.timezone })}</span>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
{a.staff.user.name} · {a.services.map((s: { service: { name: string } }) => s.service.name).join(" + ")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => checkInAction(id), a.id)} disabled={isPending}>
|
||||
Check in
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => startAppointmentAction(id), a.id)} disabled={isPending}>
|
||||
Start
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => completeAppointmentAction(id), a.id)} disabled={isPending}>
|
||||
Complete
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => markNoShowAction(id), a.id)} disabled={isPending}>
|
||||
No-show
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => reschedule(a.id)} disabled={isPending}>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => cancel(a.id)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user