From c634237904f66f7e1d20a13a74c0beca47b3197b Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 08:16:40 -0400 Subject: [PATCH] Slice C2: online ordering + simulated checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a public takeaway flow: a themed order page (menu + qty + name/phone), a public POST that places a table-less order straight into the kitchen queue, and a token-gated confirmation. The checkout is a clearly-labelled DEMO — no real payment, no processor, no card data. - Order model (folded into the unreleased 002_orders): table_id nullable, a channel enum (dine_in/online), customer_name/phone, and a confirm_token; plus a tiny per-IP rate table. Dine-in unchanged. - Orders::placeOnline() snapshots name+price from the menu (client prices never trusted), computes the total server-side, caps qty + line count, requires a name + phone, and creates a paid 'sent' online order in one transaction. onlineForConfirmation()/confirmToken() make the confirmation non-enumerable. - Public surface (plugin-owned auth per ADR 0017): themed page /order (ADR 0023, JS-free) + POST /ext/restaurant/order, guarded by a honeypot + per-IP throttle (RateLimiter) + order-size caps. Kitchen shows online tickets as 'Online · name'; Reports counts online sales automatically. - Nav gains Order; seed adds one sample online order. Design + 3-hat + full Attacker/Defender/QA security review in docs/design/slice-c2-online-ordering.md. Tests: placeOnline (snapshot/caps/ table-less/kitchen/token) + RateLimiter; 89 plugin tests green, PHPStan L6 + cs-fixer clean. Co-Authored-By: Claude Opus 4.8 --- deploy/seed-demo.php | 26 +++- docs/design/slice-c2-online-ordering.md | 173 ++++++++++++++++++++++++ plugin/src/KitchenAdmin.php | 11 +- plugin/src/Orders.php | 154 ++++++++++++++++++--- plugin/src/RateLimiter.php | 70 ++++++++++ plugin/src/RestaurantPlugin.php | 57 ++++++++ plugin/src/Schema.php | 20 ++- plugin/templates/order-confirmed.php | 35 +++++ plugin/templates/order.php | 80 +++++++++++ plugin/tests/OrdersTest.php | 77 +++++++++++ plugin/tests/RateLimiterTest.php | 64 +++++++++ theme/assets/app.css | 55 ++++++++ theme/templates/header.php | 1 + 13 files changed, 799 insertions(+), 24 deletions(-) create mode 100644 docs/design/slice-c2-online-ordering.md create mode 100644 plugin/src/RateLimiter.php create mode 100644 plugin/templates/order-confirmed.php create mode 100644 plugin/templates/order.php create mode 100644 plugin/tests/RateLimiterTest.php diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php index d15da05..8edd5f3 100644 --- a/deploy/seed-demo.php +++ b/deploy/seed-demo.php @@ -26,6 +26,7 @@ } require $__autoload ?? throw new RuntimeException('Could not locate vendor/autoload.php'); +use DanMat\Restaurant\Menu; use DanMat\Restaurant\Orders; use DanMat\Restaurant\Reservations; use DanMat\Restaurant\Tables; @@ -33,6 +34,7 @@ use Nimbus\Auth\RoleRepository; use Nimbus\Content\CollectionRepository; use Nimbus\Content\CollectionService; +use Nimbus\Content\ContentReader; use Nimbus\Content\EntryInput; use Nimbus\Content\EntryRepository; use Nimbus\Content\EntryService; @@ -188,7 +190,10 @@ $storage = static fn (): PluginStorage => new PluginStorage($db); $tables = new Tables($storage); $reservations = new Reservations($storage, $tables); -$orders = new Orders($storage, $tables, static fn (int $id): ?array => null); +// A menu-backed snapshot so online orders (placeOnline) can look items up by id; +// the manual dine-in lines below pass name+price directly and don't use it. +$menu = new Menu(static fn (): ContentReader => new ContentReader($db, new FieldTypeRegistry())); +$orders = new Orders($storage, $tables, static fn (int $id): ?array => $menu->snapshot($id)); $t = []; foreach ([['1', 2], ['2', 4], ['3', 4], ['4', 2], ['5', 6], ['6', 2], ['Patio 1', 4], ['Patio 2', 4]] as [$label, $seats]) { @@ -215,6 +220,23 @@ $orders->addItem($paid2, null, 'Miso Soup', '3.50', 3, $now); $orders->pay($paid2, 'cash', $now); +// A takeaway order placed online (simulated checkout) — it lands in the kitchen +// queue as a "New" ticket labelled by the guest, next to the dine-in tickets. Pick +// two real menu items by id from the live menu so the snapshot resolves. +$byName = []; +foreach ($menu->items() as $mi) { + $byName[$mi['name']] = $mi['id']; +} +$onlineCart = []; +foreach (['Salsa Chicken' => 1, 'Guacamole' => 2] as $dish => $qty) { + if (isset($byName[$dish])) { + $onlineCart[] = ['menu_item_id' => $byName[$dish], 'qty' => $qty]; + } +} +if ($onlineCart !== []) { + $orders->placeOnline($onlineCart, 'Grace Hopper', '555-0148', $now); +} + // A guest in the CRM, and a reservation linked to them — floor staff see the // booking; only the manager (crm:read) can open the guest record. $crm = new Contacts($storage); @@ -222,5 +244,5 @@ $reservations->save(null, ['party_name' => 'Lovelace', 'party_size' => '4', 'reserved_at' => date('Y-m-d 19:30:00'), 'table_id' => (string) $t['5'], 'contact_id' => (string) $contactId, 'notes' => 'Window seat.'], $now); $reservations->save(null, ['party_name' => 'Turing', 'party_size' => '2', 'reserved_at' => date('Y-m-d 20:00:00')], $now); -echo " floor: 8 tables (2 occupied, 2 dirty/just-paid, 1 reserved, 3 open), 2 open orders, 2 paid, 2 reservations, 1 CRM guest\n"; +echo " floor: 8 tables (2 occupied, 2 dirty/just-paid, 1 reserved, 3 open), 2 open orders, 2 paid, 1 online order, 2 reservations, 1 CRM guest\n"; echo "Done. Demo password for every staff login: {$demoPassword}\n"; diff --git a/docs/design/slice-c2-online-ordering.md b/docs/design/slice-c2-online-ordering.md new file mode 100644 index 0000000..d69a55a --- /dev/null +++ b/docs/design/slice-c2-online-ordering.md @@ -0,0 +1,173 @@ +# Slice C2 — Online ordering + simulated checkout + +**Status:** design (pre-build) · **Branch:** `nimbus-rebuild` · **Scope chosen by Dan:** +*simulated checkout* — a public menu → cart → checkout that places a real order into +the kitchen queue, with a clearly-labelled **fake** payment (no real money, no +processor, no card capture). + +This is the first **public write surface** in the app, so it carries a full security +review (public `/ext` routes have no admin auth and no automatic CSRF — ADR 0017). + +## Why / what + +The 2014 original was staff-only. Modern guests order takeaway online. Add a public +ordering page where a guest picks dishes, leaves a name + phone, and "pays" (demo); +the order drops straight into the **existing kitchen display** as a new ticket. + +**In scope:** pickup/takeaway only; a server-rendered cart (no JS required); a +simulated "Pay" that marks the order paid and sends it to the kitchen; a +confirmation page with an order number. + +**Out of scope:** real payments/processor (**forbidden** — see Security), delivery, +accounts/logins for guests, order tracking beyond the confirmation page, editing a +placed order. + +## Order model (fold into the unreleased `002_orders` migration) + +`rest_order` is table-centric (`table_id NOT NULL`, no channel/customer). Following +this repo's established pattern (the payment columns were folded into `002_orders` +while unreleased), extend `002_orders` — every deploy re-migrates from an empty DB, +so `CREATE TABLE IF NOT EXISTS` stays idempotent and no fragile `ALTER` is needed: + +- `table_id BIGINT UNSIGNED NULL` (was NOT NULL) — an online order has no table. +- `channel ENUM('dine_in','online') NOT NULL DEFAULT 'dine_in'`. +- `customer_name VARCHAR(120) NULL`, `customer_phone VARCHAR(40) NULL` — online only. + +Dine-in orders are unaffected (channel defaults to `dine_in`, table_id still set). + +## Orders service + +Add one method, mirroring the discipline of `open()`/`addItem()`/`pay()`: + +``` +placeOnline(array $cart, string $name, string $phone, string $now): array +``` + +- `$cart` = `[ [menu_item_id, qty], … ]` from the request. For each line the service + **snapshots name + unit price from the menu** via `Menu::snapshot($id)` (ADR 0029) + — **client prices are never trusted**; an unknown/​unpublished id is dropped. +- Caps: qty per line 1..`MAX_QTY` (999, existing); at most `MAX_ONLINE_LINES` (=40) + distinct lines; empty cart → rejected. +- Creates a **table-less** `rest_order` (`channel='online'`, `table_id=NULL`, + `status='sent'`, `paid=1`, `amount_paid`=computed total, `payment_method='online-demo'`, + customer name/phone), inserts the snapshotted lines, all in one transaction. It does + **not** touch table state (there is no table). Returns the created order. +- Total is always computed server-side from the snapshotted lines (never posted). + +`status='sent'` means the order appears immediately in the kitchen **New** column +next to dine-in tickets. `payment_method='online-demo'` makes the simulation obvious +in the data and in Reports. + +## Public route (ADR 0017) — `/ext/restaurant` + +- `GET /ext/restaurant/order` — the ordering page: the live menu grouped by category, + each item with a **qty number input** (0..99), a **name** + **phone** field, and a + **"Place order · Pay (demo)"** button. A visible banner: *"Demo checkout — no real + payment is taken."* **No card fields exist.** Rendered by the theme + (`order.php`), fully server-side — **no JavaScript required**, so no inline script + and no public-page CSP concern. +- `POST /ext/restaurant/order` — reads the qty inputs, builds the cart, calls + `placeOnline()`, redirects to the confirmation page with the new order id. +- `GET /ext/restaurant/order/{id}/confirmed` — a confirmation: order number, the + itemised lines, total, and "we're preparing it" — reads only that order, shows no + other order's data, and never lists orders (non-enumerating beyond the id given). + +Header/nav gains an **Order** link. + +## Kitchen / Reports integration + +- `KitchenAdmin` tickets show `table_label`; for an online order (null table) show + **"Online · {customer_name}"** instead — escape-on-render. `ticketsByStatus()` and + `get()` already return the row; add the channel/customer fields and a null-safe + label. Cooks advance online tickets exactly like dine-in (New → Preparing → Ready). +- Reports already sum `amount_paid` over paid orders, so online sales count in + revenue automatically (correct). No Reports change required beyond it continuing to + work with a null table. + +## Security review (Attacker / Defender / QA) — the crux + +Public, unauthenticated **write**. Reviewed hard. + +### 🔴 Attacker → ⚪ Defender (control · severity) + +1. **Price / total tampering** — post cheaper prices or a fake total. + → Server **snapshots unit price from the published menu** (`Menu::snapshot`) and + **computes** the total; the request carries only `menu_item_id` + `qty`. Client + price/total are ignored entirely. **Mitigated. (would be High if trusted.)** +2. **Unknown / unpublished item injection** — order a draft or arbitrary id. + → `Menu::snapshot` returns null for anything not live-published; such lines are + dropped. **Mitigated.** +3. **Over-posting / oversized order (DoS)** — 10⁶ qty, thousands of lines. + → qty clamped 1..999 per line; ≤40 distinct lines; empty → rejected; name/phone + length-capped (120/40) and truncated. Body size bounded by the platform. **Medium + → mitigated.** +4. **SQLi** — via ids/qty/name/phone. + → All writes are parameter-bound (existing `Orders` discipline); ids/qtys cast to + int; channel is a write-time enum literal, never interpolated. **Mitigated.** +5. **Stored XSS** — `customer_name`/`phone` shown to staff (kitchen) and on the + confirmation page. + → Escape-on-render everywhere (`View::e` in kitchen ticket + theme confirmation); + stored raw, escaped out. **Mitigated (High if not).** +6. **Spam / abuse (no CSRF on a public route)** — scripted flood of fake orders. + → This is not classic CSRF (no auth/session to ride). Controls: a **per-IP + throttle** (N orders/minute via plugin storage), a **honeypot** hidden field + (bots fill it → silently dropped), the order-size caps above, the hourly reset, + and Cloudflare in front. Proportionate for a demo. **Medium → mitigated; residual + accepted (demo, resets hourly).** +7. **PII** — how much guest data, where. + → Only an optional name + phone, on the order row, escaped on render, wiped every + hour by the reset. **No card/email/address.** **Low.** +8. **Real-payment misuse** — could this take money? + → **No.** There is no processor, no card field, no money movement — a labelled + simulation that only sets `paid=1, payment_method='online-demo'`. Building real + payment is explicitly **out of scope and prohibited** for me to wire. **n/a.** + +### 🟢 QA / permanence (regression tests) + +- `placeOnline` **snapshots menu price, ignoring a posted price**; computes the total. +- unknown/unpublished id **dropped**; empty cart **rejected**; qty and line-count + **clamped**. +- creates a **table-less** order (`channel='online'`, `table_id NULL`, `status='sent'`, + `paid=1`) that **appears in `ticketsByStatus(['sent'])`** — i.e. reaches the kitchen. +- kitchen ticket + confirmation **escape** a hostile `customer_name`. +- per-IP throttle refuses the (N+1)th order in the window; honeypot-filled request is + dropped. +- **Merge bar:** no Critical/High left open; all High-potentials (price tamper, XSS) + have a control + a failing-first test. Security-green to build. + +## Three-hat platform review + +**🧑‍💼 Product** — A real, common capability (online takeaway) that showcases the +platform end-to-end (public write → domain service → kitchen → reports). Demo-honest +(simulated pay). Doesn't distort the CMS. ✅ + +**🏗️ Architect** — Classification: **app plugin + theme** (the plugin owns the public +route + service + schema; the theme renders). Reuses ADR 0017 (public routes), ADR +0029 (menu read), the existing Orders/kitchen/reports. **No core change** — the +public-route, content-read, and view seams already exist. The order-model extension +is folded into the unreleased migration (no new hinge). Smallest cut that delivers +the loop. One watch-item: public write surfaces are new for this app — hence the full +security pass and the throttle. ✅ + +**👷 Principal engineer** — Server-computed totals, bound SQL, enum allow-lists, +escape-on-render, transactional insert — all carried from the dine-in Orders code. +JS-free page keeps the CSP surface nil. Testable service (`placeOnline` is +DB-backed, faked menu). Mobile: the order form at 375px (qty inputs, sticky total). +✅ + +## Decisions (confirmed by Dan 2026-09-06) + +1. **Guest details** — **name AND phone both required** (validated non-empty, + length-capped 120/40, escaped on render). +2. **Anti-abuse** — **per-IP throttle + honeypot + order-size caps** (no extra + confirm step); proportionate for a demo that resets hourly behind Cloudflare. + +## Definition of done + +- `002_orders` extended (nullable table, channel, customer fields); dine-in unchanged. +- `Orders::placeOnline()` with snapshotting, caps, transaction; unit-tested. +- Public `/ext/restaurant/order` (GET form + POST place + confirmation), JS-free, + throttled + honeypot; theme `order.php` + confirmation, responsive @375px. +- Kitchen shows online tickets ("Online · name"); Reports counts online sales. +- Header nav gains **Order**; seed adds a couple of sample online orders (optional). +- Security regression tests green; PHPStan L6 + cs-fixer + plugin CI green. diff --git a/plugin/src/KitchenAdmin.php b/plugin/src/KitchenAdmin.php index 043ad03..b548853 100644 --- a/plugin/src/KitchenAdmin.php +++ b/plugin/src/KitchenAdmin.php @@ -97,8 +97,15 @@ private function ticket(string $csrf, array $t, array $col): string . ''; } - return '
' - . '
' . self::e((string) ($t['table_label'] ?? '—')) . '' + // An online (takeaway) order has no table — label it by channel + the guest + // name the cook calls out, instead of a table number. + $isOnline = ($t['channel'] ?? 'dine_in') === 'online'; + $where = $isOnline + ? '🛍 Online · ' . self::e((string) ($t['customer_name'] ?? 'Guest')) + : self::e((string) ($t['table_label'] ?? '—')); + + return '
' + . '
' . $where . '' . '#' . self::e((string) $t['id']) . ' · ' . self::e($this->age((string) $t['updated_at'])) . '
' . '
    ' . $lines . '
