Freeze an Order Item's Recipe at order time

Price was already snapshotted per Order Item so later menu changes
don't rewrite history, but the Recipe (Step list) wasn't: advancing an
Order Item looked up its next Step from the live menuItemSteps table,
so an operator editing a Menu Item's Recipe mid-cook could silently
reshape an Order already in flight (different Machine Tag, duration,
or Step order than what the customer paid for).

Adds order_item_steps as a per-Order-Item copy of the Recipe, taken at
placement time; placeOrder, advanceOrderItemStep, and getOrderStatus
now all read from that snapshot instead of the live menu. Recorded as
docs/adr/0005 and added Recipe as a formal CONTEXT.md term.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 19:55:05 +02:00
co-authored by Claude Sonnet 5
parent d291312139
commit 9c3ad51686
8 changed files with 732 additions and 43 deletions
+49
View File
@@ -1,3 +1,4 @@
import { and, eq } from 'drizzle-orm'
import {
afterAll,
afterEach,
@@ -38,6 +39,7 @@ beforeEach(() => {
// 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.orderItemSteps)
await testDb.db.delete(schema.orderItems)
await testDb.db.delete(schema.orders)
})
@@ -335,6 +337,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', () => {
it('refuses to show order status to someone without the access token', async () => {
const order = await placeGuestOrder([