/** * Storefront API used by the theme widget via the app proxy. * Every response is computed by the same engine the tests cover; the widget only renders it. */ import { randomBytes } from "node:crypto"; import { z } from "zod"; import prisma from "../db.server"; import { firstAvailable, validateSelection } from "../engine/availability"; import { capsFor } from "../engine/capacity"; import { rankLocations } from "../engine/locations"; import { evaluateRules } from "../engine/rules"; import { addDays, localParts } from "../engine/time"; import { METHODS, type Method } from "../engine/types"; import { quoteFee } from "../engine/zones"; import { enrichCart } from "./catalog.server"; import { type ShopConfig, loadShopConfig } from "./config.server"; import { ensureFeeVariant } from "./fees.server"; import { resolveAddress } from "./geocode.server"; import { features } from "./plans"; import { ReservationService } from "./reservations.server"; import { SELECTION_ATTR } from "./settings"; import { blindIndex } from "./crypto.server"; export const reservations = new ReservationService(prisma); type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; const LineSchema = z.object({ variantId: z.union([z.string(), z.number()]), quantity: z.number().int().min(1).max(10000), price: z.number().int().min(0).optional(), title: z.string().max(300).optional(), }); export const QuerySchema = z.object({ lines: z.array(LineSchema).max(250), method: z.enum(["pickup", "delivery", "shipping"]).optional(), locationId: z.string().optional(), postal: z.string().max(12).optional(), address: z.string().max(300).optional(), country: z.string().max(60).optional(), token: z.string().max(80).optional(), locale: z.string().max(10).optional(), }); export const HoldSchema = QuerySchema.extend({ method: z.enum(["pickup", "delivery", "shipping"]), locationId: z.string(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), slot: z.string().regex(/^\d\d:\d\d-\d\d:\d\d$/), }); async function context(domain: string, admin: AdminGraphql | undefined, q: z.infer) { const cfg = await loadShopConfig(domain); const now = Date.now(); const s2a = Object.fromEntries(cfg.locations.filter((l) => l.shopifyLocationId).map((l) => [l.shopifyLocationId!, l.id])); const { cart, inventory, feeLines } = admin ? await enrichCart(admin, domain, q.lines, s2a) : { cart: { items: [], subtotal: 0 }, inventory: {}, feeLines: [] }; const address = await resolveAddress({ postal: q.postal, address: q.address, country: q.country }, cfg.locations); 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), q.token); const f = features(cfg.shop.plan); const locations = cfg.locations.slice(0, f.maxLocations); return { cfg, now, cart, inventory, feeLines, address, usage, locations, f }; } type Ctx = Awaited>; function rank(ctx: Ctx, method: Method) { const { cfg } = ctx; return rankLocations({ locations: ctx.locations, method, cart: ctx.cart, rules: ctx.f.productRules ? cfg.rules : [], peakDays: ctx.f.peakDays ? cfg.peakDays : [], usage: ctx.usage, settings: cfg.settings, now: ctx.now, address: ctx.address, inventory: ctx.f.inventoryAware ? ctx.inventory : undefined, ranking: cfg.settings.locationRanking, }); } function widgetText(cfg: ShopConfig, locale?: string) { const w = cfg.settings.widget; const t = (locale && (w.translations[locale] ?? w.translations[locale.split("-")[0]])) || {}; return { heading: t.heading ?? w.heading, labels: { pickup: t.pickup ?? w.labels.pickup, delivery: t.delivery ?? w.labels.delivery, shipping: t.shipping ?? w.labels.shipping }, accent: w.accent, showRemaining: w.showRemaining, weekStartsOn: cfg.settings.weekStartsOn, }; } export async function getAvailability(domain: string, admin: AdminGraphql | undefined, raw: unknown) { const q = QuerySchema.parse(raw); const ctx = await context(domain, admin, q); const { cfg } = ctx; const offered = METHODS.filter((m) => ctx.locations.some((l) => l.enabled && l.methods[m]?.enabled)); const ranked = Object.fromEntries(offered.map((m) => [m, rank(ctx, m)])) as Record>; const noAddress = !q.postal && !q.address; const methods = offered.map((m) => { const r = ranked[m]; const ok = r.ranked.filter((x) => x.eligible); const first = ok.map((x) => x.first?.date).filter(Boolean).sort()[0] ?? null; // Delivery can't be judged before we know where: keep it selectable and ask for a postal code, // unless something else (a product rule, stock) already rules it out. const onlyAddress = r.ranked.length > 0 && r.ranked.every((x) => x.eligible || x.reasons.every((y) => y.code === "OUTSIDE_DELIVERY_AREA")); const needsAddress = m === "delivery" && noAddress && !ok.length && onlyAddress; return { method: m, available: ok.length > 0 || needsAddress, needsAddress, firstDate: first, reason: ok.length || needsAddress ? null : r.splitRequired?.message ?? r.ranked[0]?.reasons[0]?.message ?? "Unavailable", }; }); const method: Method | null = q.method && offered.includes(q.method) ? q.method : methods.find((m) => m.available && !m.needsAddress)?.method ?? methods.find((m) => m.available)?.method ?? null; if (!method) return { ok: true, version: cfg.version, text: widgetText(cfg, q.locale), methods, method: null, locations: [], dates: [], requireScheduling: cfg.settings.requireScheduling }; const r = ranked[method]; const chosen = r.ranked.find((x) => x.location.id === q.locationId && x.eligible) ?? r.ranked.find((x) => x.eligible) ?? null; const cons = evaluateRules(ctx.cart, ctx.f.productRules ? cfg.rules : [], cfg.settings.mixedPrepPolicy); const av = chosen?.availability; return { ok: true, version: cfg.version, text: widgetText(cfg, q.locale), holdMinutes: cfg.settings.holdMinutes, requireScheduling: cfg.settings.requireScheduling, methods, method, split: r.splitRequired?.message ?? null, needsAddress: method === "delivery" && noAddress, locations: r.ranked.map((x) => ({ id: x.location.id, name: x.location.name, address: x.location.address, eligible: x.eligible, reason: x.eligible ? null : method === "delivery" && noAddress && x.reasons[0]?.code === "OUTSIDE_DELIVERY_AREA" ? "Enter your postal code to see delivery times" : x.reasons[0]?.message ?? null, first: x.first, distanceKm: x.distanceKm != null ? Math.round(x.distanceKm * 10) / 10 : null, zone: x.zone?.zone?.name ?? null, fee: x.fee ? { fee: x.fee.fee, waived: x.fee.waived, freeOver: x.zone?.zone?.freeOver ?? null } : null, pickupInstructions: method === "pickup" ? x.location.pickupInstructions ?? null : null, })), locationId: chosen?.location.id ?? null, prepNote: cons.prepDriver && av ? { item: cons.prepDriver.itemTitle, minutes: av.prepMinutes } : null, dateOnly: av?.dateOnly ?? false, timezone: av?.timezone ?? cfg.shop.timezone, dates: (av?.dates ?? []).map((d) => ({ date: d.date, available: d.available, remaining: d.remaining, peak: d.peak, surcharge: d.surcharge, waitlist: d.waitlist && ctx.f.waitlist, reasons: d.reasons.map((x) => x.message), slots: d.slots.map((s) => ({ id: s.id, start: s.start, end: s.end, available: s.available, remaining: s.remaining, surcharge: s.surcharge, reason: s.reasons[0]?.message ?? null, })), })), first: av ? firstAvailable(av) : null, }; } export async function holdSelection(domain: string, admin: AdminGraphql, raw: unknown) { const q = HoldSchema.parse(raw); const ctx = await context(domain, admin, q); const { cfg } = ctx; const r = rank(ctx, q.method); const entry = r.ranked.find((x) => x.location.id === q.locationId); if (!entry || !entry.eligible) return { ok: false, message: entry?.reasons[0]?.message ?? r.splitRequired?.message ?? "That location isn't available for this cart" }; const loc = entry.location; const v = validateSelection( { location: loc, method: q.method, cart: ctx.cart, rules: ctx.f.productRules ? cfg.rules : [], peakDays: ctx.f.peakDays ? cfg.peakDays : [], usage: ctx.usage, settings: cfg.settings, now: ctx.now, }, q.date, q.slot, ); if (!v.ok) return { ok: false, message: v.reasons[0]?.message ?? "That time isn't available" }; const extras: { label: string; amount: number }[] = []; if (v.slot.surcharge) extras.push({ label: "Time slot", amount: v.slot.surcharge }); if (v.date.peak && v.date.surcharge) extras.push({ label: v.date.peak.name, amount: v.date.surcharge }); const fee = quoteFee(q.method === "delivery" ? entry.zone?.zone ?? null : null, ctx.cart.subtotal ?? 0, extras); const token = q.token && /^[A-Za-z0-9_-]{16,80}$/.test(q.token) ? q.token : randomBytes(18).toString("base64url"); const caps = capsFor(loc, q.method, q.date, q.slot, ctx.f.peakDays ? cfg.peakDays : []); const held = await reservations.hold(cfg.shop.id, token, { locationId: loc.id, method: q.method, date: q.date, slot: q.slot }, caps, new Date(ctx.now), cfg.settings.holdMinutes, fee.fee); if (!held.ok) return { ok: false, message: held.message }; const feeVariantId = fee.fee > 0 ? await ensureFeeVariant(admin, cfg.shop.id, fee.fee) : null; const dateOnly = loc.methods[q.method]?.slotMode === "dateOnly"; const human = humanAttributes(cfg, q.method, loc.name, q.date, v.slot.start, v.slot.end, v.date.peak?.name, dateOnly); return { ok: true, token, expiresAt: held.expiresAt.toISOString(), fee, feeVariantId, attributes: { [SELECTION_ATTR]: JSON.stringify({ t: token, m: q.method, l: loc.id, d: q.date, s: q.slot, f: fee.fee, v: cfg.version }), ...human, }, }; } /** Readable cart/order attributes for staff (visible on the Shopify order page and packing slips). */ function humanAttributes(cfg: ShopConfig, method: Method, location: string, date: string, start: string, end: string, peak?: string, dateOnly?: boolean) { const label = cfg.settings.widget.labels[method]; const nice = new Date(`${date}T12:00:00Z`).toLocaleDateString("en-CA", { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); return { "Fulfillment method": label, [method === "pickup" ? "Pickup location" : "Fulfilled from"]: location, [method === "shipping" ? "Dispatch date" : `${label} date`]: nice, ...(method !== "shipping" && !dateOnly ? { [`${label} time`]: `${start}–${end}` } : {}), ...(peak ? { "Peak day": peak } : {}), }; } export async function releaseSelection(domain: string, token: string) { const cfg = await loadShopConfig(domain); await reservations.release(cfg.shop.id, token); return { ok: true }; } const WaitSchema = z.object({ method: z.enum(["pickup", "delivery", "shipping"]), locationId: z.string(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), slot: z.string().default("*"), email: z.string().email().optional(), phone: z.string().min(7).max(20).optional(), name: z.string().max(100).optional(), }); export async function joinWaitlist(domain: string, raw: unknown) { const w = WaitSchema.parse(raw); if (!w.email && !w.phone) return { ok: false, message: "Add an email or phone number" }; const cfg = await loadShopConfig(domain); if (!features(cfg.shop.plan).waitlist) return { ok: false, message: "Waitlist isn't available" }; const dup = await prisma.waitlistEntry.findFirst({ where: { shopId: cfg.shop.id, locationId: w.locationId, method: w.method, date: w.date, OR: [{ emailHash: blindIndex(w.email, "email") ?? "__" }, { phoneHash: blindIndex(w.phone, "phone") ?? "__" }], }, }); if (!dup) await prisma.waitlistEntry.create({ data: { shopId: cfg.shop.id, ...w } }); return { ok: true }; } /** Customer arriving from a waitlist offer link adopts the reserved hold. */ export async function adoptOffer(domain: string, offerToken: string) { const cfg = await loadShopConfig(domain); const hold = await prisma.hold.findUnique({ where: { token: offerToken } }); if (!hold || hold.shopId !== cfg.shop.id || hold.expiresAt <= new Date()) return { ok: false, message: "This offer has expired" }; const loc = cfg.locations.find((l) => l.id === hold.locationId); return { ok: true, token: hold.token, method: hold.method, locationId: hold.locationId, date: hold.date, slot: hold.slot, expiresAt: hold.expiresAt.toISOString(), location: loc?.name, }; }