' . $advance diff --git a/plugin/src/Orders.php b/plugin/src/Orders.php index 134a069..8ba0b25 100644 --- a/plugin/src/Orders.php +++ b/plugin/src/Orders.php @@ -32,6 +32,9 @@ final class Orders private const MAX_NAME = 200; private const MAX_QTY = 999; + /** Most distinct lines a single online order may carry (anti-abuse cap). */ + public const MAX_ONLINE_LINES = 40; + /** * @param \Closure():PluginStorage $storage resolved lazily * @param \Closure(int):(array{name:string,price:string}|null) $snapshot menu-item resolver @@ -63,15 +66,118 @@ public function open(int $tableId, string $now): int }); } + /** + * Place an ONLINE (takeaway) order: a table-less order that goes straight to the + * kitchen queue as already paid — a SIMULATED checkout (no real payment, no + * processor, no card data; `payment_method` is `online-demo`). The client sends + * only `{menu_item_id, qty}` per line; the **name and unit price are snapshotted + * from the published menu** (ADR 0029) and the **total is computed server-side**, + * so a client can never dictate prices or the amount. Unknown/unpublished items + * are dropped; qty and line-count are capped; a name and phone are required. + * + * @param list $cart + * @return array{id:int,table_id:?int,channel:string,customer_name:?string,customer_phone:?string,table_label:?string,status:string,paid:bool,amount_paid:?string,payment_method:?string,paid_at:?string,items:list,total:string,created_at:string,updated_at:string} + */ + public function placeOnline(array $cart, string $name, string $phone, string $now): array + { + $name = trim($name); + $phone = trim($phone); + if ($name === '' || $phone === '') { + throw new \InvalidArgumentException('A name and a phone number are required.'); + } + $name = mb_substr($name, 0, 120); + $phone = mb_substr($phone, 0, 40); + + // Build the lines from the menu — snapshot name + price, never trust the + // client's prices; drop anything not live-published; cap qty and line count. + $lines = []; + foreach ($cart as $entry) { + if (count($lines) >= self::MAX_ONLINE_LINES) { + break; + } + $menuItemId = (int) $entry['menu_item_id']; + $qty = (int) $entry['qty']; + if ($menuItemId <= 0 || $qty < 1) { + continue; + } + $qty = min($qty, self::MAX_QTY); + $snap = ($this->snapshot)($menuItemId); + if ($snap === null) { + continue; // unknown or unpublished menu item — dropped + } + $lines[] = ['menu_item_id' => $menuItemId, 'name' => $snap['name'], 'price' => $snap['price'], 'qty' => $qty]; + } + if ($lines === []) { + throw new \InvalidArgumentException('Your order is empty.'); + } + + $total = 0.0; + foreach ($lines as $line) { + $total += (float) $line['price'] * $line['qty']; + } + $amount = number_format($total, 2, '.', ''); + + // A random confirmation token so the confirmation page is not enumerable by + // order id — a visitor can only see the order they just placed. + $token = bin2hex(random_bytes(8)); + + $orderId = (int) $this->storage()->transaction(function () use ($lines, $name, $phone, $amount, $token, $now): int { + $id = $this->storage()->insert( + 'INSERT INTO ' . Schema::ORDER . ' (table_id, channel, customer_name, customer_phone, confirm_token, status, paid, amount_paid, payment_method, paid_at, created_at, updated_at) + VALUES (NULL, :channel, :cname, :cphone, :token, :status, 1, :amount, :method, :paid_at, :created, :updated)', + ['channel' => 'online', 'cname' => $name, 'cphone' => $phone, 'token' => $token, 'status' => 'sent', 'amount' => $amount, 'method' => 'online-demo', 'paid_at' => $now, 'created' => $now, 'updated' => $now], + ); + foreach ($lines as $line) { + $this->storage()->insert( + 'INSERT INTO ' . Schema::ORDER_ITEM . ' (order_id, menu_item_id, name, unit_price, qty, created_at) + VALUES (:order, :menu, :name, :price, :qty, :created)', + ['order' => $id, 'menu' => $line['menu_item_id'], 'name' => $line['name'], 'price' => $line['price'], 'qty' => $line['qty'], 'created' => $now], + ); + } + return $id; + }); + + $order = $this->get($orderId); + assert($order !== null); + return $order; + } + + /** The confirmation token for an order (for building its confirmation URL), or null. */ + public function confirmToken(int $orderId): ?string + { + $row = $this->storage()->selectOne( + 'SELECT confirm_token FROM ' . Schema::ORDER . ' WHERE id = :id', + ['id' => $orderId], + ); + return $row === null || ($row['confirm_token'] ?? null) === null ? null : (string) $row['confirm_token']; + } + + /** + * One ONLINE order for its confirmation page — returned only when the order is + * online AND the supplied token matches (constant-time), so the page cannot be + * enumerated by id and never reveals a dine-in or another guest's order. + * + * @return array{id:int,table_id:?int,channel:string,customer_name:?string,customer_phone:?string,table_label:?string,status:string,paid:bool,amount_paid:?string,payment_method:?string,paid_at:?string,items:list,total:string,created_at:string,updated_at:string}|null + */ + public function onlineForConfirmation(int $orderId, string $token): ?array + { + $expected = $this->confirmToken($orderId); + if ($expected === null || $token === '' || !hash_equals($expected, $token)) { + return null; + } + $order = $this->get($orderId); + return ($order !== null && $order['channel'] === 'online') ? $order : null; + } + /** * One order with its line items and computed total, or null. * - * @return array{id:int,table_id:int,table_label:?string,status:string,paid:bool,amount_paid:?string,payment_method:?string,paid_at:?string,items:list,total:string,created_at:string,updated_at:string}|null + * @return array{id:int,table_id:?int,channel:string,customer_name:?string,customer_phone:?string,table_label:?string,status:string,paid:bool,amount_paid:?string,payment_method:?string,paid_at:?string,items:list,total:string,created_at:string,updated_at:string}|null */ public function get(int $id): ?array { $row = $this->storage()->selectOne( - 'SELECT o.id, o.table_id, o.status, o.paid, o.amount_paid, o.payment_method, o.paid_at, o.created_at, o.updated_at, t.label AS table_label + 'SELECT o.id, o.table_id, o.channel, o.customer_name, o.customer_phone, o.status, o.paid, o.amount_paid, o.payment_method, o.paid_at, o.created_at, o.updated_at, t.label AS table_label FROM ' . Schema::ORDER . ' o LEFT JOIN ' . Schema::TABLE . ' t ON t.id = o.table_id WHERE o.id = :id', ['id' => $id], @@ -88,7 +194,7 @@ public function get(int $id): ?array * table, each with its computed total and item count (no line items — get() has * those). Most-recently-updated first. * - * @return list + * @return list */ public function all(?string $status = null, ?int $tableId = null): array { @@ -103,7 +209,7 @@ public function all(?string $status = null, ?int $tableId = null): array $params['table'] = $tableId; } - $sql = 'SELECT o.id, o.table_id, o.status, o.paid, o.amount_paid, o.payment_method, o.created_at, o.updated_at, t.label AS table_label, + $sql = 'SELECT o.id, o.table_id, o.channel, o.customer_name, o.status, o.paid, o.amount_paid, o.payment_method, o.created_at, o.updated_at, t.label AS table_label, (SELECT COALESCE(SUM(i.unit_price * i.qty), 0) FROM ' . Schema::ORDER_ITEM . ' i WHERE i.order_id = o.id) AS total, (SELECT COUNT(*) FROM ' . Schema::ORDER_ITEM . ' i WHERE i.order_id = o.id) AS item_count FROM ' . Schema::ORDER . ' o LEFT JOIN ' . Schema::TABLE . ' t ON t.id = o.table_id'; @@ -115,7 +221,9 @@ public function all(?string $status = null, ?int $tableId = null): array return array_map(function (array $r): array { return [ 'id' => (int) $r['id'], - 'table_id' => (int) $r['table_id'], + 'table_id' => ($r['table_id'] ?? null) === null ? null : (int) $r['table_id'], + 'channel' => (string) ($r['channel'] ?? 'dine_in'), + 'customer_name' => ($r['customer_name'] ?? null) === null ? null : (string) $r['customer_name'], 'table_label' => ($r['table_label'] ?? null) === null ? null : (string) $r['table_label'], 'status' => (string) $r['status'], 'paid' => (bool) $r['paid'], @@ -135,7 +243,7 @@ public function all(?string $status = null, ?int $tableId = null): array * (orders, then all their items), never N+1. An unknown status is ignored. * * @param list $statuses - * @return list,created_at:string,updated_at:string}> + * @return list,created_at:string,updated_at:string}> */ public function ticketsByStatus(array $statuses): array { @@ -151,7 +259,7 @@ public function ticketsByStatus(array $statuses): array $params['s' . $i] = $status; } $orders = $this->storage()->select( - 'SELECT o.id, o.table_id, o.status, o.created_at, o.updated_at, t.label AS table_label + 'SELECT o.id, o.table_id, o.channel, o.customer_name, o.status, o.created_at, o.updated_at, t.label AS table_label FROM ' . Schema::ORDER . ' o LEFT JOIN ' . Schema::TABLE . ' t ON t.id = o.table_id WHERE o.status IN (' . implode(', ', $placeholders) . ') ORDER BY o.updated_at ASC, o.id ASC', $params, @@ -179,13 +287,15 @@ public function ticketsByStatus(array $statuses): array return array_map(static function (array $r) use ($byOrder): array { $id = (int) $r['id']; return [ - 'id' => $id, - 'table_id' => (int) $r['table_id'], - 'table_label' => ($r['table_label'] ?? null) === null ? null : (string) $r['table_label'], - 'status' => (string) $r['status'], - 'items' => $byOrder[$id] ?? [], - 'created_at' => (string) $r['created_at'], - 'updated_at' => (string) $r['updated_at'], + 'id' => $id, + 'table_id' => ($r['table_id'] ?? null) === null ? null : (int) $r['table_id'], + 'channel' => (string) ($r['channel'] ?? 'dine_in'), + 'customer_name' => ($r['customer_name'] ?? null) === null ? null : (string) $r['customer_name'], + 'table_label' => ($r['table_label'] ?? null) === null ? null : (string) $r['table_label'], + 'status' => (string) $r['status'], + 'items' => $byOrder[$id] ?? [], + 'created_at' => (string) $r['created_at'], + 'updated_at' => (string) $r['updated_at'], ]; }, $orders); } @@ -197,7 +307,7 @@ public function ticketsByStatus(array $statuses): array * Marks the order paid + closed, and sets its table `dirty` for bussing — all in * one transaction. Returns the settled order. * - * @return array{id:int,table_id:int,table_label:?string,status:string,paid:bool,amount_paid:?string,payment_method:?string,paid_at:?string,items:list,total:string,created_at:string,updated_at:string} + * @return array{id:int,table_id:?int,channel:string,customer_name:?string,customer_phone:?string,table_label:?string,status:string,paid:bool,amount_paid:?string,payment_method:?string,paid_at:?string,items:list,total:string,created_at:string,updated_at:string} */ public function pay(int $orderId, string $method, string $now): array { @@ -219,8 +329,11 @@ public function pay(int $orderId, string $method, string $now): array 'UPDATE ' . Schema::ORDER . ' SET paid = 1, amount_paid = :amount, payment_method = :method, paid_at = :now, status = :status, updated_at = :now2 WHERE id = :id', ['amount' => $amount, 'method' => $method, 'now' => $now, 'status' => 'closed', 'now2' => $now, 'id' => $orderId], ); - // Turn the table over: it now needs bussing before the next party. - $this->tables->setStatus($order['table_id'], 'dirty', $now); + // Turn the table over: it now needs bussing before the next party. An + // online order has no table, so there is nothing to turn. + if ($order['table_id'] !== null) { + $this->tables->setStatus($order['table_id'], 'dirty', $now); + } }); $settled = $this->get($orderId); @@ -341,7 +454,7 @@ private function items(int $orderId): array /** * @param array $row * @param list $items - * @return array{id:int,table_id:int,table_label:?string,status:string,paid:bool,amount_paid:?string,payment_method:?string,paid_at:?string,items:list,total:string,created_at:string,updated_at:string} + * @return array{id:int,table_id:?int,channel:string,customer_name:?string,customer_phone:?string,table_label:?string,status:string,paid:bool,amount_paid:?string,payment_method:?string,paid_at:?string,items:list,total:string,created_at:string,updated_at:string} */ private function hydrate(array $row, array $items): array { @@ -351,7 +464,10 @@ private function hydrate(array $row, array $items): array } return [ 'id' => (int) $row['id'], - 'table_id' => (int) $row['table_id'], + 'table_id' => ($row['table_id'] ?? null) === null ? null : (int) $row['table_id'], + 'channel' => (string) ($row['channel'] ?? 'dine_in'), + 'customer_name' => ($row['customer_name'] ?? null) === null ? null : (string) $row['customer_name'], + 'customer_phone' => ($row['customer_phone'] ?? null) === null ? null : (string) $row['customer_phone'], 'table_label' => ($row['table_label'] ?? null) === null ? null : (string) $row['table_label'], 'status' => (string) $row['status'], 'paid' => (bool) $row['paid'], diff --git a/plugin/src/RateLimiter.php b/plugin/src/RateLimiter.php new file mode 100644 index 0000000..fb12421 --- /dev/null +++ b/plugin/src/RateLimiter.php @@ -0,0 +1,70 @@ +storage()->transaction(function () use ($ip, $now): bool { + $row = $this->storage()->selectOne( + 'SELECT window_start, count FROM ' . Schema::ORDER_RATE . ' WHERE ip = :ip', + ['ip' => $ip], + ); + $nowTs = strtotime($now) ?: time(); + if ($row === null) { + $this->storage()->execute( + 'INSERT INTO ' . Schema::ORDER_RATE . ' (ip, window_start, count) VALUES (:ip, :ws, 1)', + ['ip' => $ip, 'ws' => $now], + ); + return true; + } + $windowTs = strtotime((string) $row['window_start']) ?: 0; + if (($nowTs - $windowTs) >= $this->windowSeconds) { + $this->storage()->execute( + 'UPDATE ' . Schema::ORDER_RATE . ' SET window_start = :ws, count = 1 WHERE ip = :ip', + ['ws' => $now, 'ip' => $ip], + ); + return true; + } + if ((int) $row['count'] >= $this->limit) { + return false; + } + $this->storage()->execute( + 'UPDATE ' . Schema::ORDER_RATE . ' SET count = count + 1 WHERE ip = :ip', + ['ip' => $ip], + ); + return true; + }); + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/plugin/src/RestaurantPlugin.php b/plugin/src/RestaurantPlugin.php index c7c04e1..c51c4f4 100644 --- a/plugin/src/RestaurantPlugin.php +++ b/plugin/src/RestaurantPlugin.php @@ -9,6 +9,7 @@ use Nimbus\Plugin\Plugin; use Nimbus\Plugin\PluginContext; use Nimbus\Plugin\PluginStorage; +use Nimbus\Site\PageView; /** * The Restaurant Management System, as a NimbusCMS plugin — the application's own @@ -62,6 +63,62 @@ public function register(PluginContext $context): void // home page only, visitor-independent (cache-safe); the theme escapes it. $context->viewData()->register(new HomeViewData($menu)); + // --- Online ordering (Slice C2) ------------------------------------- + // A public, themed order page (ADR 0023) + a public POST action (ADR 0017) + // that places a table-less order into the kitchen with a SIMULATED payment. + // Public write surface: prices are snapshotted server-side (never trusted), + // the checkout is a labelled demo (no real money/card), and the endpoint is + // guarded by a honeypot + per-IP throttle + order-size caps. + $rate = new RateLimiter($storage); + + $context->pages()->register('order', static function (Request $r) use ($menu, $orders): PageView { + // Confirmation: /order?placed=&t= — token-gated, non-enumerable. + $placed = trim((string) ($r->query('placed') ?? '')); + $token = (string) ($r->query('t') ?? ''); + if ($placed !== '' && ctype_digit($placed)) { + $order = $orders->onlineForConfirmation((int) $placed, $token); + if ($order !== null) { + return new PageView('order-confirmed', ['order' => $order], ['title' => 'Order confirmed'], 200, true); + } + } + return new PageView('order', [ + 'items' => $menu->items(), + 'error' => (string) ($r->query('err') ?? ''), + ], ['title' => 'Order online']); + }, __DIR__ . '/../templates'); + + $context->routes()->post('restaurant', '/order', static function (Request $r) use ($orders, $rate): Response { + // Honeypot: a hidden field only a bot fills — quietly bounce it. + if (trim((string) ($r->input('website') ?? '')) !== '') { + return Response::redirect('/order?err=try_again'); + } + $now = date('Y-m-d H:i:s'); + if (!$rate->allow($r->ip(), $now)) { + return Response::redirect('/order?err=slow_down'); + } + $rawQty = $r->all()['qty'] ?? []; + $cart = []; + if (is_array($rawQty)) { + foreach ($rawQty as $mid => $q) { + $mid = (int) $mid; + $q = (int) $q; + if ($mid > 0 && $q > 0) { + $cart[] = ['menu_item_id' => $mid, 'qty' => $q]; + } + } + } + try { + $order = $orders->placeOnline($cart, (string) ($r->input('name') ?? ''), (string) ($r->input('phone') ?? ''), $now); + $token = (string) $orders->confirmToken($order['id']); + return Response::redirect('/order?placed=' . $order['id'] . '&t=' . urlencode($token)); + } catch (\InvalidArgumentException $e) { + $code = str_contains($e->getMessage(), 'empty') ? 'empty' : 'invalid'; + return Response::redirect('/order?err=' . $code); + } catch (\Throwable) { + return Response::redirect('/order?err=invalid'); + } + }); + // The floor board. A staff terminal is a capability-gated ADMIN PAGE, never a // public plugin route (routes carry no auth/CSRF). Gated on :write; the handler // gets the CSP nonce (2nd arg) and a CSRF token (3rd arg). diff --git a/plugin/src/Schema.php b/plugin/src/Schema.php index 1d55482..ef73bfb 100644 --- a/plugin/src/Schema.php +++ b/plugin/src/Schema.php @@ -19,6 +19,7 @@ final class Schema public const TABLE = 'rest_table'; public const ORDER = 'rest_order'; public const ORDER_ITEM = 'rest_order_item'; + public const ORDER_RATE = 'rest_order_rate'; public const RESERVATION = 'rest_reservation'; /** @return list each statement individually idempotent (ADR 0005) */ @@ -52,9 +53,17 @@ public static function tables(): array public static function orders(): array { return [ + // `table_id` is NULL for an online order (no table); `channel` + // distinguishes dine-in from online, and `customer_*` carry the online + // guest's contact for the kitchen to call the order out. (Folded into + // this unreleased migration — every deploy migrates from an empty DB.) 'CREATE TABLE IF NOT EXISTS ' . self::ORDER . " ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - table_id BIGINT UNSIGNED NOT NULL, + table_id BIGINT UNSIGNED NULL, + channel ENUM('dine_in','online') NOT NULL DEFAULT 'dine_in', + customer_name VARCHAR(120) NULL, + customer_phone VARCHAR(40) NULL, + confirm_token VARCHAR(32) NULL, status ENUM('open','sent','preparing','ready','served','closed') NOT NULL DEFAULT 'open', paid TINYINT(1) NOT NULL DEFAULT 0, amount_paid DECIMAL(10,2) NULL, @@ -76,6 +85,15 @@ public static function orders(): array created_at DATETIME NOT NULL, INDEX idx_item_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + + // A tiny per-IP fixed-window counter for the public online-order + // endpoint (anti-spam). One row per client IP; the window resets when + // it expires. Not PII of value — an IP + a count, wiped by the reset. + 'CREATE TABLE IF NOT EXISTS ' . self::ORDER_RATE . ' ( + ip VARCHAR(45) NOT NULL PRIMARY KEY, + window_start DATETIME NOT NULL, + count INT UNSIGNED NOT NULL DEFAULT 0 + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', ]; } diff --git a/plugin/templates/order-confirmed.php b/plugin/templates/order-confirmed.php new file mode 100644 index 0000000..681f1ae --- /dev/null +++ b/plugin/templates/order-confirmed.php @@ -0,0 +1,35 @@ +,total:string} $order + * @var callable $e escape a value for output + */ +$name = trim((string) ($order['customer_name'] ?? '')); +?> +
+
+ + +
    + +
  • + × + + £ +
  • + +
+

Total (paid — demo)£

+ +

Back to the menu · Place another order

+
+
diff --git a/plugin/templates/order.php b/plugin/templates/order.php new file mode 100644 index 0000000..e3e53aa --- /dev/null +++ b/plugin/templates/order.php @@ -0,0 +1,80 @@ + $items + * @var string $error an error code from a rejected submission ('' if none) + * @var callable $e escape a value for output + */ +$groups = []; +foreach ($items as $it) { + $cat = ($it['category'] ?? null) !== null && $it['category'] !== '' ? (string) $it['category'] : 'More'; + $groups[$cat][] = $it; +} +$messages = [ + 'empty' => 'Your order was empty — add a dish or two, then place it again.', + 'invalid' => 'Something wasn’t right with that order — please try again.', + 'slow_down' => 'That’s a lot of orders very quickly — give it a moment and try again.', + 'try_again' => 'Please try that again.', +]; +$errorMsg = $error !== '' ? ($messages[$error] ?? $messages['try_again']) : ''; +?> +
+
+ + + + + + +
+ + + + $rows): ?> +
+

+
    + +
  • +
    + + £ +
    + +
  • + +
+
+ + +
+

Your details

+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/plugin/tests/OrdersTest.php b/plugin/tests/OrdersTest.php index d53bb95..3acc334 100644 --- a/plugin/tests/OrdersTest.php +++ b/plugin/tests/OrdersTest.php @@ -244,4 +244,81 @@ public function test_all_filters_by_status_and_table(): void self::assertCount(1, $this->orders->all('served')); self::assertCount(1, $this->orders->all(null, $t2)); } + + // --- online ordering (Slice C2) -------------------------------------- + + public function test_place_online_is_table_less_paid_and_snapshots_prices(): void + { + // Client sends only ids + qty; the price is snapshotted, the total computed. + $order = $this->orders->placeOnline( + [['menu_item_id' => 101, 'qty' => 2], ['menu_item_id' => 102, 'qty' => 1]], + 'Grace Hopper', + '555-0148', + self::NOW, + ); + + self::assertSame('online', $order['channel']); + self::assertNull($order['table_id'], 'an online order has no table'); + self::assertSame('sent', $order['status'], 'it lands straight in the kitchen queue'); + self::assertTrue($order['paid']); + self::assertSame('online-demo', $order['payment_method']); + self::assertSame('Grace Hopper', $order['customer_name']); + self::assertSame('28.50', $order['total'], '2×12.50 + 1×3.50, computed server-side'); + } + + public function test_place_online_ignores_a_posted_price(): void + { + // A tampered price in the request is irrelevant — only id + qty are read. + $order = $this->orders->placeOnline( + [['menu_item_id' => 101, 'qty' => 1, 'price' => '0.01']], + 'Mallory', + '555-0000', + self::NOW, + ); + self::assertSame('12.50', $order['total'], 'the menu price wins, not the posted one'); + } + + public function test_place_online_drops_unknown_items_and_rejects_an_empty_cart(): void + { + $order = $this->orders->placeOnline( + [['menu_item_id' => 999999, 'qty' => 3], ['menu_item_id' => 101, 'qty' => 1]], + 'Ada', + '555-1', + self::NOW, + ); + self::assertCount(1, $order['items'], 'the unknown item is dropped'); + + $this->expectException(\InvalidArgumentException::class); + $this->orders->placeOnline([['menu_item_id' => 999999, 'qty' => 1]], 'Ada', '555-1', self::NOW); + } + + public function test_place_online_requires_a_name_and_phone(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->orders->placeOnline([['menu_item_id' => 101, 'qty' => 1]], ' ', '555-1', self::NOW); + } + + public function test_online_order_reaches_the_kitchen_queue_with_its_label(): void + { + $this->orders->placeOnline([['menu_item_id' => 101, 'qty' => 1]], 'Grace Hopper', '555-0148', self::NOW); + + $tickets = $this->orders->ticketsByStatus(['sent']); + self::assertCount(1, $tickets); + self::assertSame('online', $tickets[0]['channel']); + self::assertSame('Grace Hopper', $tickets[0]['customer_name']); + self::assertNull($tickets[0]['table_id']); + } + + public function test_online_confirmation_is_token_gated(): void + { + $order = $this->orders->placeOnline([['menu_item_id' => 101, 'qty' => 1]], 'Grace', '555-0148', self::NOW); + $token = $this->orders->confirmToken($order['id']); + self::assertNotNull($token); + + self::assertNull($this->orders->onlineForConfirmation($order['id'], 'wrong-token'), 'a wrong token reveals nothing'); + self::assertNull($this->orders->onlineForConfirmation($order['id'], ''), 'an empty token reveals nothing'); + $ok = $this->orders->onlineForConfirmation($order['id'], $token); + self::assertNotNull($ok); + self::assertSame($order['id'], $ok['id']); + } } diff --git a/plugin/tests/RateLimiterTest.php b/plugin/tests/RateLimiterTest.php new file mode 100644 index 0000000..4efdd98 --- /dev/null +++ b/plugin/tests/RateLimiterTest.php @@ -0,0 +1,64 @@ + getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach (Schema::orders() as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::ORDER_RATE); + + $storage = new PluginStorage($db); + $this->rate = new RateLimiter(static fn (): PluginStorage => $storage, 2, 60); + } + + public function test_it_allows_up_to_the_limit_then_refuses(): void + { + $now = '2026-01-01 12:00:00'; + self::assertTrue($this->rate->allow('1.2.3.4', $now), 'first is allowed'); + self::assertTrue($this->rate->allow('1.2.3.4', $now), 'second is allowed'); + self::assertFalse($this->rate->allow('1.2.3.4', $now), 'third within the window is refused'); + } + + public function test_a_different_ip_has_its_own_budget(): void + { + $now = '2026-01-01 12:00:00'; + $this->rate->allow('1.2.3.4', $now); + $this->rate->allow('1.2.3.4', $now); + self::assertFalse($this->rate->allow('1.2.3.4', $now)); + self::assertTrue($this->rate->allow('5.6.7.8', $now), 'a different IP is not throttled'); + } + + public function test_the_window_resets_after_it_expires(): void + { + self::assertTrue($this->rate->allow('1.2.3.4', '2026-01-01 12:00:00')); + self::assertTrue($this->rate->allow('1.2.3.4', '2026-01-01 12:00:00')); + self::assertFalse($this->rate->allow('1.2.3.4', '2026-01-01 12:00:30'), 'still within the window'); + self::assertTrue($this->rate->allow('1.2.3.4', '2026-01-01 12:01:05'), 'a new window allows again'); + } +} diff --git a/theme/assets/app.css b/theme/assets/app.css index ab640ea..304cd76 100644 --- a/theme/assets/app.css +++ b/theme/assets/app.css @@ -150,6 +150,61 @@ img { max-width: 100%; height: auto; } .prose { color: var(--text); } .notfound { text-align: center; } +/* Online ordering */ +.order-page { padding: 3rem 0 4rem; } +.demo-note { + display: inline-block; margin: 1rem auto 0; padding: .5rem .9rem; + background: rgba(212, 160, 23, 0.12); border: 1px solid rgba(212, 160, 23, 0.35); + border-radius: 8px; color: var(--gold-bright); font-size: .9rem; +} +.order-error { + max-width: 46rem; margin: 0 auto 1.5rem; padding: .7rem 1rem; text-align: center; + background: rgba(192, 57, 43, 0.15); border: 1px solid rgba(192, 57, 43, 0.5); + border-radius: 8px; color: #f2b8b1; +} +.order-form { max-width: 40rem; margin: 0 auto; } +.hp { position: absolute; left: -5000px; width: 1px; height: 1px; overflow: hidden; } + +.order-group { margin: 0 0 2rem; } +.order-group h2 { + font-family: var(--serif); font-weight: 400; font-size: 1.4rem; margin: 0 0 .8rem; + padding-bottom: .5rem; border-bottom: 1px solid var(--rule); +} +.order-list { list-style: none; margin: 0; padding: 0; } +.order-item { + display: flex; align-items: center; justify-content: space-between; gap: 1rem; + padding: .65rem 0; border-bottom: 1px dashed rgba(255, 255, 255, 0.06); +} +.order-item-main { display: flex; align-items: baseline; gap: .6rem; min-width: 0; } +.order-name { font-weight: 500; } +.order-price { color: var(--gold); font-variant-numeric: tabular-nums; } +.order-qty { display: inline-flex; align-items: center; gap: .5rem; white-space: nowrap; } +.order-qty-label { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; } + +.order-page input[type="number"], +.order-page input[type="text"], +.order-page input[type="tel"] { + background: var(--surface); color: var(--text); + border: 1px solid var(--rule); border-radius: 6px; padding: .5rem .6rem; font: inherit; +} +.order-page input:focus { outline: none; border-color: var(--gold); } +.order-qty input[type="number"] { width: 4.5rem; text-align: center; } + +.order-details { margin: 2rem 0 1.5rem; } +.order-details h2 { font-family: var(--serif); font-weight: 400; font-size: 1.4rem; margin: 0 0 1rem; } +.order-field { display: flex; flex-direction: column; gap: .35rem; margin-bottom: 1rem; } +.order-field label { font-size: .85rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; } +.order-field input { width: 100%; } +.order-submit { width: 100%; border: 0; cursor: pointer; font-size: .9rem; } + +.order-receipt { list-style: none; margin: 1.5rem 0 0; padding: 0; } +.order-receipt-row { display: flex; align-items: baseline; gap: .7rem; padding: .5rem 0; border-bottom: 1px dashed rgba(255, 255, 255, 0.06); } +.order-receipt-qty { color: var(--muted); font-variant-numeric: tabular-nums; } +.order-receipt-name { flex: 1; } +.order-receipt-price { color: var(--gold); font-variant-numeric: tabular-nums; } +.order-receipt-total { display: flex; justify-content: space-between; margin: 1rem 0 0; padding-top: .8rem; border-top: 1px solid var(--rule); font-weight: 600; } +.order-receipt-total span:last-child { color: var(--gold); font-variant-numeric: tabular-nums; } + /* Footer */ .site-footer { border-top: 1px solid var(--rule); padding: 2.5rem 0; margin-top: 2rem; } .footer-inner { text-align: center; } diff --git a/theme/templates/header.php b/theme/templates/header.php index b203f6e..ea252a7 100644 --- a/theme/templates/header.php +++ b/theme/templates/header.php @@ -16,6 +16,7 @@