Skip to content

Commit 26de6b4

Browse files
committed
refactor(orders): extract order domain into backend/src/orders and web/src/orders modules
1 parent 83af727 commit 26de6b4

20 files changed

Lines changed: 523 additions & 570 deletions

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"dev": "wrangler dev --persist-to .wrangler/state",
8-
"deploy": "wrangler deploy --var COMMIT_SHA:${COMMIT_SHA:-$(git rev-parse HEAD)}",
8+
"deploy": "wrangler deploy --var COMMIT_SHA:${COMMIT_SHA:-$(git rev-parse HEAD)} --var ORDER_TIME_ZONE:${ORDER_TIME_ZONE:-$(node -e \"console.log(require('../.tony-menu.local.json').backend.orderTimeZone || 'UTC')\")}",
99
"check": "tsc --noEmit",
1010
"typegen": "wrangler types",
1111
"import:backup": "tsx scripts/import-from-backup.ts",

backend/src/orders/config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { eq } from 'drizzle-orm';
2+
import { normalizeModulesConfig } from '@menu/schemas';
3+
import * as schema from '../db/schema';
4+
import type { DbInstance } from '../db';
5+
6+
/** Loads the normalized ordering config; null when unpublished or missing. */
7+
export async function loadOrdering(db: DbInstance) {
8+
const [settingsRow] = await db
9+
.select({
10+
modules: schema.settings.modules,
11+
aiChatEnabled: schema.settings.aiChatEnabled,
12+
aiVoiceEnabled: schema.settings.aiVoiceEnabled,
13+
publicationState: schema.settings.publicationState,
14+
})
15+
.from(schema.settings)
16+
.where(eq(schema.settings.id, 1))
17+
.limit(1);
18+
if (!settingsRow || settingsRow.publicationState !== 'published') return null;
19+
return normalizeModulesConfig(settingsRow.modules, settingsRow).ordering;
20+
}

