/** * 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(); const TTL = 5000; export async function loadShopConfig(domain: string, opts: { fresh?: boolean } = {}): Promise { 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 }) => Promise }; 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]])); 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 }, }, })), }, }, }); }