- 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>
163 lines
6.2 KiB
TypeScript
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;
|
|
});
|
|
}
|