256 lines
9.1 KiB
TypeScript
256 lines
9.1 KiB
TypeScript
/**
|
|
* Capacity, holds and bookings.
|
|
*
|
|
* `MemoryReservationStore` is the reference implementation of the reservation
|
|
* semantics. The production store (app/services/reservations.server.ts) implements
|
|
* the same contract on Postgres with row-level locking; both are covered by the
|
|
* same behavioural tests.
|
|
*
|
|
* Capacity unit (v1): orders, not items.
|
|
*/
|
|
import type { Booking, LocationConfig, Method, PeakDay, UsageSnapshot } from "./types";
|
|
import { peakFor, usageKey } from "./availability";
|
|
|
|
export interface Caps {
|
|
dayCap: number | null;
|
|
slotCap: number | null;
|
|
}
|
|
|
|
export function capsFor(
|
|
loc: LocationConfig,
|
|
method: Method,
|
|
date: string,
|
|
slot: string,
|
|
peaks: PeakDay[],
|
|
): Caps {
|
|
const cfg = loc.methods[method];
|
|
const peak = peakFor(peaks, loc.id, method, date);
|
|
let tplCap: number | null | undefined;
|
|
if (cfg?.slotMode === "explicit") {
|
|
const [start, end] = slot.split("-");
|
|
for (const list of Object.values(cfg.slots ?? {}))
|
|
for (const s of list ?? []) if (s.start === start && s.end === end && s.capacity != null) tplCap = s.capacity;
|
|
}
|
|
return {
|
|
dayCap: peak?.dailyCapacity ?? cfg?.dailyCapacity ?? null,
|
|
slotCap: peak?.slotCapacity ?? tplCap ?? cfg?.slotCapacity ?? null,
|
|
};
|
|
}
|
|
|
|
export interface Selection {
|
|
locationId: string;
|
|
method: Method;
|
|
date: string;
|
|
slot: string; // "HH:MM-HH:MM"
|
|
}
|
|
|
|
export interface Hold extends Selection {
|
|
token: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
export type HoldResult =
|
|
| { ok: true; hold: Hold }
|
|
| { ok: false; code: "DAY_FULL" | "SLOT_FULL"; message: string };
|
|
|
|
export type ConfirmResult =
|
|
| { ok: true; booking: Booking; duplicate: boolean; reReserved?: boolean }
|
|
| { ok: false; booking: Booking; code: "NEEDS_REVIEW"; message: string };
|
|
|
|
export interface WaitlistEntry extends Selection {
|
|
id: string;
|
|
contact: { email?: string; phone?: string; name?: string };
|
|
createdAt: number;
|
|
notifiedAt?: number;
|
|
}
|
|
|
|
export class MemoryReservationStore {
|
|
holds = new Map<string, Hold>();
|
|
bookings = new Map<string, Booking>();
|
|
waitlist: WaitlistEntry[] = [];
|
|
audit: { at: number; actor: string; bookingId: string; action: string; before?: unknown; after?: unknown }[] = [];
|
|
private seq = 0;
|
|
|
|
private counts(sel: Selection, now: number, excludeToken?: string, excludeBooking?: string) {
|
|
let day = 0;
|
|
let slot = 0;
|
|
const same = (s: Selection) => s.locationId === sel.locationId && s.method === sel.method && s.date === sel.date;
|
|
for (const b of this.bookings.values()) {
|
|
// Only confirmed bookings consume capacity; flagged ones wait for a merchant decision.
|
|
if (b.id === excludeBooking || b.status !== "confirmed") continue;
|
|
const s = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` };
|
|
if (same(s)) {
|
|
day++;
|
|
if (s.slot === sel.slot) slot++;
|
|
}
|
|
}
|
|
for (const h of this.holds.values()) {
|
|
if (h.token === excludeToken || h.expiresAt <= now) continue;
|
|
if (same(h)) {
|
|
day++;
|
|
if (h.slot === sel.slot) slot++;
|
|
}
|
|
}
|
|
return { day, slot };
|
|
}
|
|
|
|
usage(now: number): UsageSnapshot {
|
|
const out: UsageSnapshot = {};
|
|
const add = (s: Selection) => {
|
|
const k = usageKey(s.locationId, s.method, s.date);
|
|
out[k] ??= { day: 0, slots: {} };
|
|
out[k].day++;
|
|
out[k].slots[s.slot] = (out[k].slots[s.slot] ?? 0) + 1;
|
|
};
|
|
for (const b of this.bookings.values())
|
|
if (b.status === "confirmed")
|
|
add({ locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` });
|
|
for (const h of this.holds.values()) if (h.expiresAt > now) add(h);
|
|
return out;
|
|
}
|
|
|
|
private fits(sel: Selection, caps: Caps, now: number, excludeToken?: string, excludeBooking?: string) {
|
|
const c = this.counts(sel, now, excludeToken, excludeBooking);
|
|
if (caps.dayCap != null && c.day >= caps.dayCap) return "DAY_FULL" as const;
|
|
if (caps.slotCap != null && c.slot >= caps.slotCap) return "SLOT_FULL" as const;
|
|
return null;
|
|
}
|
|
|
|
/** Atomic check-and-reserve. Re-holding with the same token moves the hold. */
|
|
hold(token: string, sel: Selection, caps: Caps, now: number, holdMinutes: number): HoldResult {
|
|
const full = this.fits(sel, caps, now, token);
|
|
if (full) return { ok: false, code: full, message: full === "DAY_FULL" ? "Fully booked for this day" : "This time slot was just taken" };
|
|
const hold: Hold = { ...sel, token, expiresAt: now + holdMinutes * 60000 };
|
|
this.holds.set(token, hold);
|
|
return { ok: true, hold };
|
|
}
|
|
|
|
release(token: string) {
|
|
this.holds.delete(token);
|
|
}
|
|
|
|
sweep(now: number): number {
|
|
let n = 0;
|
|
for (const [t, h] of this.holds) if (h.expiresAt <= now) (this.holds.delete(t), n++);
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* Confirm a hold against an order. Idempotent per orderId (duplicate webhooks are safe).
|
|
* Expired hold: re-reserve if capacity still allows, otherwise flag for review —
|
|
* never silently overbook.
|
|
*/
|
|
confirm(
|
|
orderId: string,
|
|
token: string | null,
|
|
fallbackSel: Selection | null,
|
|
caps: Caps,
|
|
now: number,
|
|
details: Partial<Booking> & { timezone: string },
|
|
): ConfirmResult {
|
|
const existing = [...this.bookings.values()].find((b) => b.orderId === orderId);
|
|
if (existing) return { ok: true, booking: existing, duplicate: true };
|
|
|
|
const hold = token ? this.holds.get(token) : undefined;
|
|
const sel = hold ?? fallbackSel;
|
|
const id = `bk_${++this.seq}`;
|
|
const base = (status: Booking["status"]): Booking => ({
|
|
...details,
|
|
id,
|
|
orderId,
|
|
status,
|
|
method: sel!.method,
|
|
locationId: sel!.locationId,
|
|
date: sel!.date,
|
|
slotStart: sel!.slot.split("-")[0],
|
|
slotEnd: sel!.slot.split("-")[1],
|
|
rescheduleCount: 0,
|
|
});
|
|
if (!sel) {
|
|
const b: Booking = { ...details, id, orderId, status: "needs_review", method: "pickup", locationId: "", date: "" };
|
|
this.bookings.set(id, b);
|
|
return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Order has no scheduling data" };
|
|
}
|
|
const live = hold && hold.expiresAt > now;
|
|
if (!live) {
|
|
const full = this.fits(sel, caps, now, token ?? undefined);
|
|
if (full) {
|
|
const b = base("needs_review");
|
|
this.bookings.set(id, b);
|
|
if (token) this.holds.delete(token);
|
|
this.log(now, "system", id, "flagged", undefined, { reason: full });
|
|
return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Hold expired and the slot filled up" };
|
|
}
|
|
}
|
|
const b = base("confirmed");
|
|
this.bookings.set(id, b);
|
|
if (token) this.holds.delete(token);
|
|
this.log(now, "system", id, "confirmed");
|
|
return { ok: true, booking: b, duplicate: false, reReserved: !live };
|
|
}
|
|
|
|
/** Atomic move: validates the new slot, then releases old + takes new together. */
|
|
reschedule(
|
|
bookingId: string,
|
|
sel: Selection,
|
|
caps: Caps,
|
|
now: number,
|
|
actor: string,
|
|
): { ok: true; booking: Booking; freed: Selection } | { ok: false; message: string } {
|
|
const b = this.bookings.get(bookingId);
|
|
if (!b || b.status !== "confirmed") return { ok: false, message: "Booking can't be rescheduled" };
|
|
const full = this.fits(sel, caps, now, undefined, bookingId);
|
|
if (full) return { ok: false, message: full === "DAY_FULL" ? "That day is fully booked" : "That slot is full" };
|
|
const before = { ...b };
|
|
const freed: Selection = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` };
|
|
const [s, e] = sel.slot.split("-");
|
|
Object.assign(b, {
|
|
locationId: sel.locationId,
|
|
method: sel.method,
|
|
date: sel.date,
|
|
slotStart: s,
|
|
slotEnd: e,
|
|
rescheduleCount: (b.rescheduleCount ?? 0) + 1,
|
|
});
|
|
this.log(now, actor, bookingId, "rescheduled", pick(before), pick(b));
|
|
return { ok: true, booking: b, freed };
|
|
}
|
|
|
|
cancel(orderId: string, now: number, releaseCapacity = true): Booking | null {
|
|
const b = [...this.bookings.values()].find((x) => x.orderId === orderId);
|
|
if (!b || b.status === "cancelled") return b ?? null;
|
|
const before = { ...b };
|
|
b.status = releaseCapacity ? "cancelled" : b.status;
|
|
this.log(now, "system", b.id, "cancelled", pick(before), pick(b));
|
|
return b;
|
|
}
|
|
|
|
joinWaitlist(sel: Selection, contact: WaitlistEntry["contact"], now: number): WaitlistEntry {
|
|
const e: WaitlistEntry = { ...sel, id: `wl_${++this.seq}`, contact, createdAt: now };
|
|
this.waitlist.push(e);
|
|
return e;
|
|
}
|
|
|
|
/** Who to notify when capacity frees up for a selection (FIFO, one at a time). */
|
|
nextWaitlisted(sel: Selection): WaitlistEntry | null {
|
|
return (
|
|
this.waitlist.find(
|
|
(w) =>
|
|
!w.notifiedAt &&
|
|
w.locationId === sel.locationId &&
|
|
w.method === sel.method &&
|
|
w.date === sel.date &&
|
|
(w.slot === sel.slot || w.slot === "*"),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
private log(at: number, actor: string, bookingId: string, action: string, before?: unknown, after?: unknown) {
|
|
this.audit.push({ at, actor, bookingId, action, before, after });
|
|
}
|
|
}
|
|
|
|
function pick(b: Booking) {
|
|
return { locationId: b.locationId, method: b.method, date: b.date, slotStart: b.slotStart, slotEnd: b.slotEnd, status: b.status };
|
|
}
|