backend/src/orders/create-order.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { and, count, eq, inArray, isNull } from 'drizzle-orm';
2+
import * as schema from '../db/schema';
3+
import type { DbInstance } from '../db';
4+
5+
/** YYYYMMDD integer bucket in the restaurant timezone. */
6+
export function currentOrderDay(now = new Date(), timeZone = 'UTC'): number {
7+
const parts = new Intl.DateTimeFormat('en-US', {
8+
timeZone,
9+
year: 'numeric',
10+
month: '2-digit',
11+
day: '2-digit',
12+
}).formatToParts(now);
13+
const value = (type: string) => Number(parts.find((part) => part.type === type)!.value);
14+
return value('year') * 10000 + value('month') * 100 + value('day');
15+
}
16+
17+
/** Intents expire 30 minutes after creation (#19). */
18+
export const INTENT_TTL_MS = 30 * 60_000;
19+
20+
const MAX_NUMBERING_RETRIES = 3;
21+
22+
export type CreateOrderResult =
23+
| { ok: true; orderId: string; dailyNumber: number }
24+
| { error: 'stale_items'; staleEntryIds: string[] }
25+
| { error: 'invalid_table_session' }
26+
| { error: 'check_open' };
27+
28+
/** Shared order creation path for diner, staff, and admin submits. */
29+
export async function createOrder(
30+
db: DbInstance,
31+
idempotencyKey: string,
32+
rawLines: { entryId: string; quantity: number }[],
33+
tableSessionId?: string | null,
34+
actor: 'diner' | 'staff' | 'admin' = tableSessionId ? 'staff' : 'diner',
35+
actorName?: string | null,
36+
orderTimeZone = 'UTC',
37+
): Promise<CreateOrderResult> {
38+
const existing = await findByIdempotencyKey(db, idempotencyKey);
39+
if (existing) return existing;
40+
41+
if (tableSessionId != null) {
42+
const [session] = await db
43+
.select({ id: schema.tableSessions.id })
44+
.from(schema.tableSessions)
45+
.where(and(eq(schema.tableSessions.id, tableSessionId), isNull(schema.tableSessions.closedAt)))
46+
.limit(1);
47+
if (!session) return { error: 'invalid_table_session' };
48+
49+
const [openCheck] = await db
50+
.select({ id: schema.checks.id })
51+
.from(schema.checks)
52+
.where(and(eq(schema.checks.tableSessionId, tableSessionId), eq(schema.checks.status, 'open')))
53+
.limit(1);
54+
if (openCheck) return { error: 'check_open' };
55+
}
56+
57+
const quantities = new Map<string, number>();
58+
for (const line of rawLines) {
59+
quantities.set(line.entryId, (quantities.get(line.entryId) ?? 0) + line.quantity);
60+
}
61+
const entryIds = [...quantities.keys()];
62+
63+
const entries = await db
64+
.select({
65+
id: schema.menuEntries.id,
66+
name: schema.menuEntries.name,
67+
price: schema.menuEntries.price,
68+
hidden: schema.menuEntries.hidden,
69+
outOfStock: schema.menuEntries.outOfStock,
70+
})
71+
.from(schema.menuEntries)
72+
.where(inArray(schema.menuEntries.id, entryIds));
73+
const entryById = new Map(entries.map((e) => [e.id, e]));
74+
const staleEntryIds = entryIds.filter((id) => {
75+
const entry = entryById.get(id);
76+
return !entry || entry.hidden || entry.outOfStock;
77+
});
78+
if (staleEntryIds.length > 0) return { error: 'stale_items', staleEntryIds };
79+
80+
const destinationRows = await db
81+
.select({
82+
entryId: schema.entryDestinations.entryId,
83+
destinationId: schema.orderDestinations.id,
84+
destinationName: schema.orderDestinations.name,
85+
})
86+
.from(schema.entryDestinations)
87+
.innerJoin(schema.orderDestinations, eq(schema.entryDestinations.destinationId, schema.orderDestinations.id))
88+
.where(inArray(schema.entryDestinations.entryId, entryIds));
89+
const destinationsByEntry = new Map<string, { destinationId: string; destinationName: string }[]>();
90+
for (const row of destinationRows) {
91+
const list = destinationsByEntry.get(row.entryId) ?? [];
92+
list.push({ destinationId: row.destinationId, destinationName: row.destinationName });
93+
destinationsByEntry.set(row.entryId, list);
94+
}
95+
96+
const orderId = crypto.randomUUID();
97+
const orderDay = currentOrderDay(new Date(), orderTimeZone);
98+
const itemValues = entryIds.map((entryId) => {
99+
const entry = entryById.get(entryId)!;
100+
return {
101+
id: crypto.randomUUID(),
102+
orderId,
103+
entryId,
104+
name: entry.name,
105+
price: entry.price,
106+
quantity: quantities.get(entryId)!,
107+
};
108+
});
109+
const itemDestinationValues = itemValues.flatMap((item) =>
110+
(destinationsByEntry.get(item.entryId) ?? []).map((dest) => ({
111+
id: crypto.randomUUID(),
112+
orderItemId: item.id,
113+
destinationId: dest.destinationId,
114+
destinationName: dest.destinationName,
115+
})),
116+
);
117+
118+
for (let attempt = 0; attempt < MAX_NUMBERING_RETRIES; attempt++) {
119+
const [{ value: existingCount }] = await db
120+
.select({ value: count() })
121+
.from(schema.orders)
122+
.where(eq(schema.orders.orderDay, orderDay));
123+
const dailyNumber = existingCount + 1;
124+
125+
try {
126+
await db.batch([
127+
db.insert(schema.orders).values({
128+
id: orderId,
129+
orderDay,
130+
dailyNumber,
131+
idempotencyKey,
132+
tableSessionId: tableSessionId ?? null,
133+
}),
134+
db.insert(schema.orderItems).values(itemValues),
135+
...(itemDestinationValues.length > 0 ? [db.insert(schema.orderItemDestinations).values(itemDestinationValues)] : []),
136+
db.insert(schema.orderEvents).values({
137+
id: crypto.randomUUID(),
138+
orderId,
139+
status: 'submitted',
140+
actor,
141+
actorName: actorName ?? null,
142+
}),
143+
]);
144+
return { ok: true, orderId, dailyNumber };
145+
} catch (error) {
146+
const won = await findByIdempotencyKey(db, idempotencyKey);
147+
if (won) return won;
148+
if (attempt === MAX_NUMBERING_RETRIES - 1) throw error;
149+
}
150+
}
151+
152+
throw new Error('Could not assign order number');
153+
}
154+
155+
async function findByIdempotencyKey(db: DbInstance, idempotencyKey: string) {
156+
const [row] = await db
157+
.select({ id: schema.orders.id, dailyNumber: schema.orders.dailyNumber })
158+
.from(schema.orders)
159+
.where(eq(schema.orders.idempotencyKey, idempotencyKey))
160+
.limit(1);
161+
if (!row) return null;
162+
return { ok: true as const, orderId: row.id, dailyNumber: row.dailyNumber };
163+
}

