datewise/app/services/storefront.server.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

286 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
};
}