/** * 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:" 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 })), ); }