import { DateTime } from "luxon"; import type { Method } from "@prisma/client"; import db from "../db.server"; import { getAvailability, type AvailableSlot } from "./scheduling.server"; import { findEligibleLocationsForDelivery, meetsDeliveryDensity, excludeLocationsWithoutStock, type AdminGraphQLClient, } from "./zones.server"; import { resolveRate } from "./rates.server"; import { formatPriceLabel } from "../lib/currency"; import { resolveProductRuleConstraints, productRefsFromCartLines, type ProductRef } from "./product-rules.server"; import { resolvePickupLocations, type PickupLocationDto } from "./pickup-locations.server"; // The single availability resolver every surface calls — storefront widget // (via apps.scheduling.availability.tsx, app-proxy auth), POS (via // pos.scheduling.availability.tsx, session-token auth), and eventually // Checkout UI. CLAUDE.md's non-negotiable: "One Scheduling Service + one // capacity pool feeds every surface... behavior must never diverge between // channels" — this is that single point, not just a shared convention each // route re-implements. const MAX_DAYS = 60; const DEFAULT_DAYS = 14; export interface AvailabilityRequestParams { method: Method; locationId?: string; postalCode?: string; address?: string; days?: number; /** * Cart contents for ProductRule scoping (PRODUCT_STRATEGY.md §2). `cartLines` * (vendor/productType, straight from the storefront's /cart.js — no extra * round-trip) covers vendor/type-scoped rules for free; `productRefs` (already * resolved via the Admin API by the caller — see product-rules.server.ts's * resolveProductRefs) additionally covers product/collection/tag-scoped rules. * Both are optional so existing callers (POS, pre-cart availability checks) * keep working unchanged. */ cartLines?: Array<{ vendor?: string; productType?: string }>; productRefs?: ProductRef[]; /** * Cart variant GIDs + an Admin API client — when both are supplied, any * candidate location that stocks none of them is dropped from selection * (study §3.4 "product-inventory-based location choice"). Only the * app-proxy surface has an Admin client; POS/checkout pass neither and * skip the check. */ productVariantGids?: string[]; admin?: AdminGraphQLClient; } export interface AvailabilityResult { locationId: string | null; locationName?: string; locationLat?: number | null; locationLng?: number | null; timezone?: string; method: Method; dates: Record; zoneId: string | null; distanceKm: number | null; rate: { name: string; priceCents: number; label: string } | null; /** * All eligible pickup points, for the multi-pin selector (study §3.4). * Present only for `method === "PICKUP"`. `dates` above is for whichever * one is currently active (`locationId` / `locationName`); the caller * re-requests with `locationId` when the shopper picks a different pin. */ pickupLocations?: PickupLocationDto[]; error?: string; } function toIsoDate(date: Date): string { return date.toISOString().slice(0, 10); } /** getAvailability's consumed-capacity map key — must match scheduling.server.ts's slotKey() exactly. */ function slotKey(date: string, startMin: number): string { return `${date}|${startMin}`; } export async function resolveAvailabilityRequest( shopDomain: string, params: AvailabilityRequestParams, ): Promise { const { method, locationId: locationIdParam, postalCode, address } = params; const days = params.days && params.days > 0 ? Math.min(params.days, MAX_DAYS) : DEFAULT_DAYS; // ProductRule scoping (PRODUCT_STRATEGY.md §2): resolve before touching // locations, since an allowedLocationIds restriction narrows which // locations are even eligible to be picked, not just which one filters // through afterward. const productRules = await db.productRule.findMany({ where: { shopDomain, active: true } }); const cartProductRefs = [...(params.productRefs ?? []), ...productRefsFromCartLines(params.cartLines ?? [])]; const ruleConstraints = resolveProductRuleConstraints(productRules, cartProductRefs); if (ruleConstraints.allowedMethods != null && !ruleConstraints.allowedMethods.includes(method)) { return { locationId: null, method, dates: {}, zoneId: null, distanceKm: null, rate: null, error: "One or more items in your cart aren't available with this fulfillment method.", }; } let location: Awaited> = null; let zoneId: string | null = null; let distanceKm: number | null = null; let pickupLocations: PickupLocationDto[] | undefined; // Inventory-based location exclusion (study §3.4): only runs on the // app-proxy surface, which is the only one that hands us an Admin client // plus the cart's variant GIDs. `inStock` is the identity set otherwise. const stockCheckEnabled = Boolean(params.admin) && (params.productVariantGids?.length ?? 0) > 0; const inStock = async (locationIds: string[]): Promise> => stockCheckEnabled ? excludeLocationsWithoutStock(params.admin!, locationIds, params.productVariantGids!) : new Set(locationIds); // Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to // the nearest eligible, density-qualified zone for the shopper's address. // Falls through to the plain location lookup below for PICKUP/SHIPPING, // or when no address was supplied yet, so the picker can still show // something before that input exists. if (method === "LOCAL_DELIVERY" && (postalCode || address)) { const matches = await findEligibleLocationsForDelivery(shopDomain, { postalCode, address }); const zones = matches.length ? await db.zone.findMany({ where: { id: { in: matches.map((m) => m.zoneId) } } }) : []; const zoneById = new Map(zones.map((z) => [z.id, z])); const stockedLocationIds = await inStock([...new Set(matches.map((m) => m.locationId))]); for (const match of matches) { if (locationIdParam && match.locationId !== locationIdParam) continue; if (ruleConstraints.allowedLocationIds != null && !ruleConstraints.allowedLocationIds.includes(match.locationId)) continue; if (!stockedLocationIds.has(match.locationId)) continue; // location doesn't stock the cart const zone = zoneById.get(match.zoneId); if (!zone) continue; // eslint-disable-next-line no-await-in-loop -- checked in nearest-first order; stop at the first that qualifies if (!(await meetsDeliveryDensity(shopDomain, zone))) continue; zoneId = match.zoneId; distanceKm = match.distanceKm; location = await db.location.findFirst({ where: { id: match.locationId, shopDomain, active: true } }); break; } if (!location) { return { locationId: null, method, dates: {}, zoneId: null, distanceKm: null, rate: null, error: "This address is outside our delivery area right now.", }; } } else if (method === "PICKUP") { // Multi-pin pickup (study §3.4): resolve every eligible pickup point so // the widget/checkout can offer a chooser, then narrow the date picker // to the shopper's pick (or the first eligible one). pickupLocations = await resolvePickupLocations(shopDomain, { allowedLocationIds: ruleConstraints.allowedLocationIds, admin: params.admin, productVariantGids: params.productVariantGids, }); const activeId = locationIdParam && pickupLocations.some((l) => l.id === locationIdParam) ? locationIdParam : pickupLocations[0]?.id; location = activeId ? await db.location.findFirst({ where: { id: activeId, shopDomain, active: true } }) : null; } else { const locationWhere = ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {}; const candidates = await db.location.findMany({ where: locationIdParam ? { id: locationIdParam, shopDomain, active: true, ...locationWhere } : { shopDomain, active: true, ...locationWhere }, orderBy: { createdAt: "asc" }, }); const stocked = await inStock(candidates.map((c) => c.id)); location = candidates.find((c) => stocked.has(c.id)) ?? null; } if (!location) { return { locationId: null, method, dates: {}, zoneId: null, distanceKm: null, rate: null, pickupLocations: method === "PICKUP" ? [] : undefined, error: ruleConstraints.allowedLocationIds != null ? "One or more items in your cart aren't available at this location." : stockCheckEnabled ? "No location currently stocks every item in your cart." : method === "PICKUP" ? "No pickup location is available right now." : "No active location configured", }; } const now = DateTime.now().setZone(location.timezone); const startDate = now.toISODate()!; const endDate = now.plus({ days }).toISODate()!; const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate(); const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).toJSDate(); // Bookings are keyed by exact instant; widen the window by a day on each // side so a slot near midnight UTC-offset boundaries isn't miscounted. const bookingRangeStart = DateTime.fromISO(startDate, { zone: "utc" }).minus({ days: 1 }).toJSDate(); const bookingRangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate(); const [slotTemplates, overrides, blackouts, bookings, rates] = await Promise.all([ db.slotTemplate.findMany({ where: { shopDomain, locationId: location.id, method } }), db.slotOverride.findMany({ where: { shopDomain, locationId: location.id, method, date: { gte: rangeStart, lte: rangeEnd } }, }), db.blackoutDate.findMany({ where: { shopDomain, date: { gte: rangeStart, lte: rangeEnd }, AND: [{ OR: [{ locationId: location.id }, { locationId: null }] }, { OR: [{ method }, { method: null }] }], }, }), db.booking.findMany({ where: { shopDomain, locationId: location.id, // Per-day cap (study §3.3) counts every method at the location, so the // daily tally can't be filtered to one method — the per-slot tally // below still keys by exact start instant, which is method-specific // enough on its own. status: { in: ["confirmed", "fulfilled"] }, slotStart: { gte: bookingRangeStart, lte: bookingRangeEnd }, }, select: { slotStart: true, method: true }, }), db.rate.findMany({ where: { shopDomain, method } }), ]); const consumed = new Map(); const consumedPerDate = new Map(); for (const booking of bookings) { const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone); const isoDate = local.toISODate()!; consumedPerDate.set(isoDate, (consumedPerDate.get(isoDate) ?? 0) + 1); if (booking.method !== method) continue; // per-slot capacity is for this method's picker only const key = slotKey(isoDate, local.hour * 60 + local.minute); consumed.set(key, (consumed.get(key) ?? 0) + 1); } const availability = getAvailability({ timezone: location.timezone, dateRange: { startDate, endDate }, slotTemplates: slotTemplates.map((t) => ({ weekday: t.weekday, startMin: t.startMin, endMin: t.endMin, capacity: t.capacity, cutoffMin: t.cutoffMin, // ProductRule-driven prep-time floor (per-product/vendor/collection lead // time and cart-content-based slot blocking, PRODUCT_STRATEGY.md §2) // stacks with the slot's own leadTimeMin — getAvailability already takes // the max of leadTimeMin/cutoffMin, so folding it in here keeps that // function's signature and purity guarantee untouched. leadTimeMin: Math.max(t.leadTimeMin, ruleConstraints.minLeadTimeMin), transitMinDays: t.transitMinDays, transitMaxDays: t.transitMaxDays, })), overrides: overrides.map((o) => ({ date: toIsoDate(o.date), closed: o.closed, startMin: o.startMin, endMin: o.endMin, capacity: o.capacity, })), blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })), now, consumed, dailyCap: location.dailyOrderCap, consumedPerDate, blockedStartMins: ruleConstraints.blockedStartMins, }); const matchedRate = resolveRate(rates, { method, zoneId: zoneId ?? undefined, distanceKm: distanceKm ?? undefined }); return { locationId: location.id, locationName: location.name, locationLat: location.lat, locationLng: location.lng, timezone: location.timezone, method, dates: availability, zoneId, distanceKm, rate: matchedRate ? { name: matchedRate.name, priceCents: matchedRate.priceCents, label: formatPriceLabel(matchedRate.priceCents) } : null, pickupLocations, }; }