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 } from "./zones.server"; import { resolveRate } from "./rates.server"; import { formatPriceLabel } from "../lib/currency"; // 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; } 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; 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; let location: Awaited> = null; let zoneId: string | null = null; let distanceKm: number | null = null; // 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])); for (const match of matches) { if (locationIdParam && match.locationId !== locationIdParam) continue; 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 { location = locationIdParam ? await db.location.findFirst({ where: { id: locationIdParam, shopDomain, active: true } }) : await db.location.findFirst({ where: { shopDomain, active: true }, orderBy: { createdAt: "asc" } }); } if (!location) { return { locationId: null, method, dates: {}, zoneId: null, distanceKm: null, rate: null, error: "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, method, status: { in: ["confirmed", "fulfilled"] }, slotStart: { gte: bookingRangeStart, lte: bookingRangeEnd }, }, select: { slotStart: true }, }), db.rate.findMany({ where: { shopDomain, method } }), ]); const consumed = new Map(); for (const booking of bookings) { const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone); const key = slotKey(local.toISODate()!, 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, leadTimeMin: t.leadTimeMin, })), 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, }); 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, }; }