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,98 @@
import { describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import {
addDays,
calendarDateOf,
compareCalendarDates,
eachCalendarDate,
isAfterCalendarDate,
isBeforeCalendarDate,
minutesFromMidnight,
todayIn,
wallClockToInstant,
weekdayOf,
} from "../business-time";
describe("weekdayOf", () => {
it("maps calendar dates to the correct Weekday", () => {
// 2026-08-03 is a Monday.
expect(weekdayOf("2026-08-03")).toBe(Weekday.MONDAY);
expect(weekdayOf("2026-08-04")).toBe(Weekday.TUESDAY);
expect(weekdayOf("2026-08-08")).toBe(Weekday.SATURDAY);
expect(weekdayOf("2026-08-09")).toBe(Weekday.SUNDAY);
});
});
describe("wallClockToInstant / minutesFromMidnight round-trip", () => {
it("round-trips a plain wall-clock time in a fixed-offset zone", () => {
const instant = wallClockToInstant("2026-01-15", 9 * 60 + 30, "Europe/London");
expect(minutesFromMidnight(instant, "Europe/London")).toBe(9 * 60 + 30);
expect(calendarDateOf(instant, "Europe/London")).toBe("2026-01-15");
});
it("produces the documented UTC instant for a known GMT (winter) date", () => {
// 2026-01-15 is outside BST — Europe/London is UTC+0.
const instant = wallClockToInstant("2026-01-15", 10 * 60, "Europe/London");
expect(instant.toISOString()).toBe("2026-01-15T10:00:00.000Z");
});
});
describe("DST correctness (Europe/London BST transition, last Sunday of March 2026)", () => {
it("the same wall-clock time yields UTC instants shifted by the DST offset change", () => {
// Before the 2026-03-29 transition: Europe/London is GMT (UTC+0).
const beforeTransition = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
expect(beforeTransition.toISOString()).toBe("2026-03-28T10:00:00.000Z");
// After the transition: Europe/London is BST (UTC+1).
const afterTransition = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
expect(afterTransition.toISOString()).toBe("2026-03-30T09:00:00.000Z");
// A naive "always UTC+0" implementation would put these exactly 48h
// apart; DST-correct math puts them 47h apart because a wall-clock
// hour was skipped.
const diffHours = (afterTransition.getTime() - beforeTransition.getTime()) / 3_600_000;
expect(diffHours).toBe(47);
});
it("minutesFromMidnight recovers the same local wall-clock time on both sides of the transition", () => {
const before = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
const after = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
expect(minutesFromMidnight(before, "Europe/London")).toBe(10 * 60);
expect(minutesFromMidnight(after, "Europe/London")).toBe(10 * 60);
});
});
describe("calendar date arithmetic", () => {
it("addDays is pure calendar-date math, independent of any zone", () => {
expect(addDays("2026-02-27", 1)).toBe("2026-02-28");
expect(addDays("2026-02-28", 1)).toBe("2026-03-01"); // 2026 is not a leap year
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
});
it("compares calendar dates correctly", () => {
expect(compareCalendarDates("2026-01-01", "2026-01-02")).toBeLessThan(0);
expect(compareCalendarDates("2026-01-02", "2026-01-01")).toBeGreaterThan(0);
expect(compareCalendarDates("2026-01-01", "2026-01-01")).toBe(0);
expect(isBeforeCalendarDate("2026-01-01", "2026-01-02")).toBe(true);
expect(isAfterCalendarDate("2026-01-02", "2026-01-01")).toBe(true);
});
it("eachCalendarDate yields an inclusive range and nothing when from > to", () => {
expect([...eachCalendarDate("2026-01-01", "2026-01-03")]).toEqual([
"2026-01-01",
"2026-01-02",
"2026-01-03",
]);
expect([...eachCalendarDate("2026-01-03", "2026-01-01")]).toEqual([]);
});
it("eachCalendarDate guards against absurdly large ranges", () => {
expect(() => [...eachCalendarDate("2026-01-01", "2030-01-01")]).toThrow();
});
});
describe("todayIn", () => {
it("returns a well-formed calendar date", () => {
expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { DateTime } from "luxon";
import { Weekday } from "@/generated/prisma/enums";
/**
* All scheduling math (working hours, breaks, booking rules) happens in
* two currencies: a `CalendarDate` ("2026-08-12", no time zone attached —
* a calendar date is a calendar date regardless of zone) and
* minutes-since-local-midnight (0–1439) for wall-clock times within a
* day. Appointments themselves are stored as UTC `Date` instants (see
* prisma/schema.prisma, Appointment.startAt/endAt). This module is the
* ONLY place that converts between the two, via Luxon, so DST transitions
* are handled correctly in exactly one place instead of scattered
* `getHours()`/`setHours()` calls that silently assume the server's own
* time zone. See docs/implementation-plan.md §14.
*/
export type CalendarDate = string; // "YYYY-MM-DD"
const ISO_WEEKDAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
function requireISODate(dt: DateTime, context: string): string {
const iso = dt.toISODate();
if (!iso) throw new Error(`business-time: invalid date in ${context}`);
return iso;
}
/** Which Weekday a calendar date falls on, independent of time zone (a calendar date has no time-of-day ambiguity). */
export function weekdayOf(date: CalendarDate): Weekday {
const dt = DateTime.fromISO(date, { zone: "utc" });
return ISO_WEEKDAY_ORDER[dt.weekday - 1]!;
}
/** Minutes since local midnight (0–1439) for a UTC instant, as observed in `zone`. */
export function minutesFromMidnight(instant: Date, zone: string): number {
const dt = DateTime.fromJSDate(instant, { zone });
return dt.hour * 60 + dt.minute;
}
/** The business-local calendar date a UTC instant falls on. */
export function calendarDateOf(instant: Date, zone: string): CalendarDate {
return requireISODate(DateTime.fromJSDate(instant, { zone }), "calendarDateOf");
}
/**
* Combine a calendar date + minutes-from-midnight (a business-local wall
* clock time) into the correct UTC instant. DST-safe: Luxon resolves the
* wall clock against the zone's actual offset for that specific date, not
* a fixed offset — the same "10:00" on either side of a DST transition
* produces different UTC instants, correctly.
*/
export function wallClockToInstant(date: CalendarDate, minutesFromMidnightValue: number, zone: string): Date {
const dt = DateTime.fromISO(date, { zone }).startOf("day").plus({ minutes: minutesFromMidnightValue });
return dt.toJSDate();
}
export function todayIn(zone: string): CalendarDate {
return requireISODate(DateTime.now().setZone(zone), "todayIn");
}
/** The UTC instant at which `date` begins in `zone` — the inclusive lower bound for "appointments that fall on this business day". */
export function startOfBusinessDay(date: CalendarDate, zone: string): Date {
return wallClockToInstant(date, 0, zone);
}
/** The UTC instant at which `date` ends in `zone` (= the start of the next day) — the exclusive upper bound for "appointments that fall on this business day". */
export function endOfBusinessDay(date: CalendarDate, zone: string): Date {
return wallClockToInstant(date, 24 * 60, zone);
}
/**
* `TimeOff`/date-only columns (`@db.Date`) are compared as UTC midnight
* regardless of business time zone — a day off is the same calendar day
* everywhere, it isn't a wall-clock-in-a-zone concept the way appointment
* times are. Always go through this helper when reading or writing a
* `@db.Date` column so the convention stays consistent both directions.
*/
export function calendarDateToUtcMidnight(date: CalendarDate): Date {
return new Date(`${date}T00:00:00.000Z`);
}
/** Pure calendar-date arithmetic — deliberately zone-independent (adding whole days never crosses a DST boundary ambiguity the way wall-clock-to-instant conversion can). */
export function addDays(date: CalendarDate, days: number): CalendarDate {
return requireISODate(DateTime.fromISO(date, { zone: "utc" }).plus({ days }), "addDays");
}
export function compareCalendarDates(a: CalendarDate, b: CalendarDate): number {
return a < b ? -1 : a > b ? 1 : 0;
}
export function isBeforeCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
return compareCalendarDates(a, b) < 0;
}
export function isAfterCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
return compareCalendarDates(a, b) > 0;
}
export function diffMinutes(a: Date, b: Date): number {
return (a.getTime() - b.getTime()) / 60_000;
}
/** Every CalendarDate from `from` to `to` inclusive. Throws if the range is absurdly large, as a guard against a caller accidentally passing unbounded input. */
export function* eachCalendarDate(from: CalendarDate, to: CalendarDate): Generator<CalendarDate> {
if (isAfterCalendarDate(from, to)) return;
let cursor = from;
let guard = 0;
while (!isAfterCalendarDate(cursor, to)) {
if (++guard > 366) throw new Error("eachCalendarDate: range exceeds 366 days");
yield cursor;
cursor = addDays(cursor, 1);
}
}