import type { ActionFunctionArgs } from "@remix-run/node"; import type { Method } from "@prisma/client"; import { authenticate } from "../shopify.server"; import db from "../db.server"; import { slotDateTime, weekdayOf } from "../lib/time"; import { remainingCapacity } from "../services/capacity.server"; import { tryCreateHold, releaseHold, countActiveHolds } from "../services/holds.server"; // Public app-proxy endpoint (see apps.scheduling.availability.tsx for the // path-mirroring rationale). Called by the widget the moment a shopper // picks a slot, before it writes the cart attribute — this is what // actually reserves capacity (PRODUCT_STRATEGY.md §3.1, §4.1: "the // last-slot race condition"). The cart attribute write alone would just be // two shoppers racing to write the same free-text field; nothing would // stop both orders from completing. const VALID_METHODS = new Set(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]); export const action = async ({ request }: ActionFunctionArgs) => { const { session } = await authenticate.public.appProxy(request); if (!session) { return Response.json({ error: "Shop not found" }, { status: 404 }); } const body = await request.json(); const { intent, locationId, method, date, startMin, cartToken } = body as { intent?: "create" | "release"; locationId?: string; method?: string; date?: string; startMin?: number; cartToken?: string; }; if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) { return Response.json({ error: "Missing or invalid parameters" }, { status: 400 }); } const location = await db.location.findFirst({ where: { id: locationId, shopDomain: session.shop, active: true }, }); if (!location) { return Response.json({ error: "Location not found" }, { status: 404 }); } const slotStart = slotDateTime(date, startMin, location.timezone); const slot = { shopDomain: session.shop, locationId: location.id, method: method as Method, slotStartIso: slotStart.toUTC().toISO()!, }; if (intent === "release") { await releaseHold(slot, cartToken); return Response.json({ ok: true }); } const template = await db.slotTemplate.findFirst({ where: { shopDomain: session.shop, locationId: location.id, method: slot.method, weekday: weekdayOf(date, location.timezone), startMin, }, }); if (!template) { return Response.json({ error: "Slot not found" }, { status: 404 }); } const confirmedCount = await db.booking.count({ where: { shopDomain: session.shop, locationId: location.id, method: slot.method, slotStart: slotStart.toJSDate(), status: "confirmed", }, }); const capacityBudget = remainingCapacity({ capacity: template.capacity, consumed: confirmedCount }); if (capacityBudget <= 0) { return Response.json({ success: false, error: "Slot is full" }, { status: 409 }); } const result = await tryCreateHold(slot, cartToken, capacityBudget); if (!result.success) { return Response.json({ success: false, error: "Slot was just taken" }, { status: 409 }); } const activeHolds = await countActiveHolds(slot); return Response.json({ success: true, expiresAt: result.expiresAt, remaining: Math.max(0, capacityBudget - activeHolds), }); };