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>
This commit is contained in:
parent
0303eba07a
commit
21f0ee704b
10
.env.example
10
.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=
|
||||
|
||||
@ -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<typeof loader>();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<TitleBar title="Delivery Date & Time" />
|
||||
@ -17,6 +26,7 @@ export default function Index() {
|
||||
<Layout>
|
||||
<Layout.Section>
|
||||
<Card>
|
||||
{locations.length === 0 ? (
|
||||
<EmptyState
|
||||
heading="No locations configured yet"
|
||||
action={{ content: "Add a location", url: "/app/locations" }}
|
||||
@ -27,6 +37,19 @@ export default function Index() {
|
||||
taking scheduled Shipping, Local Delivery, or Pickup orders.
|
||||
</Text>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<BlockStack gap="300">
|
||||
<Text as="h2" variant="headingMd">
|
||||
{locations.length} location{locations.length === 1 ? "" : "s"} configured
|
||||
</Text>
|
||||
{locations.map((location) => (
|
||||
<InlineStack key={location.id} align="space-between">
|
||||
<Text as="span">{location.name}</Text>
|
||||
<Badge>{`${location._count.slotTemplates} slot templates`}</Badge>
|
||||
</InlineStack>
|
||||
))}
|
||||
</BlockStack>
|
||||
)}
|
||||
</Card>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
|
||||
170
app/routes/app.blackouts._index.tsx
Normal file
170
app/routes/app.blackouts._index.tsx
Normal file
@ -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<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
if (locations.length === 0) {
|
||||
return (
|
||||
<Page>
|
||||
<TitleBar title="Blackout dates" />
|
||||
<Card>
|
||||
<EmptyState
|
||||
heading="Add a location first"
|
||||
action={{ content: "Add location", url: "/app/locations/new" }}
|
||||
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
|
||||
>
|
||||
<Text as="p">Blackout dates can be scoped to one location, or left blank for all locations.</Text>
|
||||
</EmptyState>
|
||||
</Card>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<TitleBar title="Blackout dates" />
|
||||
<BlockStack gap="400">
|
||||
<Card padding="0">
|
||||
{blackouts.length === 0 ? (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Text as="p" tone="subdued">
|
||||
No blackout dates yet.
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<IndexTable
|
||||
itemCount={blackouts.length}
|
||||
headings={[
|
||||
{ title: "Date" },
|
||||
{ title: "Location" },
|
||||
{ title: "Method" },
|
||||
{ title: "Reason" },
|
||||
{ title: "" },
|
||||
]}
|
||||
selectable={false}
|
||||
>
|
||||
{blackouts.map((b, index) => (
|
||||
<IndexTable.Row id={b.id} key={b.id} position={index}>
|
||||
<IndexTable.Cell>{b.date.slice(0, 10)}</IndexTable.Cell>
|
||||
<IndexTable.Cell>{b.location?.name ?? "All locations"}</IndexTable.Cell>
|
||||
<IndexTable.Cell>{b.method?.replace("_", " ") ?? "All methods"}</IndexTable.Cell>
|
||||
<IndexTable.Cell>{b.reason ?? "—"}</IndexTable.Cell>
|
||||
<IndexTable.Cell>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<Button submit variant="plain" tone="critical">
|
||||
Remove
|
||||
</Button>
|
||||
</Form>
|
||||
</IndexTable.Cell>
|
||||
</IndexTable.Row>
|
||||
))}
|
||||
</IndexTable>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form method="post">
|
||||
<BlockStack gap="300">
|
||||
<Text as="h3" variant="headingSm">
|
||||
Add a blackout date
|
||||
</Text>
|
||||
<InlineStack gap="300" wrap>
|
||||
<TextField label="Date" name="date" type="date" autoComplete="off" />
|
||||
<Select
|
||||
label="Location"
|
||||
name="locationId"
|
||||
options={[{ label: "All locations", value: "" }, ...locations.map((l) => ({ label: l.name, value: l.id }))]}
|
||||
/>
|
||||
<Select
|
||||
label="Method"
|
||||
name="method"
|
||||
options={[
|
||||
{ label: "All methods", value: "" },
|
||||
...METHODS.map((m) => ({ label: m.replace("_", " "), value: m })),
|
||||
]}
|
||||
/>
|
||||
<TextField label="Reason (optional)" name="reason" autoComplete="off" />
|
||||
</InlineStack>
|
||||
<div>
|
||||
<Button submit variant="primary" loading={isSubmitting}>
|
||||
Add blackout date
|
||||
</Button>
|
||||
</div>
|
||||
</BlockStack>
|
||||
</Form>
|
||||
</Card>
|
||||
</BlockStack>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
141
app/routes/app.locations.$id.tsx
Normal file
141
app/routes/app.locations.$id.tsx
Normal file
@ -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<string, string> = {};
|
||||
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<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
return (
|
||||
<Page backAction={{ url: "/app/locations" }}>
|
||||
<TitleBar title={location.name} />
|
||||
<LocationForm key={location.id} location={location} errors={actionData?.errors} isSubmitting={isSubmitting} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationForm({
|
||||
location,
|
||||
errors,
|
||||
isSubmitting,
|
||||
}: {
|
||||
location: { id: string; name: string; address: string; timezone: string; active: boolean };
|
||||
errors?: Record<string, string>;
|
||||
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 (
|
||||
<BlockStack gap="400">
|
||||
<Card>
|
||||
<Form method="post">
|
||||
<FormLayout>
|
||||
<TextField
|
||||
label="Location name"
|
||||
name="name"
|
||||
autoComplete="off"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
error={errors?.name}
|
||||
requiredIndicator
|
||||
/>
|
||||
<TextField label="Address" name="address" autoComplete="off" multiline={2} value={address} onChange={setAddress} />
|
||||
<TextField
|
||||
label="Timezone (IANA)"
|
||||
name="timezone"
|
||||
autoComplete="off"
|
||||
value={timezone}
|
||||
onChange={setTimezone}
|
||||
error={errors?.timezone}
|
||||
requiredIndicator
|
||||
/>
|
||||
<Checkbox label="Active" name="active" value="true" checked={active} onChange={setActive} />
|
||||
<InlineStack gap="200">
|
||||
<Button submit variant="primary" loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button url={`/app/slots?locationId=${location.id}`}>Manage weekly slots</Button>
|
||||
<Button url={`/app/blackouts?locationId=${location.id}`}>Manage blackout dates</Button>
|
||||
</InlineStack>
|
||||
</FormLayout>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<InlineStack align="space-between" blockAlign="center">
|
||||
<BlockStack gap="050">
|
||||
<strong>Delete this location</strong>
|
||||
<span>Removes all its slot templates and blackout dates.</span>
|
||||
</BlockStack>
|
||||
<Button submit tone="critical" variant="secondary">
|
||||
Delete location
|
||||
</Button>
|
||||
</InlineStack>
|
||||
</Form>
|
||||
</Card>
|
||||
</BlockStack>
|
||||
);
|
||||
}
|
||||
152
app/routes/app.locations._index.tsx
Normal file
152
app/routes/app.locations._index.tsx
Normal file
@ -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<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
const [vertical, setVertical] = useState<string>(verticals[0]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<TitleBar title="Locations" />
|
||||
<BlockStack gap="500">
|
||||
<Layout>
|
||||
<Layout.Section>
|
||||
{locations.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
heading="Set up your first location"
|
||||
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
|
||||
>
|
||||
<BlockStack gap="400">
|
||||
<Text as="p" variant="bodyMd">
|
||||
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.
|
||||
</Text>
|
||||
<InlineStack gap="300" blockAlign="center">
|
||||
<div style={{ minWidth: 180 }}>
|
||||
<Select
|
||||
label="Vertical"
|
||||
labelHidden
|
||||
options={verticals.map((v) => ({ label: capitalize(v), value: v }))}
|
||||
value={vertical}
|
||||
onChange={setVertical}
|
||||
/>
|
||||
</div>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="seed-template" />
|
||||
<input type="hidden" name="vertical" value={vertical} />
|
||||
<Button submit variant="primary" loading={isSubmitting}>
|
||||
Seed {capitalize(vertical)} template
|
||||
</Button>
|
||||
</Form>
|
||||
<Button url="/app/locations/new">Add location manually</Button>
|
||||
</InlineStack>
|
||||
</BlockStack>
|
||||
</EmptyState>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="0">
|
||||
<ResourceList
|
||||
items={locations}
|
||||
resourceName={{ singular: "location", plural: "locations" }}
|
||||
renderItem={(location) => (
|
||||
<ResourceItem
|
||||
id={location.id}
|
||||
url={`/app/locations/${location.id}`}
|
||||
accessibilityLabel={`View ${location.name}`}
|
||||
>
|
||||
<InlineStack align="space-between" blockAlign="center">
|
||||
<BlockStack gap="100">
|
||||
<Text as="h3" variant="bodyMd" fontWeight="bold">
|
||||
{location.name}
|
||||
</Text>
|
||||
<Text as="span" variant="bodySm" tone="subdued">
|
||||
{location.address || "No address set"} · {location.timezone}
|
||||
</Text>
|
||||
</BlockStack>
|
||||
<InlineStack gap="200" blockAlign="center">
|
||||
<Badge>{`${location._count.slotTemplates} slot templates`}</Badge>
|
||||
<Badge tone={location.active ? "success" : "critical"}>
|
||||
{location.active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</InlineStack>
|
||||
</InlineStack>
|
||||
</ResourceItem>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Layout.Section>
|
||||
{locations.length > 0 && (
|
||||
<Layout.Section>
|
||||
<ButtonGroup>
|
||||
<Button url="/app/locations/new" variant="primary">
|
||||
Add location
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Layout.Section>
|
||||
)}
|
||||
</Layout>
|
||||
</BlockStack>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
70
app/routes/app.locations.new.tsx
Normal file
70
app/routes/app.locations.new.tsx
Normal file
@ -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<string, string> = {};
|
||||
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<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<TitleBar title="Add location" />
|
||||
<Card>
|
||||
<Form method="post">
|
||||
<FormLayout>
|
||||
<TextField
|
||||
label="Location name"
|
||||
name="name"
|
||||
autoComplete="off"
|
||||
error={actionData?.errors?.name}
|
||||
requiredIndicator
|
||||
/>
|
||||
<TextField label="Address" name="address" autoComplete="off" multiline={2} />
|
||||
<TextField
|
||||
label="Timezone (IANA, e.g. America/Toronto)"
|
||||
name="timezone"
|
||||
autoComplete="off"
|
||||
value={timezone}
|
||||
onChange={setTimezone}
|
||||
error={actionData?.errors?.timezone}
|
||||
requiredIndicator
|
||||
helpText="All slot math for this location is computed in this timezone."
|
||||
/>
|
||||
<BlockStack>
|
||||
<Button submit variant="primary" loading={navigation.state === "submitting"}>
|
||||
Create location
|
||||
</Button>
|
||||
</BlockStack>
|
||||
</FormLayout>
|
||||
</Form>
|
||||
</Card>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
268
app/routes/app.slots._index.tsx
Normal file
268
app/routes/app.slots._index.tsx
Normal file
@ -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<string, string> = {};
|
||||
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<typeof loader>();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
if (locations.length === 0) {
|
||||
return (
|
||||
<Page>
|
||||
<TitleBar title="Weekly slots" />
|
||||
<Card>
|
||||
<EmptyState
|
||||
heading="Add a location first"
|
||||
action={{ content: "Add location", url: "/app/locations/new" }}
|
||||
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
|
||||
>
|
||||
<Text as="p">Weekly slot templates belong to a location.</Text>
|
||||
</EmptyState>
|
||||
</Card>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<TitleBar title="Weekly slots" />
|
||||
<BlockStack gap="400">
|
||||
<Card>
|
||||
<Select
|
||||
label="Location"
|
||||
options={locations.map((l) => ({ label: l.name, value: l.id }))}
|
||||
value={activeLocationId ?? undefined}
|
||||
onChange={(value) => setSearchParams({ locationId: value })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card padding="0">
|
||||
{slotTemplates.length === 0 ? (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Text as="p" tone="subdued">
|
||||
No slot templates yet for this location. Add one below.
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<IndexTable
|
||||
itemCount={slotTemplates.length}
|
||||
headings={[
|
||||
{ title: "Weekday" },
|
||||
{ title: "Method" },
|
||||
{ title: "Window" },
|
||||
{ title: "Capacity" },
|
||||
{ title: "Cutoff (min)" },
|
||||
{ title: "Lead time (min)" },
|
||||
{ title: "" },
|
||||
]}
|
||||
selectable={false}
|
||||
>
|
||||
{slotTemplates.map((slot, index) => (
|
||||
<IndexTable.Row id={slot.id} key={slot.id} position={index}>
|
||||
<IndexTable.Cell>{WEEKDAY_NAMES[slot.weekday]}</IndexTable.Cell>
|
||||
<IndexTable.Cell>{slot.method.replace("_", " ")}</IndexTable.Cell>
|
||||
<IndexTable.Cell>
|
||||
{minutesToTime(slot.startMin)}–{minutesToTime(slot.endMin)}
|
||||
</IndexTable.Cell>
|
||||
<IndexTable.Cell>{slot.capacity}</IndexTable.Cell>
|
||||
<IndexTable.Cell>{slot.cutoffMin}</IndexTable.Cell>
|
||||
<IndexTable.Cell>{slot.leadTimeMin}</IndexTable.Cell>
|
||||
<IndexTable.Cell>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={slot.id} />
|
||||
<Button submit variant="plain" tone="critical">
|
||||
Remove
|
||||
</Button>
|
||||
</Form>
|
||||
</IndexTable.Cell>
|
||||
</IndexTable.Row>
|
||||
))}
|
||||
</IndexTable>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AddSlotForm key={activeLocationId} locationId={activeLocationId ?? ""} isSubmitting={isSubmitting} />
|
||||
</BlockStack>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="locationId" value={locationId} />
|
||||
<BlockStack gap="300">
|
||||
<Text as="h3" variant="headingSm">
|
||||
Add a slot template
|
||||
</Text>
|
||||
<InlineStack gap="300" wrap>
|
||||
<Select
|
||||
label="Weekday"
|
||||
name="weekday"
|
||||
options={WEEKDAY_NAMES.map((name, value) => ({ label: name, value: String(value) }))}
|
||||
/>
|
||||
<Select
|
||||
label="Method"
|
||||
name="method"
|
||||
options={METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Start time"
|
||||
name="startTime"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={setStartTime}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<TextField
|
||||
label="End time"
|
||||
name="endTime"
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={setEndTime}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<TextField
|
||||
label="Capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
value={capacity}
|
||||
onChange={setCapacity}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<TextField
|
||||
label="Cutoff (min)"
|
||||
name="cutoffMin"
|
||||
type="number"
|
||||
value={cutoffMin}
|
||||
onChange={setCutoffMin}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<TextField
|
||||
label="Lead time (min)"
|
||||
name="leadTimeMin"
|
||||
type="number"
|
||||
value={leadTimeMin}
|
||||
onChange={setLeadTimeMin}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InlineStack>
|
||||
<div>
|
||||
<Button submit variant="primary" loading={isSubmitting}>
|
||||
Add slot template
|
||||
</Button>
|
||||
</div>
|
||||
</BlockStack>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -24,6 +24,9 @@ export default function App() {
|
||||
<Link to="/app" rel="home">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/app/locations">Locations</Link>
|
||||
<Link to="/app/slots">Weekly slots</Link>
|
||||
<Link to="/app/blackouts">Blackout dates</Link>
|
||||
</NavMenu>
|
||||
<Outlet />
|
||||
</AppProvider>
|
||||
|
||||
186
app/services/templates.server.ts
Normal file
186
app/services/templates.server.ts
Normal file
@ -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<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;
|
||||
});
|
||||
}
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@ -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:
|
||||
@ -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
|
||||
);
|
||||
@ -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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -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"
|
||||
@ -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])
|
||||
}
|
||||
|
||||
40
tests/unit/templates.test.ts
Normal file
40
tests/unit/templates.test.ts
Normal file
@ -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"));
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user