metatrondelivery/app/services/templates.server.ts
metatroncubeswdev 21f0ee704b feat: Phase 1 — core data model & admin CRUD
Adds the scheduling core to Prisma (Shop, Location, Method enum,
SlotTemplate, SlotOverride, BlackoutDate — IMPLEMENTATION_PLAN.md §4) and
Polaris admin screens to manage them:

- /app/locations: list/create/edit/delete locations, one-click vertical
  template seeding (bakery/florist/grocer)
- /app/slots: weekly slot template editor, scoped per location
- /app/blackouts: blackout dates, scoped to one location or all of them

services/templates.server.ts keeps the vertical presets as a pure,
unit-tested function (getVerticalTemplate) separate from the thin I/O
wrapper (seedVerticalTemplate) that does the actual Prisma writes, per
CLAUDE.md's "pure functions, inject data, no I/O in the math" rule.

Switched dev DB from SQLite to Postgres (docker-compose.yml, ports 5433/6380
to avoid clashing with other local projects already on 5432/6379): SQLite
doesn't support Prisma enums at all, and IMPLEMENTATION_PLAN.md's schema
relies on them (Method) plus Postgres-only array fields in later phases
(Zone.postalCodes, ProductRule.allowedLocationIds). Only one throwaway
migration existed, so switching now avoids compounding the rework later.

Also fixed a Remix/Polaris integration issue hit while building these forms:
this Polaris version's TextField/Checkbox are fully controlled (no
defaultValue/defaultChecked), and `data()` imported from `@remix-run/react`
(rather than `@remix-run/node`) breaks useActionData's type inference —
both are now handled correctly across the new routes.

Verified: lint, typecheck, unit tests (incl. template-seeding fixtures),
build, and a live end-to-end run of seedVerticalTemplate against the
Postgres container all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:47:59 -04:00

187 lines
5.0 KiB
TypeScript

import type { Method } from "@prisma/client";
import db from "../db.server";
export type VerticalKey = "bakery" | "florist" | "grocer";
export interface SlotTemplateInput {
method: Method;
weekday: number; // 0 = Sunday .. 6 = Saturday
startMin: number;
endMin: number;
capacity: number;
cutoffMin: number;
leadTimeMin: number;
}
export interface VerticalTemplate {
vertical: VerticalKey;
location: {
name: string;
timezone: string;
};
slotTemplates: SlotTemplateInput[];
widgetCopy: {
pickupLabel: string;
deliveryLabel: string;
shippingLabel: string;
};
}
const HOUR = 60;
// Tue-Sat, pickup + local delivery. Bakers need same-day cutoff buffer for
// remaining-batch prep (strategy §3.2 resource capacity), hence the shorter
// weekday window and a same-day cutoff.
function bakeryTemplate(): VerticalTemplate {
const weekdays = [2, 3, 4, 5, 6]; // Tue-Sat
return {
vertical: "bakery",
location: { name: "Main Bakery", timezone: "America/Toronto" },
slotTemplates: weekdays.flatMap((weekday) => [
{
method: "PICKUP" as Method,
weekday,
startMin: 9 * HOUR,
endMin: 17 * HOUR,
capacity: 20,
cutoffMin: 2 * HOUR,
leadTimeMin: 24 * HOUR,
},
{
method: "LOCAL_DELIVERY" as Method,
weekday,
startMin: 11 * HOUR,
endMin: 16 * HOUR,
capacity: 10,
cutoffMin: 3 * HOUR,
leadTimeMin: 24 * HOUR,
},
]),
widgetCopy: {
pickupLabel: "Pickup date",
deliveryLabel: "Delivery date",
shippingLabel: "Shipping date",
},
};
}
// Mon-Sat, pickup + local delivery. Shorter lead time than bakery (florists
// typically make up arrangements same-day) but tighter per-slot capacity
// (single arranger/driver bottleneck).
function floristTemplate(): VerticalTemplate {
const weekdays = [1, 2, 3, 4, 5, 6]; // Mon-Sat
return {
vertical: "florist",
location: { name: "Main Shop", timezone: "America/Toronto" },
slotTemplates: weekdays.flatMap((weekday) => [
{
method: "PICKUP" as Method,
weekday,
startMin: 9 * HOUR,
endMin: 18 * HOUR,
capacity: 15,
cutoffMin: HOUR,
leadTimeMin: 4 * HOUR,
},
{
method: "LOCAL_DELIVERY" as Method,
weekday,
startMin: 10 * HOUR,
endMin: 17 * HOUR,
capacity: 8,
cutoffMin: 2 * HOUR,
leadTimeMin: 4 * HOUR,
},
]),
widgetCopy: {
pickupLabel: "Pickup date",
deliveryLabel: "Delivery date",
shippingLabel: "Shipping date",
},
};
}
// Every day, delivery + pickup + shipping, longer hours and higher
// per-slot capacity (route-based, strategy §3.2 delivery-density).
function grocerTemplate(): VerticalTemplate {
const weekdays = [0, 1, 2, 3, 4, 5, 6]; // every day
return {
vertical: "grocer",
location: { name: "Main Store", timezone: "America/Toronto" },
slotTemplates: weekdays.flatMap((weekday) => [
{
method: "LOCAL_DELIVERY" as Method,
weekday,
startMin: 8 * HOUR,
endMin: 20 * HOUR,
capacity: 40,
cutoffMin: 2 * HOUR,
leadTimeMin: 2 * HOUR,
},
{
method: "PICKUP" as Method,
weekday,
startMin: 8 * HOUR,
endMin: 20 * HOUR,
capacity: 60,
cutoffMin: HOUR,
leadTimeMin: HOUR,
},
]),
widgetCopy: {
pickupLabel: "Pickup date",
deliveryLabel: "Delivery date",
shippingLabel: "Shipping date",
},
};
}
const TEMPLATES: Record<VerticalKey, () => VerticalTemplate> = {
bakery: bakeryTemplate,
florist: floristTemplate,
grocer: grocerTemplate,
};
export function getVerticalTemplate(vertical: VerticalKey): VerticalTemplate {
return TEMPLATES[vertical]();
}
export function listVerticalTemplates(): VerticalKey[] {
return Object.keys(TEMPLATES) as VerticalKey[];
}
// I/O wrapper: applies a vertical's preset by creating a Location + its
// SlotTemplates and merging widgetCopy into Shop.settings, all in one
// transaction. Keeps the actual preset shape (above) pure and unit-testable.
export async function seedVerticalTemplate(shopDomain: string, vertical: VerticalKey) {
const template = getVerticalTemplate(vertical);
return db.$transaction(async (tx) => {
// Only seed widgetCopy for a brand-new shop — never clobber a merchant's
// existing customized settings by re-running/switching templates.
await tx.shop.upsert({
where: { shopDomain },
create: { shopDomain, settings: { widgetCopy: template.widgetCopy } },
update: {},
});
const location = await tx.location.create({
data: {
shopDomain,
name: template.location.name,
address: "",
timezone: template.location.timezone,
slotTemplates: {
create: template.slotTemplates.map((slot) => ({
shopDomain,
...slot,
})),
},
},
include: { slotTemplates: true },
});
return location;
});
}