backend/src/orders/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './config';
2+
export * from './create-order';
3+
export * from './read-models';
4+
export * from './status';

backend/src/orders/read-models.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { asc, desc, eq, inArray } from 'drizzle-orm';
2+
import * as schema from '../db/schema';
3+
import type { DbInstance } from '../db';
4+
5+
export async function buildAdminOrdersForDay(db: DbInstance, day: number) {
6+
const orderRows = await db
7+
.select({
8+
id: schema.orders.id,
9+
dailyNumber: schema.orders.dailyNumber,
10+
status: schema.orders.status,
11+
rejectReason: schema.orders.rejectReason,
12+
createdAt: schema.orders.createdAt,
13+
tableName: schema.tables.name,
14+
areaName: schema.areas.name,
15+
updatedAt: schema.orders.updatedAt,
16+
})
17+
.from(schema.orders)
18+
.leftJoin(schema.tableSessions, eq(schema.orders.tableSessionId, schema.tableSessions.id))
19+
.leftJoin(schema.tables, eq(schema.tableSessions.tableId, schema.tables.id))
20+
.leftJoin(schema.areas, eq(schema.tables.areaId, schema.areas.id))
21+
.where(eq(schema.orders.orderDay, day))
22+
.orderBy(desc(schema.orders.dailyNumber));
23+
24+
const orderIds = orderRows.map((o) => o.id);
25+
const itemRows = orderIds.length > 0 ? await db.select().from(schema.orderItems).where(inArray(schema.orderItems.orderId, orderIds)) : [];
26+
const itemIds = itemRows.map((i) => i.id);
27+
const destRows = itemIds.length > 0 ? await db.select().from(schema.orderItemDestinations).where(inArray(schema.orderItemDestinations.orderItemId, itemIds)) : [];
28+
const eventRows = orderIds.length > 0 ? await db.select().from(schema.orderEvents).where(inArray(schema.orderEvents.orderId, orderIds)) : [];
29+
30+
const submittedBy = new Map<string, string>();
31+
for (const e of eventRows) if (e.status === 'submitted' && e.actorName) submittedBy.set(e.orderId, e.actorName);
32+
33+
const destsByItem = new Map<string, typeof destRows>();
34+
for (const d of destRows) {
35+
const list = destsByItem.get(d.orderItemId) ?? [];
36+
list.push(d);
37+
destsByItem.set(d.orderItemId, list);
38+
}
39+
const itemsByOrder = new Map<string, typeof itemRows>();
40+
for (const i of itemRows) {
41+
const list = itemsByOrder.get(i.orderId) ?? [];
42+
list.push(i);
43+
itemsByOrder.set(i.orderId, list);
44+
}
45+
46+
return orderRows.map((o) => ({
47+
id: o.id,
48+
dailyNumber: o.dailyNumber,
49+
status: o.status,
50+
rejectReason: o.rejectReason,
51+
createdAt: o.createdAt,
52+
updatedAt: o.updatedAt,
53+
tableName: o.tableName ? (o.areaName ? `${o.areaName} · ${o.tableName}` : o.tableName) : null,
54+
submittedBy: submittedBy.get(o.id) ?? null,
55+
items: (itemsByOrder.get(o.id) ?? []).map((i) => ({
56+
id: i.id,
57+
name: i.name,
58+
price: i.price,
59+
quantity: i.quantity,
60+
destinations: (destsByItem.get(i.id) ?? []).map((d) => ({
61+
id: d.id,
62+
destinationId: d.destinationId,
63+
destinationName: d.destinationName,
64+
printedAt: d.printedAt,
65+
})),
66+
})),
67+
}));
68+
}
69+
70+
export async function buildStaffSessionDetail(db: DbInstance, sessionId: string) {
71+
const [session] = await db
72+
.select({
73+
id: schema.tableSessions.id,
74+
openedAt: schema.tableSessions.openedAt,
75+
tableId: schema.tables.id,
76+
tableName: schema.tables.name,
77+
areaName: schema.areas.name,
78+
})
79+
.from(schema.tableSessions)
80+
.innerJoin(schema.tables, eq(schema.tableSessions.tableId, schema.tables.id))
81+
.leftJoin(schema.areas, eq(schema.tables.areaId, schema.areas.id))
82+
.where(eq(schema.tableSessions.id, sessionId))
83+
.limit(1);
84+
if (!session) return null;
85+
86+
const orderRows = await db.select().from(schema.orders).where(eq(schema.orders.tableSessionId, sessionId)).orderBy(asc(schema.orders.createdAt));
87+
const orderIds = orderRows.map((o) => o.id);
88+
const itemRows = orderIds.length > 0 ? await db.select().from(schema.orderItems).where(inArray(schema.orderItems.orderId, orderIds)) : [];
89+
const eventRows = orderIds.length > 0
90+
? await db.select().from(schema.orderEvents).where(inArray(schema.orderEvents.orderId, orderIds)).orderBy(asc(schema.orderEvents.createdAt))
91+
: [];
92+
93+
const eventsByOrder = new Map<string, typeof eventRows>();
94+
for (const e of eventRows) {
95+
const list = eventsByOrder.get(e.orderId) ?? [];
96+
list.push(e);
97+
eventsByOrder.set(e.orderId, list);
98+
}
99+
const itemsByOrder = new Map<string, typeof itemRows>();
100+
for (const i of itemRows) {
101+
const list = itemsByOrder.get(i.orderId) ?? [];
102+
list.push(i);
103+
itemsByOrder.set(i.orderId, list);
104+
}
105+
106+
return {
107+
sessionId: session.id,
108+
tableId: session.tableId,
109+
tableName: session.areaName ? `${session.areaName} · ${session.tableName}` : session.tableName,
110+
openedAt: session.openedAt,
111+
orders: orderRows.map((o) => ({
112+
id: o.id,
113+
dailyNumber: o.dailyNumber,
114+
status: o.status,
115+
createdAt: o.createdAt,
116+
items: (itemsByOrder.get(o.id) ?? []).map((i) => ({
117+
id: i.id,
118+
name: i.name,
119+
price: i.price,
120+
quantity: i.quantity,
121+
})),
122+
events: (eventsByOrder.get(o.id) ?? []).map((e) => ({
123+
status: e.status,
124+
actor: e.actor,
125+
actorName: e.actorName,
126+
at: e.createdAt,
127+
})),
128+
})),
129+
};
130+
}

