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:
2026-09-09 18:46:58 +02:00
co-authored by Claude Sonnet 5
parent e532a95f0b
commit 456c3acda5
38 changed files with 13572 additions and 1 deletions
+5
View File
@@ -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 })
+126
View File
@@ -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'),
})
+96
View File
@@ -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)
}
+749
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient()
+11
View File
@@ -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()],
})
+104
View File
@@ -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
}
+86
View File
@@ -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>>
}
}
+19
View File
@@ -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>
}
}
+52
View File
@@ -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,
})
+11
View File
@@ -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),
},
},
})
+12
View File
@@ -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 })
+9
View File
@@ -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))
+95
View File
@@ -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),
)
+17
View File
@@ -0,0 +1,17 @@
@import "tailwindcss";
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
}