datewise/app/services/bookings.server.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

105 lines
5.3 KiB
TypeScript

/**
* Booking changes initiated by staff or customers. Customers go through the same engine
* validation as checkout (rules, cutoffs, freshness, capacity); staff may override capacity.
*/
import prisma from "../db.server";
import { computeAvailability, validateSelection } from "../engine/availability";
import { capsFor, type Selection } from "../engine/capacity";
import { canCustomerReschedule } from "../engine/customer";
import { addDays, localParts, zonedToUtc } from "../engine/time";
import type { Cart, Method } from "../engine/types";
import { loadShopConfig } from "./config.server";
import { features } from "./plans";
import { rowToBooking, scheduleReminders } from "./reminders.server";
import { ReservationService } from "./reservations.server";
import { offerFreedPlace } from "./waitlist.server";
const reservations = new ReservationService(prisma);
export async function rescheduleBooking(opts: {
domain: string;
bookingId: string;
sel: Selection;
actor: string; // "customer" | "merchant:<id>"
force?: boolean;
}) {
const cfg = await loadShopConfig(opts.domain, { fresh: true });
const row = await prisma.booking.findFirst({ where: { id: opts.bookingId, shopId: cfg.shop.id } });
if (!row) return { ok: false as const, message: "Booking not found" };
const loc = cfg.locations.find((l) => l.id === opts.sel.locationId);
if (!loc) return { ok: false as const, message: "Unknown location" };
const now = Date.now();
const isCustomer = opts.actor === "customer";
if (isCustomer) {
const can = canCustomerReschedule(rowToBooking(row), cfg.settings.reschedule, now);
if (!can.ok) return { ok: false as const, message: can.reason };
if (!cfg.settings.reschedule.allowMethodChange && (opts.sel.method !== row.method || opts.sel.locationId !== row.locationId))
return { ok: false as const, message: "Only the date and time can be changed" };
}
// Customers: full rule check with the booked items. Staff: hours/closures only (no product rules).
const items = ((row.items as { title: string; quantity: number }[] | null) ?? []).map((i, n) => ({
productId: `item-${n}`, variantId: `item-${n}`, title: i.title, quantity: i.quantity, price: 0,
}));
const cart: Cart = { items: isCustomer ? items : [] };
const today = localParts(now, cfg.shop.timezone).date;
const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date(now));
if (row.status === "confirmed" && row.locationId && row.date && row.slotStart) {
// Don't count the booking against itself when re-validating.
const k = `${row.locationId}|${row.method}|${row.date}`;
if (usage[k]) {
usage[k].day = Math.max(0, usage[k].day - 1);
const sk = `${row.slotStart}-${row.slotEnd}`;
usage[k].slots[sk] = Math.max(0, (usage[k].slots[sk] ?? 0) - 1);
}
}
const peaks = features(cfg.shop.plan).peakDays ? cfg.peakDays : [];
const v = validateSelection(
{ location: loc, method: opts.sel.method as Method, cart, rules: isCustomer ? cfg.rules : [], peakDays: peaks, usage, settings: cfg.settings, now },
opts.sel.date,
opts.sel.slot,
);
const capacityOnly = !v.ok && v.reasons.every((r) => r.code === "SLOT_FULL" || r.code === "DAY_FULL");
if (!v.ok && !(opts.force && !isCustomer && capacityOnly)) return { ok: false as const, message: v.reasons[0]?.message ?? "Not available" };
const [s, e] = opts.sel.slot.split("-");
const r = await reservations.reschedule(cfg.shop.id, row.id, opts.sel, capsFor(loc, opts.sel.method, opts.sel.date, opts.sel.slot, peaks), new Date(now), opts.actor, {
startUtc: new Date(zonedToUtc(opts.sel.date, s, loc.timezone)),
endUtc: new Date(zonedToUtc(opts.sel.date, e, loc.timezone)),
force: opts.force && !isCustomer,
});
if (!r.ok) return r;
await scheduleReminders(row.id);
if (r.freed) await offerFreedPlace(opts.domain, r.freed);
return { ok: true as const };
}
/** Options for a reschedule picker: available slots for a booking's location/method. */
export async function rescheduleOptions(domain: string, bookingId: string, opts: { customer: boolean }) {
const cfg = await loadShopConfig(domain);
const row = await prisma.booking.findFirst({ where: { id: bookingId, shopId: cfg.shop.id } });
if (!row) return [];
const loc = cfg.locations.find((l) => l.id === (row.locationId || cfg.locations[0]?.id));
if (!loc) return [];
const now = Date.now();
const today = localParts(now, cfg.shop.timezone).date;
const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date(now));
const items = ((row.items as { title: string; quantity: number }[] | null) ?? []).map((i, n) => ({ productId: `item-${n}`, variantId: `item-${n}`, title: i.title, quantity: i.quantity, price: 0 }));
const av = computeAvailability({
location: loc,
method: row.method as Method,
cart: { items: opts.customer ? items : [] },
rules: opts.customer ? cfg.rules : [],
peakDays: features(cfg.shop.plan).peakDays ? cfg.peakDays : [],
usage,
settings: cfg.settings,
now,
});
return av.dates.flatMap((d) =>
d.slots
.filter((s) => s.available || (!opts.customer && s.reasons.every((r) => /FULL/.test(r.code))))
.map((s) => ({ locationId: loc.id, method: row.method, date: d.date, slot: s.id, start: s.start, end: s.end, remaining: s.remaining, full: !s.available })),
);
}