backend/src/orders/status.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { eq } from 'drizzle-orm';
2+
import { ORDER_STATUS_TRANSITIONS, type OrderStatus } from '@menu/schemas';
3+
import * as schema from '../db/schema';
4+
import type { DbInstance } from '../db';
5+
6+
export async function transitionOrderStatus(
7+
db: DbInstance,
8+
orderId: string,
9+
status: 'ready' | 'served' | 'rejected',
10+
actor: 'admin' | 'staff',
11+
actorName: string | null = null,
12+
rejectReason?: string,
13+
) {
14+
const [order] = await db
15+
.select({ status: schema.orders.status })
16+
.from(schema.orders)
17+
.where(eq(schema.orders.id, orderId))
18+
.limit(1);
19+
if (!order) return { error: 'not_found' as const };
20+
21+
const allowed = ORDER_STATUS_TRANSITIONS[order.status as OrderStatus] ?? [];
22+
if (!allowed.includes(status)) {
23+
return { error: 'illegal_transition' as const, from: order.status, to: status };
24+
}
25+
26+
await db.batch([
27+
db
28+
.update(schema.orders)
29+
.set({
30+
status,
31+
rejectReason: status === 'rejected' ? rejectReason : null,
32+
updatedAt: Date.now(),
33+
})
34+
.where(eq(schema.orders.id, orderId)),
35+
db.insert(schema.orderEvents).values({
36+
id: crypto.randomUUID(),
37+
orderId,
38+
status,
39+
actor,
40+
actorName,
41+
}),
42+
]);
43+
return { ok: true as const, status };
44+
}

backend/src/routes/admin-checks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { UpdateCheckBodySchema, SettleCheckBodySchema, computeCheckTotals, type
99
import * as schema from '../db/schema';
1010
import type { AppBindings } from '../types';
1111
import type { DbInstance } from '../db';
12-
import { createOrder } from './orders';
12+
import { createOrder } from '../orders';
1313

1414
/**
1515
* Checks (conto, #15 follow-up): create/settle/void a table session's bill, plus

0 commit comments

Comments
 (0)