Implement ordering MVP domain layer per spec (issue #1)
Scaffolds the TanStack Start + Drizzle/Postgres + Better Auth app and implements the core ordering domain: menu browsing, guest/account order placement with online payment, per-Order-Item independent cooking and cancellation, dynamic Machine-Tag pool assignment, and multi-step recipes (see docs/adr/0001-0003). Adds a guest-access-token / session-based ownership check on every read/mutation so an order can't be viewed, cancelled, or picked up by anyone but its owner. Covered by 14 tests running the domain layer directly against a real, ephemeral Postgres (testcontainers) — createServerFn couldn't be called outside a real request context, so tests exercise the domain functions the server functions thinly wrap instead of the server functions themselves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"projectName": "ghost-kitchen-scaffold",
|
||||
"mode": "file-router",
|
||||
"typescript": true,
|
||||
"tailwind": true,
|
||||
"packageManager": "npm",
|
||||
"git": false,
|
||||
"install": true,
|
||||
"intent": true,
|
||||
"addOnOptions": {
|
||||
"drizzle": {
|
||||
"database": "postgresql"
|
||||
}
|
||||
},
|
||||
"projectPreset": "default",
|
||||
"includeExamples": false,
|
||||
"routerOnly": false,
|
||||
"version": 1,
|
||||
"framework": "react",
|
||||
"chosenAddOns": [
|
||||
"drizzle",
|
||||
"better-auth",
|
||||
"eslint"
|
||||
]
|
||||
}
|
||||
+12
-1
@@ -1,2 +1,13 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
.output
|
||||
.vinxi
|
||||
__unconfig*
|
||||
todos.json
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files.watcherExclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
Welcome to your new TanStack Start app!
|
||||
|
||||
# Getting Started
|
||||
|
||||
To run this application:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
# Building For Production
|
||||
|
||||
To build this application for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||
|
||||
### Removing Tailwind CSS
|
||||
|
||||
If you prefer not to use Tailwind CSS:
|
||||
|
||||
1. Remove the demo pages in `src/routes/demo/`
|
||||
2. Replace the Tailwind import in `src/styles.css` with your own styles
|
||||
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
|
||||
4. Remove `@tailwindcss/vite` and `tailwindcss` from `package.json`
|
||||
|
||||
## Linting & Formatting
|
||||
|
||||
|
||||
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run format
|
||||
npm run check
|
||||
```
|
||||
|
||||
|
||||
## Setting up Better Auth
|
||||
|
||||
1. Generate and set the `BETTER_AUTH_SECRET` environment variable in your `.env.local`:
|
||||
|
||||
```bash
|
||||
npx -y @better-auth/cli secret
|
||||
```
|
||||
|
||||
2. Visit the [Better Auth documentation](https://www.better-auth.com) to unlock the full potential of authentication in your app.
|
||||
|
||||
### Adding a Database (Optional)
|
||||
|
||||
Better Auth can work in stateless mode, but to persist user data, add a database:
|
||||
|
||||
```typescript
|
||||
// src/lib/auth.ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { Pool } from "pg";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
}),
|
||||
// ... rest of config
|
||||
});
|
||||
```
|
||||
|
||||
Then run migrations:
|
||||
|
||||
```bash
|
||||
npx -y @better-auth/cli migrate
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Routing
|
||||
|
||||
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
|
||||
|
||||
### Adding A Route
|
||||
|
||||
To add a new route to your application just add a new file in the `./src/routes` directory.
|
||||
|
||||
TanStack will automatically generate the content of the route file for you.
|
||||
|
||||
Now that you have two routes you can use a `Link` component to navigate between them.
|
||||
|
||||
### Adding Links
|
||||
|
||||
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
|
||||
|
||||
```tsx
|
||||
import { Link } from "@tanstack/react-router";
|
||||
```
|
||||
|
||||
Then anywhere in your JSX you can use it like so:
|
||||
|
||||
```tsx
|
||||
<Link to="/about">About</Link>
|
||||
```
|
||||
|
||||
This will create a link that will navigate to the `/about` route.
|
||||
|
||||
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
|
||||
|
||||
### Using A Layout
|
||||
|
||||
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
|
||||
|
||||
Here is an example layout that includes a header:
|
||||
|
||||
```tsx
|
||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ title: 'My App' },
|
||||
],
|
||||
}),
|
||||
shellComponent: ({ children }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/about">About</Link>
|
||||
</nav>
|
||||
</header>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
||||
|
||||
## Server Functions
|
||||
|
||||
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
|
||||
|
||||
```tsx
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
|
||||
const getServerTime = createServerFn({
|
||||
method: 'GET',
|
||||
}).handler(async () => {
|
||||
return new Date().toISOString()
|
||||
})
|
||||
|
||||
// Use in a component
|
||||
function MyComponent() {
|
||||
const [time, setTime] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
getServerTime().then(setTime)
|
||||
}, [])
|
||||
|
||||
return <div>Server time: {time}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
You can create API routes by using the `server` property in your route definitions:
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
export const Route = createFileRoute('/api/hello')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () => json({ message: 'Hello, World!' }),
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Data Fetching
|
||||
|
||||
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
|
||||
|
||||
For example:
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/people')({
|
||||
loader: async () => {
|
||||
const response = await fetch('https://swapi.dev/api/people')
|
||||
return response.json()
|
||||
},
|
||||
component: PeopleComponent,
|
||||
})
|
||||
|
||||
function PeopleComponent() {
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<ul>
|
||||
{data.results.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
|
||||
|
||||
|
||||
|
||||
# Learn More
|
||||
|
||||
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
|
||||
|
||||
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).
|
||||
@@ -0,0 +1,13 @@
|
||||
import { config } from 'dotenv'
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
config({ path: ['.env.local', '.env'] })
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './src/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
CREATE TABLE "brands" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "machine_tags" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "machines" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"machine_tag_id" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "menu_item_steps" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"menu_item_id" integer NOT NULL,
|
||||
"sequence" integer NOT NULL,
|
||||
"machine_tag_id" integer NOT NULL,
|
||||
"estimated_duration_seconds" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "menu_items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"brand_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"price_cents" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "order_items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order_id" integer NOT NULL,
|
||||
"menu_item_id" integer NOT NULL,
|
||||
"price_cents" integer NOT NULL,
|
||||
"status" text DEFAULT 'placed' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "orders" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"customer_id" text,
|
||||
"guest_name" text,
|
||||
"guest_contact" text,
|
||||
"status" text DEFAULT 'placed' NOT NULL,
|
||||
"payment_charge_id" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "preparation_step_instances" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order_item_id" integer NOT NULL,
|
||||
"sequence" integer NOT NULL,
|
||||
"machine_tag_id" integer NOT NULL,
|
||||
"estimated_duration_seconds" integer NOT NULL,
|
||||
"status" text DEFAULT 'queued' NOT NULL,
|
||||
"machine_id" integer,
|
||||
"started_at" timestamp,
|
||||
"done_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "machines" ADD CONSTRAINT "machines_machine_tag_id_machine_tags_id_fk" FOREIGN KEY ("machine_tag_id") REFERENCES "public"."machine_tags"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "menu_item_steps" ADD CONSTRAINT "menu_item_steps_menu_item_id_menu_items_id_fk" FOREIGN KEY ("menu_item_id") REFERENCES "public"."menu_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "menu_item_steps" ADD CONSTRAINT "menu_item_steps_machine_tag_id_machine_tags_id_fk" FOREIGN KEY ("machine_tag_id") REFERENCES "public"."machine_tags"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "menu_items" ADD CONSTRAINT "menu_items_brand_id_brands_id_fk" FOREIGN KEY ("brand_id") REFERENCES "public"."brands"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_menu_item_id_menu_items_id_fk" FOREIGN KEY ("menu_item_id") REFERENCES "public"."menu_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "preparation_step_instances" ADD CONSTRAINT "preparation_step_instances_order_item_id_order_items_id_fk" FOREIGN KEY ("order_item_id") REFERENCES "public"."order_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "preparation_step_instances" ADD CONSTRAINT "preparation_step_instances_machine_tag_id_machine_tags_id_fk" FOREIGN KEY ("machine_tag_id") REFERENCES "public"."machine_tags"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "preparation_step_instances" ADD CONSTRAINT "preparation_step_instances_machine_id_machines_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machines"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "machine_tags_name_idx" ON "machine_tags" USING btree ("name");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "orders" ADD COLUMN "guest_access_token" text;
|
||||
@@ -0,0 +1,514 @@
|
||||
{
|
||||
"id": "f8244983-d51b-4ac7-a6af-e55c9f39edcd",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.brands": {
|
||||
"name": "brands",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.machine_tags": {
|
||||
"name": "machine_tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"machine_tags_name_idx": {
|
||||
"name": "machine_tags_name_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.machines": {
|
||||
"name": "machines",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"machine_tag_id": {
|
||||
"name": "machine_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"machines_machine_tag_id_machine_tags_id_fk": {
|
||||
"name": "machines_machine_tag_id_machine_tags_id_fk",
|
||||
"tableFrom": "machines",
|
||||
"tableTo": "machine_tags",
|
||||
"columnsFrom": [
|
||||
"machine_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.menu_item_steps": {
|
||||
"name": "menu_item_steps",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"menu_item_id": {
|
||||
"name": "menu_item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sequence": {
|
||||
"name": "sequence",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"machine_tag_id": {
|
||||
"name": "machine_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"estimated_duration_seconds": {
|
||||
"name": "estimated_duration_seconds",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"menu_item_steps_menu_item_id_menu_items_id_fk": {
|
||||
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
|
||||
"tableFrom": "menu_item_steps",
|
||||
"tableTo": "menu_items",
|
||||
"columnsFrom": [
|
||||
"menu_item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
|
||||
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
|
||||
"tableFrom": "menu_item_steps",
|
||||
"tableTo": "machine_tags",
|
||||
"columnsFrom": [
|
||||
"machine_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.menu_items": {
|
||||
"name": "menu_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"brand_id": {
|
||||
"name": "brand_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"menu_items_brand_id_brands_id_fk": {
|
||||
"name": "menu_items_brand_id_brands_id_fk",
|
||||
"tableFrom": "menu_items",
|
||||
"tableTo": "brands",
|
||||
"columnsFrom": [
|
||||
"brand_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.order_items": {
|
||||
"name": "order_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"order_id": {
|
||||
"name": "order_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"menu_item_id": {
|
||||
"name": "menu_item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'placed'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"order_items_order_id_orders_id_fk": {
|
||||
"name": "order_items_order_id_orders_id_fk",
|
||||
"tableFrom": "order_items",
|
||||
"tableTo": "orders",
|
||||
"columnsFrom": [
|
||||
"order_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"order_items_menu_item_id_menu_items_id_fk": {
|
||||
"name": "order_items_menu_item_id_menu_items_id_fk",
|
||||
"tableFrom": "order_items",
|
||||
"tableTo": "menu_items",
|
||||
"columnsFrom": [
|
||||
"menu_item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.orders": {
|
||||
"name": "orders",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_id": {
|
||||
"name": "customer_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"guest_name": {
|
||||
"name": "guest_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"guest_contact": {
|
||||
"name": "guest_contact",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'placed'"
|
||||
},
|
||||
"payment_charge_id": {
|
||||
"name": "payment_charge_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.preparation_step_instances": {
|
||||
"name": "preparation_step_instances",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"order_item_id": {
|
||||
"name": "order_item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sequence": {
|
||||
"name": "sequence",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"machine_tag_id": {
|
||||
"name": "machine_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"estimated_duration_seconds": {
|
||||
"name": "estimated_duration_seconds",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"machine_id": {
|
||||
"name": "machine_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"done_at": {
|
||||
"name": "done_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"preparation_step_instances_order_item_id_order_items_id_fk": {
|
||||
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
|
||||
"tableFrom": "preparation_step_instances",
|
||||
"tableTo": "order_items",
|
||||
"columnsFrom": [
|
||||
"order_item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
|
||||
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
|
||||
"tableFrom": "preparation_step_instances",
|
||||
"tableTo": "machine_tags",
|
||||
"columnsFrom": [
|
||||
"machine_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"preparation_step_instances_machine_id_machines_id_fk": {
|
||||
"name": "preparation_step_instances_machine_id_machines_id_fk",
|
||||
"tableFrom": "preparation_step_instances",
|
||||
"tableTo": "machines",
|
||||
"columnsFrom": [
|
||||
"machine_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"id": "816e0a1c-3005-433c-911b-2e006cc9e91b",
|
||||
"prevId": "f8244983-d51b-4ac7-a6af-e55c9f39edcd",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.brands": {
|
||||
"name": "brands",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.machine_tags": {
|
||||
"name": "machine_tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"machine_tags_name_idx": {
|
||||
"name": "machine_tags_name_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.machines": {
|
||||
"name": "machines",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"machine_tag_id": {
|
||||
"name": "machine_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"machines_machine_tag_id_machine_tags_id_fk": {
|
||||
"name": "machines_machine_tag_id_machine_tags_id_fk",
|
||||
"tableFrom": "machines",
|
||||
"tableTo": "machine_tags",
|
||||
"columnsFrom": [
|
||||
"machine_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.menu_item_steps": {
|
||||
"name": "menu_item_steps",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"menu_item_id": {
|
||||
"name": "menu_item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sequence": {
|
||||
"name": "sequence",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"machine_tag_id": {
|
||||
"name": "machine_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"estimated_duration_seconds": {
|
||||
"name": "estimated_duration_seconds",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"menu_item_steps_menu_item_id_menu_items_id_fk": {
|
||||
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
|
||||
"tableFrom": "menu_item_steps",
|
||||
"tableTo": "menu_items",
|
||||
"columnsFrom": [
|
||||
"menu_item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
|
||||
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
|
||||
"tableFrom": "menu_item_steps",
|
||||
"tableTo": "machine_tags",
|
||||
"columnsFrom": [
|
||||
"machine_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.menu_items": {
|
||||
"name": "menu_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"brand_id": {
|
||||
"name": "brand_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"menu_items_brand_id_brands_id_fk": {
|
||||
"name": "menu_items_brand_id_brands_id_fk",
|
||||
"tableFrom": "menu_items",
|
||||
"tableTo": "brands",
|
||||
"columnsFrom": [
|
||||
"brand_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.order_items": {
|
||||
"name": "order_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"order_id": {
|
||||
"name": "order_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"menu_item_id": {
|
||||
"name": "menu_item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'placed'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"order_items_order_id_orders_id_fk": {
|
||||
"name": "order_items_order_id_orders_id_fk",
|
||||
"tableFrom": "order_items",
|
||||
"tableTo": "orders",
|
||||
"columnsFrom": [
|
||||
"order_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"order_items_menu_item_id_menu_items_id_fk": {
|
||||
"name": "order_items_menu_item_id_menu_items_id_fk",
|
||||
"tableFrom": "order_items",
|
||||
"tableTo": "menu_items",
|
||||
"columnsFrom": [
|
||||
"menu_item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.orders": {
|
||||
"name": "orders",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_id": {
|
||||
"name": "customer_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"guest_name": {
|
||||
"name": "guest_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"guest_contact": {
|
||||
"name": "guest_contact",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"guest_access_token": {
|
||||
"name": "guest_access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'placed'"
|
||||
},
|
||||
"payment_charge_id": {
|
||||
"name": "payment_charge_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.preparation_step_instances": {
|
||||
"name": "preparation_step_instances",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"order_item_id": {
|
||||
"name": "order_item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sequence": {
|
||||
"name": "sequence",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"machine_tag_id": {
|
||||
"name": "machine_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"estimated_duration_seconds": {
|
||||
"name": "estimated_duration_seconds",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"machine_id": {
|
||||
"name": "machine_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"done_at": {
|
||||
"name": "done_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"preparation_step_instances_order_item_id_order_items_id_fk": {
|
||||
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
|
||||
"tableFrom": "preparation_step_instances",
|
||||
"tableTo": "order_items",
|
||||
"columnsFrom": [
|
||||
"order_item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
|
||||
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
|
||||
"tableFrom": "preparation_step_instances",
|
||||
"tableTo": "machine_tags",
|
||||
"columnsFrom": [
|
||||
"machine_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"preparation_step_instances_machine_id_machines_id_fk": {
|
||||
"name": "preparation_step_instances_machine_id_machines_id_fk",
|
||||
"tableFrom": "preparation_step_instances",
|
||||
"tableTo": "machines",
|
||||
"columnsFrom": [
|
||||
"machine_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1788971014996,
|
||||
"tag": "0000_tense_lady_vermin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1788972318759,
|
||||
"tag": "0001_graceful_frank_castle",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// @ts-check
|
||||
|
||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
||||
|
||||
export default [
|
||||
...tanstackConfig,
|
||||
{
|
||||
rules: {
|
||||
'import/no-cycle': 'off',
|
||||
'import/order': 'off',
|
||||
'sort-imports': 'off',
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'pnpm/json-enforce-catalog': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['eslint.config.js', 'prettier.config.js'],
|
||||
},
|
||||
]
|
||||
Generated
+10169
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "ghost-kitchen",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
"generate-routes": "tsr generate",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:pull": "drizzle-kit pull",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"lint": "eslint",
|
||||
"format": "prettier --write . && eslint --fix",
|
||||
"check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "latest",
|
||||
"@tanstack/react-router": "latest",
|
||||
"@tanstack/react-router-devtools": "latest",
|
||||
"@tanstack/react-start": "latest",
|
||||
"better-auth": "^1.5.3",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"stripe": "^22.6.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/devtools-vite": "latest",
|
||||
"@tanstack/eslint-config": "latest",
|
||||
"@tanstack/router-cli": "^1.132.0",
|
||||
"@testcontainers/postgresql": "^12.1.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^9.20.0",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"lightningcss"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('prettier').Config} */
|
||||
const config = {
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" fill="none" viewBox="0 0 160 160"><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 43.48 67.304)"/><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 76.94 46.534)"/><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 128.424 46.535)"/><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 94.957 67.304)"/></svg>
|
||||
|
After Width: | Height: | Size: 608 B |
@@ -0,0 +1,5 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||
|
||||
import * as schema from './schema.ts'
|
||||
|
||||
export const db = drizzle(process.env.DATABASE_URL!, { schema })
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
integer,
|
||||
pgTable,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core'
|
||||
|
||||
// A Kitchen runs one Brand. Since each Kitchen is its own independent
|
||||
// deployment (see docs/adr/0001-one-server-per-kitchen.md), this table
|
||||
// only ever holds a single row, seeded per deployment.
|
||||
export const brands = pgTable('brands', {
|
||||
id: serial().primaryKey(),
|
||||
name: text().notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
// A capability label a Machine can carry, e.g. "oven", "grill", "assembly".
|
||||
export const machineTags = pgTable(
|
||||
'machine_tags',
|
||||
{
|
||||
id: serial().primaryKey(),
|
||||
name: text().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex('machine_tags_name_idx').on(table.name)],
|
||||
)
|
||||
|
||||
// A physical cooking hardware unit. MVP simplification: one Tag per
|
||||
// Machine (a Kitchen adds more Machines of the same Tag for parallelism,
|
||||
// see docs/adr/0003-machine-tag-pool-dynamic-assignment.md).
|
||||
export const machines = pgTable('machines', {
|
||||
id: serial().primaryKey(),
|
||||
name: text().notNull(),
|
||||
machineTagId: integer('machine_tag_id')
|
||||
.notNull()
|
||||
.references(() => machineTags.id),
|
||||
})
|
||||
|
||||
export const menuItems = pgTable('menu_items', {
|
||||
id: serial().primaryKey(),
|
||||
brandId: integer('brand_id')
|
||||
.notNull()
|
||||
.references(() => brands.id),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
priceCents: integer('price_cents').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
// A MenuItem's Recipe: its ordered list of required Preparation Steps.
|
||||
export const menuItemSteps = pgTable('menu_item_steps', {
|
||||
id: serial().primaryKey(),
|
||||
menuItemId: integer('menu_item_id')
|
||||
.notNull()
|
||||
.references(() => menuItems.id),
|
||||
sequence: integer().notNull(),
|
||||
machineTagId: integer('machine_tag_id')
|
||||
.notNull()
|
||||
.references(() => machineTags.id),
|
||||
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
||||
})
|
||||
|
||||
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
|
||||
export type OrderStatus = (typeof orderStatusValues)[number]
|
||||
|
||||
export const orders = pgTable('orders', {
|
||||
id: serial().primaryKey(),
|
||||
// References better-auth's `user.id` for an account order; null for a guest order.
|
||||
customerId: text('customer_id'),
|
||||
guestName: text('guest_name'),
|
||||
guestContact: text('guest_contact'),
|
||||
// Bearer token handed to a guest at placement time so they can prove
|
||||
// ownership of the Order on later calls (there's no session to check
|
||||
// against for a guest). Null for an account Order.
|
||||
guestAccessToken: text('guest_access_token'),
|
||||
status: text().$type<OrderStatus>().notNull().default('placed'),
|
||||
paymentChargeId: text('payment_charge_id'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
export const orderItemStatusValues = [
|
||||
'placed',
|
||||
'paid',
|
||||
'ready',
|
||||
'picked_up',
|
||||
'cancelled',
|
||||
] as const
|
||||
export type OrderItemStatus = (typeof orderItemStatusValues)[number]
|
||||
|
||||
export const orderItems = pgTable('order_items', {
|
||||
id: serial().primaryKey(),
|
||||
orderId: integer('order_id')
|
||||
.notNull()
|
||||
.references(() => orders.id),
|
||||
menuItemId: integer('menu_item_id')
|
||||
.notNull()
|
||||
.references(() => menuItems.id),
|
||||
// Captured at order time so later menu price changes don't rewrite history.
|
||||
priceCents: integer('price_cents').notNull(),
|
||||
status: text().$type<OrderItemStatus>().notNull().default('placed'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
export const preparationStepStatusValues = [
|
||||
'queued',
|
||||
'in_progress',
|
||||
'done',
|
||||
] as const
|
||||
export type PreparationStepStatus = (typeof preparationStepStatusValues)[number]
|
||||
|
||||
export const preparationStepInstances = pgTable('preparation_step_instances', {
|
||||
id: serial().primaryKey(),
|
||||
orderItemId: integer('order_item_id')
|
||||
.notNull()
|
||||
.references(() => orderItems.id),
|
||||
sequence: integer().notNull(),
|
||||
machineTagId: integer('machine_tag_id')
|
||||
.notNull()
|
||||
.references(() => machineTags.id),
|
||||
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
||||
status: text().$type<PreparationStepStatus>().notNull().default('queued'),
|
||||
machineId: integer('machine_id').references(() => machines.id),
|
||||
startedAt: timestamp('started_at'),
|
||||
doneAt: timestamp('done_at'),
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
|
||||
import * as schema from '#/db/schema.ts'
|
||||
|
||||
// Sample Brand/menu/hardware data for local dev and as the shared fixture
|
||||
// tests seed against. Specific menu items are explicitly out of scope for
|
||||
// the ordering spec — Pizza/Burger here are illustrative, not final content.
|
||||
export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
||||
const [brand] = await db
|
||||
.insert(schema.brands)
|
||||
.values({ name: 'Ghost Kitchen' })
|
||||
.returning()
|
||||
|
||||
const [ovenTag] = await db
|
||||
.insert(schema.machineTags)
|
||||
.values({ name: 'oven' })
|
||||
.returning()
|
||||
const [grillTag] = await db
|
||||
.insert(schema.machineTags)
|
||||
.values({ name: 'grill' })
|
||||
.returning()
|
||||
const [assemblyTag] = await db
|
||||
.insert(schema.machineTags)
|
||||
.values({ name: 'assembly' })
|
||||
.returning()
|
||||
|
||||
const [oven1] = await db
|
||||
.insert(schema.machines)
|
||||
.values({ name: 'Oven 1', machineTagId: ovenTag.id })
|
||||
.returning()
|
||||
const [grill1] = await db
|
||||
.insert(schema.machines)
|
||||
.values({ name: 'Grill 1', machineTagId: grillTag.id })
|
||||
.returning()
|
||||
const [assembly1] = await db
|
||||
.insert(schema.machines)
|
||||
.values({ name: 'Assembly Station 1', machineTagId: assemblyTag.id })
|
||||
.returning()
|
||||
|
||||
const [pizza] = await db
|
||||
.insert(schema.menuItems)
|
||||
.values({
|
||||
brandId: brand.id,
|
||||
name: 'Pizza',
|
||||
description: 'Classic oven-baked pizza.',
|
||||
priceCents: 1299,
|
||||
})
|
||||
.returning()
|
||||
|
||||
await db.insert(schema.menuItemSteps).values({
|
||||
menuItemId: pizza.id,
|
||||
sequence: 1,
|
||||
machineTagId: ovenTag.id,
|
||||
estimatedDurationSeconds: 600,
|
||||
})
|
||||
|
||||
const [burger] = await db
|
||||
.insert(schema.menuItems)
|
||||
.values({
|
||||
brandId: brand.id,
|
||||
name: 'Burger',
|
||||
description: 'Grilled burger, assembled to order.',
|
||||
priceCents: 999,
|
||||
})
|
||||
.returning()
|
||||
|
||||
await db.insert(schema.menuItemSteps).values([
|
||||
{
|
||||
menuItemId: burger.id,
|
||||
sequence: 1,
|
||||
machineTagId: grillTag.id,
|
||||
estimatedDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
menuItemId: burger.id,
|
||||
sequence: 2,
|
||||
machineTagId: assemblyTag.id,
|
||||
estimatedDurationSeconds: 90,
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
brand,
|
||||
machineTags: { oven: ovenTag, grill: grillTag, assembly: assemblyTag },
|
||||
machines: { oven1, grill1, assembly1 },
|
||||
menuItems: { pizza, burger },
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const { drizzle } = await import('drizzle-orm/node-postgres')
|
||||
const db = drizzle(process.env.DATABASE_URL!, { schema })
|
||||
await seedGhostKitchen(db)
|
||||
console.log('Seeded Ghost Kitchen sample data.')
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
|
||||
import * as schema from '#/db/schema.ts'
|
||||
import { PaymentDeclinedError } from '#/lib/payment.ts'
|
||||
import type { PaymentProvider } from '#/lib/payment.ts'
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>
|
||||
// The callback type `db.transaction()` hands its work to. Helpers below
|
||||
// accept either a top-level Db or an in-flight transaction so they compose
|
||||
// into one atomic unit of work when called from within `db.transaction()`.
|
||||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0]
|
||||
type DbOrTx = Db | Tx
|
||||
|
||||
// Drizzle types a `SELECT ... LIMIT 1`-shaped destructure's element as always
|
||||
// present, even though zero rows is a real possibility at runtime (without
|
||||
// noUncheckedIndexedAccess). Route those reads through this helper so their
|
||||
// optionality is genuinely reflected in the type.
|
||||
const firstRow = <T>(rows: Array<T>): T | undefined => rows[0]
|
||||
|
||||
export class OrderItemNotCancellableError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(`This item can no longer be cancelled: ${reason}`)
|
||||
this.name = 'OrderItemNotCancellableError'
|
||||
}
|
||||
}
|
||||
|
||||
export class OrderItemNotReadyError extends Error {
|
||||
constructor() {
|
||||
super('This item is not ready for pickup yet')
|
||||
this.name = 'OrderItemNotReadyError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NoStepInProgressError extends Error {
|
||||
constructor() {
|
||||
super('This item has no Preparation Step currently in progress')
|
||||
this.name = 'NoStepInProgressError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
constructor() {
|
||||
super('You do not have access to this Order')
|
||||
this.name = 'ForbiddenError'
|
||||
}
|
||||
}
|
||||
|
||||
export type MenuItemWithRecipe = {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
priceCents: number
|
||||
steps: Array<{
|
||||
sequence: number
|
||||
machineTag: string
|
||||
estimatedDurationSeconds: number
|
||||
}>
|
||||
}
|
||||
|
||||
export const getMenu = async (
|
||||
db: Db,
|
||||
brandId: number,
|
||||
): Promise<Array<MenuItemWithRecipe>> => {
|
||||
const items = await db
|
||||
.select()
|
||||
.from(schema.menuItems)
|
||||
.where(eq(schema.menuItems.brandId, brandId))
|
||||
|
||||
const steps = await db
|
||||
.select({
|
||||
menuItemId: schema.menuItemSteps.menuItemId,
|
||||
sequence: schema.menuItemSteps.sequence,
|
||||
machineTag: schema.machineTags.name,
|
||||
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
||||
})
|
||||
.from(schema.menuItemSteps)
|
||||
.innerJoin(
|
||||
schema.machineTags,
|
||||
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
||||
)
|
||||
.where(
|
||||
inArray(
|
||||
schema.menuItemSteps.menuItemId,
|
||||
items.map((item) => item.id),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.menuItemSteps.sequence))
|
||||
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
priceCents: item.priceCents,
|
||||
steps: steps
|
||||
.filter((step) => step.menuItemId === item.id)
|
||||
.map(({ sequence, machineTag, estimatedDurationSeconds }) => ({
|
||||
sequence,
|
||||
machineTag,
|
||||
estimatedDurationSeconds,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
// Assigns queued Preparation Steps for a given Machine Tag to whichever
|
||||
// Machine carrying that Tag is idle, FIFO by queue position. Called
|
||||
// whenever a Step is created (order placed) or freed (a Step finishes) —
|
||||
// see docs/adr/0003-machine-tag-pool-dynamic-assignment.md.
|
||||
//
|
||||
// Locks that Tag's Machine rows for the duration of the caller's
|
||||
// transaction, so two concurrent calls for the same Tag (e.g. two orders
|
||||
// placed at once) can't both read the same idle Machine as free and
|
||||
// double-book it — the second call blocks here until the first commits.
|
||||
export const assignQueuedStepsForTag = async (
|
||||
db: DbOrTx,
|
||||
machineTagId: number,
|
||||
) => {
|
||||
const tagMachines = await db
|
||||
.select({ id: schema.machines.id })
|
||||
.from(schema.machines)
|
||||
.where(eq(schema.machines.machineTagId, machineTagId))
|
||||
.for('update')
|
||||
|
||||
if (tagMachines.length === 0) return
|
||||
|
||||
const busyMachineIds = new Set(
|
||||
(
|
||||
await db
|
||||
.select({ machineId: schema.preparationStepInstances.machineId })
|
||||
.from(schema.preparationStepInstances)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.preparationStepInstances.machineTagId, machineTagId),
|
||||
eq(schema.preparationStepInstances.status, 'in_progress'),
|
||||
),
|
||||
)
|
||||
)
|
||||
.map((row) => row.machineId)
|
||||
.filter((id): id is number => id !== null),
|
||||
)
|
||||
|
||||
const idleMachineIds = tagMachines
|
||||
.map((machine) => machine.id)
|
||||
.filter((id) => !busyMachineIds.has(id))
|
||||
|
||||
if (idleMachineIds.length === 0) return
|
||||
|
||||
const queuedSteps = await db
|
||||
.select({ id: schema.preparationStepInstances.id })
|
||||
.from(schema.preparationStepInstances)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.preparationStepInstances.machineTagId, machineTagId),
|
||||
eq(schema.preparationStepInstances.status, 'queued'),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.preparationStepInstances.id))
|
||||
|
||||
const assignments = queuedSteps.slice(0, idleMachineIds.length)
|
||||
|
||||
for (const [index, step] of assignments.entries()) {
|
||||
await db
|
||||
.update(schema.preparationStepInstances)
|
||||
.set({
|
||||
status: 'in_progress',
|
||||
machineId: idleMachineIds[index],
|
||||
startedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.preparationStepInstances.id, step.id))
|
||||
}
|
||||
}
|
||||
|
||||
const createFirstStepForOrderItem = async (
|
||||
db: DbOrTx,
|
||||
orderItemId: number,
|
||||
menuItemId: number,
|
||||
) => {
|
||||
const firstStep = firstRow(
|
||||
await db
|
||||
.select()
|
||||
.from(schema.menuItemSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.menuItemSteps.menuItemId, menuItemId),
|
||||
eq(schema.menuItemSteps.sequence, 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// A menu item with no Preparation Steps at all is a data integrity bug
|
||||
// (see spec: every menu item's Recipe has at least one Step), not a valid
|
||||
// "nothing to do" case — surfacing it loudly here beats leaving the Order
|
||||
// Item permanently stuck with no Step and no way to progress or cancel.
|
||||
if (!firstStep) {
|
||||
throw new Error(`Menu item ${menuItemId} has no Preparation Steps defined`)
|
||||
}
|
||||
|
||||
const [instance] = await db
|
||||
.insert(schema.preparationStepInstances)
|
||||
.values({
|
||||
orderItemId,
|
||||
sequence: firstStep.sequence,
|
||||
machineTagId: firstStep.machineTagId,
|
||||
estimatedDurationSeconds: firstStep.estimatedDurationSeconds,
|
||||
status: 'queued',
|
||||
})
|
||||
.returning()
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
export type PlaceOrderInput = {
|
||||
items: Array<{ menuItemId: number }>
|
||||
customer:
|
||||
| { type: 'guest'; name: string; contact: string }
|
||||
| { type: 'account'; customerId: string }
|
||||
}
|
||||
|
||||
export const placeOrder = async (
|
||||
db: Db,
|
||||
paymentProvider: PaymentProvider,
|
||||
input: PlaceOrderInput,
|
||||
) => {
|
||||
if (input.items.length === 0) {
|
||||
throw new Error('An Order must contain at least one Order Item')
|
||||
}
|
||||
|
||||
const menuItemIds = input.items.map((item) => item.menuItemId)
|
||||
const menuItemRows = await db
|
||||
.select()
|
||||
.from(schema.menuItems)
|
||||
.where(inArray(schema.menuItems.id, menuItemIds))
|
||||
const menuItemById = new Map(menuItemRows.map((row) => [row.id, row]))
|
||||
|
||||
for (const id of menuItemIds) {
|
||||
if (!menuItemById.has(id)) {
|
||||
throw new Error(`Unknown menu item: ${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Catch the "menu item has no Recipe Steps" data bug before charging
|
||||
// anyone, rather than charging first and refunding once
|
||||
// createFirstStepForOrderItem discovers it mid-transaction.
|
||||
const itemIdsWithSteps = new Set(
|
||||
(
|
||||
await db
|
||||
.select({ menuItemId: schema.menuItemSteps.menuItemId })
|
||||
.from(schema.menuItemSteps)
|
||||
.where(inArray(schema.menuItemSteps.menuItemId, menuItemIds))
|
||||
).map((row) => row.menuItemId),
|
||||
)
|
||||
for (const id of menuItemIds) {
|
||||
if (!itemIdsWithSteps.has(id)) {
|
||||
throw new Error(`Menu item ${id} has no Preparation Steps defined`)
|
||||
}
|
||||
}
|
||||
|
||||
const totalCents = input.items.reduce(
|
||||
(sum, item) => sum + menuItemById.get(item.menuItemId)!.priceCents,
|
||||
0,
|
||||
)
|
||||
|
||||
// Payment happens before anything is queued: a declined charge means no
|
||||
// Order Item is ever created (see spec: "Order rejected if payment fails").
|
||||
let charge
|
||||
try {
|
||||
charge = await paymentProvider.charge(totalCents)
|
||||
} catch (error) {
|
||||
if (error instanceof PaymentDeclinedError) throw error
|
||||
throw new PaymentDeclinedError()
|
||||
}
|
||||
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [order] = await tx
|
||||
.insert(schema.orders)
|
||||
.values({
|
||||
customerId:
|
||||
input.customer.type === 'account'
|
||||
? input.customer.customerId
|
||||
: null,
|
||||
guestName:
|
||||
input.customer.type === 'guest' ? input.customer.name : null,
|
||||
guestContact:
|
||||
input.customer.type === 'guest' ? input.customer.contact : null,
|
||||
guestAccessToken:
|
||||
input.customer.type === 'guest' ? crypto.randomUUID() : null,
|
||||
status: 'paid',
|
||||
paymentChargeId: charge.chargeId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
const tagsToAssign = new Set<number>()
|
||||
|
||||
for (const item of input.items) {
|
||||
const menuItem = menuItemById.get(item.menuItemId)!
|
||||
const [orderItem] = await tx
|
||||
.insert(schema.orderItems)
|
||||
.values({
|
||||
orderId: order.id,
|
||||
menuItemId: menuItem.id,
|
||||
priceCents: menuItem.priceCents,
|
||||
status: 'paid',
|
||||
})
|
||||
.returning()
|
||||
|
||||
const step = await createFirstStepForOrderItem(
|
||||
tx,
|
||||
orderItem.id,
|
||||
menuItem.id,
|
||||
)
|
||||
tagsToAssign.add(step.machineTagId)
|
||||
}
|
||||
|
||||
for (const tagId of tagsToAssign) {
|
||||
await assignQueuedStepsForTag(tx, tagId)
|
||||
}
|
||||
|
||||
return order
|
||||
})
|
||||
} catch (error) {
|
||||
// The charge already happened; a failed transaction must not leave the
|
||||
// customer paying for an Order that doesn't exist.
|
||||
await paymentProvider.refund(charge.chargeId, totalCents)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type OrderAccess = { customerId?: string; guestAccessToken?: string }
|
||||
|
||||
const assertOrderAccess = (
|
||||
order: Pick<
|
||||
typeof schema.orders.$inferSelect,
|
||||
'customerId' | 'guestAccessToken'
|
||||
>,
|
||||
access: OrderAccess,
|
||||
) => {
|
||||
if (order.customerId) {
|
||||
if (access.customerId === order.customerId) return
|
||||
} else if (order.guestAccessToken) {
|
||||
if (access.guestAccessToken === order.guestAccessToken) return
|
||||
}
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
|
||||
// Steps are created one at a time (see createFirstStepForOrderItem /
|
||||
// advanceOrderItemStep), so at most one non-done instance ever exists for a
|
||||
// given Order Item at once.
|
||||
const getCurrentStepInstance = async (db: DbOrTx, orderItemId: number) => {
|
||||
const instances = await db
|
||||
.select()
|
||||
.from(schema.preparationStepInstances)
|
||||
.where(eq(schema.preparationStepInstances.orderItemId, orderItemId))
|
||||
.orderBy(asc(schema.preparationStepInstances.sequence))
|
||||
|
||||
return instances.find((row) => row.status !== 'done') ?? null
|
||||
}
|
||||
|
||||
const updateOrderCompleteIfEverySiblingIsDone = async (
|
||||
db: DbOrTx,
|
||||
orderId: number,
|
||||
) => {
|
||||
const siblingItems = await db
|
||||
.select({ status: schema.orderItems.status })
|
||||
.from(schema.orderItems)
|
||||
.where(eq(schema.orderItems.orderId, orderId))
|
||||
|
||||
const orderComplete = siblingItems.every(
|
||||
(item) => item.status === 'picked_up' || item.status === 'cancelled',
|
||||
)
|
||||
|
||||
if (orderComplete) {
|
||||
await db
|
||||
.update(schema.orders)
|
||||
.set({ status: 'complete' })
|
||||
.where(eq(schema.orders.id, orderId))
|
||||
}
|
||||
}
|
||||
|
||||
export const cancelOrderItem = async (
|
||||
db: Db,
|
||||
paymentProvider: PaymentProvider,
|
||||
orderItemId: number,
|
||||
access: OrderAccess,
|
||||
) => {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const orderItem = firstRow(
|
||||
await tx
|
||||
.select()
|
||||
.from(schema.orderItems)
|
||||
.where(eq(schema.orderItems.id, orderItemId)),
|
||||
)
|
||||
if (!orderItem) throw new Error('Order item not found')
|
||||
|
||||
const order = firstRow(
|
||||
await tx
|
||||
.select()
|
||||
.from(schema.orders)
|
||||
.where(eq(schema.orders.id, orderItem.orderId)),
|
||||
)
|
||||
if (!order) throw new Error('Order not found')
|
||||
assertOrderAccess(order, access)
|
||||
|
||||
if (orderItem.status === 'cancelled') {
|
||||
throw new OrderItemNotCancellableError('it has already been cancelled')
|
||||
}
|
||||
if (orderItem.status === 'ready' || orderItem.status === 'picked_up') {
|
||||
throw new OrderItemNotCancellableError('it is already done cooking')
|
||||
}
|
||||
|
||||
const currentStep = await getCurrentStepInstance(tx, orderItemId)
|
||||
if (!currentStep || currentStep.status !== 'queued') {
|
||||
throw new OrderItemNotCancellableError('it has already started cooking')
|
||||
}
|
||||
|
||||
await tx
|
||||
.delete(schema.preparationStepInstances)
|
||||
.where(eq(schema.preparationStepInstances.id, currentStep.id))
|
||||
|
||||
await tx
|
||||
.update(schema.orderItems)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(eq(schema.orderItems.id, orderItemId))
|
||||
|
||||
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
|
||||
|
||||
return { order, orderItem }
|
||||
})
|
||||
|
||||
if (result.order.paymentChargeId) {
|
||||
await paymentProvider.refund(
|
||||
result.order.paymentChargeId,
|
||||
result.orderItem.priceCents,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
|
||||
await db.transaction(async (tx) => {
|
||||
const currentStep = await getCurrentStepInstance(tx, orderItemId)
|
||||
if (!currentStep || currentStep.status !== 'in_progress') {
|
||||
throw new NoStepInProgressError()
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(schema.preparationStepInstances)
|
||||
.set({ status: 'done', doneAt: new Date() })
|
||||
.where(eq(schema.preparationStepInstances.id, currentStep.id))
|
||||
|
||||
const orderItem = firstRow(
|
||||
await tx
|
||||
.select()
|
||||
.from(schema.orderItems)
|
||||
.where(eq(schema.orderItems.id, orderItemId)),
|
||||
)
|
||||
if (!orderItem) throw new Error('Order item not found')
|
||||
|
||||
const nextStepDefinition = firstRow(
|
||||
await tx
|
||||
.select()
|
||||
.from(schema.menuItemSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.menuItemSteps.menuItemId, orderItem.menuItemId),
|
||||
eq(schema.menuItemSteps.sequence, currentStep.sequence + 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if (nextStepDefinition) {
|
||||
await tx.insert(schema.preparationStepInstances).values({
|
||||
orderItemId,
|
||||
sequence: nextStepDefinition.sequence,
|
||||
machineTagId: nextStepDefinition.machineTagId,
|
||||
estimatedDurationSeconds: nextStepDefinition.estimatedDurationSeconds,
|
||||
status: 'queued',
|
||||
})
|
||||
await assignQueuedStepsForTag(tx, nextStepDefinition.machineTagId)
|
||||
} else {
|
||||
await tx
|
||||
.update(schema.orderItems)
|
||||
.set({ status: 'ready' })
|
||||
.where(eq(schema.orderItems.id, orderItemId))
|
||||
}
|
||||
|
||||
// Freeing this machine may unblock another queued Step for the same Tag.
|
||||
await assignQueuedStepsForTag(tx, currentStep.machineTagId)
|
||||
})
|
||||
}
|
||||
|
||||
export const markOrderItemPickedUp = async (
|
||||
db: Db,
|
||||
orderItemId: number,
|
||||
access: OrderAccess,
|
||||
) => {
|
||||
await db.transaction(async (tx) => {
|
||||
const orderItem = firstRow(
|
||||
await tx
|
||||
.select()
|
||||
.from(schema.orderItems)
|
||||
.where(eq(schema.orderItems.id, orderItemId)),
|
||||
)
|
||||
if (!orderItem) throw new Error('Order item not found')
|
||||
|
||||
const order = firstRow(
|
||||
await tx
|
||||
.select()
|
||||
.from(schema.orders)
|
||||
.where(eq(schema.orders.id, orderItem.orderId)),
|
||||
)
|
||||
if (!order) throw new Error('Order not found')
|
||||
assertOrderAccess(order, access)
|
||||
|
||||
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
|
||||
|
||||
await tx
|
||||
.update(schema.orderItems)
|
||||
.set({ status: 'picked_up' })
|
||||
.where(eq(schema.orderItems.id, orderItemId))
|
||||
|
||||
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
|
||||
})
|
||||
}
|
||||
|
||||
export type OrderItemStatusView = {
|
||||
id: number
|
||||
menuItemName: string
|
||||
status: schema.OrderItemStatus
|
||||
cancellable: boolean
|
||||
steps: Array<{
|
||||
sequence: number
|
||||
machineTag: string
|
||||
estimatedDurationSeconds: number
|
||||
status: 'done' | 'in_progress' | 'queued' | 'pending'
|
||||
queuePosition: number | null
|
||||
}>
|
||||
etaSeconds: number | null
|
||||
}
|
||||
|
||||
export const getOrderStatus = async (
|
||||
db: Db,
|
||||
orderId: number,
|
||||
access: OrderAccess,
|
||||
) => {
|
||||
const order = firstRow(
|
||||
await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)),
|
||||
)
|
||||
if (!order) throw new Error('Order not found')
|
||||
assertOrderAccess(order, access)
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(schema.orderItems)
|
||||
.where(eq(schema.orderItems.orderId, orderId))
|
||||
|
||||
const menuItemIds = [...new Set(items.map((item) => item.menuItemId))]
|
||||
const orderItemIds = items.map((item) => item.id)
|
||||
|
||||
const menuItemsById = new Map(
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(schema.menuItems)
|
||||
.where(inArray(schema.menuItems.id, menuItemIds))
|
||||
).map((row) => [row.id, row]),
|
||||
)
|
||||
|
||||
const recipeRows = await db
|
||||
.select({
|
||||
menuItemId: schema.menuItemSteps.menuItemId,
|
||||
sequence: schema.menuItemSteps.sequence,
|
||||
machineTag: schema.machineTags.name,
|
||||
machineTagId: schema.menuItemSteps.machineTagId,
|
||||
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
||||
})
|
||||
.from(schema.menuItemSteps)
|
||||
.innerJoin(
|
||||
schema.machineTags,
|
||||
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
||||
)
|
||||
.where(inArray(schema.menuItemSteps.menuItemId, menuItemIds))
|
||||
.orderBy(asc(schema.menuItemSteps.sequence))
|
||||
|
||||
const allInstances =
|
||||
orderItemIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(schema.preparationStepInstances)
|
||||
.where(
|
||||
inArray(schema.preparationStepInstances.orderItemId, orderItemIds),
|
||||
)
|
||||
|
||||
const instancesByOrderItem = new Map<
|
||||
number,
|
||||
Array<(typeof allInstances)[number]>
|
||||
>()
|
||||
for (const instance of allInstances) {
|
||||
const list = instancesByOrderItem.get(instance.orderItemId) ?? []
|
||||
list.push(instance)
|
||||
instancesByOrderItem.set(instance.orderItemId, list)
|
||||
}
|
||||
|
||||
const queuedTagIds = [
|
||||
...new Set(
|
||||
allInstances
|
||||
.filter((instance) => instance.status === 'queued')
|
||||
.map((instance) => instance.machineTagId),
|
||||
),
|
||||
]
|
||||
|
||||
const queuedStepsByTag = new Map<number, Array<{ id: number }>>()
|
||||
const machineCountByTag = new Map<number, number>()
|
||||
for (const tagId of queuedTagIds) {
|
||||
queuedStepsByTag.set(
|
||||
tagId,
|
||||
await db
|
||||
.select({ id: schema.preparationStepInstances.id })
|
||||
.from(schema.preparationStepInstances)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.preparationStepInstances.machineTagId, tagId),
|
||||
eq(schema.preparationStepInstances.status, 'queued'),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.preparationStepInstances.id)),
|
||||
)
|
||||
machineCountByTag.set(
|
||||
tagId,
|
||||
(
|
||||
await db
|
||||
.select({ id: schema.machines.id })
|
||||
.from(schema.machines)
|
||||
.where(eq(schema.machines.machineTagId, tagId))
|
||||
).length,
|
||||
)
|
||||
}
|
||||
|
||||
const itemViews: Array<OrderItemStatusView> = items.map((item) => {
|
||||
const menuItem = menuItemsById.get(item.menuItemId)
|
||||
const recipe = recipeRows.filter(
|
||||
(step) => step.menuItemId === item.menuItemId,
|
||||
)
|
||||
const instances = instancesByOrderItem.get(item.id) ?? []
|
||||
const instanceBySequence = new Map(
|
||||
instances.map((row) => [row.sequence, row]),
|
||||
)
|
||||
|
||||
let etaSeconds: number | null = 0
|
||||
|
||||
const steps = recipe.map((step) => {
|
||||
const instance = instanceBySequence.get(step.sequence)
|
||||
|
||||
if (!instance) {
|
||||
if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds
|
||||
return {
|
||||
sequence: step.sequence,
|
||||
machineTag: step.machineTag,
|
||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||
status: 'pending' as const,
|
||||
queuePosition: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (instance.status === 'done') {
|
||||
return {
|
||||
sequence: step.sequence,
|
||||
machineTag: step.machineTag,
|
||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||
status: 'done' as const,
|
||||
queuePosition: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (instance.status === 'in_progress') {
|
||||
const elapsedSeconds = instance.startedAt
|
||||
? Math.floor((Date.now() - instance.startedAt.getTime()) / 1000)
|
||||
: 0
|
||||
if (etaSeconds !== null) {
|
||||
etaSeconds += Math.max(
|
||||
step.estimatedDurationSeconds - elapsedSeconds,
|
||||
0,
|
||||
)
|
||||
}
|
||||
return {
|
||||
sequence: step.sequence,
|
||||
machineTag: step.machineTag,
|
||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||
status: 'in_progress' as const,
|
||||
queuePosition: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Queued: rank among queued steps for the same Tag (batched above),
|
||||
// then spread across that Tag's Machines for a rough ETA.
|
||||
const queuedForTag = queuedStepsByTag.get(step.machineTagId) ?? []
|
||||
const position =
|
||||
queuedForTag.findIndex((row) => row.id === instance.id) + 1
|
||||
const machineCount = Math.max(
|
||||
machineCountByTag.get(step.machineTagId) ?? 0,
|
||||
1,
|
||||
)
|
||||
|
||||
if (etaSeconds !== null) {
|
||||
const waitSeconds =
|
||||
Math.floor(Math.max(position - 1, 0) / machineCount) *
|
||||
step.estimatedDurationSeconds
|
||||
etaSeconds += waitSeconds + step.estimatedDurationSeconds
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: step.sequence,
|
||||
machineTag: step.machineTag,
|
||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||
status: 'queued' as const,
|
||||
queuePosition: position,
|
||||
}
|
||||
})
|
||||
|
||||
if (
|
||||
item.status === 'ready' ||
|
||||
item.status === 'picked_up' ||
|
||||
item.status === 'cancelled'
|
||||
) {
|
||||
etaSeconds = null
|
||||
}
|
||||
|
||||
const currentInstance = instances.find((row) => row.status !== 'done')
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
menuItemName: menuItem?.name ?? '',
|
||||
status: item.status,
|
||||
cancellable:
|
||||
item.status !== 'cancelled' &&
|
||||
item.status !== 'ready' &&
|
||||
item.status !== 'picked_up' &&
|
||||
currentInstance?.status === 'queued',
|
||||
steps,
|
||||
etaSeconds,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: order.id,
|
||||
status: order.status,
|
||||
items: itemViews,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
|
||||
const BetterAuthHeader = () => {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="h-8 w-8 bg-neutral-100 dark:bg-neutral-800 animate-pulse" />
|
||||
)
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{session.user.image ? (
|
||||
<img src={session.user.image} alt="" className="h-8 w-8" />
|
||||
) : (
|
||||
<div className="h-8 w-8 bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{session.user.name.charAt(0).toUpperCase() || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
void authClient.signOut()
|
||||
}}
|
||||
className="flex-1 h-9 px-4 text-sm font-medium bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-50 border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default BetterAuthHeader
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
export const authClient = createAuthClient()
|
||||
@@ -0,0 +1,11 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
||||
import { Pool } from 'pg'
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [tanstackStartCookies()],
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
// This package is ESM (`"type": "module"`), so the `require` global doesn't
|
||||
// exist here — build one explicitly to load Stripe's CJS build lazily.
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export type Charge = {
|
||||
chargeId: string
|
||||
amountCents: number
|
||||
}
|
||||
|
||||
// The one true external dependency in the ordering flow. Kept behind an
|
||||
// interface so tests can run against a fast in-memory fake instead of a real
|
||||
// payment network, per the spec's testing decisions.
|
||||
export type PaymentProvider = {
|
||||
charge: (amountCents: number) => Promise<Charge>
|
||||
refund: (chargeId: string, amountCents: number) => Promise<void>
|
||||
}
|
||||
|
||||
export class PaymentDeclinedError extends Error {
|
||||
constructor() {
|
||||
super('Payment was declined')
|
||||
this.name = 'PaymentDeclinedError'
|
||||
}
|
||||
}
|
||||
|
||||
let nextFakeChargeId = 1
|
||||
|
||||
// Default provider for local dev/tests: always succeeds, tracks refunds
|
||||
// in-memory so tests can assert on them without a network dependency.
|
||||
export const createFakePaymentProvider = (): PaymentProvider & {
|
||||
refundsFor: (chargeId: string) => number
|
||||
} => {
|
||||
const refundedByCharge = new Map<string, number>()
|
||||
|
||||
return {
|
||||
charge: async (amountCents) => {
|
||||
const chargeId = `fake_charge_${nextFakeChargeId++}`
|
||||
return { chargeId, amountCents }
|
||||
},
|
||||
refund: async (chargeId, amountCents) => {
|
||||
const alreadyRefunded = refundedByCharge.get(chargeId) ?? 0
|
||||
refundedByCharge.set(chargeId, alreadyRefunded + amountCents)
|
||||
},
|
||||
refundsFor: (chargeId) => refundedByCharge.get(chargeId) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Real provider, used when STRIPE_SECRET_KEY is configured. Uses Stripe
|
||||
// Payment Intents (chosen for clean per-item partial refunds, see the spec's
|
||||
// Implementation Decisions).
|
||||
export const createStripePaymentProvider = (
|
||||
secretKey: string,
|
||||
): PaymentProvider => {
|
||||
// Imported lazily so the `stripe` package is never touched by the fake
|
||||
// provider path (e.g. in tests) and its Node client isn't constructed
|
||||
// unless STRIPE_SECRET_KEY is actually set.
|
||||
const StripeCtor = require('stripe') as typeof Stripe
|
||||
const stripe = new StripeCtor(secretKey)
|
||||
|
||||
return {
|
||||
charge: async (amountCents) => {
|
||||
const intent = await stripe.paymentIntents.create({
|
||||
amount: amountCents,
|
||||
currency: 'usd',
|
||||
confirm: true,
|
||||
payment_method: 'pm_card_visa',
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
allow_redirects: 'never',
|
||||
},
|
||||
})
|
||||
if (intent.status !== 'succeeded') {
|
||||
throw new PaymentDeclinedError()
|
||||
}
|
||||
return { chargeId: intent.id, amountCents }
|
||||
},
|
||||
refund: async (chargeId, amountCents) => {
|
||||
await stripe.refunds.create({
|
||||
payment_intent: chargeId,
|
||||
amount: amountCents,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let defaultProvider: PaymentProvider | undefined
|
||||
|
||||
// Single process-wide provider: Stripe when configured, otherwise the fake.
|
||||
export const getPaymentProvider = (): PaymentProvider => {
|
||||
if (!defaultProvider) {
|
||||
defaultProvider = process.env.STRIPE_SECRET_KEY
|
||||
? createStripePaymentProvider(process.env.STRIPE_SECRET_KEY)
|
||||
: createFakePaymentProvider()
|
||||
}
|
||||
return defaultProvider
|
||||
}
|
||||
|
||||
// Test-only escape hatch so each test file can inject its own fake instance.
|
||||
export const setPaymentProviderForTesting = (provider: PaymentProvider) => {
|
||||
defaultProvider = provider
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
id: '/api/auth/$',
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/api/auth/$'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/api/auth/$'
|
||||
id: '__root__' | '/' | '/api/auth/$'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/auth/$': {
|
||||
id: '/api/auth/$'
|
||||
path: '/api/auth/$'
|
||||
fullPath: '/api/auth/$'
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter() {
|
||||
const router = createTanStackRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
|
||||
import appCss from '../styles.css?url'
|
||||
|
||||
const RootDocument = ({ children }: { children: React.ReactNode }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: 'utf-8',
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{
|
||||
title: 'TanStack Start Starter',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: appCss,
|
||||
},
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { auth } from '#/lib/auth'
|
||||
|
||||
export const Route = createFileRoute('/api/auth/$')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => auth.handler(request),
|
||||
POST: ({ request }) => auth.handler(request),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
const Home = () => (
|
||||
<div className="p-8">
|
||||
<h1 className="text-4xl font-bold">Welcome to TanStack Start</h1>
|
||||
<p className="mt-4 text-lg">
|
||||
Edit <code>src/routes/index.tsx</code> to get started.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Route = createFileRoute('/')({ component: Home })
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { db } from '#/db/index.ts'
|
||||
import { getMenu as getMenuForBrand } from '#/domain/ordering.ts'
|
||||
|
||||
export const getMenu = createServerFn({ method: 'GET' })
|
||||
.validator(z.object({ brandId: z.number() }))
|
||||
.handler(async ({ data }) => getMenuForBrand(db, data.brandId))
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { getRequest } from '@tanstack/react-start/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { db } from '#/db/index.ts'
|
||||
import * as ordering from '#/domain/ordering.ts'
|
||||
import { auth } from '#/lib/auth.ts'
|
||||
import { getPaymentProvider } from '#/lib/payment.ts'
|
||||
|
||||
const getSessionCustomerId = async () => {
|
||||
const session = await auth.api.getSession({ headers: getRequest().headers })
|
||||
return session?.user.id
|
||||
}
|
||||
|
||||
const customerSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('guest'),
|
||||
name: z.string().min(1),
|
||||
contact: z.string().min(1),
|
||||
}),
|
||||
// No customerId here: an account Order is always attributed to whoever is
|
||||
// actually signed in server-side, never to an id the client asserts.
|
||||
z.object({ type: z.literal('account') }),
|
||||
])
|
||||
|
||||
export const placeOrder = createServerFn({ method: 'POST' })
|
||||
.validator(
|
||||
z.object({
|
||||
items: z.array(z.object({ menuItemId: z.number() })).min(1),
|
||||
customer: customerSchema,
|
||||
}),
|
||||
)
|
||||
.handler(async ({ data }) => {
|
||||
if (data.customer.type === 'account') {
|
||||
const customerId = await getSessionCustomerId()
|
||||
if (!customerId)
|
||||
throw new Error('You must be signed in to place an account order')
|
||||
return ordering.placeOrder(db, getPaymentProvider(), {
|
||||
items: data.items,
|
||||
customer: { type: 'account', customerId },
|
||||
})
|
||||
}
|
||||
|
||||
return ordering.placeOrder(db, getPaymentProvider(), {
|
||||
items: data.items,
|
||||
customer: data.customer,
|
||||
})
|
||||
})
|
||||
|
||||
const accessSchema = z.object({ guestAccessToken: z.string().optional() })
|
||||
|
||||
const resolveAccess = async (
|
||||
data: z.infer<typeof accessSchema>,
|
||||
): Promise<ordering.OrderAccess> => ({
|
||||
customerId: await getSessionCustomerId(),
|
||||
guestAccessToken: data.guestAccessToken,
|
||||
})
|
||||
|
||||
export const getOrderStatus = createServerFn({ method: 'GET' })
|
||||
.validator(accessSchema.extend({ orderId: z.number() }))
|
||||
.handler(async ({ data }) =>
|
||||
ordering.getOrderStatus(db, data.orderId, await resolveAccess(data)),
|
||||
)
|
||||
|
||||
export const cancelOrderItem = createServerFn({ method: 'POST' })
|
||||
.validator(accessSchema.extend({ orderItemId: z.number() }))
|
||||
.handler(async ({ data }) =>
|
||||
ordering.cancelOrderItem(
|
||||
db,
|
||||
getPaymentProvider(),
|
||||
data.orderItemId,
|
||||
await resolveAccess(data),
|
||||
),
|
||||
)
|
||||
|
||||
export const markOrderItemPickedUp = createServerFn({ method: 'POST' })
|
||||
.validator(accessSchema.extend({ orderItemId: z.number() }))
|
||||
.handler(async ({ data }) =>
|
||||
ordering.markOrderItemPickedUp(
|
||||
db,
|
||||
data.orderItemId,
|
||||
await resolveAccess(data),
|
||||
),
|
||||
)
|
||||
|
||||
// Internal stand-in for a Machine finishing a Preparation Step, until
|
||||
// Phase 2 hardware integration lands (see spec's Implementation Decisions).
|
||||
// Deliberately not customer-facing and not access-checked here — in a real
|
||||
// deployment this must be restricted to an internal/ops caller, not exposed
|
||||
// on a route a customer's session could reach.
|
||||
export const advanceOrderItemStep = createServerFn({ method: 'POST' })
|
||||
.validator(z.object({ orderItemId: z.number() }))
|
||||
.handler(async ({ data }) =>
|
||||
ordering.advanceOrderItemStep(db, data.orderItemId),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { PostgreSqlContainer } from '@testcontainers/postgresql'
|
||||
import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql'
|
||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator'
|
||||
|
||||
import * as schema from '#/db/schema.ts'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const migrationsFolder = path.resolve(__dirname, '../../drizzle')
|
||||
|
||||
export type TestDatabase = {
|
||||
db: NodePgDatabase<typeof schema>
|
||||
connectionUri: string
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
// A real, ephemeral Postgres per test file, per the spec's testing
|
||||
// decisions: no in-memory fake, no mocked Drizzle client.
|
||||
export const startTestDatabase = async (): Promise<TestDatabase> => {
|
||||
const container: StartedPostgreSqlContainer = await new PostgreSqlContainer(
|
||||
'postgres:16-alpine',
|
||||
).start()
|
||||
|
||||
const connectionUri = container.getConnectionUri()
|
||||
const db = drizzle(connectionUri, { schema })
|
||||
await migrate(db, { migrationsFolder })
|
||||
|
||||
return {
|
||||
db,
|
||||
connectionUri,
|
||||
stop: async () => {
|
||||
// End the pg Pool before tearing down the container, or a pending
|
||||
// connection emits an unhandled "terminating connection" error.
|
||||
await db.$client.end()
|
||||
await container.stop()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest'
|
||||
|
||||
import * as schema from '#/db/schema.ts'
|
||||
import * as ordering from '#/domain/ordering.ts'
|
||||
import { createFakePaymentProvider } from '#/lib/payment.ts'
|
||||
import { seedGhostKitchen } from '#/db/seed.ts'
|
||||
|
||||
import { startTestDatabase } from './helpers/test-db.ts'
|
||||
import type { TestDatabase } from './helpers/test-db.ts'
|
||||
|
||||
let testDb: TestDatabase
|
||||
let seed: Awaited<ReturnType<typeof seedGhostKitchen>>
|
||||
let payment: ReturnType<typeof createFakePaymentProvider>
|
||||
|
||||
beforeAll(async () => {
|
||||
testDb = await startTestDatabase()
|
||||
seed = await seedGhostKitchen(testDb.db)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.stop()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
payment = createFakePaymentProvider()
|
||||
})
|
||||
|
||||
// Every Order/OrderItem/Step is scoped to a single test — each test places
|
||||
// its own Order(s) — so the Machines seeded once in beforeAll stay idle
|
||||
// between tests instead of leaking a busy state into the next one.
|
||||
afterEach(async () => {
|
||||
await testDb.db.delete(schema.preparationStepInstances)
|
||||
await testDb.db.delete(schema.orderItems)
|
||||
await testDb.db.delete(schema.orders)
|
||||
})
|
||||
|
||||
const placeGuestOrder = (items: Array<{ menuItemId: number }>) =>
|
||||
ordering.placeOrder(testDb.db, payment, {
|
||||
items,
|
||||
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
|
||||
})
|
||||
|
||||
// Tests act as the guest who placed the order, so they present its access
|
||||
// token exactly like a real caller would — this exercises the same
|
||||
// ownership check a stranger guessing the order id would fail.
|
||||
const accessFor = (order: { guestAccessToken: string | null }) => ({
|
||||
guestAccessToken: order.guestAccessToken ?? undefined,
|
||||
})
|
||||
|
||||
const getStatus = (order: { id: number; guestAccessToken: string | null }) =>
|
||||
ordering.getOrderStatus(testDb.db, order.id, accessFor(order))
|
||||
|
||||
describe('menu', () => {
|
||||
it('lists menu items for a brand with their recipe steps', async () => {
|
||||
const menu = await ordering.getMenu(testDb.db, seed.brand.id)
|
||||
|
||||
const pizza = menu.find((item) => item.name === 'Pizza')
|
||||
expect(pizza?.steps).toEqual([
|
||||
{ sequence: 1, machineTag: 'oven', estimatedDurationSeconds: 600 },
|
||||
])
|
||||
|
||||
const burger = menu.find((item) => item.name === 'Burger')
|
||||
expect(burger?.steps).toEqual([
|
||||
{ sequence: 1, machineTag: 'grill', estimatedDurationSeconds: 240 },
|
||||
{ sequence: 2, machineTag: 'assembly', estimatedDurationSeconds: 90 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('placing an order', () => {
|
||||
it('rejects an order with no items', async () => {
|
||||
await expect(placeGuestOrder([])).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('charges the total price and queues the first Preparation Step for each Order Item', async () => {
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
{ menuItemId: seed.menuItems.burger.id },
|
||||
])
|
||||
|
||||
expect(order.status).toBe('paid')
|
||||
expect(order.paymentChargeId).toBeTruthy()
|
||||
|
||||
const status = await getStatus(order)
|
||||
expect(status.items).toHaveLength(2)
|
||||
|
||||
const pizzaItem = status.items.find(
|
||||
(item) => item.menuItemName === 'Pizza',
|
||||
)!
|
||||
// Only one oven, but the pizza is the only thing waiting for it, so it
|
||||
// starts immediately.
|
||||
expect(pizzaItem.steps[0].status).toBe('in_progress')
|
||||
expect(pizzaItem.cancellable).toBe(false)
|
||||
|
||||
const burgerItem = status.items.find(
|
||||
(item) => item.menuItemName === 'Burger',
|
||||
)!
|
||||
expect(burgerItem.steps[0].status).toBe('in_progress')
|
||||
expect(burgerItem.steps[1].status).toBe('pending')
|
||||
})
|
||||
|
||||
it('does not let a second Order Item jump the queue ahead of one already assigned to the only Machine of that Tag', async () => {
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
const secondOrder = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
|
||||
const firstStatus = await getStatus(order)
|
||||
const secondStatus = await getStatus(secondOrder)
|
||||
|
||||
expect(firstStatus.items[0].steps[0].status).toBe('in_progress')
|
||||
expect(secondStatus.items[0].steps[0].status).toBe('queued')
|
||||
expect(secondStatus.items[0].steps[0].queuePosition).toBe(1)
|
||||
expect(secondStatus.items[0].cancellable).toBe(true)
|
||||
})
|
||||
|
||||
it('refunds the charge if the DB transaction fails after payment has already gone through', async () => {
|
||||
let capturedChargeId: string | undefined
|
||||
const spyingPayment: typeof payment = {
|
||||
...payment,
|
||||
charge: async (amountCents) => {
|
||||
const charge = await payment.charge(amountCents)
|
||||
capturedChargeId = charge.chargeId
|
||||
return charge
|
||||
},
|
||||
}
|
||||
|
||||
// Pre-charge validation still runs against the real DB (so it passes
|
||||
// normally); only the transaction itself is forced to fail, simulating
|
||||
// an unexpected DB error after the customer has already been charged.
|
||||
const dbWithFailingTransaction = Object.create(
|
||||
testDb.db,
|
||||
) as typeof testDb.db
|
||||
dbWithFailingTransaction.transaction = () => {
|
||||
throw new Error('simulated DB failure after payment')
|
||||
}
|
||||
|
||||
await expect(
|
||||
ordering.placeOrder(dbWithFailingTransaction, spyingPayment, {
|
||||
items: [{ menuItemId: seed.menuItems.pizza.id }],
|
||||
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
|
||||
}),
|
||||
).rejects.toThrow('simulated DB failure after payment')
|
||||
|
||||
expect(capturedChargeId).toBeTruthy()
|
||||
expect(payment.refundsFor(capturedChargeId!)).toBe(
|
||||
seed.menuItems.pizza.priceCents,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelling an Order Item', () => {
|
||||
it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => {
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
{ menuItemId: seed.menuItems.burger.id },
|
||||
])
|
||||
|
||||
const before = await getStatus(order)
|
||||
const pizzaItem = before.items.find(
|
||||
(item) => item.menuItemName === 'Pizza',
|
||||
)!
|
||||
const burgerItem = before.items.find(
|
||||
(item) => item.menuItemName === 'Burger',
|
||||
)!
|
||||
expect(pizzaItem.status).toBe('paid') // still queued, oven is busy
|
||||
expect(burgerItem.steps[0].status).toBe('in_progress') // grill was free
|
||||
|
||||
await ordering.cancelOrderItem(
|
||||
testDb.db,
|
||||
payment,
|
||||
pizzaItem.id,
|
||||
accessFor(order),
|
||||
)
|
||||
|
||||
expect(payment.refundsFor(order.paymentChargeId!)).toBe(
|
||||
seed.menuItems.pizza.priceCents,
|
||||
)
|
||||
|
||||
const after = await getStatus(order)
|
||||
const cancelledPizza = after.items.find(
|
||||
(item) => item.menuItemName === 'Pizza',
|
||||
)!
|
||||
const untouchedBurger = after.items.find(
|
||||
(item) => item.menuItemName === 'Burger',
|
||||
)!
|
||||
expect(cancelledPizza.status).toBe('cancelled')
|
||||
expect(untouchedBurger.steps[0].status).toBe('in_progress')
|
||||
})
|
||||
|
||||
it('completes the order once its only item is cancelled', async () => {
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
|
||||
await ordering.cancelOrderItem(
|
||||
testDb.db,
|
||||
payment,
|
||||
(await getStatus(order)).items[0].id,
|
||||
accessFor(order),
|
||||
)
|
||||
|
||||
const after = await getStatus(order)
|
||||
expect(after.status).toBe('complete')
|
||||
})
|
||||
|
||||
it('refuses to cancel an item that has already started cooking, with a message reflecting that', async () => {
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
const status = await getStatus(order)
|
||||
const pizzaItem = status.items[0]
|
||||
expect(pizzaItem.steps[0].status).toBe('in_progress')
|
||||
|
||||
await expect(
|
||||
ordering.cancelOrderItem(
|
||||
testDb.db,
|
||||
payment,
|
||||
pizzaItem.id,
|
||||
accessFor(order),
|
||||
),
|
||||
).rejects.toThrow(/already started cooking/)
|
||||
})
|
||||
|
||||
it('rejects a cancellation with the wrong access token', async () => {
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.burger.id },
|
||||
])
|
||||
const status = await getStatus(order)
|
||||
|
||||
await expect(
|
||||
ordering.cancelOrderItem(testDb.db, payment, status.items[0].id, {
|
||||
guestAccessToken: 'not-the-real-token',
|
||||
}),
|
||||
).rejects.toThrow(ordering.ForbiddenError)
|
||||
})
|
||||
|
||||
it('gives an accurate message for a double-cancel', async () => {
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.burger.id },
|
||||
])
|
||||
const itemId = (await getStatus(order)).items[0].id
|
||||
|
||||
await ordering.cancelOrderItem(testDb.db, payment, itemId, accessFor(order))
|
||||
|
||||
await expect(
|
||||
ordering.cancelOrderItem(testDb.db, payment, itemId, accessFor(order)),
|
||||
).rejects.toThrow(/already been cancelled/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('advancing steps and pickup', () => {
|
||||
it('moves a multi-step item through each step, freeing the machine, and completes the order once every item is picked up independently', async () => {
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.burger.id },
|
||||
])
|
||||
let status = await getStatus(order)
|
||||
const burgerItemId = status.items[0].id
|
||||
|
||||
expect(status.items[0].steps[0].status).toBe('in_progress')
|
||||
|
||||
await ordering.advanceOrderItemStep(testDb.db, burgerItemId)
|
||||
|
||||
status = await getStatus(order)
|
||||
expect(status.items[0].steps[0].status).toBe('done')
|
||||
expect(status.items[0].steps[1].status).toBe('in_progress')
|
||||
expect(status.items[0].status).toBe('paid')
|
||||
|
||||
await ordering.advanceOrderItemStep(testDb.db, burgerItemId)
|
||||
|
||||
status = await getStatus(order)
|
||||
expect(status.items[0].steps[1].status).toBe('done')
|
||||
expect(status.items[0].status).toBe('ready')
|
||||
|
||||
await ordering.markOrderItemPickedUp(
|
||||
testDb.db,
|
||||
burgerItemId,
|
||||
accessFor(order),
|
||||
)
|
||||
|
||||
status = await getStatus(order)
|
||||
expect(status.items[0].status).toBe('picked_up')
|
||||
expect(status.status).toBe('complete')
|
||||
})
|
||||
|
||||
it('assigns a freed machine to the next queued step for the same Tag', async () => {
|
||||
const firstOrder = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
const secondOrder = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
|
||||
let firstStatus = await getStatus(firstOrder)
|
||||
const firstItemId = firstStatus.items[0].id
|
||||
|
||||
let secondStatus = await getStatus(secondOrder)
|
||||
expect(secondStatus.items[0].steps[0].status).toBe('queued')
|
||||
|
||||
await ordering.advanceOrderItemStep(testDb.db, firstItemId)
|
||||
|
||||
firstStatus = await getStatus(firstOrder)
|
||||
expect(firstStatus.items[0].status).toBe('ready')
|
||||
|
||||
secondStatus = await getStatus(secondOrder)
|
||||
expect(secondStatus.items[0].steps[0].status).toBe('in_progress')
|
||||
})
|
||||
|
||||
it('refuses to mark an item picked up before it is ready', async () => {
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
const status = await getStatus(order)
|
||||
|
||||
await expect(
|
||||
ordering.markOrderItemPickedUp(
|
||||
testDb.db,
|
||||
status.items[0].id,
|
||||
accessFor(order),
|
||||
),
|
||||
).rejects.toThrow(ordering.OrderItemNotReadyError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('access control', () => {
|
||||
it('refuses to show order status to someone without the access token', async () => {
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
|
||||
await expect(
|
||||
ordering.getOrderStatus(testDb.db, order.id, {}),
|
||||
).rejects.toThrow(ordering.ForbiddenError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
|
||||
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"paths": {
|
||||
"#/*": ["./src/*"],
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"target": "react"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { devtools } from '@tanstack/devtools-vite'
|
||||
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const config = defineConfig({
|
||||
resolve: { tsconfigPaths: true },
|
||||
plugins: [devtools(), tailwindcss(), tanstackStart(), viteReact()],
|
||||
})
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'#': new URL('./src', import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user