Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7fdbd4779 | ||
|
|
506319ce4f | ||
|
|
9c3ad51686 | ||
|
|
d291312139 |
+13
-2
@@ -16,12 +16,23 @@ _Avoid_: Store, location (as a separate multi-tenant concept — this project ha
|
|||||||
The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens.
|
The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens.
|
||||||
|
|
||||||
**Order**:
|
**Order**:
|
||||||
A customer's placed, paid request, containing one or more Order Items. An Order is complete only once every Order Item within it has been picked up.
|
A customer's placed, paid request, containing one or more Order Items. An Order is complete once every Order Item within it has reached a terminal state — picked up or cancelled. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.)
|
||||||
|
|
||||||
|
**Charge**:
|
||||||
|
The single payment transaction covering an Order's full total, created once at placement time. Can be partially refunded multiple times over the Order's life — once per Order Item that gets cancelled — rather than each Order Item carrying its own separate payment.
|
||||||
|
_Avoid_: Payment (too generic — this project always means one Charge per Order, not a collection of per-item payments)
|
||||||
|
|
||||||
**Order Item**:
|
**Order Item**:
|
||||||
A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time before its first Preparation Step begins; locked once that step starts.
|
A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time its current Preparation Step has not yet been claimed by a Machine — including the gap between one Step finishing and the next Step being claimed. Locked as soon as a Step is claimed by a Machine (in progress).
|
||||||
_Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state)
|
_Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state)
|
||||||
|
|
||||||
|
**Guest Access Token**:
|
||||||
|
A bearer credential issued to a guest customer (one without an account) at Order placement time. Since a guest has no session, this token is the only proof of ownership accepted on later calls against that Order (status lookup, cancellation, pickup).
|
||||||
|
_Avoid_: Session (guests never get a session; this is a different, bearer-credential mechanism)
|
||||||
|
|
||||||
|
**Recipe**:
|
||||||
|
The ordered list of Preparation Steps required to prepare a Menu Item. A Menu Item's Recipe can be edited later by an operator; an Order Item freezes a copy of the Recipe at the moment it's ordered, so a later Recipe edit never reshapes a Step list a customer already paid for.
|
||||||
|
|
||||||
**Preparation Step**:
|
**Preparation Step**:
|
||||||
One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion.
|
One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion.
|
||||||
_Avoid_: Stage (use Step consistently)
|
_Avoid_: Stage (use Step consistently)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# Order Items are independent, not the Order
|
# Order Items are independent, not the Order
|
||||||
|
|
||||||
Preparation, queuing, pickup, and cancellation are all tracked per Order Item, not per Order. An Order is just a bundle that is "complete" once every Order Item in it has been picked up — but each Order Item cooks on its own Machine, becomes ready on its own, gets notified and picked up on its own, and can be cancelled on its own (locked only once its own first Preparation Step starts). We chose this over treating the Order as the atomic unit because items in the same order can have very different cook times (a pizza vs. a burger); forcing them to stay in lockstep would mean a finished item goes cold waiting on a slower one, or a customer loses the ability to cancel a not-yet-started item just because a sibling item already started cooking.
|
Preparation, queuing, pickup, and cancellation are all tracked per Order Item, not per Order. An Order is just a bundle that is "complete" once every Order Item in it has reached a terminal state — but each Order Item cooks on its own Machine, becomes ready on its own, gets notified and picked up on its own, and can be cancelled on its own (locked only once its own current Preparation Step is claimed by a Machine). We chose this over treating the Order as the atomic unit because items in the same order can have very different cook times (a pizza vs. a burger); forcing them to stay in lockstep would mean a finished item goes cold waiting on a slower one, or a customer loses the ability to cancel a not-yet-started item just because a sibling item already started cooking.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Single Charge per Order, refunded incrementally per cancelled Item
|
||||||
|
|
||||||
|
An Order is charged once, in full, at placement time — before any Order Item exists — rather than opening a separate charge per Order Item. When an Order Item is later cancelled, its share of the total is refunded against that same Charge, so one Charge can be partially refunded multiple times over an Order's life, once per cancelled Item. We chose one Charge per Order because it means a single payment authorization per customer checkout, keeping the payment-provider integration and the customer-facing checkout flow simple; the alternative — a Charge per Order Item — would let items be refunded fully independently of one another, but would mean authorizing the customer's card multiple times per Order and tracking a payment-provider transaction per Item instead of per Order.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Recipe snapshot per Order Item
|
||||||
|
|
||||||
|
An Order Item's full Recipe (every Preparation Step, in order) is copied from the Menu Item's live Recipe into a per-Order-Item `orderItemSteps` table at order-placement time, rather than each Step being looked up from the live Recipe as the Order Item progresses. We chose this because price is already snapshotted per Order Item for the same reason ("later menu changes don't rewrite history") — an operator editing a Menu Item's Recipe (reordering Steps, changing a Step's Machine Tag or duration) should never reshape an Order Item a customer already paid for and is partway through cooking. The live `menuItemSteps` table remains the source of truth for menu browsing and for new Orders; only `orderItemSteps` is read once an Order Item exists.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE "order_item_steps" (
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "order_item_steps" ADD CONSTRAINT "order_item_steps_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 "order_item_steps" ADD CONSTRAINT "order_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;
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
{
|
||||||
|
"id": "aecfb37f-fb88-44ba-8e02-f290a3929293",
|
||||||
|
"prevId": "816e0a1c-3005-433c-911b-2e006cc9e91b",
|
||||||
|
"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_item_steps": {
|
||||||
|
"name": "order_item_steps",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_steps_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_steps_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_steps",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
|
||||||
|
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
|
||||||
|
"tableFrom": "order_item_steps",
|
||||||
|
"tableTo": "machine_tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_tag_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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1788972318759,
|
"when": 1788972318759,
|
||||||
"tag": "0001_graceful_frank_castle",
|
"tag": "0001_graceful_frank_castle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788976393301,
|
||||||
|
"tag": "0002_large_lake",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -102,6 +102,22 @@ export const orderItems = pgTable('order_items', {
|
|||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A frozen copy of a Menu Item's Recipe as it existed when a given Order
|
||||||
|
// Item was placed. A later edit to the live Recipe (menuItemSteps) never
|
||||||
|
// reshapes an Order Item already in flight — see
|
||||||
|
// docs/adr/0005-recipe-snapshot-per-order-item.md.
|
||||||
|
export const orderItemSteps = pgTable('order_item_steps', {
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
|
||||||
export const preparationStepStatusValues = [
|
export const preparationStepStatusValues = [
|
||||||
'queued',
|
'queued',
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|||||||
+147
-92
@@ -170,31 +170,48 @@ export const assignQueuedStepsForTag = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createFirstStepForOrderItem = async (
|
// Copies the Menu Item's live Recipe into orderItemSteps for this Order
|
||||||
|
// Item, then creates its first Preparation Step instance from that copy —
|
||||||
|
// so a later edit to the live Recipe never reshapes an Order Item already
|
||||||
|
// in flight (see docs/adr/0005-recipe-snapshot-per-order-item.md).
|
||||||
|
const initializeOrderItemRecipe = async (
|
||||||
db: DbOrTx,
|
db: DbOrTx,
|
||||||
orderItemId: number,
|
orderItemId: number,
|
||||||
menuItemId: number,
|
menuItemId: number,
|
||||||
) => {
|
) => {
|
||||||
const firstStep = firstRow(
|
const liveSteps = await db
|
||||||
await db
|
.select()
|
||||||
.select()
|
.from(schema.menuItemSteps)
|
||||||
.from(schema.menuItemSteps)
|
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
|
||||||
.where(
|
.orderBy(asc(schema.menuItemSteps.sequence))
|
||||||
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
|
// 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
|
// (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
|
// "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.
|
// Item permanently stuck with no Step and no way to progress or cancel.
|
||||||
if (!firstStep) {
|
if (liveSteps.length === 0) {
|
||||||
throw new Error(`Menu item ${menuItemId} has no Preparation Steps defined`)
|
throw new Error(`Menu item ${menuItemId} has no Preparation Steps defined`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipeSteps = await db
|
||||||
|
.insert(schema.orderItemSteps)
|
||||||
|
.values(
|
||||||
|
liveSteps.map((step) => ({
|
||||||
|
orderItemId,
|
||||||
|
sequence: step.sequence,
|
||||||
|
machineTagId: step.machineTagId,
|
||||||
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
const firstStep = recipeSteps.find((step) => step.sequence === 1)
|
||||||
|
if (!firstStep) {
|
||||||
|
throw new Error(
|
||||||
|
`Menu item ${menuItemId} has no Preparation Step with sequence 1`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const [instance] = await db
|
const [instance] = await db
|
||||||
.insert(schema.preparationStepInstances)
|
.insert(schema.preparationStepInstances)
|
||||||
.values({
|
.values({
|
||||||
@@ -240,7 +257,7 @@ export const placeOrder = async (
|
|||||||
|
|
||||||
// Catch the "menu item has no Recipe Steps" data bug before charging
|
// Catch the "menu item has no Recipe Steps" data bug before charging
|
||||||
// anyone, rather than charging first and refunding once
|
// anyone, rather than charging first and refunding once
|
||||||
// createFirstStepForOrderItem discovers it mid-transaction.
|
// initializeOrderItemRecipe discovers it mid-transaction.
|
||||||
const itemIdsWithSteps = new Set(
|
const itemIdsWithSteps = new Set(
|
||||||
(
|
(
|
||||||
await db
|
await db
|
||||||
@@ -304,7 +321,7 @@ export const placeOrder = async (
|
|||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
const step = await createFirstStepForOrderItem(
|
const step = await initializeOrderItemRecipe(
|
||||||
tx,
|
tx,
|
||||||
orderItem.id,
|
orderItem.id,
|
||||||
menuItem.id,
|
menuItem.id,
|
||||||
@@ -320,8 +337,17 @@ export const placeOrder = async (
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The charge already happened; a failed transaction must not leave the
|
// The charge already happened; a failed transaction must not leave the
|
||||||
// customer paying for an Order that doesn't exist.
|
// customer paying for an Order that doesn't exist. If the compensating
|
||||||
await paymentProvider.refund(charge.chargeId, totalCents)
|
// refund itself fails, the transaction failure is still the real cause —
|
||||||
|
// log the refund failure rather than letting it hide that error.
|
||||||
|
try {
|
||||||
|
await paymentProvider.refund(charge.chargeId, totalCents)
|
||||||
|
} catch (refundError) {
|
||||||
|
console.error(
|
||||||
|
`Failed to refund charge ${charge.chargeId} after a failed order transaction:`,
|
||||||
|
refundError,
|
||||||
|
)
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,55 +409,90 @@ export const cancelOrderItem = async (
|
|||||||
orderItemId: number,
|
orderItemId: number,
|
||||||
access: OrderAccess,
|
access: OrderAccess,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.transaction(async (tx) => {
|
const orderItem = firstRow(
|
||||||
const orderItem = firstRow(
|
await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orderItems)
|
||||||
|
.where(eq(schema.orderItems.id, orderItemId)),
|
||||||
|
)
|
||||||
|
if (!orderItem) throw new Error('Order item not found')
|
||||||
|
|
||||||
|
const order = firstRow(
|
||||||
|
await db
|
||||||
|
.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(db, orderItemId)
|
||||||
|
if (!currentStep || currentStep.status !== 'queued') {
|
||||||
|
throw new OrderItemNotCancellableError('it has already started cooking')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund before touching any state, mirroring placeOrder's charge-first
|
||||||
|
// pattern: if the refund itself fails, nothing about this cancellation is
|
||||||
|
// ever recorded, exactly like a declined charge leaves no Order behind.
|
||||||
|
if (order.paymentChargeId) {
|
||||||
|
await paymentProvider.refund(order.paymentChargeId, orderItem.priceCents)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Re-check status = 'queued' as part of the delete itself, atomically:
|
||||||
|
// a Machine may have claimed this Step (assignQueuedStepsForTag), or a
|
||||||
|
// concurrent cancelOrderItem call may have already deleted it, in the
|
||||||
|
// time since the read above (which can include the refund call, an
|
||||||
|
// external network round trip). If so, zero rows match and the catch
|
||||||
|
// below re-charges to undo the refund we already issued.
|
||||||
|
const deletedSteps = await tx
|
||||||
|
.delete(schema.preparationStepInstances)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.preparationStepInstances.id, currentStep.id),
|
||||||
|
eq(schema.preparationStepInstances.status, 'queued'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: schema.preparationStepInstances.id })
|
||||||
|
|
||||||
|
if (deletedSteps.length === 0) {
|
||||||
|
throw new OrderItemNotCancellableError(
|
||||||
|
'it has already started cooking',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
await tx
|
await tx
|
||||||
.select()
|
.update(schema.orderItems)
|
||||||
.from(schema.orderItems)
|
.set({ status: 'cancelled' })
|
||||||
.where(eq(schema.orderItems.id, orderItemId)),
|
.where(eq(schema.orderItems.id, orderItemId))
|
||||||
)
|
|
||||||
if (!orderItem) throw new Error('Order item not found')
|
|
||||||
|
|
||||||
const order = firstRow(
|
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
|
||||||
await tx
|
})
|
||||||
.select()
|
} catch (error) {
|
||||||
.from(schema.orders)
|
// The refund already happened; an aborted cancellation must not leave
|
||||||
.where(eq(schema.orders.id, orderItem.orderId)),
|
// the customer refunded for an item that's still cooking. If the
|
||||||
)
|
// compensating re-charge itself fails, the cancellation failure is
|
||||||
if (!order) throw new Error('Order not found')
|
// still the real cause — log the re-charge failure rather than letting
|
||||||
assertOrderAccess(order, access)
|
// it hide that error.
|
||||||
|
if (order.paymentChargeId) {
|
||||||
if (orderItem.status === 'cancelled') {
|
try {
|
||||||
throw new OrderItemNotCancellableError('it has already been cancelled')
|
await paymentProvider.charge(orderItem.priceCents)
|
||||||
|
} catch (rechargeError) {
|
||||||
|
console.error(
|
||||||
|
`Failed to re-charge ${orderItem.priceCents} cents for order item ${orderItemId} after an aborted cancellation:`,
|
||||||
|
rechargeError,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (orderItem.status === 'ready' || orderItem.status === 'picked_up') {
|
throw error
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,22 +508,14 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
|
|||||||
.set({ status: 'done', doneAt: new Date() })
|
.set({ status: 'done', doneAt: new Date() })
|
||||||
.where(eq(schema.preparationStepInstances.id, currentStep.id))
|
.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(
|
const nextStepDefinition = firstRow(
|
||||||
await tx
|
await tx
|
||||||
.select()
|
.select()
|
||||||
.from(schema.menuItemSteps)
|
.from(schema.orderItemSteps)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.menuItemSteps.menuItemId, orderItem.menuItemId),
|
eq(schema.orderItemSteps.orderItemId, orderItemId),
|
||||||
eq(schema.menuItemSteps.sequence, currentStep.sequence + 1),
|
eq(schema.orderItemSteps.sequence, currentStep.sequence + 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -565,21 +618,25 @@ export const getOrderStatus = async (
|
|||||||
).map((row) => [row.id, row]),
|
).map((row) => [row.id, row]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipeRows = await db
|
const recipeRows =
|
||||||
.select({
|
orderItemIds.length === 0
|
||||||
menuItemId: schema.menuItemSteps.menuItemId,
|
? []
|
||||||
sequence: schema.menuItemSteps.sequence,
|
: await db
|
||||||
machineTag: schema.machineTags.name,
|
.select({
|
||||||
machineTagId: schema.menuItemSteps.machineTagId,
|
orderItemId: schema.orderItemSteps.orderItemId,
|
||||||
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
sequence: schema.orderItemSteps.sequence,
|
||||||
})
|
machineTag: schema.machineTags.name,
|
||||||
.from(schema.menuItemSteps)
|
machineTagId: schema.orderItemSteps.machineTagId,
|
||||||
.innerJoin(
|
estimatedDurationSeconds:
|
||||||
schema.machineTags,
|
schema.orderItemSteps.estimatedDurationSeconds,
|
||||||
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
})
|
||||||
)
|
.from(schema.orderItemSteps)
|
||||||
.where(inArray(schema.menuItemSteps.menuItemId, menuItemIds))
|
.innerJoin(
|
||||||
.orderBy(asc(schema.menuItemSteps.sequence))
|
schema.machineTags,
|
||||||
|
eq(schema.orderItemSteps.machineTagId, schema.machineTags.id),
|
||||||
|
)
|
||||||
|
.where(inArray(schema.orderItemSteps.orderItemId, orderItemIds))
|
||||||
|
.orderBy(asc(schema.orderItemSteps.sequence))
|
||||||
|
|
||||||
const allInstances =
|
const allInstances =
|
||||||
orderItemIds.length === 0
|
orderItemIds.length === 0
|
||||||
@@ -638,9 +695,7 @@ export const getOrderStatus = async (
|
|||||||
|
|
||||||
const itemViews: Array<OrderItemStatusView> = items.map((item) => {
|
const itemViews: Array<OrderItemStatusView> = items.map((item) => {
|
||||||
const menuItem = menuItemsById.get(item.menuItemId)
|
const menuItem = menuItemsById.get(item.menuItemId)
|
||||||
const recipe = recipeRows.filter(
|
const recipe = recipeRows.filter((step) => step.orderItemId === item.id)
|
||||||
(step) => step.menuItemId === item.menuItemId,
|
|
||||||
)
|
|
||||||
const instances = instancesByOrderItem.get(item.id) ?? []
|
const instances = instancesByOrderItem.get(item.id) ?? []
|
||||||
const instanceBySequence = new Map(
|
const instanceBySequence = new Map(
|
||||||
instances.map((row) => [row.sequence, row]),
|
instances.map((row) => [row.sequence, row]),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
import {
|
import {
|
||||||
afterAll,
|
afterAll,
|
||||||
afterEach,
|
afterEach,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
it,
|
it,
|
||||||
|
vi,
|
||||||
} from 'vitest'
|
} from 'vitest'
|
||||||
|
|
||||||
import * as schema from '#/db/schema.ts'
|
import * as schema from '#/db/schema.ts'
|
||||||
@@ -38,6 +40,7 @@ beforeEach(() => {
|
|||||||
// between tests instead of leaking a busy state into the next one.
|
// between tests instead of leaking a busy state into the next one.
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await testDb.db.delete(schema.preparationStepInstances)
|
await testDb.db.delete(schema.preparationStepInstances)
|
||||||
|
await testDb.db.delete(schema.orderItemSteps)
|
||||||
await testDb.db.delete(schema.orderItems)
|
await testDb.db.delete(schema.orderItems)
|
||||||
await testDb.db.delete(schema.orders)
|
await testDb.db.delete(schema.orders)
|
||||||
})
|
})
|
||||||
@@ -157,6 +160,34 @@ describe('placing an order', () => {
|
|||||||
seed.menuItems.pizza.priceCents,
|
seed.menuItems.pizza.priceCents,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('surfaces the original transaction failure even if the compensating refund also fails', async () => {
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const failingRefundPayment: typeof payment = {
|
||||||
|
...payment,
|
||||||
|
refund: async () => {
|
||||||
|
throw new Error('simulated payment provider outage')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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, failingRefundPayment, {
|
||||||
|
items: [{ menuItemId: seed.menuItems.pizza.id }],
|
||||||
|
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('simulated DB failure after payment')
|
||||||
|
|
||||||
|
expect(consoleError).toHaveBeenCalled()
|
||||||
|
consoleError.mockRestore()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('cancelling an Order Item', () => {
|
describe('cancelling an Order Item', () => {
|
||||||
@@ -247,6 +278,80 @@ describe('cancelling an Order Item', () => {
|
|||||||
).rejects.toThrow(ordering.ForbiddenError)
|
).rejects.toThrow(ordering.ForbiddenError)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('recharges the customer if the DB transaction fails after the refund has already gone through', async () => {
|
||||||
|
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||||
|
const order = await placeGuestOrder([
|
||||||
|
{ menuItemId: seed.menuItems.pizza.id },
|
||||||
|
])
|
||||||
|
const itemId = (await getStatus(order)).items[0].id
|
||||||
|
|
||||||
|
let rechargedAmountCents: number | undefined
|
||||||
|
const spyingPayment: typeof payment = {
|
||||||
|
...payment,
|
||||||
|
charge: async (amountCents) => {
|
||||||
|
rechargedAmountCents = amountCents
|
||||||
|
return payment.charge(amountCents)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbWithFailingTransaction = Object.create(
|
||||||
|
testDb.db,
|
||||||
|
) as typeof testDb.db
|
||||||
|
dbWithFailingTransaction.transaction = () => {
|
||||||
|
throw new Error('simulated DB failure after refund')
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordering.cancelOrderItem(
|
||||||
|
dbWithFailingTransaction,
|
||||||
|
spyingPayment,
|
||||||
|
itemId,
|
||||||
|
accessFor(order),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('simulated DB failure after refund')
|
||||||
|
|
||||||
|
expect(payment.refundsFor(order.paymentChargeId!)).toBe(
|
||||||
|
seed.menuItems.pizza.priceCents,
|
||||||
|
)
|
||||||
|
expect(rechargedAmountCents).toBe(seed.menuItems.pizza.priceCents)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces the original cancellation failure even if the compensating re-charge also fails', async () => {
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||||
|
const order = await placeGuestOrder([
|
||||||
|
{ menuItemId: seed.menuItems.pizza.id },
|
||||||
|
])
|
||||||
|
const itemId = (await getStatus(order)).items[0].id
|
||||||
|
|
||||||
|
const failingRechargePayment: typeof payment = {
|
||||||
|
...payment,
|
||||||
|
charge: async () => {
|
||||||
|
throw new Error('simulated payment provider outage')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbWithFailingTransaction = Object.create(
|
||||||
|
testDb.db,
|
||||||
|
) as typeof testDb.db
|
||||||
|
dbWithFailingTransaction.transaction = () => {
|
||||||
|
throw new Error('simulated DB failure after refund')
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordering.cancelOrderItem(
|
||||||
|
dbWithFailingTransaction,
|
||||||
|
failingRechargePayment,
|
||||||
|
itemId,
|
||||||
|
accessFor(order),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('simulated DB failure after refund')
|
||||||
|
|
||||||
|
expect(consoleError).toHaveBeenCalled()
|
||||||
|
consoleError.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
it('gives an accurate message for a double-cancel', async () => {
|
it('gives an accurate message for a double-cancel', async () => {
|
||||||
await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill
|
await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill
|
||||||
const order = await placeGuestOrder([
|
const order = await placeGuestOrder([
|
||||||
@@ -335,6 +440,53 @@ describe('advancing steps and pickup', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('recipe snapshot', () => {
|
||||||
|
it("keeps an Order Item on the Recipe as it existed at order time, even if the Menu Item's live Recipe is edited afterwards", async () => {
|
||||||
|
const order = await placeGuestOrder([
|
||||||
|
{ menuItemId: seed.menuItems.burger.id },
|
||||||
|
])
|
||||||
|
|
||||||
|
const burgerSecondStepEdit = and(
|
||||||
|
eq(schema.menuItemSteps.menuItemId, seed.menuItems.burger.id),
|
||||||
|
eq(schema.menuItemSteps.sequence, 2),
|
||||||
|
)
|
||||||
|
// Edit the live Recipe after the Order was placed: swap the burger's
|
||||||
|
// second Step to a different Machine Tag and duration.
|
||||||
|
await testDb.db
|
||||||
|
.update(schema.menuItemSteps)
|
||||||
|
.set({
|
||||||
|
machineTagId: seed.machineTags.oven.id,
|
||||||
|
estimatedDurationSeconds: 999,
|
||||||
|
})
|
||||||
|
.where(burgerSecondStepEdit)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const beforeAdvance = await getStatus(order)
|
||||||
|
const burgerItemId = beforeAdvance.items[0].id
|
||||||
|
const pendingStep = beforeAdvance.items[0].steps[1]
|
||||||
|
expect(pendingStep.status).toBe('pending')
|
||||||
|
expect(pendingStep.machineTag).toBe('assembly')
|
||||||
|
expect(pendingStep.estimatedDurationSeconds).toBe(90)
|
||||||
|
|
||||||
|
await ordering.advanceOrderItemStep(testDb.db, burgerItemId)
|
||||||
|
|
||||||
|
const afterAdvance = await getStatus(order)
|
||||||
|
const secondStep = afterAdvance.items[0].steps[1]
|
||||||
|
expect(secondStep.status).toBe('in_progress')
|
||||||
|
expect(secondStep.machineTag).toBe('assembly')
|
||||||
|
expect(secondStep.estimatedDurationSeconds).toBe(90)
|
||||||
|
} finally {
|
||||||
|
await testDb.db
|
||||||
|
.update(schema.menuItemSteps)
|
||||||
|
.set({
|
||||||
|
machineTagId: seed.machineTags.assembly.id,
|
||||||
|
estimatedDurationSeconds: 90,
|
||||||
|
})
|
||||||
|
.where(burgerSecondStepEdit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('access control', () => {
|
describe('access control', () => {
|
||||||
it('refuses to show order status to someone without the access token', async () => {
|
it('refuses to show order status to someone without the access token', async () => {
|
||||||
const order = await placeGuestOrder([
|
const order = await placeGuestOrder([
|
||||||
|
|||||||
Reference in New Issue
Block a user