datewise/app/services/config.server.ts
metatroncubeswdev ee8056efc2 fix: install race, silent Admin API errors, widget width; pickup setup hint
- ensureShop: concurrent installs reuse the row created by the other request (P2002)
- order tagging and booking metafield log Shopify userErrors instead of failing silently
- widget keeps a readable 760px width in full-width theme sections
- setup guide links to Shopify's pickup settings so checkout offers pickup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:15:36 -04:00

163 lines
6.2 KiB
TypeScript

/**
* Loads a shop's scheduling configuration and maps DB rows to engine types.
*/
import type { Location, PeakDay as PeakRow, ProductRule as RuleRow, Shop } from "@prisma/client";
import prisma from "../db.server";
import type { LocationConfig, Method, PeakDay, ProductRule } from "../engine/types";
import { type AppSettings, withDefaults } from "./settings";
export interface ShopConfig {
shop: Shop;
settings: AppSettings;
locations: LocationConfig[];
rules: ProductRule[];
peakDays: PeakDay[];
version: number;
}
export function toLocationConfig(l: Location): LocationConfig {
return {
id: l.id,
shopifyLocationId: l.shopifyLocationId ?? undefined,
name: l.name,
address: l.address,
timezone: l.timezone,
lat: l.lat ?? undefined,
lng: l.lng ?? undefined,
enabled: l.enabled,
priority: l.priority,
pickupInstructions: l.pickupInstructions ?? undefined,
methods: l.methods as unknown as LocationConfig["methods"],
exceptions: (l.exceptions ?? []) as unknown as LocationConfig["exceptions"],
zones: (l.zones ?? []) as unknown as LocationConfig["zones"],
};
}
export function toRule(r: RuleRow): ProductRule {
return {
id: r.id,
name: r.name,
enabled: r.enabled,
priority: r.priority,
target: r.target as unknown as ProductRule["target"],
action: r.action as unknown as ProductRule["action"],
message: r.message ?? undefined,
};
}
export function toPeak(p: PeakRow): PeakDay {
return {
id: p.id,
name: p.name,
dates: p.dates,
locationIds: p.locationIds.length ? p.locationIds : undefined,
methods: p.methods.length ? (p.methods as Method[]) : undefined,
dailyCapacity: p.dailyCapacity,
slotCapacity: p.slotCapacity,
extraPrepMinutes: p.extraPrepMinutes,
cutoff: p.cutoffDaysBefore != null && p.cutoffTime ? { daysBefore: p.cutoffDaysBefore, time: p.cutoffTime } : null,
surcharge: p.surcharge,
waitlist: p.waitlist,
message: p.message ?? undefined,
};
}
const cache = new Map<string, { at: number; cfg: ShopConfig }>();
const TTL = 5000;
export async function loadShopConfig(domain: string, opts: { fresh?: boolean } = {}): Promise<ShopConfig> {
const hit = cache.get(domain);
if (!opts.fresh && hit && Date.now() - hit.at < TTL) return hit.cfg;
const shop = await prisma.shop.findUnique({
where: { domain },
include: { locations: { orderBy: { priority: "asc" } }, rules: true, peakDays: true },
});
if (!shop || shop.uninstalledAt) throw new Response("Shop not installed", { status: 404 });
const cfg: ShopConfig = {
shop,
settings: withDefaults(shop.settings),
locations: shop.locations.map(toLocationConfig),
rules: shop.rules.map(toRule),
peakDays: shop.peakDays.map(toPeak),
version: shop.configVersion,
};
cache.set(domain, { at: Date.now(), cfg });
return cfg;
}
/** Call after any configuration write. Bookings keep the version they were accepted under. */
export async function bumpConfig(shopId: string, domain: string) {
await prisma.shop.update({ where: { id: shopId }, data: { configVersion: { increment: 1 } } });
cache.delete(domain);
}
export async function getShop(domain: string) {
const shop = await prisma.shop.findUnique({ where: { domain } });
if (!shop) throw new Response("Shop not found", { status: 404 });
return shop;
}
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
const DEFAULT_HOURS = { start: "10:00", end: "18:00" };
/**
* First install: create the shop row and import Shopify locations as pickup-ready
* (disabled until the merchant reviews hours). Re-install: clear uninstalledAt.
*/
export async function ensureShop(domain: string, admin: AdminGraphql) {
const res = await admin.graphql(`#graphql
query DatewiseInstall {
shop { ianaTimezone currencyCode }
locations(first: 50, includeInactive: false) {
nodes { id name isActive fulfillsOnlineOrders address { formatted latitude longitude } }
}
}`);
const { data } = (await res.json()) as {
data: {
shop: { ianaTimezone: string; currencyCode: string };
locations: { nodes: { id: string; name: string; fulfillsOnlineOrders: boolean; address: { formatted: string[]; latitude: number | null; longitude: number | null } }[] };
};
};
const existing = await prisma.shop.findUnique({ where: { domain } });
if (existing) {
await prisma.shop.update({
where: { domain },
data: { uninstalledAt: null, timezone: data.shop.ianaTimezone, currency: data.shop.currencyCode },
});
return existing;
}
const weekdays = Object.fromEntries([1, 2, 3, 4, 5, 6].map((d) => [d, [DEFAULT_HOURS]]));
// Two requests can arrive together right after install; if the other one created the row first, use it.
return prisma.shop.create({
data: {
domain,
timezone: data.shop.ianaTimezone,
currency: data.shop.currencyCode,
settings: withDefaults({}) as object,
locations: {
create: data.locations.nodes
.filter((l) => l.fulfillsOnlineOrders)
.map((l, i) => ({
shopifyLocationId: l.id,
name: l.name,
address: l.address.formatted.join(", "),
timezone: data.shop.ianaTimezone,
lat: l.address.latitude,
lng: l.address.longitude,
enabled: false,
priority: i + 1,
methods: {
pickup: { enabled: true, weeklyHours: weekdays, slotMode: "generated", slotMinutes: 60, prepMinutes: 120, prepMode: "operating", sameDayCutoff: "14:00", horizonDays: 30, slotCapacity: 5 },
delivery: { enabled: false, weeklyHours: weekdays, slotMode: "generated", slotMinutes: 180, prepMinutes: 240, prepMode: "elapsed", sameDayCutoff: "11:00", horizonDays: 30, slotCapacity: 5, dailyCapacity: 15 },
shipping: { enabled: false, weeklyHours: Object.fromEntries([1, 2, 3, 4, 5].map((d) => [d, [DEFAULT_HOURS]])), slotMode: "dateOnly", prepMinutes: 0, prepMode: "operating", sameDayCutoff: "13:00", horizonDays: 14 },
},
})),
},
},
}).catch(async (e: unknown) => {
if ((e as { code?: string })?.code === "P2002") return prisma.shop.findUniqueOrThrow({ where: { domain } });
throw e;
});
}