From 21f0ee704b0cb7a747db70b01083d203f3da61d9 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Sun, 23 Aug 2026 17:47:59 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20=E2=80=94=20core=20data=20m?= =?UTF-8?q?odel=20&=20admin=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 10 +- app/routes/app._index.tsx | 49 +++- app/routes/app.blackouts._index.tsx | 170 +++++++++++ app/routes/app.locations.$id.tsx | 141 +++++++++ app/routes/app.locations._index.tsx | 152 ++++++++++ app/routes/app.locations.new.tsx | 70 +++++ app/routes/app.slots._index.tsx | 268 ++++++++++++++++++ app/routes/app.tsx | 3 + app/services/templates.server.ts | 186 ++++++++++++ docker-compose.yml | 23 ++ .../migration.sql | 20 -- .../migration.sql | 124 ++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 89 +++++- tests/unit/templates.test.ts | 40 +++ 15 files changed, 1309 insertions(+), 39 deletions(-) create mode 100644 app/routes/app.blackouts._index.tsx create mode 100644 app/routes/app.locations.$id.tsx create mode 100644 app/routes/app.locations._index.tsx create mode 100644 app/routes/app.locations.new.tsx create mode 100644 app/routes/app.slots._index.tsx create mode 100644 app/services/templates.server.ts create mode 100644 docker-compose.yml delete mode 100644 prisma/migrations/20240530213853_create_session_table/migration.sql create mode 100644 prisma/migrations/20260823213223_init_scheduling_core/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 tests/unit/templates.test.ts diff --git a/.env.example b/.env.example index cae9875..d9f1f0f 100644 --- a/.env.example +++ b/.env.example @@ -6,11 +6,13 @@ SCOPES=read_products,read_customers,read_orders,write_orders,read_locations,read SHOPIFY_APP_URL=https://replace-with-your-tunnel-url.example.com SHOP_CUSTOM_DOMAIN= -# App DB (Prisma). SQLite locally, Postgres in staging/prod. -DATABASE_URL="file:dev.sqlite" +# App DB (Prisma/Postgres — same engine dev through prod so enum/array +# schema features behave identically everywhere). `docker compose up -d` +# starts a local instance on the port below. +DATABASE_URL="postgresql://app:app@localhost:5433/delivery_datetime_dev" -# Redis (slot-holds TTL, BullMQ queues). -REDIS_URL=redis://127.0.0.1:6379 +# Redis (slot-holds TTL, BullMQ queues). `docker compose up -d` starts this too. +REDIS_URL=redis://127.0.0.1:6380 # Google Maps (Phase 5 — geocoding, radius/distance eligibility, map display). GOOGLE_MAPS_API_KEY= diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx index 1f912e0..b5d0ead 100644 --- a/app/routes/app._index.tsx +++ b/app/routes/app._index.tsx @@ -1,15 +1,24 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -import { Page, Layout, Text, Card, BlockStack, EmptyState } from "@shopify/polaris"; +import { useLoaderData } from "@remix-run/react"; +import { Page, Layout, Text, Card, BlockStack, EmptyState, InlineStack, Badge } from "@shopify/polaris"; import { TitleBar } from "@shopify/app-bridge-react"; import { authenticate } from "../shopify.server"; +import db from "../db.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { - await authenticate.admin(request); + const { session } = await authenticate.admin(request); - return null; + const locations = await db.location.findMany({ + where: { shopDomain: session.shop }, + include: { _count: { select: { slotTemplates: true } } }, + }); + + return { locations }; }; export default function Index() { + const { locations } = useLoaderData(); + return ( @@ -17,16 +26,30 @@ export default function Index() { - - - Set up a fulfillment location and its weekly slots to start - taking scheduled Shipping, Local Delivery, or Pickup orders. - - + {locations.length === 0 ? ( + + + Set up a fulfillment location and its weekly slots to start + taking scheduled Shipping, Local Delivery, or Pickup orders. + + + ) : ( + + + {locations.length} location{locations.length === 1 ? "" : "s"} configured + + {locations.map((location) => ( + + {location.name} + {`${location._count.slotTemplates} slot templates`} + + ))} + + )} diff --git a/app/routes/app.blackouts._index.tsx b/app/routes/app.blackouts._index.tsx new file mode 100644 index 0000000..ce55f5c --- /dev/null +++ b/app/routes/app.blackouts._index.tsx @@ -0,0 +1,170 @@ +import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; +import { Form, useLoaderData, useNavigation } from "@remix-run/react"; +import { + Page, + Card, + BlockStack, + InlineStack, + Text, + Button, + Select, + TextField, + IndexTable, + EmptyState, +} from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; +import type { Method } from "@prisma/client"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; + +const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { session } = await authenticate.admin(request); + + const [locations, blackouts] = await Promise.all([ + db.location.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }), + db.blackoutDate.findMany({ + where: { shopDomain: session.shop }, + include: { location: true }, + orderBy: { date: "asc" }, + }), + ]); + + return { locations, blackouts }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { session } = await authenticate.admin(request); + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "delete") { + const id = String(formData.get("id") || ""); + await db.blackoutDate.deleteMany({ where: { id, shopDomain: session.shop } }); + return data({ ok: true }); + } + + const date = String(formData.get("date") || ""); + const locationId = String(formData.get("locationId") || "") || null; + const method = (String(formData.get("method") || "") || null) as Method | null; + const reason = String(formData.get("reason") || "").trim() || null; + + if (!date) { + return data({ errors: { date: "Date is required" } }); + } + + await db.blackoutDate.create({ + data: { + shopDomain: session.shop, + date: new Date(`${date}T00:00:00.000Z`), + locationId, + method, + reason, + }, + }); + + return data({ ok: true }); +}; + +export default function BlackoutsIndex() { + const { locations, blackouts } = useLoaderData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + if (locations.length === 0) { + return ( + + + + + Blackout dates can be scoped to one location, or left blank for all locations. + + + + ); + } + + return ( + + + + + {blackouts.length === 0 ? ( +
+ + No blackout dates yet. + +
+ ) : ( + + {blackouts.map((b, index) => ( + + {b.date.slice(0, 10)} + {b.location?.name ?? "All locations"} + {b.method?.replace("_", " ") ?? "All methods"} + {b.reason ?? "—"} + +
+ + + +
+
+
+ ))} +
+ )} +
+ + +
+ + + Add a blackout date + + + + ({ label: m.replace("_", " "), value: m })), + ]} + /> + + +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/routes/app.locations.$id.tsx b/app/routes/app.locations.$id.tsx new file mode 100644 index 0000000..f6a0e3e --- /dev/null +++ b/app/routes/app.locations.$id.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; +import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react"; +import { + Page, + Card, + BlockStack, + FormLayout, + TextField, + Button, + InlineStack, + Checkbox, +} from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { session } = await authenticate.admin(request); + + const location = await db.location.findFirst({ + where: { id: params.id, shopDomain: session.shop }, + }); + + if (!location) { + throw new Response("Not found", { status: 404 }); + } + + return { location }; +}; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const { session } = await authenticate.admin(request); + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "delete") { + await db.location.deleteMany({ where: { id: params.id, shopDomain: session.shop } }); + return redirect("/app/locations"); + } + + const name = String(formData.get("name") || "").trim(); + const address = String(formData.get("address") || "").trim(); + const timezone = String(formData.get("timezone") || "").trim(); + const active = formData.get("active") === "true"; + + const errors: Record = {}; + if (!name) errors.name = "Name is required"; + if (!timezone) errors.timezone = "Timezone is required"; + if (Object.keys(errors).length > 0) { + return { errors }; + } + + await db.location.updateMany({ + where: { id: params.id, shopDomain: session.shop }, + data: { name, address, timezone, active }, + }); + + return { errors }; +}; + +export default function EditLocation() { + const { location } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + return ( + + + + + ); +} + +function LocationForm({ + location, + errors, + isSubmitting, +}: { + location: { id: string; name: string; address: string; timezone: string; active: boolean }; + errors?: Record; + isSubmitting: boolean; +}) { + const [name, setName] = useState(location.name); + const [address, setAddress] = useState(location.address); + const [timezone, setTimezone] = useState(location.timezone); + const [active, setActive] = useState(location.active); + + return ( + + +
+ + + + + + + + + + + +
+
+ +
+ + + + Delete this location + Removes all its slot templates and blackout dates. + + + +
+
+
+ ); +} diff --git a/app/routes/app.locations._index.tsx b/app/routes/app.locations._index.tsx new file mode 100644 index 0000000..496b0c6 --- /dev/null +++ b/app/routes/app.locations._index.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; +import { Form, useLoaderData, useNavigation } from "@remix-run/react"; +import { + Page, + Layout, + Card, + BlockStack, + InlineStack, + Text, + Button, + ButtonGroup, + ResourceList, + ResourceItem, + Badge, + EmptyState, + Select, +} from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; +import { listVerticalTemplates, seedVerticalTemplate, type VerticalKey } from "../services/templates.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { session } = await authenticate.admin(request); + + const locations = await db.location.findMany({ + where: { shopDomain: session.shop }, + include: { _count: { select: { slotTemplates: true, blackouts: true } } }, + orderBy: { createdAt: "asc" }, + }); + + return { locations, verticals: listVerticalTemplates() }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { session } = await authenticate.admin(request); + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "seed-template") { + const vertical = formData.get("vertical") as VerticalKey; + await seedVerticalTemplate(session.shop, vertical); + return data({ ok: true }); + } + + if (intent === "delete-location") { + const id = formData.get("id") as string; + await db.location.deleteMany({ where: { id, shopDomain: session.shop } }); + return data({ ok: true }); + } + + return data({ ok: false }, { status: 400 }); +}; + +export default function LocationsIndex() { + const { locations, verticals } = useLoaderData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + const [vertical, setVertical] = useState(verticals[0]); + + return ( + + + + + + {locations.length === 0 ? ( + + + + + Start from a vertical template — it seeds a location with a + realistic weekly Pickup/Delivery schedule you can then edit, + or add a location from scratch. + + +
+ + + + + + + + + + ) : ( + + ( + + + + + {location.name} + + + {location.address || "No address set"} · {location.timezone} + + + + {`${location._count.slotTemplates} slot templates`} + + {location.active ? "Active" : "Inactive"} + + + + + )} + /> + + )} + + {locations.length > 0 && ( + + + + + + )} + + + + ); +} + +function capitalize(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/app/routes/app.locations.new.tsx b/app/routes/app.locations.new.tsx new file mode 100644 index 0000000..12bc30f --- /dev/null +++ b/app/routes/app.locations.new.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { Page, Card, BlockStack, FormLayout, TextField, Button } from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { session } = await authenticate.admin(request); + const formData = await request.formData(); + + const name = String(formData.get("name") || "").trim(); + const address = String(formData.get("address") || "").trim(); + const timezone = String(formData.get("timezone") || "").trim(); + + const errors: Record = {}; + if (!name) errors.name = "Name is required"; + if (!timezone) errors.timezone = "Timezone is required"; + if (Object.keys(errors).length > 0) { + return { errors }; + } + + const location = await db.location.create({ + data: { shopDomain: session.shop, name, address, timezone }, + }); + + return redirect(`/app/locations/${location.id}`); +}; + +export default function NewLocation() { + const actionData = useActionData(); + const navigation = useNavigation(); + const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone); + + return ( + + + +
+ + + + + + + + +
+
+
+ ); +} diff --git a/app/routes/app.slots._index.tsx b/app/routes/app.slots._index.tsx new file mode 100644 index 0000000..f1cdf77 --- /dev/null +++ b/app/routes/app.slots._index.tsx @@ -0,0 +1,268 @@ +import { useState } from "react"; +import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; +import { Form, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react"; +import { + Page, + Card, + BlockStack, + InlineStack, + Text, + Button, + Select, + TextField, + IndexTable, + EmptyState, +} from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; +import type { Method } from "@prisma/client"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; + +const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]; + +function minutesToTime(min: number): string { + const h = Math.floor(min / 60) + .toString() + .padStart(2, "0"); + const m = (min % 60).toString().padStart(2, "0"); + return `${h}:${m}`; +} + +function timeToMinutes(time: string): number { + const [h, m] = time.split(":").map(Number); + return h * 60 + m; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { session } = await authenticate.admin(request); + const url = new URL(request.url); + const locationId = url.searchParams.get("locationId"); + + const locations = await db.location.findMany({ + where: { shopDomain: session.shop }, + orderBy: { createdAt: "asc" }, + }); + + const activeLocationId = locationId || locations[0]?.id || null; + + const slotTemplates = activeLocationId + ? await db.slotTemplate.findMany({ + where: { shopDomain: session.shop, locationId: activeLocationId }, + orderBy: [{ weekday: "asc" }, { startMin: "asc" }], + }) + : []; + + return { locations, activeLocationId, slotTemplates }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { session } = await authenticate.admin(request); + const formData = await request.formData(); + const intent = formData.get("intent"); + const locationId = String(formData.get("locationId") || ""); + + if (intent === "delete") { + const id = String(formData.get("id") || ""); + await db.slotTemplate.deleteMany({ where: { id, shopDomain: session.shop } }); + return data({ ok: true }); + } + + const method = formData.get("method") as Method; + const weekday = Number(formData.get("weekday")); + const startMin = timeToMinutes(String(formData.get("startTime"))); + const endMin = timeToMinutes(String(formData.get("endTime"))); + const capacity = Number(formData.get("capacity")); + const cutoffMin = Number(formData.get("cutoffMin") || 0); + const leadTimeMin = Number(formData.get("leadTimeMin") || 0); + + const errors: Record = {}; + if (!locationId) errors.locationId = "Choose a location"; + if (startMin >= endMin) errors.endTime = "End time must be after start time"; + if (!capacity || capacity < 1) errors.capacity = "Capacity must be at least 1"; + if (Object.keys(errors).length > 0) { + return data({ errors }); + } + + await db.slotTemplate.create({ + data: { + shopDomain: session.shop, + locationId, + method, + weekday, + startMin, + endMin, + capacity, + cutoffMin, + leadTimeMin, + }, + }); + + return data({ ok: true }); +}; + +export default function SlotsIndex() { + const { locations, activeLocationId, slotTemplates } = useLoaderData(); + const [, setSearchParams] = useSearchParams(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + if (locations.length === 0) { + return ( + + + + + Weekly slot templates belong to a location. + + + + ); + } + + return ( + + + + + + + + + + + ))} + + )} + + + + + + ); +} + +function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmitting: boolean }) { + const [startTime, setStartTime] = useState("09:00"); + const [endTime, setEndTime] = useState("17:00"); + const [capacity, setCapacity] = useState("10"); + const [cutoffMin, setCutoffMin] = useState("60"); + const [leadTimeMin, setLeadTimeMin] = useState("0"); + + return ( + +
+ + + + Add a slot template + + + ({ label: m.replace("_", " "), value: m }))} + /> + + + + + + +
+ +
+
+
+
+ ); +} diff --git a/app/routes/app.tsx b/app/routes/app.tsx index 1b71be4..e64ef62 100644 --- a/app/routes/app.tsx +++ b/app/routes/app.tsx @@ -24,6 +24,9 @@ export default function App() { Home + Locations + Weekly slots + Blackout dates diff --git a/app/services/templates.server.ts b/app/services/templates.server.ts new file mode 100644 index 0000000..9c77e64 --- /dev/null +++ b/app/services/templates.server.ts @@ -0,0 +1,186 @@ +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 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; + }); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1731e5b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +name: delivery-datetime-app + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: delivery_datetime_dev + ports: + - "5433:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6380:6379" + +volumes: + postgres-data: diff --git a/prisma/migrations/20240530213853_create_session_table/migration.sql b/prisma/migrations/20240530213853_create_session_table/migration.sql deleted file mode 100644 index 3f8c675..0000000 --- a/prisma/migrations/20240530213853_create_session_table/migration.sql +++ /dev/null @@ -1,20 +0,0 @@ --- CreateTable -CREATE TABLE "Session" ( - "id" TEXT NOT NULL PRIMARY KEY, - "shop" TEXT NOT NULL, - "state" TEXT NOT NULL, - "isOnline" BOOLEAN NOT NULL DEFAULT false, - "scope" TEXT, - "expires" DATETIME, - "accessToken" TEXT NOT NULL, - "userId" BIGINT, - "firstName" TEXT, - "lastName" TEXT, - "email" TEXT, - "accountOwner" BOOLEAN NOT NULL DEFAULT false, - "locale" TEXT, - "collaborator" BOOLEAN DEFAULT false, - "emailVerified" BOOLEAN DEFAULT false, - "refreshToken" TEXT, - "refreshTokenExpires" DATETIME -); diff --git a/prisma/migrations/20260823213223_init_scheduling_core/migration.sql b/prisma/migrations/20260823213223_init_scheduling_core/migration.sql new file mode 100644 index 0000000..b8fbf3d --- /dev/null +++ b/prisma/migrations/20260823213223_init_scheduling_core/migration.sql @@ -0,0 +1,124 @@ +-- CreateEnum +CREATE TYPE "Method" AS ENUM ('SHIPPING', 'LOCAL_DELIVERY', 'PICKUP'); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "shop" TEXT NOT NULL, + "state" TEXT NOT NULL, + "isOnline" BOOLEAN NOT NULL DEFAULT false, + "scope" TEXT, + "expires" TIMESTAMP(3), + "accessToken" TEXT NOT NULL, + "userId" BIGINT, + "firstName" TEXT, + "lastName" TEXT, + "email" TEXT, + "accountOwner" BOOLEAN NOT NULL DEFAULT false, + "locale" TEXT, + "collaborator" BOOLEAN DEFAULT false, + "emailVerified" BOOLEAN DEFAULT false, + "refreshToken" TEXT, + "refreshTokenExpires" TIMESTAMP(3), + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Shop" ( + "id" TEXT NOT NULL, + "shopDomain" TEXT NOT NULL, + "plan" TEXT NOT NULL DEFAULT 'basic', + "tier" TEXT NOT NULL DEFAULT 'free', + "timezone" TEXT NOT NULL DEFAULT 'UTC', + "settings" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Shop_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Location" ( + "id" TEXT NOT NULL, + "shopDomain" TEXT NOT NULL, + "name" TEXT NOT NULL, + "address" TEXT NOT NULL, + "lat" DOUBLE PRECISION, + "lng" DOUBLE PRECISION, + "timezone" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Location_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SlotTemplate" ( + "id" TEXT NOT NULL, + "shopDomain" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "method" "Method" NOT NULL, + "weekday" INTEGER NOT NULL, + "startMin" INTEGER NOT NULL, + "endMin" INTEGER NOT NULL, + "capacity" INTEGER NOT NULL, + "cutoffMin" INTEGER, + "leadTimeMin" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SlotTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SlotOverride" ( + "id" TEXT NOT NULL, + "shopDomain" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "method" "Method" NOT NULL, + "closed" BOOLEAN NOT NULL DEFAULT false, + "startMin" INTEGER, + "endMin" INTEGER, + "capacity" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SlotOverride_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BlackoutDate" ( + "id" TEXT NOT NULL, + "shopDomain" TEXT NOT NULL, + "locationId" TEXT, + "method" "Method", + "date" TIMESTAMP(3) NOT NULL, + "reason" TEXT, + "source" TEXT NOT NULL DEFAULT 'manual', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BlackoutDate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Shop_shopDomain_key" ON "Shop"("shopDomain"); + +-- CreateIndex +CREATE INDEX "Location_shopDomain_idx" ON "Location"("shopDomain"); + +-- CreateIndex +CREATE INDEX "SlotTemplate_shopDomain_locationId_method_weekday_idx" ON "SlotTemplate"("shopDomain", "locationId", "method", "weekday"); + +-- CreateIndex +CREATE INDEX "SlotOverride_shopDomain_locationId_date_idx" ON "SlotOverride"("shopDomain", "locationId", "date"); + +-- CreateIndex +CREATE INDEX "BlackoutDate_shopDomain_date_idx" ON "BlackoutDate"("shopDomain", "date"); + +-- AddForeignKey +ALTER TABLE "SlotTemplate" ADD CONSTRAINT "SlotTemplate_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SlotOverride" ADD CONSTRAINT "SlotOverride_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BlackoutDate" ADD CONSTRAINT "BlackoutDate_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4532d09..99f41ea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,8 +9,8 @@ generator client { // enough when changing adapters. // See https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string for more information datasource db { - provider = "sqlite" - url = "file:dev.sqlite" + provider = "postgresql" + url = env("DATABASE_URL") } model Session { @@ -32,3 +32,88 @@ model Session { refreshToken String? refreshTokenExpires DateTime? } + +// --- Scheduling domain (IMPLEMENTATION_PLAN.md §4) ------------------------- +// Every model below is scoped by shopDomain (multi-tenant, per CLAUDE.md). +// Built incrementally per phase; see the plan for models not yet added +// (CapacityResource, Zone, ProductRule, Rate, SlotHold, Booking, ...). + +enum Method { + SHIPPING + LOCAL_DELIVERY + PICKUP +} + +model Shop { + id String @id @default(cuid()) + shopDomain String @unique + plan String @default("basic") // basic|shopify|advanced|plus + tier String @default("free") // free|starter|growth|pro + timezone String @default("UTC") + settings Json @default("{}") // widget copy, i18n, feature flags + createdAt DateTime @default(now()) +} + +model Location { + id String @id @default(cuid()) + shopDomain String + name String + address String + lat Float? + lng Float? + timezone String + active Boolean @default(true) + slotTemplates SlotTemplate[] + overrides SlotOverride[] + blackouts BlackoutDate[] + createdAt DateTime @default(now()) + + @@index([shopDomain]) +} + +model SlotTemplate { + id String @id @default(cuid()) + shopDomain String + locationId String + location Location @relation(fields: [locationId], references: [id], onDelete: Cascade) + method Method + weekday Int // 0-6 (0 = Sunday), location-local + startMin Int // minutes from midnight, local + endMin Int + capacity Int // default order capacity if no resources + cutoffMin Int? // cutoff before slot (minutes) + leadTimeMin Int @default(0) + createdAt DateTime @default(now()) + + @@index([shopDomain, locationId, method, weekday]) +} + +model SlotOverride { + id String @id @default(cuid()) + shopDomain String + locationId String + location Location @relation(fields: [locationId], references: [id], onDelete: Cascade) + date DateTime // date-only, location-local + method Method + closed Boolean @default(false) + startMin Int? + endMin Int? + capacity Int? + createdAt DateTime @default(now()) + + @@index([shopDomain, locationId, date]) +} + +model BlackoutDate { + id String @id @default(cuid()) + shopDomain String + locationId String? // null = all locations + location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade) + method Method? // null = all methods + date DateTime + reason String? + source String @default("manual") // manual|holiday-import + createdAt DateTime @default(now()) + + @@index([shopDomain, date]) +} diff --git a/tests/unit/templates.test.ts b/tests/unit/templates.test.ts new file mode 100644 index 0000000..cbafd4c --- /dev/null +++ b/tests/unit/templates.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { getVerticalTemplate, listVerticalTemplates } from "../../app/services/templates.server"; + +describe("getVerticalTemplate", () => { + it("lists exactly the three Phase 1 vertical presets", () => { + expect(listVerticalTemplates().sort()).toEqual(["bakery", "florist", "grocer"]); + }); + + it.each(listVerticalTemplates())("%s: every slot has a valid weekday and start < end", (vertical) => { + const template = getVerticalTemplate(vertical); + expect(template.slotTemplates.length).toBeGreaterThan(0); + for (const slot of template.slotTemplates) { + expect(slot.weekday).toBeGreaterThanOrEqual(0); + expect(slot.weekday).toBeLessThanOrEqual(6); + expect(slot.startMin).toBeGreaterThanOrEqual(0); + expect(slot.endMin).toBeLessThanOrEqual(24 * 60); + expect(slot.startMin).toBeLessThan(slot.endMin); + expect(slot.capacity).toBeGreaterThan(0); + expect(slot.cutoffMin).toBeGreaterThanOrEqual(0); + expect(slot.leadTimeMin).toBeGreaterThanOrEqual(0); + } + }); + + it("bakery covers Tue-Sat only (closed Sun/Mon)", () => { + const weekdays = new Set(getVerticalTemplate("bakery").slotTemplates.map((s) => s.weekday)); + expect(weekdays.has(0)).toBe(false); + expect(weekdays.has(1)).toBe(false); + expect(weekdays.has(2)).toBe(true); + expect(weekdays.has(6)).toBe(true); + }); + + it("grocer is the only preset open every day of the week", () => { + const weekdays = new Set(getVerticalTemplate("grocer").slotTemplates.map((s) => s.weekday)); + expect(weekdays.size).toBe(7); + }); + + it("is deterministic (calling twice yields an equivalent template)", () => { + expect(getVerticalTemplate("florist")).toEqual(getVerticalTemplate("florist")); + }); +});