286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
/**
|
||
* 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";
|
||
|
||
export const reservations = new ReservationService(prisma);
|
||
|
||
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
|
||
|
||
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<typeof QuerySchema>) {
|
||
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<ReturnType<typeof context>>;
|
||
|
||
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<Method, ReturnType<typeof rank>>;
|
||
|
||
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: [{ email: w.email ?? "__" }, { phone: w.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,
|
||
};
|
||
}
|