Table Ordering & POS System Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement a table-based ordering and POS system for House of Legends dinner theater. Guests pre-book tables (via existing booking flow), then order food/beverages via a QR-code PWA at their table or through a staff POS. Kitchen sees real-time orders via KDS. All tabs are added to the reservation and paid together.
Architecture: Single Next.js app with role-based routes. Convex backend handles all real-time subscriptions. Staff login via existing Clerk auth. Guest PWA accessed via QR code token (no login). Orders are per-table, fire immediately to kitchen/bar.
Tech Stack: Next.js 16 (App Router, SSG), Convex (real-time DB + functions), Clerk (staff auth), Tailwind CSS v4, PWA (service worker + manifest), nuqs for URL state management (category, filters, tab state in URL), qrcode library for QR generation.
Business Summary
What this does: Implements a table-based ordering and POS system where guests scan a QR code at their pre-booked table to browse the menu and order food/beverages via PWA, with orders firing real-time to kitchen (KDS) and bar displays. Staff can also take orders on behalf of guests via a POS interface.
Why it matters: Increases per-table revenue by making ordering frictionless and available throughout the experience. Reduces wait times and staff burden by routing orders directly to the correct station (kitchen vs bar). All charges attach to the reservation tab, simplifying checkout.
Time to implement: 10-15 days | Complexity: High
Dependencies: Requires booking-flow (for QR token generation on reservation confirmation), staff-operations (for staff auth helpers staffMutation/adminMutation). QR token validation is standalone and can be implemented in parallel with auth helpers.
Context & Key Constraints
[P0 GAP]
staffMutation/adminMutationnot yet implemented. The existingconvex/auth.tsonly providesgetCurrentUser,upsertUser, andisAdminhelpers. Any plan referencingstaffMutationoradminMutationmust be blocked on the foundation plan implementing these wrappers. Use plainmutationwith inline role checks viactx.auth.getUserIdentity()instead.
[P0 RULE] No dynamic URL segments. All routing uses query params via
nuqs— never/table/[tableId]or/booking/[occurrenceId]. Guest PWA URL:/table?tableId=xxx&token=xxx. Staff POS:/pos/kitchen?station=KITCHEN.
[P1 RULE] No
console.log. Useconsolafor all structured logging.
[P1 RULE] All mutation error throws must use POS_ERRORS constants. Do not use inline strings like
throw new Error("INVALID_CAPACITY")— usethrow new Error(POS_ERRORS.INVALID_CAPACITY)with the named constant from the enrichment section.
File Map
convex/
├── schema.ts # MODIFY — add tables, menuItems, orders, orderItems
└── functions/
├── tables.ts # CREATE — table CRUD
├── menu.ts # CREATE — menu item CRUD
└── orders.ts # CREATE — order lifecycle
apps/frontend/
├── components/
│ ├── menu/
│ │ ├── menu-card.tsx # CREATE — individual menu item card
│ │ ├── menu-category.tsx # CREATE — category section
│ │ └── cart-button.tsx # CREATE — floating cart button
│ ├── pos/
│ │ ├── kitchen-board.tsx # CREATE — KDS kanban board
│ │ ├── order-item-card.tsx # CREATE — order item with status actions
│ │ └── table-status.tsx # CREATE — table status chip
│ └── ui/
├── app/[locale]/
│ ├── table/
│ │ └── page.tsx # CREATE — guest PWA: menu + cart (nuqs URL: ?tableId=&token=)
│ └── pos/
│ ├── page.tsx # CREATE — role-based POS home (redirects)Phase 1: Schema Extensions
Task 1: Add POS Tables to Schema
Files:
-
Modify:
convex/schema.ts -
Step 1: Read existing schema
cat convex/schema.ts- Step 2: Add POS tables (same as staff-operations-plan.md Phase 1)
Tables: tables, menuItems, orders, orderItems
All indexes: by_status, by_category, by_station, by_available, by_table, by_reservation, by_order, by_station_status
- Step 3: Commit
git add convex/schema.ts
git commit -m "feat(table-pos): add POS tables to schema"Phase 2: Convex Functions
Task 2: Create POS Functions
Files:
- Create:
convex/functions/tables.ts - Create:
convex/functions/menu.ts - Create:
convex/functions/orders.ts
[P0 GAP] Auth wrappers.
staffMutationandadminMutationdo not exist inconvex/auth.ts. Use plainmutationwith inline role checks viactx.auth.getUserIdentity().
- Step 1: Create functions — inline role check pattern
// convex/functions/tables.ts
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { Id } from "../_generated/dataModel";
import { consola } from "consola";
// Inline role check — staffMutation/adminMutation not available yet
// Convex QueryBuilder types are inferred automatically
async function requireStaffOrAdmin(
ctx: HandlerContext,
): Promise<{ _id: Id<"users">; role: string; email: string }> {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("UNAUTHORIZED");
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) {
throw new Error("UNAUTHORIZED");
}
return user;
}
type HandlerContext = {
auth: { getUserIdentity: () => Promise<{ email?: string } | null> };
db: {
query: (table: string) => {
withIndex: (
name: string,
fn: (q: QueryBuilder) => { first: () => Promise<unknown> },
) => { first: () => Promise<unknown> };
};
};
};
type QueryBuilder = {
eq: (field: string, value: string) => { first: () => Promise<unknown> };
};
// Public mutations (guest PWA) — no auth required
export const getOrCreateForTable = mutation({
args: {
tableId: v.id("tables"),
reservationId: v.optional(v.id("reservations")),
},
handler: async (ctx, { tableId, reservationId }) => {
// No auth — guests use QR token (validated elsewhere in Next.js)
const existing = await ctx.db
.query("orders")
.withIndex("by_table", (q) => q.eq("tableId", tableId))
.collect()
.then((orders) =>
orders.find((o) => o.status === "OPEN" || o.status === "SUBMITTED"),
);
if (existing) return existing;
const id = await ctx.db.insert("orders", {
tableId,
reservationId: reservationId ?? undefined,
status: "OPEN",
totalAmount: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
});
return await ctx.db.get(id);
},
});- Step 2: Commit
git add convex/functions/tables.ts convex/functions/menu.ts convex/functions/orders.ts
git commit -m "feat(table-pos): add POS Convex functions"Phase 3: QR Token Security
Task 3: Implement Token Generation and Validation
Files:
-
Create:
apps/frontend/lib/qr-token.ts -
Step 1: Create HMAC token utilities
// apps/frontend/lib/qr-token.ts
import { createHmac } from "crypto";
type TokenPayload = {
tableId: string;
reservationId: string;
expiresAt: number; // Unix timestamp
};
const SECRET = process.env.QR_TOKEN_SECRET!;
/**
* Generate a signed QR token for table access.
* Token = base64url(payload) + "." + hmac_sha256
*/
export function generateTableToken(payload: TokenPayload): string {
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
const sig = createHmac("sha256", SECRET)
.update(payloadB64)
.digest("base64url");
return `${payloadB64}.${sig}`;
}
/**
* Validate and decode a QR token.
* Returns null if invalid, expired, or tampered.
*/
export function verifyTableToken(token: string): TokenPayload | null {
try {
const [payloadB64, sig] = token.split(".");
const expectedSig = createHmac("sha256", SECRET)
.update(payloadB64)
.digest("base64url");
if (sig !== expectedSig) return null;
const payload: TokenPayload = JSON.parse(
Buffer.from(payloadB64, "base64url").toString(),
);
if (payload.expiresAt < Date.now()) return null;
return payload;
} catch {
return null;
}
}- Step 2: Token generation in booking flow
When a reservation is confirmed, generate the table token:
// In the reservation confirmation flow
import { generateTableToken } from "~/lib/qr-token";
const token = generateTableToken({
tableId: reservation.tableId,
reservationId: reservation._id,
expiresAt: showDateTimestamp + 24 * 60 * 60 * 1000, // 24h after show
});
const tableUrl = `${NEXT_PUBLIC_APP_URL}/${locale}/table?tableId=${tableId}&token=${token}`;- Step 3: Token validation in PWA page
// apps/frontend/app/table/page.tsx
"use client";
import { Suspense, useMemo } from "react";
import { useQuery, useMutation } from "convex/react";
import { useQueryState } from "nuqs";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { verifyTableToken } from "~/lib/qr-token";
import { MenuCategory } from "~/components/menu/menu-category";
import { CartButton } from "~/components/menu/cart-button";
function TablePWALoading() {
return (
<div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-[#2E2E2E] rounded w-48" />
<div className="h-4 bg-[#2E2E2E] rounded w-32" />
</div>
</div>
);
}
export default function TablePage() {
const t = useTranslations();
// CORRECT: nuqs URL state — no dynamic route segments
const [tableId] = useQueryState("tableId", { defaultValue: "" });
const [token] = useQueryState("token", { defaultValue: "" });
// Validate token on load
const validPayload = useMemo(() => {
if (!token) return null;
return verifyTableToken(token);
}, [token]);
if (!validPayload || validPayload.tableId !== tableId) {
return (
<div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center">
<div className="text-center">
<h1 className="font-serif text-[#C5A059] text-xl mb-2">
{t("pos.table.invalidTokenTitle")}
</h1>
<p className="text-[#808080]">{t("pos.table.invalidTokenMessage")}</p>
</div>
</div>
);
}
// Get or create order for this table
const openOrder = useQuery(
api.orders.getOpenOrderByTable,
tableId ? { tableId } : "skip"
);
return (
<div className="min-h-screen bg-[#1a1a1a] text-[#e6e6e6]">
<header className="sticky top-0 z-10 bg-[#2E2E2E] border-b border-[#4d4d4d] px-4 py-3">
<div className="max-w-lg mx-auto flex justify-between items-center">
<div>
<h1 className="font-serif text-[#C5A059] text-lg">
{t("pos.table.title", { tableId })}
</h1>
<p className="text-xs text-[#808080]">{t("pos.table.venue")}</p>
</div>
{openOrder && openOrder.status !== "OPEN" && (
<span className="text-xs bg-[#C5A059]/20 text-[#C5A059] px-2 py-1 rounded">
{t("pos.table.orderSent")}
</span>
)}
</div>
</header>
<Suspense fallback={<TablePWALoading />}>
<div className="max-w-lg mx-auto">
<MenuCategory />
<CartButton tableId={tableId} openOrder={openOrder} />
</div>
</Suspense>
</div>
);
}- Step 4: Commit
git add apps/frontend/lib/qr-token.ts
git commit -m "feat(table-pos): add QR token generation and validation"Phase 4: Guest PWA Components
Task 4: Create Menu Components
Files:
-
Create:
apps/frontend/components/menu/menu-card.tsx -
Create:
apps/frontend/components/menu/menu-category.tsx -
Create:
apps/frontend/components/menu/cart-button.tsx -
Step 1: Create menu card component
// apps/frontend/components/menu/menu-card.tsx
"use client";
import { useCallback, useTransition } from "react";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
type MenuItem = {
_id: string;
name: string;
description: string;
price: number;
category: string;
};
type OpenOrder = {
_id: string;
status: string;
} | null;
export function MenuCard({
item,
openOrder,
onCartChange,
}: {
item: MenuItem;
openOrder: OpenOrder;
onCartChange: () => void;
}) {
const t = useTranslations();
const [isPending, startTransition] = useTransition();
const addItem = useMutation(api.orders.addItem);
const handleAdd = useCallback(() => {
if (!openOrder) return;
startTransition(() => {
addItem({ orderId: openOrder._id, menuItemId: item._id, quantity: 1 });
onCartChange();
});
}, [openOrder, addItem, item._id, onCartChange]);
return (
<div
className="bg-[#2E2E2E] border border-[#4d4d4d] p-4 rounded-lg"
data-testid="menu-item-card"
data-category={item.category}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="font-medium text-[#e6e6e6]">{item.name}</h3>
<p className="text-sm text-[#808080] mt-1">{item.description}</p>
</div>
<span className="text-[#C5A059] font-medium ml-4">
{item.price.toLocaleString()}{t("common.currencyShort")}
</span>
</div>
<div className="mt-3">
<button
onClick={handleAdd}
disabled={isPending || !openOrder}
data-testid="menu-item-add-btn"
className="text-sm bg-[#C5A059] text-[#1a1a1a] px-3 py-1.5 rounded font-medium hover:bg-[#DEC89E] transition-colors disabled:opacity-50"
>
{t("common.add")}
</button>
</div>
</div>
);
}- Step 2: Create menu category component
// apps/frontend/components/menu/menu-category.tsx
"use client";
import { useQuery } from "convex/react";
import { useQueryState } from "nuqs";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { MenuCard } from "./menu-card";
const CATEGORIES = [
{ key: "APPETIZER", labelKey: "menu.category.appetizer" },
{ key: "MAIN", labelKey: "menu.category.main" },
{ key: "DESSERT", labelKey: "menu.category.dessert" },
{ key: "DRINK", labelKey: "menu.category.drink" },
{ key: "COCKTAIL", labelKey: "menu.category.cocktail" },
];
export function MenuCategory() {
const t = useTranslations();
const [category, setCategory] = useQueryState("cat", {
defaultValue: "APPETIZER",
serialize: (v) => v,
parse: (v) => v,
});
// CORRECT: useQuery(api.fn, args) — pass function reference
const items = useQuery(api.menu.listByCategory, { category });
return (
<div data-testid="menu-grid">
<div className="flex gap-1 px-4 py-3 overflow-x-auto">
{CATEGORIES.map((cat) => (
<button
key={cat.key}
onClick={() => setCategory(cat.key)}
data-testid={`category-tab-${cat.key}`}
className={`px-3 py-1.5 text-sm rounded whitespace-nowrap ${
category === cat.key
? "bg-[#C5A059] text-[#1a1a1a] font-medium"
: "bg-[#2E2E2E] text-[#808080] border border-[#4d4d4d]"
}`}
>
{t(cat.labelKey)}
</button>
))}
</div>
<div className="px-4 pb-24 space-y-3">
{items?.map((item) => (
<MenuCard key={item._id} item={item} openOrder={null} onCartChange={() => {}} />
))}
</div>
</div>
);
}- Step 3: Commit
git add apps/frontend/components/menu/
git commit -m "feat(table-pos): add menu components"Phase 5: PWA Manifest & Offline
Task 5: PWA Setup
Files:
-
Create:
apps/frontend/public/manifest.webmanifest -
Step 1: Create manifest
{
"name": "House of Legends - Table Ordering",
"short_name": "HOL Table",
"description": "Order food and drinks at your table",
"start_url": "/table",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#C5A059",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}- Step 2: Add iOS meta tags to locale layout
// In apps/frontend/app/[locale]/layout.tsx, add to <head>:
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />- Step 3: Commit
git add apps/frontend/public/manifest.webmanifest
git commit -m "feat(table-pos): add PWA manifest"Enrichment Sections
1. Zod Schemas
// Shared POS schemas
import { z } from "zod";
export const CreateTableSchema = z.object({
name: z.string().min(1, "Table number is required"), // e.g., "T01"
capacity: z.number().int().positive("Capacity must be at least 1"),
});
export const CreateMenuItemSchema = z.object({
name: z.string().min(1, "Menu item name is required"),
description: z.string(),
price: z.number().nonnegative("Price must be non-negative"),
category: z.enum([
"APPETIZER",
"MAIN",
"DESSERT",
"DRINK",
"COCKTAIL",
"WINE",
"BEER",
"SOFT_DRINK",
"OTHER",
]),
station: z.enum(["KITCHEN", "BAR"]),
imageUrl: z.string().url().optional(),
});
export const CreateOrderSchema = z.object({
tableId: z.string().min(1, "Table ID is required"),
reservationId: z.string().optional(),
});
export const AddItemSchema = z.object({
orderId: z.string().min(1, "Order ID is required"),
menuItemId: z.string().min(1, "Menu item ID is required"),
quantity: z.number().int().positive("Quantity must be at least 1"),
notes: z.string().optional(),
});
export const TokenPayloadSchema = z.object({
tableId: z.string().min(1),
reservationId: z.string().min(1),
expiresAt: z.number().positive(),
});2. Error Handling
// convex/functions/orders.ts
export const POS_ERROR_CODES = {
MENU_ITEM_NOT_FOUND: "MENU_ITEM_NOT_FOUND",
ORDER_NOT_FOUND: "ORDER_NOT_FOUND",
ORDER_NOT_OPEN: "ORDER_NOT_OPEN",
INVALID_QUANTITY: "INVALID_QUANTITY",
ORDER_EMPTY: "ORDER_EMPTY",
UNAUTHORIZED: "UNAUTHORIZED",
INVALID_TOKEN: "INVALID_TOKEN",
TOKEN_EXPIRED: "TOKEN_EXPIRED",
} as const;
type POSErrorCode = keyof typeof POS_ERROR_CODES;| Function | Error Code | Message Key | Condition |
|---|---|---|---|
verifyTableToken | INVALID_TOKEN | errors.pos.invalidToken | Token signature mismatch |
verifyTableToken | TOKEN_EXPIRED | errors.pos.tokenExpired | Token past expiry |
addItem | MENU_ITEM_NOT_FOUND | errors.menu.notFound | Menu item does not exist |
addItem | ORDER_NOT_OPEN | errors.order.notOpen | Order already submitted |
submitOrder | ORDER_EMPTY | errors.order.empty | No items in order |
3. Convex Real-time Subscription Pattern
// Guest PWA — open order for table
const openOrder = useQuery(
api.orders.getOpenOrderByTable,
tableId ? { tableId } : "skip",
);
// Kitchen KDS — items by station and status
const kitchenItems = useQuery(api.orders.getByStationAndStatus, {
station: "KITCHEN",
});
const barItems = useQuery(api.orders.getByStationAndStatus, {
station: "BAR",
});
// Menu items by category
const menuItems = useQuery(api.menu.listByCategory, { category });4. Mobile/Responsive Considerations
| Component | Mobile Behavior |
|---|---|
| Guest PWA | Single-column menu; floating cart button; bottom sheet cart |
| Kitchen KDS | Horizontal scroll kanban; sticky header |
| Floor POS | Table list left; menu grid right (tablet+) |
| Reception | Table list view with status indicators |
| Offline banner | Persistent top banner when navigator.onLine === false |
5. PWA / Offline Behavior
Service Worker Strategy:
- Menu page shell: Cache-First with background revalidation
- Menu item data: Network-First with 5s timeout, fallback to cache
- Convex subscriptions: Always online (real-time required)
Offline Capabilities:
- Menu browsing: WORKS (cached)
- Add to cart: BLOCKED (requires Convex write)
- Order submission: BLOCKED (requires Convex write)
- Order status tracking: BLOCKED (real-time required)
Offline UI:
- Banner: "You are offline. Menu browsing available. Ordering requires connection."
- Add buttons: disabled with tooltip6. i18n / next-intl Requirements
{
"pos": {
"table": {
"title": "Table {tableId}",
"venue": "House of Legends",
"orderSent": "Order Sent",
"sendOrder": "Send Order",
"callStaff": "Call Staff",
"invalidTokenTitle": "Invalid Link",
"invalidTokenMessage": "This link is invalid or has expired. Please contact staff.",
"offline": "You are offline. Menu browsing available. Ordering requires connection."
},
"orderStatus": {
"pending": "Pending",
"submitted": "Submitted",
"preparing": "Preparing",
"ready": "Ready",
"served": "Served"
},
"cart": {
"yourOrder": "Your Order",
"empty": "Your cart is empty",
"sendOrder": "Send Order",
"total": "Total"
}
},
"menu": {
"category": {
"appetizer": "Appetizers",
"main": "Main Courses",
"dessert": "Desserts",
"drink": "Drinks",
"cocktail": "Cocktails",
"wine": "Wine",
"softDrink": "Soft Drinks"
}
},
"common": {
"add": "Add",
"currencyShort": " VND",
"loading": "Loading..."
},
"errors": {
"pos": {
"invalidToken": "This link is invalid. Please contact staff.",
"tokenExpired": "This link has expired. Please contact staff."
},
"menu": {
"notFound": "Menu item not found"
},
"order": {
"notOpen": "Order is not open for modifications",
"empty": "Cannot submit an empty order"
}
}
}7. Environment-Specific Configuration
# Server-only:
CLERK_SECRET_KEY=
QR_TOKEN_SECRET= # HMAC secret for table QR tokens (32+ bytes)
# Client-safe:
NEXT_PUBLIC_CONVEX_URL=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
NEXT_PUBLIC_APP_URL= # Frontend URL for token generation8. TDD Test Cases
E2E Tests (Playwright):
// e2e/table-pos.spec.ts
test("POS-E2E-1.1: Valid token renders PWA menu", async ({ page }) => {
// Given: Guest scans valid QR code
// When: Guest navigates to /table?tableId=table1&token=validToken
// Then: Menu grid renders with categories
await page.goto("/en/table?tableId=table1&token=validToken");
await expect(page.locator('[data-testid="menu-grid"]')).toBeVisible();
});
test("POS-E2E-1.2: Invalid token shows error state", async ({ page }) => {
// Given: Guest uses tampered or expired token
// When: Guest navigates to /table?tableId=table1&token=invalidToken
// Then: Error message shown, menu not accessible
await page.goto("/en/table?tableId=table1&token=invalidToken");
await expect(page.getByText("Invalid Link")).toBeVisible();
});
test("POS-E2E-1.3: Add item updates cart count", async ({ page }) => {
// Given: Guest PWA is open with valid token and open order
// When: Guest taps "Add" on a menu item
// Then: Cart count increments
await page.goto("/en/table?tableId=table1&token=validToken");
await page.getByTestId("menu-item-add-btn").first().click();
await expect(page.getByTestId("cart-count")).toContainText("1");
});
test("POS-E2E-1.4: Submit order changes status to SUBMITTED", async ({
page,
}) => {
// Given: Guest has items in cart
// When: Guest taps "Send Order"
// Then: Order status changes to SUBMITTED, badge appears
await page.goto("/en/table?tableId=table1&token=validToken");
await page.getByTestId("menu-item-add-btn").first().click();
await page.getByTestId("send-order-btn").click();
await expect(page.locator('[data-testid="order-sent-badge"]')).toBeVisible();
});
test("POS-E2E-1.5: Offline banner shows when disconnected", async ({
page,
}) => {
// Given: Guest PWA is open
// When: Browser goes offline
// Then: Offline banner appears, Add buttons disabled
await page.goto("/en/table?tableId=table1&token=validToken");
await page.context().setOffline(true);
await expect(page.getByText("You are offline")).toBeVisible();
});Component Tests (Vitest + RTL):
// __tests__/lib/qr-token.test.ts
it("POS-CT-1.1: generateTableToken creates valid signed token", () => {
const payload = {
tableId: "table1",
reservationId: "res1",
expiresAt: Date.now() + 86400000,
};
const token = generateTableToken(payload);
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
});
it("POS-CT-1.2: verifyTableToken returns payload for valid token", () => {
const payload = {
tableId: "table1",
reservationId: "res1",
expiresAt: Date.now() + 86400000,
};
const token = generateTableToken(payload);
const result = verifyTableToken(token);
expect(result).toEqual(payload);
});
it("POS-CT-1.3: verifyTableToken returns null for tampered token", () => {
const payload = {
tableId: "table1",
reservationId: "res1",
expiresAt: Date.now() + 86400000,
};
const token = generateTableToken(payload);
const tampered = token.slice(0, -5) + "XXXXX";
expect(verifyTableToken(tampered)).toBeNull();
});
it("POS-CT-1.4: verifyTableToken returns null for expired token", () => {
const payload = {
tableId: "table1",
reservationId: "res1",
expiresAt: Date.now() - 1000,
};
const token = generateTableToken(payload);
expect(verifyTableToken(token)).toBeNull();
});Backend Tests (Vitest):
// __tests__/convex/orders.test.ts
it("POS-BE-1.1: getOrCreateForTable creates new order when none exists", async () => {
const ctx = createMockContext({ orders: [] });
const result = await ctx.runMutation(api.orders.getOrCreateForTable, {
tableId: "table1",
});
expect(result.status).toBe("OPEN");
expect(result.totalAmount).toBe(0);
});
it("POS-BE-1.2: getOrCreateForTable returns existing OPEN order", async () => {
const ctx = createMockContext({
orders: [{ tableId: "table1", status: "OPEN", totalAmount: 50000 }],
});
const result = await ctx.runMutation(api.orders.getOrCreateForTable, {
tableId: "table1",
});
expect(result.totalAmount).toBe(50000);
});
it("POS-BE-1.3: addItem throws for nonexistent menu item", async () => {
const ctx = createMockContext({ menuItems: {} });
await expect(
ctx.runMutation(api.orders.addItem, {
orderId: "order1",
menuItemId: "nonexistent",
quantity: 1,
}),
).rejects.toThrow("MENU_ITEM_NOT_FOUND");
});9. Cross-Plan Dependencies
| Dependency | Plan | Shared Schema |
|---|---|---|
| Required by | package-bundle-pricing | orders.totalAmount accumulated at checkout |
| Required by | notifications-crm | reservation.tableId links to table |
| Depends on | staff-operations | Shared tables, menuItems, orders, orderItems schema |
| QR token generation | booking-flow | Token generated on reservation confirmation |
| Token validation | guest-profiles (Spec 05) | Token validation shared with guest onboarding |
10. Performance Considerations
| Scenario | At Scale (100 tables, 500 concurrent guests) |
|---|---|
| Token validation | HMAC-SHA256 is O(1); negligible latency |
| Menu browsing | Cached per category; Network-First with 5s timeout prevents stale data |
| Order submission | Convex mutation < 100ms; real-time push to KDS < 500ms end-to-end |
| Offline browsing | Cache-First for shell; serve from Service Worker while revalidating in background |
| Concurrent orders | Convex handles 50+ concurrent writes; compound index by_station_status prevents scans |
Acceptance Criteria
- QR token is validated server-side before PWA renders — invalid/expired tokens show error state
- Guest can browse menu by category without logging in
- Adding item to cart updates order total in real-time
- Submitting order fires items to correct KDS (KITCHEN vs BAR)
- Kitchen KDS kanban moves items through SUBMITTED -> PREPARING -> READY -> SERVED
- Guest PWA reflects order status changes in real-time
- Multiple orders accumulate under same reservation
- Staff POS can create and submit orders on behalf of guests
- Service worker caches menu page for offline browsing
- Offline state shows clear banner, cannot submit orders
User Stories
| ID | As a... | I want to... | So that... | Priority |
|---|---|---|---|---|
| POS-US01 | Guest | Scan QR code and browse menu at my table | I can order food and drinks without waiting for staff | Must |
| POS-US02 | Guest | See order status update in real-time | I know when my drinks are being prepared | Must |
| POS-US03 | Guest | Have my orders be routed to correct station | Kitchen sees food, bar sees drinks | Must |
| POS-US04 | Staff | Take orders on behalf of guests | I can help guests who prefer not to use the PWA | Must |
| POS-US05 | Guest | Access the PWA offline for menu browsing | I can browse the menu even without internet | Should |
| POS-US06 | System | Validate QR tokens server-side | Prevent unauthorized access to other tables | Must |
Test Scenarios
| ID | Scenario | Given | When | Then |
|---|---|---|---|---|
| POS-TS01 | Valid token renders PWA | Valid HMAC-signed token | Guest loads /table page | Menu renders, no error |
| POS-TS02 | Invalid token shows error | Tampered token | Guest loads /table page | Error state shown |
| POS-TS03 | Expired token shows error | Expired timestamp in token | Guest loads /table page | Error state shown |
| POS-TS04 | Add to cart | Open order exists | Guest taps "Add" on menu item | Cart count increments, total updates |
| POS-TS05 | Submit order fires to KDS | Items in cart | Guest taps "Send Order" | Order status changes to SUBMITTED, KDS sees items |
| POS-TS06 | Station filter | Food + bar items in same order | Guest submits | Kitchen sees food, bar sees drinks |
| POS-TS07 | Offline menu browsing | Service worker cached | Go offline, reload | Menu categories + items visible |
| POS-TS08 | Offline submit blocked | Offline state | Guest taps "Send Order" | Button disabled, offline banner shown |
| POS-TS09 | Real-time update | Guest PWA + KDS open | Status changed in KDS | Guest PWA item status updates without refresh |
| POS-TS10 | Token validation failure | Table ID mismatch in token vs URL | Guest loads /table page | Error state shown |
Consistency Audit: table-pos-system
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Phase 4, TablePage | Uses nuqs URL state (/table?tableId=xxx&token=xxx) — CORRECT | Verified compliance with P0 rule |
| 2 | Phase 2 | Auth helper usage pattern | Added explicit requireStaffOrAdmin inline pattern with clean QueryBuilder type |
| 3 | Phase 3 | Token validation shows error state (not just 404) | Proper error UI with translation keys |
| 4 | File Map | References staffMutation/adminMutation from convex/auth.ts which don't exist | Changed to plain mutation with inline role checks |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | QR token lib | No logging needed for security-sensitive code | Clean implementation with no console usage |
| 2 | PWA page | Missing Suspense boundary | Added Suspense with TablePWALoading skeleton |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | staffMutation/adminMutation do not exist in convex/auth.ts | Foundation plan must implement these auth wrappers |
| 2 | PWA manifest icons need to be created as actual PNG files | Design team must provide 192x192 and 512x512 icons |
Schema Consistency Check
orderstable links totablesviatableId(correct)orderItemslinks toordersviaorderIdand tomenuItemsviamenuItemId(correct)- All indexes (
by_table,by_station_status,by_category) match query patterns - Token payload includes
tableId,reservationId,expiresAt— no PII stored in token