From e5dadb2e9eaded4dda8e7ce9f01ab18974f2d5d6 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 2 Aug 2026 20:08:06 -0400 Subject: [PATCH 01/32] Begin Nimbus rebuild: Menu vertical proven on the platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starts rebuilding the Restaurant Management System as an application on NimbusCMS, and uses it to validate the platform. The legacy PHP stays on master and in history; this branch builds the modern version on Nimbus. Menu is the first vertical and needs no changes to Nimbus core: categories and priced menu items are ordinary Nimbus collections (a number price, a relation to categories), managed in the admin and served over the read API. - app/collections.php declares the app's content model as data. - bin/provision-menu.sh installs that model onto a running Nimbus using only its public HTTP admin API — the app installs itself, no internal classes, no core changes. Verified end to end and idempotent (re-running skips). - docs/PLATFORM-VALIDATION.md is the running ledger: Menu proven, plus three findings the vertical surfaced (F1 API returns relations as bare ids; F2 no supported way to consume Nimbus from a separate app repo; F3 number decimal formatting). None blocked Menu, so none has been built — each becomes a Nimbus capability + ADR only when a later vertical makes it a blocker. Success for the whole initiative is finishing the restaurant system without any restaurant-specific logic landing in Nimbus core. --- README-NIMBUS.md | 48 +++++++++++++++++++ app/collections.php | 35 ++++++++++++++ bin/provision-menu.sh | 82 +++++++++++++++++++++++++++++++ docs/PLATFORM-VALIDATION.md | 96 +++++++++++++++++++++++++++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 README-NIMBUS.md create mode 100644 app/collections.php create mode 100755 bin/provision-menu.sh create mode 100644 docs/PLATFORM-VALIDATION.md diff --git a/README-NIMBUS.md b/README-NIMBUS.md new file mode 100644 index 0000000..d752537 --- /dev/null +++ b/README-NIMBUS.md @@ -0,0 +1,48 @@ +# Restaurant Management System — rebuild on NimbusCMS + +This branch (`nimbus-rebuild`) is rebuilding the Restaurant Management System as +an **application on [NimbusCMS](https://github.com/NimbusCMS/nimbus)**, instead +of the original hand-rolled PHP. The legacy system is preserved on `master` and +in git history. + +The rebuild has a second purpose beyond the app itself: it is the **first real +validation of Nimbus as a platform.** If a full restaurant system can be built +on Nimbus without pushing restaurant-specific logic into the CMS core, the +platform is proven. What Nimbus can and cannot yet do is tracked, feature by +feature, in [`docs/PLATFORM-VALIDATION.md`](docs/PLATFORM-VALIDATION.md). + +## Status + +- ✅ **Menu** — categories and priced menu items, modelled as Nimbus + collections and served over its API. Proven on stock Nimbus, no core changes. +- ⬜ Tables, Orders, Kitchen display, Reservations, Reports — next. + +## Layout + +``` +app/collections.php the app's content model, declared as data +bin/provision-menu.sh installs that model onto a running Nimbus via its public API +docs/PLATFORM-VALIDATION.md the running ledger of what Nimbus needed +``` + +## Running the Menu vertical + +Point it at a running Nimbus instance (see the Nimbus repo for how to start one): + +```bash +NIMBUS_URL=http://localhost:8080 \ +ADMIN_EMAIL=admin@nimbus.test ADMIN_PASSWORD=password \ + bin/provision-menu.sh +``` + +Then mint a token in Nimbus (`php bin/nimbus token:create --name="Menu"`) and +read the menu as any frontend would: + +```bash +curl -H "Authorization: Bearer " \ + http://localhost:8080/api/v1/collections/menu_items/entries +``` + +> How this app should ultimately *consume* Nimbus — a Composer dependency, a +> published image, or a Nimbus project it extends — is an open platform decision, +> recorded as finding **F2** in the validation ledger. diff --git a/app/collections.php b/app/collections.php new file mode 100644 index 0000000..a33df86 --- /dev/null +++ b/app/collections.php @@ -0,0 +1,35 @@ + [ + 'name' => 'Categories', + 'icon' => 'C', + 'fields' => [ + ['handle' => 'name', 'label' => 'Name', 'type' => 'text'], + ], + ], + + 'menu_items' => [ + 'name' => 'Menu Items', + 'icon' => 'M', + 'fields' => [ + ['handle' => 'price', 'label' => 'Price', 'type' => 'number'], + ['handle' => 'category', 'label' => 'Category', 'type' => 'relation', 'target' => 'categories'], + ], + ], +]; diff --git a/bin/provision-menu.sh b/bin/provision-menu.sh new file mode 100755 index 0000000..b5d2601 --- /dev/null +++ b/bin/provision-menu.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# Provision the Restaurant Menu onto a running Nimbus instance, using only +# Nimbus's public HTTP admin API — no Nimbus core changes, no internal classes. +# This is the app installing its own content model, proven end to end: the +# collections appear in the admin and the menu is served over the read API. +# +# It is deliberately idempotent-ish: it skips collections that already exist, +# so re-running is safe. +# +# Usage: +# bin/provision-menu.sh +# env: NIMBUS_URL (default http://localhost:8080) +# ADMIN_EMAIL / ADMIN_PASSWORD (a Nimbus admin login) +set -euo pipefail + +BASE="${NIMBUS_URL:-http://localhost:8080}" +EMAIL="${ADMIN_EMAIL:-admin@nimbus.test}" +PASSWORD="${ADMIN_PASSWORD:-password}" +JAR="$(mktemp)" +trap 'rm -f "$JAR"' EXIT + +say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; } +pass() { printf ' \033[32m✓\033[0m %s\n' "$1"; } +fail() { printf ' \033[31m✗\033[0m %s\n' "$1"; exit 1; } + +get() { curl -sSL -b "$JAR" -c "$JAR" "$BASE$1"; } +postr(){ curl -sS -b "$JAR" -c "$JAR" -o /dev/null -w '%{http_code} %{redirect_url}' -X POST "$BASE$1" "${@:2}"; } +tok() { get "$1" | grep -o 'name="_token" value="[^"]*"' | head -1 | cut -d'"' -f4; } +has() { printf '%s' "$2" | grep -qF -- "$1"; } + +say "Signing in to Nimbus at $BASE" +has 302 "$(postr /admin/login -d "_token=$(tok /admin/login)" -d "email=$EMAIL" -d "password=$PASSWORD")" \ + || fail "login failed — check ADMIN_EMAIL / ADMIN_PASSWORD" +pass "signed in" + +# A collection exists if the collections index lists its handle. +collection_exists() { has "/admin/collections/$1/entries" "$(get /admin/collections)"; } + +say "Categories collection" +if collection_exists categories; then + pass "already present" +else + has msg=created "$(postr /admin/collections \ + -d "_token=$(tok /admin/collections/new)" \ + -d "name=Categories" -d "handle=categories" -d "kind=collection" -d "icon=C" \ + -d "fields[0][label]=Name" -d "fields[0][handle]=name" -d "fields[0][type]=text")" \ + || fail "could not create categories" + pass "created" +fi + +say "Menu Items collection (price + category relation)" +if collection_exists menu_items; then + pass "already present" +else + has msg=created "$(postr /admin/collections \ + -d "_token=$(tok /admin/collections/new)" \ + -d "name=Menu Items" -d "handle=menu_items" -d "kind=collection" -d "icon=M" \ + -d "fields[0][label]=Price" -d "fields[0][handle]=price" -d "fields[0][type]=number" \ + -d "fields[1][label]=Category" -d "fields[1][handle]=category" -d "fields[1][type]=relation" -d "fields[1][target]=categories")" \ + || fail "could not create menu_items" + pass "created" +fi + +say "Seeding a sample menu" +seed_entry() { # collection, title, slug, extra -d args... + local col="$1" title="$2" slug="$3"; shift 3 + if has "/$col/entries/" "$(get "/admin/collections/$col/entries")" && has "$slug" "$(get "/admin/collections/$col/entries")"; then + pass "$title already present"; return + fi + has msg=created "$(postr "/admin/collections/$col/entries" \ + -d "_token=$(tok "/admin/collections/$col/entries/new")" \ + -d "title=$title" -d "slug=$slug" -d "status=published" "$@")" \ + && pass "$title" || fail "could not create $title" +} + +seed_entry categories "Mains" "mains" +CATID="$(get '/admin/collections/categories/entries' | grep -o '/entries/[0-9]*/edit' | head -1 | grep -o '[0-9]*')" +seed_entry menu_items "Margherita Pizza" "margherita" -d "f[price]=12.50" -d "f[category]=$CATID" +seed_entry menu_items "Caesar Salad" "caesar" -d "f[price]=8.00" -d "f[category]=$CATID" + +printf '\n\033[32m✓ Menu provisioned. Mint a token (php bin/nimbus token:create) and GET\n %s/api/v1/collections/menu_items/entries\033[0m\n' "$BASE" diff --git a/docs/PLATFORM-VALIDATION.md b/docs/PLATFORM-VALIDATION.md new file mode 100644 index 0000000..27d0ffc --- /dev/null +++ b/docs/PLATFORM-VALIDATION.md @@ -0,0 +1,96 @@ +# Platform validation ledger + +This repository is being rebuilt as the **first real application on +[NimbusCMS](https://github.com/NimbusCMS/nimbus)**. Its job is not only to be a +restaurant system — it is to *validate the platform* by being demanding. + +The rule, for every feature: + +1. Build it with Nimbus's existing **public** APIs. +2. If blocked, name the exact missing capability. +3. Add the **smallest** core capability that unblocks it — but only if it is + broadly reusable. +4. Keep genuinely restaurant-specific logic (tables, kitchen flow, reservations) + **inside this app**, never in Nimbus core. + +Every capability Nimbus gains because this app needed it is recorded below. +Success is finishing the Restaurant system **without any restaurant-specific +logic landing in Nimbus core.** + +--- + +## Vertical status + +| Vertical | Status | Needed a new core capability? | +|----------|--------|-------------------------------| +| **Menu** (categories, priced items) | ✅ proven on stock Nimbus | No | +| Tables | ⬜ not started | likely: a user/staff reference field | +| Orders | ⬜ not started | likely: repeatable line items, workflow state | +| Kitchen display | ⬜ not started | likely: plugin routes + admin pages | +| Reservations | ⬜ not started | tbd | +| Reports | ⬜ not started | likely: dashboard widgets / aggregation | +| Staff & roles | ⬜ not started | likely: custom roles / capability model | + +--- + +## Menu — proven + +Categories and priced menu items are ordinary Nimbus collections. No core +change was required to **model or serve** a menu. + +- `categories` — a collection with a text `name`. +- `menu_items` — a collection with a `number` price and a `relation` to + `categories`. + +Provisioned entirely through Nimbus's public admin API by +[`bin/provision-menu.sh`](../bin/provision-menu.sh); the content model is +declared in [`app/collections.php`](../app/collections.php). Served over the +read API at `GET /api/v1/collections/menu_items/entries`. + +```json +{ "data": [ { "slug": "margherita", "title": "Margherita Pizza", + "fields": { "price": 12.5, "category": [15] } } ], "meta": { "total": 2 } } +``` + +--- + +## Findings (candidate Nimbus capabilities) + +These are things the Menu vertical surfaced. None *blocked* Menu, so none has +been built yet — they are logged for when a later vertical makes them a +blocker, at which point each becomes a Nimbus core PR with its own ADR. + +### F1 — The API returns relations as bare ids + +`"category": [15]` means a frontend must make a second call per category to +render "Margherita — *Mains* — $12.50". **Candidate capability:** relation (and +in general, reference) *expansion* in the read API — the same enrichment media +fields already get. Broadly reusable; almost every real frontend wants it. +**Severity:** high — likely the first capability Orders/Menu-frontend forces. + +### F2 — No supported way to consume Nimbus from a separate app repo + +Nimbus runs only as the **root project** today: `Config::basePath()` resolves +to the package directory, it is not on Packagist, and there is no published +image or "library mode". So an application cannot simply +`composer require nimbuscms/nimbus` and point it at its own config/uploads. +Provisioning is possible only by scripting the HTTP admin API (which is what +this app does). + +**Candidate capabilities**, smallest first: +- a documented "build your app as a Nimbus project" layout (works today); +- a declarative provisioning command (`bin/nimbus collections:sync `) so + apps stop scripting HTTP form posts; +- **library/consumption mode** — Nimbus resolvable as a Composer dependency with + the *consuming* project's root as the base path; +- Packagist publication + a runnable image. + +**Severity:** foundational, but not a Menu blocker. This is the decision that +most shapes how every Nimbus application is packaged, so it is called out +separately for a deliberate choice rather than an accidental one. + +### F3 — Numbers drop trailing decimals (`8.00` → `8`) + +Minor. Money reads back as `8` and `12.5`. Formatting is arguably the +frontend's job, so this stays an app concern unless a shared "money/decimal" +field type proves reusable across apps. From e477eefe4a63731927199d1929db3787105ee050 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sat, 5 Sep 2026 12:10:00 -0400 Subject: [PATCH 02/32] Design: rebuild architecture + ADR-0001 (co-located restaurant plugin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-first record for the rebuild, before any code: - docs/ARCHITECTURE.md — thesis, the architecture decision (Restaurant as a co-located Nimbus plugin composed with the CRM + menu collections + a theme), full legacy→Nimbus domain map, plugin shape, capability/role model, the terminals, findings resolution, an 8-slice build sequence (Tables first), legacy-issue disposition, and the definition of done. - docs/adr/0001 — records the pivotal decision and the alternatives (compose generic plugins; collections-only external app) that were weighed and why they were not chosen. - PLATFORM-VALIDATION.md — F2 (how an app consumes Nimbus) marked DECIDED, pointing at ADR-0001; original analysis kept for the record. No code yet: each build slice still runs both Nimbus review skills first. Co-Authored-By: Claude Opus 4.8 --- docs/ARCHITECTURE.md | 246 ++++++++++++++++++ docs/PLATFORM-VALIDATION.md | 15 +- ...restaurant-as-a-colocated-nimbus-plugin.md | 64 +++++ 3 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/adr/0001-restaurant-as-a-colocated-nimbus-plugin.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3e24af2 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,246 @@ +# Restaurant Management System on NimbusCMS — rebuild architecture + +Design-first record for rebuilding the Restaurant Management System as an +**application on [NimbusCMS](https://github.com/NimbusCMS/nimbus)**. No code is +written from this document until it has passed both Nimbus review skills +(`nimbus-review-loop` and `nimbus-security-review`); this is the thing they +review. + +Companion documents: +- [`PLATFORM-VALIDATION.md`](PLATFORM-VALIDATION.md) — the running ledger of what + Nimbus needed (findings F1–F3). +- [`adr/0001-restaurant-as-a-colocated-nimbus-plugin.md`](adr/0001-restaurant-as-a-colocated-nimbus-plugin.md) + — the decision this document elaborates. + +--- + +## 1. Thesis + +Two goals, in priority order: + +1. **Rebuild the app** — a working restaurant system: menu, floor, orders, + kitchen, payment, reservations, reports, and staff roles. +2. **Validate the platform** — prove a full, real application can be built on + stock Nimbus **without any restaurant-specific logic landing in Nimbus core.** + Every capability Nimbus gains because this app demanded it is the smallest, + broadly-reusable one, lands with its own ADR in the Nimbus repo, and is + recorded in the validation ledger. + +The legacy system (procedural PHP, `oose.sql`, MD5 passwords) is preserved on +`master`. The rebuild lives on `nimbus-rebuild`. + +--- + +## 2. The architecture decision + +**The Restaurant is a Nimbus *plugin*, co-located in this repository, composed +with the official CRM plugin and a theme, and deployed onto a Nimbus site.** + +- **Restaurant-specific behaviour** (tables, orders, kitchen flow, reservations, + reports) is a single plugin — `restaurant` — that lives **inside this repo** + under [`plugin/`](../plugin). It mirrors the mature official-plugin pattern + (Inventory / Commerce / CRM): its own `rest_*` tables (ADR 0005), a + wildcard-immune capability (ADR 0015), capability-gated admin pages (ADR 0020), + plugin routes for the staff/kitchen terminals, an MCP toolset (ADR 0016), and a + guide (ADR 0013). **Zero Nimbus core change** — this is the whole point. +- **Guests** are not reinvented: a guest is a **CRM contact** + ([`nimbuscms/crm`](https://github.com/NimbusCMS/plugin-crm)). Reservations and + (optionally) orders reference a CRM contact id. This is why the CRM was built + first. +- **The menu** stays ordinary Nimbus **collections** (`categories`, + `menu_items`) — content, already proven, served over the read API to the public + site. +- **The public menu + staff UI theme** is a Nimbus theme (the existing + [`nimbus-theme-cafe`](https://github.com/DanMat/nimbus-theme-cafe) is the + starting point). + +### Why a plugin, not more collections + +Menu is *content*. Tables, orders and the kitchen are **operational state and +behaviour** — a table's status changes as guests are seated and cleared, an order +moves through a workflow, a cook marks a ticket ready, a manager reads today's +revenue, staff actions are gated by role. That is precisely the plugin hinge +surface (own tables + workflow + capability-gated admin pages + routes + MCP), not +the collections/content surface. Forcing live operational state into generic +content collections would be awkward and would tempt restaurant logic into core. + +### How the app consumes Nimbus (resolves finding F2) + +Nimbus discovers plugins from Composer's `installed.json` by +`"type": "nimbuscms-plugin"`. So the co-located plugin needs no special support: + +- `plugin/composer.json` declares `"type": "nimbuscms-plugin"`, + `extra.nimbus.id = "danmat.restaurant"`, and the PSR-4 plugin class. +- A deployed **Nimbus site** (its own thin repo / compose project, as with + Foodmart) adds a Composer **path repository** pointing at this repo's `plugin/` + directory and `composer require`s it; the CRM and theme come from their GitHub + packages exactly as the demo image already wires official plugins. + +No Packagist publication is required for the app's own plugin, and Nimbus stays +the root project. This is the smallest thing that works today and keeps the app +self-contained in one repo — the outcome the F2 finding asked us to choose +deliberately. + +--- + +## 3. Legacy → Nimbus domain map + +Every legacy table (`oose.sql`) has a home. Nothing restaurant-specific goes to +core. + +| Legacy table | Rebuild home | Shape | +|---|---|---| +| `category` | collection `categories` ✅ | text `name` | +| `item` | collection `menu_items` ✅ | `number` price, `relation` → categories | +| `floorplan` | plugin table `rest_table` | label/number, seats, `status` enum (`open`/`occupied`/`dirty`/`reserved`), assigned staff (user ref) | +| `orders` + `orderlist` (serialised string) | plugin tables `rest_order` + `rest_order_item` | order: table ref, `status` workflow, `paid`, totals, opened-by staff, optional CRM guest; items: proper rows (menu item ref/snapshot, qty, line state) | +| `employee` + `role` | Nimbus **users** + plugin **capabilities** | waiter / cook / host / busboy / manager / admin → capability grants (see §5) | +| (payment: `paid`) | fields on `rest_order` | amount, method, `paid_at` | +| (reports: manager revenue) | aggregation over `rest_order` | revenue-by-day dashboard page + MCP tool | +| (guests) | **CRM contact** | reused; reservations/orders link a contact id | +| (reservations — new) | plugin table `rest_reservation` | table ref, datetime, party size, CRM guest ref, status | + +The serialised `orderlist` string (`"Chicken Marsala-1-8.21;…"`) — the legacy +system's worst modelling wart — becomes first-class `rest_order_item` rows, each +snapshotting name + unit price at order time so a later menu edit never rewrites +history. + +--- + +## 4. Plugin shape (`plugin/`) + +Mirrors the CRM plugin's structure exactly: + +``` +plugin/ + composer.json type: nimbuscms-plugin, id danmat.restaurant + src/ + RestaurantPlugin.php register(): migrations, capabilities, admin, routes, mcp, guide + Schema.php rest_table / rest_order / rest_order_item / rest_reservation + Tables.php floor service (status transitions, assignment) + Orders.php order + line-item service (workflow, totals, payment) + Reservations.php reservation service (+ CRM guest link) + Reports.php revenue aggregation + *Admin.php floor board, order/ticket screens, kitchen display, reports + RestaurantToolset.php MCP: an agent can run the floor and kitchen + Guide.php agent guide + tests/ one DB-backed test suite per service, mirroring CRM +``` + +Discipline carried over from the CRM build (non-negotiable): +- **bound SQL everywhere**; enums/statuses are **write-time allow-lists**, never + interpolated; +- **store raw, escape on render**; every admin value escaped, styles in a + nonce'd `'; + } + + /** Escape a value for HTML output (the admin CSP is nonce-only; every value is escaped). */ + private static function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/plugin/tests/RestaurantToolsetTest.php b/plugin/tests/RestaurantToolsetTest.php new file mode 100644 index 0000000..9bf10f5 --- /dev/null +++ b/plugin/tests/RestaurantToolsetTest.php @@ -0,0 +1,126 @@ + 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::tables() as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + + $storage = new PluginStorage($db); + $this->toolset = new RestaurantToolset(new Tables(static fn (): PluginStorage => $storage)); + $this->toolset->bindTo('danmat.restaurant'); + $this->ctx = new EntryOpContext('127.0.0.1', '/api/v1/mcp'); + + Authorizer::useManagement(['danmat.restaurant']); + } + + protected function tearDown(): void + { + Authorizer::reset(); + } + + private function principal(string ...$scopes): TokenPrincipal + { + return new TokenPrincipal(1, 'floor-bot', array_values($scopes)); + } + + public function test_the_tools_are_namespaced_and_split_read_from_write(): void + { + $names = array_column($this->toolset->definitions($this->principal('danmat.restaurant:read', 'danmat.restaurant:write')), 'name'); + self::assertSame(['restaurant_tables', 'restaurant_table_get', 'restaurant_table_set', 'restaurant_table_status', 'restaurant_table_delete'], $names); + } + + public function test_a_read_only_token_sees_only_the_read_tools(): void + { + $names = array_column($this->toolset->definitions($this->principal('danmat.restaurant:read')), 'name'); + self::assertSame(['restaurant_tables', 'restaurant_table_get'], $names); + } + + public function test_a_content_token_cannot_reach_the_floor(): void + { + self::assertSame([], $this->toolset->definitions($this->principal('*:write', '*:read'))); + + $this->expectException(McpError::class); + $this->expectExceptionMessage('Unknown tool "restaurant_tables"'); + $this->toolset->call('restaurant_tables', [], $this->principal('*:read', '*:write'), $this->ctx); + } + + public function test_a_read_token_cannot_call_a_write_tool(): void + { + $this->expectException(McpError::class); + $this->toolset->call('restaurant_table_set', ['label' => 'X'], $this->principal('danmat.restaurant:read'), $this->ctx); + } + + public function test_set_status_get_and_delete_round_trip(): void + { + $write = $this->principal('danmat.restaurant:read', 'danmat.restaurant:write'); + + $out = $this->toolset->call('restaurant_table_set', ['label' => 'Patio 1', 'seats' => 4], $write, $this->ctx); + self::assertTrue($out['ok']); + self::assertSame('open', $out['table']['status']); + $id = $out['table']['id']; + + $moved = $this->toolset->call('restaurant_table_status', ['id' => $id, 'status' => 'occupied'], $write, $this->ctx); + self::assertTrue($moved['changed']); + self::assertSame('occupied', $moved['table']['status']); + + $got = $this->toolset->call('restaurant_table_get', ['id' => $id], $write, $this->ctx); + self::assertSame('Patio 1', $got['table']['label']); + + $del = $this->toolset->call('restaurant_table_delete', ['id' => $id], $write, $this->ctx); + self::assertTrue($del['deleted']); + self::assertNull($this->toolset->call('restaurant_table_get', ['id' => $id], $write, $this->ctx)['table']); + } + + public function test_a_duplicate_label_comes_back_as_data_not_an_exception(): void + { + $write = $this->principal('danmat.restaurant:write'); + $this->toolset->call('restaurant_table_set', ['label' => '5'], $write, $this->ctx); + $out = $this->toolset->call('restaurant_table_set', ['label' => '5'], $write, $this->ctx); + self::assertFalse($out['ok']); + self::assertSame('invalid', $out['error']); + } + + public function test_a_bad_status_comes_back_as_data(): void + { + $write = $this->principal('danmat.restaurant:read', 'danmat.restaurant:write'); + $id = $this->toolset->call('restaurant_table_set', ['label' => '9'], $write, $this->ctx)['table']['id']; + $out = $this->toolset->call('restaurant_table_status', ['id' => $id, 'status' => 'nope'], $write, $this->ctx); + self::assertFalse($out['ok']); + self::assertSame('invalid', $out['error']); + } +} diff --git a/plugin/tests/TablesAdminTest.php b/plugin/tests/TablesAdminTest.php new file mode 100644 index 0000000..9897a33 --- /dev/null +++ b/plugin/tests/TablesAdminTest.php @@ -0,0 +1,82 @@ + 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::tables() as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + $this->admin = new TablesAdmin($this->tables); + } + + public function test_the_board_escapes_a_hostile_label(): void + { + $this->tables->save(null, ['label' => ''], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, null, 'n'); + + self::assertStringNotContainsString('', $html, 'the label is escaped'); + self::assertStringContainsString('<script>', $html); + self::assertStringContainsString('value="CSRF123"', $html, 'the CSRF token is in the forms'); + } + + public function test_the_board_shows_status_and_quick_actions(): void + { + $this->tables->save(null, ['label' => '1', 'status' => 'occupied'], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, null, 'n'); + + self::assertStringContainsString('rz-status-occupied', $html, 'the card is marked with its status'); + self::assertStringContainsString('action="/admin/restaurant/table-status"', $html, 'quick-action posts to the status action'); + // An occupied table offers "Clear" (→ dirty), not "Seat". + self::assertStringContainsString('value="dirty"', $html); + } + + public function test_the_edit_form_loads_and_escapes_the_table(): void + { + $id = $this->tables->save(null, ['label' => '">7', 'seats' => '8'], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, (string) $id, null, 'n'); + + self::assertStringContainsString('Edit table', $html); + self::assertStringContainsString('value="8"', $html, 'seats loaded into the form'); + self::assertStringNotContainsString('7', $html, 'the loaded label is escaped'); + } + + public function test_an_empty_floor_prompts_to_add(): void + { + self::assertStringContainsString('No tables yet', $this->admin->render('CSRF123', null, null, null, 'n')); + } +} diff --git a/plugin/tests/TablesTest.php b/plugin/tests/TablesTest.php new file mode 100644 index 0000000..35db063 --- /dev/null +++ b/plugin/tests/TablesTest.php @@ -0,0 +1,135 @@ + 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::tables() as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + } + + private const NOW = '2026-01-01 09:00:00'; + + public function test_create_defaults_get_and_update_round_trip(): void + { + $id = $this->tables->save(null, ['label' => 'Patio 1'], self::NOW); + $t = $this->tables->get($id); + self::assertNotNull($t); + self::assertSame('Patio 1', $t['label']); + self::assertSame(2, $t['seats'], 'seats defaults to 2'); + self::assertSame('open', $t['status'], 'status defaults to open'); + + $this->tables->save($id, ['seats' => '4'], '2026-01-02 09:00:00'); + $t = $this->tables->get($id); + self::assertSame(4, $t['seats']); + self::assertSame('Patio 1', $t['label'], 'unsent fields are unchanged'); + } + + public function test_a_table_needs_a_label(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->tables->save(null, ['seats' => '4'], self::NOW); + } + + public function test_labels_are_unique(): void + { + $this->tables->save(null, ['label' => '12'], self::NOW); + $this->expectException(\InvalidArgumentException::class); + $this->tables->save(null, ['label' => '12'], self::NOW); + } + + public function test_a_table_can_be_renamed_to_its_own_label(): void + { + $id = $this->tables->save(null, ['label' => '7'], self::NOW); + // Updating without changing the label must not trip the uniqueness check. + $this->tables->save($id, ['label' => '7', 'seats' => '6'], self::NOW); + self::assertSame(6, $this->tables->get($id)['seats']); + } + + public function test_seats_must_be_a_sane_number(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->tables->save(null, ['label' => 'X', 'seats' => '-3'], self::NOW); + } + + public function test_status_is_an_allow_list_on_save(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->tables->save(null, ['label' => 'X', 'status' => 'on-fire'], self::NOW); + } + + public function test_set_status_moves_a_table_and_rejects_a_bad_status(): void + { + $id = $this->tables->save(null, ['label' => '3'], self::NOW); + self::assertSame(1, $this->tables->setStatus($id, 'occupied', self::NOW)); + self::assertSame('occupied', $this->tables->get($id)['status']); + + $this->expectException(\InvalidArgumentException::class); + $this->tables->setStatus($id, 'levitating', self::NOW); + } + + public function test_over_posting_is_ignored(): void + { + $id = $this->tables->save(null, ['label' => 'G', 'id' => 4242, 'created_at' => '1900-01-01 00:00:00', 'evil' => 'x'], self::NOW); + self::assertNotSame(4242, $id, 'the id is server-assigned'); + $t = $this->tables->get($id); + self::assertSame(self::NOW, $t['created_at'], 'created_at is server-set, not over-posted'); + self::assertArrayNotHasKey('evil', $t); + } + + public function test_all_filters_by_status(): void + { + $this->tables->save(null, ['label' => '1', 'status' => 'open'], self::NOW); + $this->tables->save(null, ['label' => '2', 'status' => 'occupied'], self::NOW); + $this->tables->save(null, ['label' => '3', 'status' => 'occupied'], self::NOW); + + self::assertCount(3, $this->tables->all()); + self::assertCount(2, $this->tables->all('occupied')); + self::assertCount(1, $this->tables->all('open')); + // An unknown status filter falls back to "all", never an injection. + self::assertCount(3, $this->tables->all("' OR '1'='1")); + } + + public function test_delete_is_total(): void + { + $id = $this->tables->save(null, ['label' => 'Gone'], self::NOW); + self::assertSame(1, $this->tables->delete($id)); + self::assertNull($this->tables->get($id)); + self::assertSame(0, $this->tables->delete($id), 'a second delete is a no-op'); + } + + public function test_updating_a_missing_table_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->tables->save(424242, ['label' => 'Ghost'], self::NOW); + } +} From 35fc99a441b9f7719bc7914babda4ee866fb7fa4 Mon Sep 17 00:00:00 2001 From: Danny Matthew Date: Sat, 5 Sep 2026 15:39:59 -0400 Subject: [PATCH 05/32] =?UTF-8?q?Slice=202:=20Orders=20=E2=80=94=20line=20?= =?UTF-8?q?items,=20workflow,=20totals,=20menu=20picker=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heart of service, and the first consumer of the new core content-read capability (ADR 0029 in NimbusCMS). - Schema: rest_order + rest_order_item (002_orders). Lines snapshot name + unit_price at order time; menu_item_id is a soft breadcrumb, never a join. - Orders service: open (validates the table, occupies it, atomic); add/set-qty/ remove line items; server-COMPUTED totals (never stored/trusted); workflow status allow-list (open→sent→preparing→ready→served→closed); transactional delete takes the lines with it. Depends on Tables + a snapshot resolver closure, so it's unit-tested with no core schema. - Menu: a thin adapter over PluginContext::content() (ADR 0029) mapping menu_items collection entries to the picker list + a price snapshot; behind a MenuSource seam so the admin/MCP are testable without a collection. - MCP: menu + order_open/orders/order_get/order_status/order_add_item/ order_set_item_qty/order_remove_item/order_delete, all gated on the wildcard-immune danmat.restaurant capability, non-enumerating. - Admin: an orders terminal (list + status filter, open-on-table, and a single-order screen with the menu picker, quantities, line removal, workflow actions and the computed total). Capability-gated admin page + CSRF, mobile-reflow. - Guide: menu + orders section. - Tests: OrdersTest (open/occupy, snapshot, totals, qty, workflow, delete cascade, filters), OrdersAdminTest (escape + total + picker via a fake MenuSource), RestaurantToolsetTest extended (order tools listed, content token can't reach orders, an order run end-to-end over MCP). cs-fixer + PHPStan green locally (borrowed-vendor); phpunit runs in CI. Co-authored-by: Claude Opus 4.8 --- plugin/src/Guide.php | 23 ++ plugin/src/Menu.php | 82 +++++++ plugin/src/MenuSource.php | 21 ++ plugin/src/Orders.php | 302 +++++++++++++++++++++++++ plugin/src/OrdersAdmin.php | 236 +++++++++++++++++++ plugin/src/RestaurantPlugin.php | 106 ++++++++- plugin/src/RestaurantToolset.php | 184 ++++++++++++++- plugin/src/Schema.php | 42 +++- plugin/tests/OrdersAdminTest.php | 98 ++++++++ plugin/tests/OrdersTest.php | 180 +++++++++++++++ plugin/tests/RestaurantToolsetTest.php | 52 ++++- 11 files changed, 1316 insertions(+), 10 deletions(-) create mode 100644 plugin/src/Menu.php create mode 100644 plugin/src/MenuSource.php create mode 100644 plugin/src/Orders.php create mode 100644 plugin/src/OrdersAdmin.php create mode 100644 plugin/tests/OrdersAdminTest.php create mode 100644 plugin/tests/OrdersTest.php diff --git a/plugin/src/Guide.php b/plugin/src/Guide.php index d2b95e0..7b8296c 100644 --- a/plugin/src/Guide.php +++ b/plugin/src/Guide.php @@ -23,6 +23,9 @@ public static function text(): string content `*:write` token cannot reach it, and a tool you lack the capability for is invisible. + Amounts are strings with two decimals; order totals are always computed from + the line items, never set directly. + ## Tables (the floor) A table has a unique `label` (its number/name), a `seats` count, and a @@ -40,6 +43,26 @@ public static function text(): string - `restaurant_table_delete` — remove a table by `id`. Values are stored as you send them and escaped when displayed. + + ## Menu & orders + + The menu is a Nimbus collection; read it with `restaurant_menu` (each item has + an id, name and price). An order lives on a table and moves through a workflow: + `open` → `sent` (to the kitchen) → `preparing` → `ready` → `served` → `closed`. + + - `restaurant_order_open` — open an order on a table (which becomes occupied). + - `restaurant_orders` — list orders, filter by `status` and/or `table_id`. + - `restaurant_order_get` — one order with its line items and computed total. + - `restaurant_order_status` — advance the workflow. + - `restaurant_order_add_item` — add a line: give a `menu_item_id` to add from + the menu (its name + price are snapshotted), or a `name` + `price` for a + manual line, plus a `qty`. + - `restaurant_order_set_item_qty` — change a line's quantity (0 removes it). + - `restaurant_order_remove_item` — remove a line. + - `restaurant_order_delete` — delete an order and its lines. + + A line snapshots the item's name and price when added, so editing the menu + later never changes an existing order. MD; } } diff --git a/plugin/src/Menu.php b/plugin/src/Menu.php new file mode 100644 index 0000000..7445d1e --- /dev/null +++ b/plugin/src/Menu.php @@ -0,0 +1,82 @@ + + */ + public function items(): array + { + $out = []; + foreach (($this->reader)()->entries(self::COLLECTION, 500) as $entry) { + $out[] = [ + 'id' => (int) ($entry['id'] ?? 0), + 'name' => (string) ($entry['title'] ?? ''), + 'price' => $this->price($entry), + 'category' => $this->category($entry), + ]; + } + return $out; + } + + /** + * The name + unit price to snapshot onto an order line, for one menu item id, + * or null if there is no such published item. + * + * @return array{name:string,price:string}|null + */ + public function snapshot(int $menuItemId): ?array + { + $entry = ($this->reader)()->entry(self::COLLECTION, $menuItemId); + if ($entry === null) { + return null; + } + return ['name' => (string) ($entry['title'] ?? ''), 'price' => $this->price($entry)]; + } + + /** @param array $entry */ + private function price(array $entry): string + { + $fields = is_array($entry['fields'] ?? null) ? $entry['fields'] : []; + $raw = $fields['price'] ?? 0; + return number_format(is_numeric($raw) ? (float) $raw : 0.0, 2, '.', ''); + } + + /** @param array $entry */ + private function category(array $entry): ?string + { + $fields = is_array($entry['fields'] ?? null) ? $entry['fields'] : []; + $rel = $fields['category'] ?? null; + if (is_array($rel) && isset($rel[0]) && is_array($rel[0])) { + $title = (string) ($rel[0]['title'] ?? ''); + return $title === '' ? null : $title; + } + return null; + } +} diff --git a/plugin/src/MenuSource.php b/plugin/src/MenuSource.php new file mode 100644 index 0000000..36d5077 --- /dev/null +++ b/plugin/src/MenuSource.php @@ -0,0 +1,21 @@ + + */ + public function items(): array; +} diff --git a/plugin/src/Orders.php b/plugin/src/Orders.php new file mode 100644 index 0000000..94fefcd --- /dev/null +++ b/plugin/src/Orders.php @@ -0,0 +1,302 @@ + the order workflow, in order */ + public const STATUSES = ['open', 'sent', 'preparing', 'ready', 'served', 'closed']; + + private const MAX_NAME = 200; + private const MAX_QTY = 999; + + /** + * @param \Closure():PluginStorage $storage resolved lazily + * @param \Closure(int):(array{name:string,price:string}|null) $snapshot menu-item resolver + */ + public function __construct( + private \Closure $storage, + private Tables $tables, + private \Closure $snapshot, + ) { + } + + /** + * Open an order on a table (which the guests are now seated at, so the table + * becomes occupied) — atomically. Returns the new order id. + */ + public function open(int $tableId, string $now): int + { + if ($this->tables->get($tableId) === null) { + throw new \InvalidArgumentException("No table with id {$tableId}."); + } + return (int) $this->storage()->transaction(function () use ($tableId, $now): int { + $id = $this->storage()->insert( + 'INSERT INTO ' . Schema::ORDER . ' (table_id, status, paid, created_at, updated_at) + VALUES (:table, :status, 0, :created, :updated)', + ['table' => $tableId, 'status' => 'open', 'created' => $now, 'updated' => $now], + ); + $this->tables->setStatus($tableId, 'occupied', $now); + return $id; + }); + } + + /** + * 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,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.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], + ); + if ($row === null) { + return null; + } + $items = $this->items($id); + return $this->hydrate($row, $items); + } + + /** + * Orders for the list / MCP, optionally filtered by allow-listed status and/or + * table, each with its computed total and item count (no line items — get() has + * those). Most-recently-updated first. + * + * @return list + */ + public function all(?string $status = null, ?int $tableId = null): array + { + $where = []; + $params = []; + if ($status !== null && $status !== '' && in_array($status, self::STATUSES, true)) { + $where[] = 'o.status = :status'; + $params['status'] = $status; + } + if ($tableId !== null) { + $where[] = 'o.table_id = :table'; + $params['table'] = $tableId; + } + + $sql = 'SELECT o.id, o.table_id, o.status, o.paid, 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'; + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= ' ORDER BY o.updated_at DESC, o.id DESC'; + + return array_map(function (array $r): array { + return [ + 'id' => (int) $r['id'], + 'table_id' => (int) $r['table_id'], + 'table_label' => ($r['table_label'] ?? null) === null ? null : (string) $r['table_label'], + 'status' => (string) $r['status'], + 'paid' => (bool) $r['paid'], + 'item_count' => (int) $r['item_count'], + 'total' => number_format((float) $r['total'], 2, '.', ''), + 'created_at' => (string) $r['created_at'], + 'updated_at' => (string) $r['updated_at'], + ]; + }, $this->storage()->select($sql, $params)); + } + + /** Move an order to an allow-listed workflow status. Returns rows changed. */ + public function setStatus(int $id, string $status, string $now): int + { + if (!in_array($status, self::STATUSES, true)) { + throw new \InvalidArgumentException('"status" must be one of: ' . implode(', ', self::STATUSES) . '.'); + } + return $this->storage()->execute( + 'UPDATE ' . Schema::ORDER . ' SET status = :status, updated_at = :now WHERE id = :id', + ['status' => $status, 'now' => $now, 'id' => $id], + ); + } + + /** + * Add a line to an order. Give a `menuItemId` to add from the menu (its name + + * price are snapshotted via the resolver), or a `name` + `price` for a manual + * line. Returns the new line id. + */ + public function addItem(int $orderId, ?int $menuItemId, ?string $name, ?string $price, int $qty, string $now): int + { + if ($this->orderExists($orderId) === false) { + throw new \InvalidArgumentException("No order with id {$orderId}."); + } + if ($qty < 1 || $qty > self::MAX_QTY) { + throw new \InvalidArgumentException('"qty" must be a whole number between 1 and ' . self::MAX_QTY . '.'); + } + + if ($menuItemId !== null) { + $snap = ($this->snapshot)($menuItemId); + if ($snap === null) { + throw new \InvalidArgumentException("No menu item with id {$menuItemId}."); + } + $lineName = $snap['name']; + $linePrice = $snap['price']; + } else { + $lineName = $this->name($name); + $linePrice = $this->price($price); + } + + return (int) $this->storage()->transaction(function () use ($orderId, $menuItemId, $lineName, $linePrice, $qty, $now): int { + $lineId = $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' => $orderId, 'menu' => $menuItemId, 'name' => $lineName, 'price' => $linePrice, 'qty' => $qty, 'created' => $now], + ); + $this->touch($orderId, $now); + return $lineId; + }); + } + + /** Change a line's quantity; a quantity of 0 removes it. Returns rows affected. */ + public function setItemQty(int $itemId, int $qty, string $now): int + { + if ($qty < 0 || $qty > self::MAX_QTY) { + throw new \InvalidArgumentException('"qty" must be a whole number between 0 and ' . self::MAX_QTY . '.'); + } + $orderId = $this->orderIdOfItem($itemId); + if ($orderId === null) { + return 0; + } + if ($qty === 0) { + return $this->removeItem($itemId); + } + $n = $this->storage()->execute( + 'UPDATE ' . Schema::ORDER_ITEM . ' SET qty = :qty WHERE id = :id', + ['qty' => $qty, 'id' => $itemId], + ); + $this->touch($orderId, $now); + return $n; + } + + /** Remove a line item. Returns rows removed. */ + public function removeItem(int $itemId): int + { + return $this->storage()->execute('DELETE FROM ' . Schema::ORDER_ITEM . ' WHERE id = :id', ['id' => $itemId]); + } + + /** Delete an order and its line items, atomically. Returns order rows removed. */ + public function delete(int $id): int + { + return (int) $this->storage()->transaction(function () use ($id): int { + $this->storage()->execute('DELETE FROM ' . Schema::ORDER_ITEM . ' WHERE order_id = :id', ['id' => $id]); + return $this->storage()->execute('DELETE FROM ' . Schema::ORDER . ' WHERE id = :id', ['id' => $id]); + }); + } + + // --- internals ------------------------------------------------------- + + /** + * @return list + */ + private function items(int $orderId): array + { + $rows = $this->storage()->select( + 'SELECT id, menu_item_id, name, unit_price, qty FROM ' . Schema::ORDER_ITEM . ' WHERE order_id = :id ORDER BY id', + ['id' => $orderId], + ); + return array_map(static function (array $r): array { + $unit = (float) $r['unit_price']; + $qty = (int) $r['qty']; + return [ + 'id' => (int) $r['id'], + 'menu_item_id' => $r['menu_item_id'] === null ? null : (int) $r['menu_item_id'], + 'name' => (string) $r['name'], + 'unit_price' => number_format($unit, 2, '.', ''), + 'qty' => $qty, + 'line_total' => number_format($unit * $qty, 2, '.', ''), + ]; + }, $rows); + } + + /** + * @param array $row + * @param list $items + * @return array{id:int,table_id:int,table_label:?string,status:string,paid:bool,items:list,total:string,created_at:string,updated_at:string} + */ + private function hydrate(array $row, array $items): array + { + $total = 0.0; + foreach ($items as $item) { + $total += (float) $item['line_total']; + } + return [ + 'id' => (int) $row['id'], + 'table_id' => (int) $row['table_id'], + 'table_label' => ($row['table_label'] ?? null) === null ? null : (string) $row['table_label'], + 'status' => (string) $row['status'], + 'paid' => (bool) $row['paid'], + 'items' => $items, + 'total' => number_format($total, 2, '.', ''), + 'created_at' => (string) $row['created_at'], + 'updated_at' => (string) $row['updated_at'], + ]; + } + + private function orderExists(int $id): bool + { + return $this->storage()->selectOne('SELECT id FROM ' . Schema::ORDER . ' WHERE id = :id', ['id' => $id]) !== null; + } + + private function orderIdOfItem(int $itemId): ?int + { + $row = $this->storage()->selectOne('SELECT order_id FROM ' . Schema::ORDER_ITEM . ' WHERE id = :id', ['id' => $itemId]); + return $row === null ? null : (int) $row['order_id']; + } + + private function touch(int $orderId, string $now): void + { + $this->storage()->execute('UPDATE ' . Schema::ORDER . ' SET updated_at = :now WHERE id = :id', ['now' => $now, 'id' => $orderId]); + } + + private function name(?string $name): string + { + $name = trim((string) $name); + if ($name === '') { + throw new \InvalidArgumentException('A manual line needs a name.'); + } + if (mb_strlen($name) > self::MAX_NAME) { + throw new \InvalidArgumentException('An item name must be ' . self::MAX_NAME . ' characters or fewer.'); + } + return $name; + } + + private function price(?string $price): string + { + $raw = str_replace([',', ' '], '', trim((string) $price)); + if ($raw === '' || preg_match('/^\d{1,8}(\.\d{1,2})?$/', $raw) !== 1) { + throw new \InvalidArgumentException('A manual line needs a non-negative price with up to two decimals.'); + } + return number_format((float) $raw, 2, '.', ''); + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/plugin/src/OrdersAdmin.php b/plugin/src/OrdersAdmin.php new file mode 100644 index 0000000..e00c70b --- /dev/null +++ b/plugin/src/OrdersAdmin.php @@ -0,0 +1,236 @@ +` block, gated on `danmat.restaurant:write` + * + CSRF, mobile-first. The total shown here is computed server-side from the lines — + * never a client value. + */ +final class OrdersAdmin +{ + private const NOTICES = [ + 'opened' => ['ok', 'Order opened.'], + 'added' => ['ok', 'Item added.'], + 'updated' => ['ok', 'Order updated.'], + 'removed' => ['ok', 'Item removed.'], + 'deleted' => ['ok', 'Order deleted.'], + 'notable' => ['err', 'Pick a table to open an order on.'], + 'invalid' => ['err', 'Check the details and try again.'], + ]; + + private const STATUS_LABELS = [ + 'open' => 'Open', + 'sent' => 'Sent to kitchen', + 'preparing' => 'Preparing', + 'ready' => 'Ready', + 'served' => 'Served', + 'closed' => 'Closed', + ]; + + public function __construct( + private Orders $orders, + private Tables $tables, + private MenuSource $menu, + ) { + } + + public function render(string $csrf = '', ?string $notice = null, ?string $view = null, ?string $status = null, string $nonce = ''): string + { + $viewId = ($view !== null && preg_match('/^\d+$/', trim($view)) === 1) ? (int) trim($view) : null; + $viewOrder = $viewId !== null ? $this->orders->get($viewId) : null; + + $html = $this->styles($nonce) . '

Orders

' . $this->notice($notice); + + if ($viewOrder !== null) { + return $html . $this->orderScreen($csrf, $viewOrder); + } + + $filter = ($status !== null && in_array(trim($status), Orders::STATUSES, true)) ? trim($status) : null; + return $html + . $this->openForm($csrf) + . $this->filterBar($filter) + . $this->list($this->orders->all($filter)); + } + + private function openForm(string $csrf): string + { + $options = ''; + foreach ($this->tables->all() as $t) { + $options .= ''; + } + return '

Open an order

' + . '
' + . '' + . '' + . '
' + . '
'; + } + + private function filterBar(?string $active): string + { + $chips = 'All'; + foreach (Orders::STATUSES as $s) { + $chips .= '' . self::e(self::STATUS_LABELS[$s]) . ''; + } + return '
' . $chips . '
'; + } + + /** @param list> $orders */ + private function list(array $orders): string + { + if ($orders === []) { + return '

No orders.

'; + } + $rows = ''; + foreach ($orders as $o) { + $rows .= '' + . '#' . self::e((string) $o['id']) . '' + . '' . self::e((string) ($o['table_label'] ?? '—')) . '' + . '' . self::e(self::STATUS_LABELS[(string) $o['status']] ?? (string) $o['status']) . '' + . '' . self::e((string) $o['item_count']) . '' + . '' . self::e((string) $o['total']) . '' + . ''; + } + return '' . $rows . '
OrderTableStatusItemsTotal
'; + } + + /** @param array $order */ + private function orderScreen(string $csrf, array $order): string + { + $id = (int) $order['id']; + + $lines = ''; + foreach ($order['items'] as $item) { + $lines .= '' + . '' . self::e((string) $item['name']) . '' + . '' . self::e((string) $item['unit_price']) . '' + . '
' + . '' + . '' + . '' + . '' + . '
' + . '' . self::e((string) $item['line_total']) . '' + . '
' + . '' + . '' + . '' + . '
' + . ''; + } + if ($lines === '') { + $lines = 'No items yet — add from the menu below.'; + } + + return '

← All orders

' + . '
' + . '

Order #' . self::e((string) $id) . ' · ' . self::e((string) ($order['table_label'] ?? '—')) . '

' + . '' . self::e(self::STATUS_LABELS[(string) $order['status']] ?? (string) $order['status']) . '' + . '
' + . '' . $lines . '' + . '
ItemPriceQtyLine
Total' . self::e((string) $order['total']) . '
' + . $this->addItemForm($csrf, $id) + . $this->statusActions($csrf, $id, (string) $order['status']) + . '
' + . '' + . '' + . '
'; + } + + private function addItemForm(string $csrf, int $orderId): string + { + $options = ''; + foreach ($this->menu->items() as $m) { + $label = $m['name'] . ' · ' . $m['price'] . ($m['category'] !== null ? ' · ' . $m['category'] : ''); + $options .= ''; + } + return '

Add an item

' + . '
' + . '' + . '' + . '' + . '' + . '' + . '
' + . '
'; + } + + private function statusActions(string $csrf, int $orderId, string $status): string + { + $moves = match ($status) { + 'open' => [['sent', 'Send to kitchen']], + 'sent', 'preparing', 'ready' => [['served', 'Mark served']], + 'served' => [['closed', 'Close order']], + default => [], + }; + if ($moves === []) { + return ''; + } + $html = '
'; + foreach ($moves as [$to, $verb]) { + $html .= '
' + . '' + . '' + . '' + . '' + . '
'; + } + return $html . '
'; + } + + private function notice(?string $code): string + { + if ($code === null || !isset(self::NOTICES[$code])) { + return ''; + } + [$kind, $message] = self::NOTICES[$code]; + return '
' . self::e($message) . '
'; + } + + private function styles(string $nonce): string + { + return ''; + } + + private static function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/plugin/src/RestaurantPlugin.php b/plugin/src/RestaurantPlugin.php index fe80966..65f0e6d 100644 --- a/plugin/src/RestaurantPlugin.php +++ b/plugin/src/RestaurantPlugin.php @@ -18,7 +18,8 @@ * on its own `rest_*` tables (ADR 0005), behind its own wildcard-immune capability * (ADR 0015), on capability-gated admin pages (ADR 0020) and MCP tools (ADR 0016). * - * Slice 1: the floor (tables). Orders, kitchen, payment, reservations and reports + * Slice 1: the floor (tables). Slice 2: orders + line items (menu read via the core + * content-read capability, ADR 0029). Kitchen, payment, reservations and reports * follow, each as its own slice. */ final class RestaurantPlugin implements Plugin @@ -29,6 +30,7 @@ final class RestaurantPlugin implements Plugin public function register(PluginContext $context): void { $context->migrations()->register('001_tables', Schema::tables()); + $context->migrations()->register('002_orders', Schema::orders()); // One coarse, wildcard-immune capability for v1 (danmat.restaurant:read/write). // Fine-grained staff roles are a recorded platform finding (F4), not app hacks. @@ -37,9 +39,13 @@ public function register(PluginContext $context): void // Storage is taken lazily, so register() runs no query and loads without a database. $storage = static fn (): PluginStorage => $context->storage(); $tables = new Tables($storage); + // The menu is a Nimbus collection, read in-process via the content-read + // capability (ADR 0029); Orders snapshots a line's name+price through it. + $menu = new Menu(static fn () => $context->content()); + $orders = new Orders($storage, $tables, static fn (int $menuItemId): ?array => $menu->snapshot($menuItemId)); // The agent surface — every tool gates on danmat.restaurant:read|write (ADR 0016). - $context->mcp()->register(new RestaurantToolset($tables)); + $context->mcp()->register(new RestaurantToolset($tables, $orders, $menu)); // 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 @@ -93,6 +99,102 @@ public function register(PluginContext $context): void return Response::redirect('/admin/restaurant?ok=deleted'); }); + // Orders terminal — list, open-on-table, and a single-order screen with the + // menu picker. A capability-gated admin page, same as the floor. + $context->adminPages()->register( + 'restaurant-orders', + 'Orders', + '🧾', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new OrdersAdmin($orders, $tables, $menu))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('view'), $r->query('status'), $nonce), + self::ID . ':write', + ); + + // Where an order action returns to: back to the order screen it was on + // (?view), else the list. + $backToOrder = static function (Request $r): string { + $view = trim((string) ($r->input('view') ?? '')); + return ($view !== '' && ctype_digit($view)) ? '/admin/restaurant-orders?view=' . $view . '&' : '/admin/restaurant-orders?'; + }; + + $context->adminPages()->action('restaurant-orders', 'order-open', static function (Request $r) use ($orders): Response { + $tableIn = trim((string) ($r->input('table_id') ?? '')); + if ($tableIn === '' || !ctype_digit($tableIn)) { + return Response::redirect('/admin/restaurant-orders?err=notable'); + } + try { + $id = $orders->open((int) $tableIn, date('Y-m-d H:i:s')); + return Response::redirect('/admin/restaurant-orders?view=' . $id . '&ok=opened'); + } catch (\Throwable) { + return Response::redirect('/admin/restaurant-orders?err=invalid'); + } + }); + + $context->adminPages()->action('restaurant-orders', 'order-status', static function (Request $r) use ($orders, $backToOrder): Response { + $base = $backToOrder($r); + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn !== '' && ctype_digit($idIn)) { + try { + $orders->setStatus((int) $idIn, (string) ($r->input('status') ?? ''), date('Y-m-d H:i:s')); + } catch (\Throwable) { + return Response::redirect($base . 'err=invalid'); + } + } + return Response::redirect($base . 'ok=updated'); + }); + + $context->adminPages()->action('restaurant-orders', 'order-add-item', static function (Request $r) use ($orders, $backToOrder): Response { + $base = $backToOrder($r); + $orderIn = trim((string) ($r->input('order_id') ?? '')); + if ($orderIn === '' || !ctype_digit($orderIn)) { + return Response::redirect($base . 'err=invalid'); + } + $menuIn = trim((string) ($r->input('menu_item_id') ?? '')); + $qtyIn = trim((string) ($r->input('qty') ?? '1')); + try { + $orders->addItem( + (int) $orderIn, + ($menuIn !== '' && ctype_digit($menuIn)) ? (int) $menuIn : null, + ($n = (string) ($r->input('name') ?? '')) !== '' ? $n : null, + ($p = (string) ($r->input('price') ?? '')) !== '' ? $p : null, + (ctype_digit($qtyIn) && (int) $qtyIn > 0) ? (int) $qtyIn : 1, + date('Y-m-d H:i:s'), + ); + return Response::redirect($base . 'ok=added'); + } catch (\Throwable) { + return Response::redirect($base . 'err=invalid'); + } + }); + + $context->adminPages()->action('restaurant-orders', 'order-set-qty', static function (Request $r) use ($orders, $backToOrder): Response { + $base = $backToOrder($r); + $itemIn = trim((string) ($r->input('item_id') ?? '')); + $qtyIn = trim((string) ($r->input('qty') ?? '')); + if ($itemIn !== '' && ctype_digit($itemIn) && $qtyIn !== '' && ctype_digit($qtyIn)) { + try { + $orders->setItemQty((int) $itemIn, (int) $qtyIn, date('Y-m-d H:i:s')); + } catch (\Throwable) { + return Response::redirect($base . 'err=invalid'); + } + } + return Response::redirect($base . 'ok=updated'); + }); + + $context->adminPages()->action('restaurant-orders', 'order-remove-item', static function (Request $r) use ($orders, $backToOrder): Response { + $itemIn = trim((string) ($r->input('item_id') ?? '')); + if ($itemIn !== '' && ctype_digit($itemIn)) { + $orders->removeItem((int) $itemIn); + } + return Response::redirect($backToOrder($r) . 'ok=removed'); + }); + + $context->adminPages()->action('restaurant-orders', 'order-delete', static function (Request $r) use ($orders): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn !== '' && ctype_digit($idIn)) { + $orders->delete((int) $idIn); + } + return Response::redirect('/admin/restaurant-orders?ok=deleted'); + }); + // Teach an MCP agent how to drive the restaurant (ADR 0013). $context->skills()->register('Restaurant', Guide::text()); } diff --git a/plugin/src/RestaurantToolset.php b/plugin/src/RestaurantToolset.php index 8c84e36..f62b68e 100644 --- a/plugin/src/RestaurantToolset.php +++ b/plugin/src/RestaurantToolset.php @@ -25,8 +25,11 @@ */ final class RestaurantToolset extends PluginToolset { - public function __construct(private Tables $tables) - { + public function __construct( + private Tables $tables, + private Orders $orders, + private MenuSource $menu, + ) { } public function namespace(): string @@ -76,9 +79,186 @@ protected function tools(): array 'required' => ['id'], 'properties' => ['id' => $id], ], $this->tableDelete(...)), + + new PluginTool('menu', 'read', 'List the menu items available to order (from the menu collection), each with a price.', [ + 'type' => 'object', + 'properties' => new \stdClass(), + ], $this->menu(...)), + + new PluginTool('order_open', 'write', 'Open a new order on a table (which becomes occupied). Returns the order.', [ + 'type' => 'object', + 'required' => ['table_id'], + 'properties' => ['table_id' => ['type' => 'integer', 'description' => 'The table to open the order on.']], + ], $this->orderOpen(...)), + + new PluginTool('orders', 'read', 'List orders, optionally filtered by status and/or table.', [ + 'type' => 'object', + 'properties' => [ + 'status' => ['type' => 'string', 'enum' => Orders::STATUSES, 'description' => 'Optional workflow-status filter.'], + 'table_id' => ['type' => 'integer', 'description' => 'Optional table filter.'], + ], + ], $this->orders(...)), + + new PluginTool('order_get', 'read', 'One order with its line items and computed total, or none.', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer', 'description' => 'The order id.']], + ], $this->orderGet(...)), + + new PluginTool('order_status', 'write', 'Advance an order through the workflow (open→sent→preparing→ready→served→closed).', [ + 'type' => 'object', + 'required' => ['id', 'status'], + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'The order id.'], + 'status' => ['type' => 'string', 'enum' => Orders::STATUSES, 'description' => 'The new status.'], + ], + ], $this->orderStatus(...)), + + new PluginTool('order_add_item', 'write', 'Add a line to an order — from the menu (menu_item_id, snapshotting its name+price) or a manual line (name+price).', [ + 'type' => 'object', + 'required' => ['order_id'], + 'properties' => [ + 'order_id' => ['type' => 'integer', 'description' => 'The order to add to.'], + 'menu_item_id' => ['type' => 'integer', 'description' => 'A menu item id to add (snapshots its name + price).'], + 'name' => ['type' => 'string', 'description' => 'A manual line name (when not adding from the menu).'], + 'price' => ['type' => 'string', 'description' => 'A manual line unit price (with menu_item_id omitted).'], + 'qty' => ['type' => 'integer', 'description' => 'How many. Defaults to 1.'], + ], + ], $this->orderAddItem(...)), + + new PluginTool('order_set_item_qty', 'write', 'Change a line item quantity; 0 removes it.', [ + 'type' => 'object', + 'required' => ['item_id', 'qty'], + 'properties' => [ + 'item_id' => ['type' => 'integer', 'description' => 'The line item id.'], + 'qty' => ['type' => 'integer', 'description' => 'The new quantity (0 to remove).'], + ], + ], $this->orderSetItemQty(...)), + + new PluginTool('order_remove_item', 'write', 'Remove a line item from an order.', [ + 'type' => 'object', + 'required' => ['item_id'], + 'properties' => ['item_id' => ['type' => 'integer', 'description' => 'The line item id.']], + ], $this->orderRemoveItem(...)), + + new PluginTool('order_delete', 'write', 'Delete an order and its line items.', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer', 'description' => 'The order id.']], + ], $this->orderDelete(...)), ]; } + /** + * @param array $a + * @return array + */ + private function menu(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $items = $this->menu->items(); + return ['menu' => $items, 'count' => count($items)]; + } + + /** + * @param array $a + * @return array + */ + private function orderOpen(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $orderId = $this->orders->open($this->requireInt($a, 'table_id'), $this->now()); + return ['ok' => true, 'order' => $this->orders->get($orderId)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function orders(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $list = $this->orders->all($this->nullableStr($a, 'status'), $this->nullableInt($a, 'table_id')); + return ['orders' => $list, 'count' => count($list)]; + } + + /** + * @param array $a + * @return array + */ + private function orderGet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['id' => $id, 'order' => $this->orders->get($id)]; + } + + /** + * @param array $a + * @return array + */ + private function orderStatus(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->requireInt($a, 'id'); + $changed = $this->orders->setStatus($id, (string) ($a['status'] ?? ''), $this->now()); + return ['ok' => true, 'changed' => $changed > 0, 'order' => $this->orders->get($id)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function orderAddItem(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $orderId = $this->requireInt($a, 'order_id'); + $qtyRaw = $this->nullableInt($a, 'qty'); + $this->orders->addItem( + $orderId, + $this->nullableInt($a, 'menu_item_id'), + $this->nullableStr($a, 'name'), + $this->nullableStr($a, 'price'), + $qtyRaw ?? 1, + $this->now(), + ); + return ['ok' => true, 'order' => $this->orders->get($orderId)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function orderSetItemQty(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $itemId = $this->requireInt($a, 'item_id'); + $qty = $this->requireInt($a, 'qty'); + $changed = $this->orders->setItemQty($itemId, $qty, $this->now()); + return ['ok' => true, 'changed' => $changed > 0]; + }); + } + + /** + * @param array $a + * @return array + */ + private function orderRemoveItem(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $itemId = $this->requireInt($a, 'item_id'); + return ['ok' => true, 'removed' => $this->orders->removeItem($itemId) > 0]; + } + + /** + * @param array $a + * @return array + */ + private function orderDelete(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['ok' => true, 'deleted' => $this->orders->delete($id) > 0]; + } + /** * @param array $a * @return array diff --git a/plugin/src/Schema.php b/plugin/src/Schema.php index 395a40e..ca3242f 100644 --- a/plugin/src/Schema.php +++ b/plugin/src/Schema.php @@ -16,7 +16,9 @@ */ final class Schema { - public const TABLE = 'rest_table'; + public const TABLE = 'rest_table'; + public const ORDER = 'rest_order'; + public const ORDER_ITEM = 'rest_order_item'; /** @return list each statement individually idempotent (ADR 0005) */ public static function tables(): array @@ -34,4 +36,42 @@ public static function tables(): array ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ]; } + + /** + * Orders and their line items — the heart of service. An order sits on a table + * and moves through a workflow (`open` → `sent` → `preparing` → `ready` → + * `served` → `closed`); `paid` is orthogonal (payment is its own slice). Each + * line **snapshots** the item's name and unit price at order time, so a later + * menu edit never rewrites a bill. `menu_item_id` is a soft reference to the + * `menu_items` collection entry a line came from (null for a manual line) — it + * is a breadcrumb, never a join dependency, since the snapshot is authoritative. + * + * @return list each statement individually idempotent (ADR 0005) + */ + public static function orders(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::ORDER . " ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + table_id BIGINT UNSIGNED NOT NULL, + status ENUM('open','sent','preparing','ready','served','closed') NOT NULL DEFAULT 'open', + paid TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_order_table (table_id), + INDEX idx_order_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + 'CREATE TABLE IF NOT EXISTS ' . self::ORDER_ITEM . ' ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT UNSIGNED NOT NULL, + menu_item_id BIGINT UNSIGNED NULL, + name VARCHAR(200) NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + qty SMALLINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + INDEX idx_item_order (order_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + ]; + } } diff --git a/plugin/tests/OrdersAdminTest.php b/plugin/tests/OrdersAdminTest.php new file mode 100644 index 0000000..98373a8 --- /dev/null +++ b/plugin/tests/OrdersAdminTest.php @@ -0,0 +1,98 @@ + 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::tables(), ...Schema::orders()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + $db->execute('TRUNCATE ' . Schema::ORDER); + $db->execute('TRUNCATE ' . Schema::ORDER_ITEM); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + $this->orders = new Orders(static fn (): PluginStorage => $storage, $this->tables, static fn (int $id): ?array => null); + + $menu = new class () implements MenuSource { + public function items(): array + { + return [['id' => 101, 'name' => 'Margherita', 'price' => '12.50', 'category' => 'Mains']]; + } + }; + $this->admin = new OrdersAdmin($this->orders, $this->tables, $menu); + } + + private function openOrder(): int + { + $t = $this->tables->save(null, ['label' => '1'], '2026-01-01 12:00:00'); + return $this->orders->open($t, '2026-01-01 12:00:00'); + } + + public function test_the_list_shows_the_open_form_and_a_table_option(): void + { + $this->tables->save(null, ['label' => 'Patio 2'], '2026-01-01 12:00:00'); + + $html = $this->admin->render('CSRF123', null, null, null, 'n'); + + self::assertStringContainsString('Open an order', $html); + self::assertStringContainsString('action="/admin/restaurant-orders/order-open"', $html); + self::assertStringContainsString('Patio 2', $html, 'the table is offered'); + self::assertStringContainsString('value="CSRF123"', $html); + } + + public function test_the_order_screen_escapes_a_manual_line_and_shows_the_total(): void + { + $id = $this->openOrder(); + $this->orders->addItem($id, null, 'Corkage', '5', 2, '2026-01-01 12:00:00'); + + $html = $this->admin->render('CSRF123', null, (string) $id, null, 'n'); + + self::assertStringContainsString('Order #' . $id, $html); + self::assertStringNotContainsString('Corkage', $html, 'a hostile line name is escaped'); + self::assertStringContainsString('<b>Corkage</b>', $html); + self::assertStringContainsString('10.00', $html, 'the computed total shows'); + self::assertStringContainsString('Margherita', $html, 'the menu picker is populated'); + self::assertStringContainsString('Send to kitchen', $html, 'an open order can be sent to the kitchen'); + } + + public function test_a_sent_order_offers_mark_served(): void + { + $id = $this->openOrder(); + $this->orders->setStatus($id, 'sent', '2026-01-01 12:00:00'); + + $html = $this->admin->render('CSRF123', null, (string) $id, null, 'n'); + self::assertStringContainsString('Mark served', $html); + self::assertStringNotContainsString('Send to kitchen', $html); + } +} diff --git a/plugin/tests/OrdersTest.php b/plugin/tests/OrdersTest.php new file mode 100644 index 0000000..c457f3f --- /dev/null +++ b/plugin/tests/OrdersTest.php @@ -0,0 +1,180 @@ + [name, price]. */ + private const MENU = [ + 101 => ['name' => 'Margherita', 'price' => '12.50'], + 102 => ['name' => 'Miso Soup', 'price' => '3.50'], + ]; + + protected function setUp(): void + { + $db = new Connection([ + 'host' => 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::tables(), ...Schema::orders()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + $db->execute('TRUNCATE ' . Schema::ORDER); + $db->execute('TRUNCATE ' . Schema::ORDER_ITEM); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + $this->orders = new Orders( + static fn (): PluginStorage => $storage, + $this->tables, + static fn (int $id): ?array => self::MENU[$id] ?? null, + ); + } + + private const NOW = '2026-01-01 12:00:00'; + + private function table(string $label = '1'): int + { + return $this->tables->save(null, ['label' => $label], self::NOW); + } + + public function test_opening_an_order_occupies_the_table(): void + { + $t = $this->table(); + $id = $this->orders->open($t, self::NOW); + + $order = $this->orders->get($id); + self::assertNotNull($order); + self::assertSame('open', $order['status']); + self::assertSame($t, $order['table_id']); + self::assertSame('occupied', $this->tables->get($t)['status'], 'seating a party occupies the table'); + } + + public function test_opening_on_a_missing_table_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->orders->open(999999, self::NOW); + } + + public function test_adding_a_menu_item_snapshots_name_and_price_and_totals(): void + { + $id = $this->orders->open($this->table(), self::NOW); + $this->orders->addItem($id, 101, null, null, 2, self::NOW); // 2 × 12.50 + $this->orders->addItem($id, 102, null, null, 1, self::NOW); // 1 × 3.50 + + $order = $this->orders->get($id); + self::assertCount(2, $order['items']); + self::assertSame('Margherita', $order['items'][0]['name']); + self::assertSame('12.50', $order['items'][0]['unit_price']); + self::assertSame('25.00', $order['items'][0]['line_total']); + self::assertSame('28.50', $order['total'], 'the total is computed from the lines'); + } + + public function test_a_manual_line_is_allowed_but_a_bad_price_is_rejected(): void + { + $id = $this->orders->open($this->table(), self::NOW); + $this->orders->addItem($id, null, 'Corkage', '5', 1, self::NOW); + self::assertSame('5.00', $this->orders->get($id)['items'][0]['unit_price']); + + $this->expectException(\InvalidArgumentException::class); + $this->orders->addItem($id, null, 'Bad', 'free', 1, self::NOW); + } + + public function test_adding_an_unknown_menu_item_is_rejected(): void + { + $id = $this->orders->open($this->table(), self::NOW); + $this->expectException(\InvalidArgumentException::class); + $this->orders->addItem($id, 999, null, null, 1, self::NOW); + } + + public function test_adding_to_a_missing_order_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->orders->addItem(424242, 101, null, null, 1, self::NOW); + } + + public function test_a_bad_quantity_is_rejected(): void + { + $id = $this->orders->open($this->table(), self::NOW); + $this->expectException(\InvalidArgumentException::class); + $this->orders->addItem($id, 101, null, null, 0, self::NOW); + } + + public function test_set_qty_changes_and_zero_removes(): void + { + $id = $this->orders->open($this->table(), self::NOW); + $this->orders->addItem($id, 101, null, null, 1, self::NOW); + $itemId = $this->orders->get($id)['items'][0]['id']; + + $this->orders->setItemQty($itemId, 3, self::NOW); + self::assertSame('37.50', $this->orders->get($id)['total']); + + $this->orders->setItemQty($itemId, 0, self::NOW); + self::assertSame([], $this->orders->get($id)['items'], 'qty 0 removes the line'); + } + + public function test_remove_item(): void + { + $id = $this->orders->open($this->table(), self::NOW); + $this->orders->addItem($id, 102, null, null, 1, self::NOW); + $itemId = $this->orders->get($id)['items'][0]['id']; + + self::assertSame(1, $this->orders->removeItem($itemId)); + self::assertSame('0.00', $this->orders->get($id)['total']); + } + + public function test_status_workflow_is_an_allow_list(): void + { + $id = $this->orders->open($this->table(), self::NOW); + self::assertSame(1, $this->orders->setStatus($id, 'sent', self::NOW)); + self::assertSame('sent', $this->orders->get($id)['status']); + + $this->expectException(\InvalidArgumentException::class); + $this->orders->setStatus($id, 'incinerated', self::NOW); + } + + public function test_delete_takes_the_line_items_with_it(): void + { + $id = $this->orders->open($this->table(), self::NOW); + $this->orders->addItem($id, 101, null, null, 1, self::NOW); + + self::assertSame(1, $this->orders->delete($id)); + self::assertNull($this->orders->get($id)); + } + + public function test_all_filters_by_status_and_table(): void + { + $t1 = $this->table('1'); + $t2 = $this->table('2'); + $o1 = $this->orders->open($t1, self::NOW); + $this->orders->open($t2, self::NOW); + $this->orders->setStatus($o1, 'served', self::NOW); + + self::assertCount(2, $this->orders->all()); + self::assertCount(1, $this->orders->all('served')); + self::assertCount(1, $this->orders->all(null, $t2)); + } +} diff --git a/plugin/tests/RestaurantToolsetTest.php b/plugin/tests/RestaurantToolsetTest.php index 9bf10f5..abd25e5 100644 --- a/plugin/tests/RestaurantToolsetTest.php +++ b/plugin/tests/RestaurantToolsetTest.php @@ -4,6 +4,8 @@ namespace DanMat\Restaurant\Tests; +use DanMat\Restaurant\Menu; +use DanMat\Restaurant\Orders; use DanMat\Restaurant\RestaurantToolset; use DanMat\Restaurant\Schema; use DanMat\Restaurant\Tables; @@ -35,13 +37,21 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach (Schema::tables() as $sql) { + foreach ([...Schema::tables(), ...Schema::orders()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::TABLE); + $db->execute('TRUNCATE ' . Schema::ORDER); + $db->execute('TRUNCATE ' . Schema::ORDER_ITEM); - $storage = new PluginStorage($db); - $this->toolset = new RestaurantToolset(new Tables(static fn (): PluginStorage => $storage)); + $storage = new PluginStorage($db); + $tables = new Tables(static fn (): PluginStorage => $storage); + $orders = new Orders(static fn (): PluginStorage => $storage, $tables, static fn (int $id): ?array => null); + // The menu reader is never exercised here (order lines are manual), so a + // reader that would need core content is fine left unbuilt. + $menu = new Menu(static fn () => throw new \RuntimeException('no content reader in this test')); + + $this->toolset = new RestaurantToolset($tables, $orders, $menu); $this->toolset->bindTo('danmat.restaurant'); $this->ctx = new EntryOpContext('127.0.0.1', '/api/v1/mcp'); @@ -61,13 +71,45 @@ private function principal(string ...$scopes): TokenPrincipal public function test_the_tools_are_namespaced_and_split_read_from_write(): void { $names = array_column($this->toolset->definitions($this->principal('danmat.restaurant:read', 'danmat.restaurant:write')), 'name'); - self::assertSame(['restaurant_tables', 'restaurant_table_get', 'restaurant_table_set', 'restaurant_table_status', 'restaurant_table_delete'], $names); + self::assertSame([ + 'restaurant_tables', 'restaurant_table_get', 'restaurant_table_set', 'restaurant_table_status', 'restaurant_table_delete', + 'restaurant_menu', 'restaurant_order_open', 'restaurant_orders', 'restaurant_order_get', 'restaurant_order_status', + 'restaurant_order_add_item', 'restaurant_order_set_item_qty', 'restaurant_order_remove_item', 'restaurant_order_delete', + ], $names); } public function test_a_read_only_token_sees_only_the_read_tools(): void { $names = array_column($this->toolset->definitions($this->principal('danmat.restaurant:read')), 'name'); - self::assertSame(['restaurant_tables', 'restaurant_table_get'], $names); + self::assertSame(['restaurant_tables', 'restaurant_table_get', 'restaurant_menu', 'restaurant_orders', 'restaurant_order_get'], $names); + } + + public function test_an_order_can_be_run_end_to_end_over_mcp(): void + { + $write = $this->principal('danmat.restaurant:read', 'danmat.restaurant:write'); + $tid = $this->toolset->call('restaurant_table_set', ['label' => '5'], $write, $this->ctx)['table']['id']; + + $opened = $this->toolset->call('restaurant_order_open', ['table_id' => $tid], $write, $this->ctx); + self::assertTrue($opened['ok']); + $orderId = $opened['order']['id']; + self::assertSame('occupied', $this->toolset->call('restaurant_table_get', ['id' => $tid], $write, $this->ctx)['table']['status']); + + // A manual line (no menu read needed): 2 × 6.00. + $this->toolset->call('restaurant_order_add_item', ['order_id' => $orderId, 'name' => 'House wine', 'price' => '6', 'qty' => 2], $write, $this->ctx); + $got = $this->toolset->call('restaurant_order_get', ['id' => $orderId], $write, $this->ctx); + self::assertSame('12.00', $got['order']['total']); + + $this->toolset->call('restaurant_order_status', ['id' => $orderId, 'status' => 'sent'], $write, $this->ctx); + self::assertSame('sent', $this->toolset->call('restaurant_order_get', ['id' => $orderId], $write, $this->ctx)['order']['status']); + + self::assertTrue($this->toolset->call('restaurant_order_delete', ['id' => $orderId], $write, $this->ctx)['deleted']); + } + + public function test_a_content_token_cannot_reach_orders(): void + { + $this->expectException(McpError::class); + $this->expectExceptionMessage('Unknown tool "restaurant_order_open"'); + $this->toolset->call('restaurant_order_open', ['table_id' => 1], $this->principal('*:read', '*:write'), $this->ctx); } public function test_a_content_token_cannot_reach_the_floor(): void From c9b472df541707c0a29dc3c95cf7f0bb111e62a4 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sat, 5 Sep 2026 15:40:51 -0400 Subject: [PATCH 06/32] docs: Orders + Tables done; F1/A2 closed by core ADR 0029 Ledger + README: Tables and Orders verticals marked done; findings F1 (relation/reference expansion) and A2 (plugins reading a collection in-process) resolved by the NimbusCMS plugin content-read capability (ADR 0029) that the Orders vertical forced. Co-Authored-By: Claude Opus 4.8 --- README-NIMBUS.md | 9 ++++++--- docs/PLATFORM-VALIDATION.md | 22 +++++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/README-NIMBUS.md b/README-NIMBUS.md index 11421c8..23e9601 100644 --- a/README-NIMBUS.md +++ b/README-NIMBUS.md @@ -21,9 +21,12 @@ collections. Zero Nimbus core change. - ✅ **Menu** — categories and priced menu items, as Nimbus collections. Proven. - ✅ **Tables (the floor)** — the `restaurant` plugin: `rest_table` with live - status, a mobile floor board, capability-gated admin + MCP. First operational - vertical; validated the plugin architecture. -- ⬜ Orders, Kitchen display, Payment, Staff & roles, Reservations, Reports — next. + status, a mobile floor board, capability-gated admin + MCP. +- ✅ **Orders** — `rest_order` + `rest_order_item`: open on a table, pick from the + menu (snapshotting name + price), quantities, workflow, server-computed totals, + admin + MCP. Forced and consumes the new core content-read capability (ADR 0029), + closing findings F1/A2. +- ⬜ Kitchen display, Payment, Staff & roles, Reservations, Reports — next. ## Layout diff --git a/docs/PLATFORM-VALIDATION.md b/docs/PLATFORM-VALIDATION.md index 28260d1..88b9d66 100644 --- a/docs/PLATFORM-VALIDATION.md +++ b/docs/PLATFORM-VALIDATION.md @@ -24,8 +24,8 @@ logic landing in Nimbus core.** | Vertical | Status | Needed a new core capability? | |----------|--------|-------------------------------| | **Menu** (categories, priced items) | ✅ proven on stock Nimbus | No | -| Tables | ⬜ not started | likely: a user/staff reference field | -| Orders | ⬜ not started | likely: repeatable line items, workflow state | +| **Tables** | ✅ done (plugin) | No — coarse capability only (see F4) | +| **Orders** | ✅ done (plugin) | **Yes — the plugin content-read capability (ADR 0029 in core), which closed F1/A2** | | Kitchen display | ⬜ not started | likely: plugin routes + admin pages | | Reservations | ⬜ not started | tbd | | Reports | ⬜ not started | likely: dashboard widgets / aggregation | @@ -60,7 +60,21 @@ These are things the Menu vertical surfaced. None *blocked* Menu, so none has been built yet — they are logged for when a later vertical makes them a blocker, at which point each becomes a Nimbus core PR with its own ADR. -### F1 — The API returns relations as bare ids +### F1 / A2 — Plugins had no in-process way to read a collection — ✅ RESOLVED (NimbusCMS ADR 0029) + +**Resolved 2026-09-05** by a core capability the Orders vertical forced: a +read-only, published-only `ContentReader` exposed to plugins as +`PluginContext::content()` ([NimbusCMS PR #209](https://github.com/NimbusCMS/nimbus/pull/209), +core ADR 0029). The restaurant's `Menu` reads `menu_items` through it and snapshots +each ordered line's name + price. This is the platform-validation initiative working +as intended: the app drove the *smallest broadly-reusable* core capability, landed +with its own ADR + reviews in core, no restaurant-specific logic in it. The +relation-expansion note below is subsumed — `ContentReader` returns entries with +references expanded (like a theme). + +
Original F1 analysis (subsumed by ADR 0029) + +#### F1 — The API returns relations as bare ids `"category": [15]` means a frontend must make a second call per category to render "Margherita — *Mains* — $12.50". **Candidate capability:** relation (and @@ -68,6 +82,8 @@ in general, reference) *expansion* in the read API — the same enrichment media fields already get. Broadly reusable; almost every real frontend wants it. **Severity:** high — likely the first capability Orders/Menu-frontend forces. +
+ ### F2 — How an application consumes Nimbus — ✅ DECIDED (ADR-0001) **Resolved 2026-09-05** ([`adr/0001`](adr/0001-restaurant-as-a-colocated-nimbus-plugin.md)): From 78d86db905e584f0cfa433b035ca35b101af6811 Mon Sep 17 00:00:00 2001 From: Danny Matthew Date: Sat, 5 Sep 2026 15:49:20 -0400 Subject: [PATCH 07/32] Slice 3: Kitchen display (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cook's screen — no new schema, built on the Orders workflow. - Orders::ticketsByStatus(statuses) — the kitchen queue: orders in the given allow-listed statuses with their line items, oldest-first (FIFO), batched into two bound queries (never N+1). - KitchenAdmin: a three-column board (New=sent → Preparing → Ready), each ticket showing table, items and age; Start (sent→preparing) and Ready (preparing→ready) advance actions; the floor still owns "served". A capability-gated admin page (not a public route, per the security review), CSRF on advance, mobile-reflow, and a nonce'd auto-refresh (15s). - MCP: a `kitchen` read tool (the queue); advancing reuses order_status. - Guide: kitchen section. - Tests: OrdersTest (ticketsByStatus FIFO + item batching + unknown-status ignored), KitchenAdminTest (columns, advance action, escape-on-render, nonce'd refresh, ready has no kitchen action), RestaurantToolsetTest (kitchen tool listed + queue lists sent/preparing tickets). cs-fixer + PHPStan green locally; phpunit runs in CI. Co-authored-by: Claude Opus 4.8 --- plugin/src/Guide.php | 7 ++ plugin/src/KitchenAdmin.php | 166 +++++++++++++++++++++++++ plugin/src/Orders.php | 61 +++++++++ plugin/src/RestaurantPlugin.php | 26 +++- plugin/src/RestaurantToolset.php | 15 +++ plugin/tests/KitchenAdminTest.php | 95 ++++++++++++++ plugin/tests/OrdersTest.php | 29 +++++ plugin/tests/RestaurantToolsetTest.php | 19 ++- 8 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 plugin/src/KitchenAdmin.php create mode 100644 plugin/tests/KitchenAdminTest.php diff --git a/plugin/src/Guide.php b/plugin/src/Guide.php index 7b8296c..4ac84b5 100644 --- a/plugin/src/Guide.php +++ b/plugin/src/Guide.php @@ -63,6 +63,13 @@ public static function text(): string A line snapshots the item's name and price when added, so editing the menu later never changes an existing order. + + ## Kitchen + + - `restaurant_kitchen` — the kitchen queue: orders in `sent`, `preparing` or + `ready`, oldest first, each with its items. Advance a ticket by setting its + status with `restaurant_order_status`: `sent` → `preparing` (started) → + `ready` (up for the pass). The floor then marks it `served`. MD; } } diff --git a/plugin/src/KitchenAdmin.php b/plugin/src/KitchenAdmin.php new file mode 100644 index 0000000..104a6a7 --- /dev/null +++ b/plugin/src/KitchenAdmin.php @@ -0,0 +1,166 @@ + ['label' => 'New', 'next' => 'preparing', 'verb' => 'Start'], + 'preparing' => ['label' => 'Preparing', 'next' => 'ready', 'verb' => 'Ready'], + 'ready' => ['label' => 'Ready', 'next' => null, 'verb' => null], + ]; + + private const NOTICES = [ + 'advanced' => ['ok', 'Ticket updated.'], + 'invalid' => ['err', 'Could not update that ticket.'], + ]; + + public function __construct(private Orders $orders) + { + } + + public function render(string $csrf = '', ?string $notice = null, string $nonce = ''): string + { + $tickets = $this->orders->ticketsByStatus(array_keys(self::COLUMNS)); + + $byStatus = []; + foreach (array_keys(self::COLUMNS) as $s) { + $byStatus[$s] = []; + } + foreach ($tickets as $t) { + $byStatus[(string) $t['status']][] = $t; + } + + $cols = ''; + foreach (self::COLUMNS as $status => $col) { + $cols .= $this->column($csrf, $status, $col, $byStatus[$status]); + } + + return $this->styles($nonce) + . '

Kitchen

' + . $this->notice($notice) + . '

Tickets on the line. Start a ticket when you begin it, mark it ready when it is up for the pass.

' + . '
' . $cols . '
' + . $this->autoRefresh($nonce); + } + + /** + * @param array{label:string,next:?string,verb:?string} $col + * @param list> $tickets + */ + private function column(string $csrf, string $status, array $col, array $tickets): string + { + $cards = ''; + foreach ($tickets as $t) { + $cards .= $this->ticket($csrf, $t, $col); + } + if ($cards === '') { + $cards = '

Nothing here.

'; + } + + return '
' + . '

' . self::e($col['label']) . ' ' . count($tickets) . '

' + . '
' . $cards . '
'; + } + + /** + * @param array $t + * @param array{label:string,next:?string,verb:?string} $col + */ + private function ticket(string $csrf, array $t, array $col): string + { + $lines = ''; + foreach ($t['items'] as $item) { + $lines .= '
  • ' . self::e((string) $item['qty']) . '× ' . self::e((string) $item['name']) . '
  • '; + } + if ($lines === '') { + $lines = '
  • No items
  • '; + } + + $advance = ''; + if ($col['next'] !== null && $col['verb'] !== null) { + $advance = '
    ' + . '' + . '' + . '' + . '
    '; + } + + return '
    ' + . '
    ' . self::e((string) ($t['table_label'] ?? '—')) . '' + . '#' . self::e((string) $t['id']) . ' · ' . self::e($this->age((string) $t['updated_at'])) . '
    ' + . '
      ' . $lines . '
    ' + . $advance + . '
    '; + } + + /** A compact "how long on the line" label from a datetime. */ + private function age(string $updatedAt): string + { + $ts = strtotime($updatedAt); + if ($ts === false) { + return ''; + } + $mins = max(0, (int) floor((time() - $ts) / 60)); + if ($mins < 60) { + return $mins . 'm'; + } + return intdiv($mins, 60) . 'h ' . ($mins % 60) . 'm'; + } + + private function notice(?string $code): string + { + if ($code === null || !isset(self::NOTICES[$code])) { + return ''; + } + [$kind, $message] = self::NOTICES[$code]; + return '
    ' . self::e($message) . '
    '; + } + + private function autoRefresh(string $nonce): string + { + // The admin CSP is nonce-only for script-src; the handler is given the nonce + // precisely so a page can run a small inline script (ADR 0020). + return ''; + } + + private function styles(string $nonce): string + { + return ''; + } + + private static function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/plugin/src/Orders.php b/plugin/src/Orders.php index 94fefcd..e204e53 100644 --- a/plugin/src/Orders.php +++ b/plugin/src/Orders.php @@ -124,6 +124,67 @@ public function all(?string $status = null, ?int $tableId = null): array }, $this->storage()->select($sql, $params)); } + /** + * Orders currently in the given (allow-listed) statuses, each with its line + * items — the kitchen queue. Oldest first (FIFO). Batched into two bound queries + * (orders, then all their items), never N+1. An unknown status is ignored. + * + * @param list $statuses + * @return list,created_at:string,updated_at:string}> + */ + public function ticketsByStatus(array $statuses): array + { + $valid = array_values(array_filter($statuses, static fn (string $s): bool => in_array($s, self::STATUSES, true))); + if ($valid === []) { + return []; + } + + $placeholders = []; + $params = []; + foreach ($valid as $i => $status) { + $placeholders[] = ':s' . $i; + $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 + 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, + ); + if ($orders === []) { + return []; + } + + $ids = array_map(static fn (array $r): int => (int) $r['id'], $orders); + $itemPh = []; + $itemParam = []; + foreach ($ids as $i => $oid) { + $itemPh[] = ':o' . $i; + $itemParam['o' . $i] = $oid; + } + $itemRows = $this->storage()->select( + 'SELECT order_id, name, qty FROM ' . Schema::ORDER_ITEM . ' WHERE order_id IN (' . implode(', ', $itemPh) . ') ORDER BY id', + $itemParam, + ); + $byOrder = []; + foreach ($itemRows as $r) { + $byOrder[(int) $r['order_id']][] = ['name' => (string) $r['name'], 'qty' => (int) $r['qty']]; + } + + 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'], + ]; + }, $orders); + } + /** Move an order to an allow-listed workflow status. Returns rows changed. */ public function setStatus(int $id, string $status, string $now): int { diff --git a/plugin/src/RestaurantPlugin.php b/plugin/src/RestaurantPlugin.php index 65f0e6d..20ab0c0 100644 --- a/plugin/src/RestaurantPlugin.php +++ b/plugin/src/RestaurantPlugin.php @@ -19,8 +19,8 @@ * (ADR 0015), on capability-gated admin pages (ADR 0020) and MCP tools (ADR 0016). * * Slice 1: the floor (tables). Slice 2: orders + line items (menu read via the core - * content-read capability, ADR 0029). Kitchen, payment, reservations and reports - * follow, each as its own slice. + * content-read capability, ADR 0029). Slice 3: the kitchen display. Payment, + * reservations and reports follow, each as its own slice. */ final class RestaurantPlugin implements Plugin { @@ -195,6 +195,28 @@ public function register(PluginContext $context): void return Response::redirect('/admin/restaurant-orders?ok=deleted'); }); + // Kitchen display — the cook's screen. A capability-gated admin page (not a + // public route), read-mostly with an advance action per ticket. + $context->adminPages()->register( + 'restaurant-kitchen', + 'Kitchen', + '👨‍🍳', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new KitchenAdmin($orders))->render($csrf, $r->query('ok') ?? $r->query('err'), $nonce), + self::ID . ':write', + ); + + $context->adminPages()->action('restaurant-kitchen', 'advance', static function (Request $r) use ($orders): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn !== '' && ctype_digit($idIn)) { + try { + $orders->setStatus((int) $idIn, (string) ($r->input('status') ?? ''), date('Y-m-d H:i:s')); + } catch (\Throwable) { + return Response::redirect('/admin/restaurant-kitchen?err=invalid'); + } + } + return Response::redirect('/admin/restaurant-kitchen?ok=advanced'); + }); + // Teach an MCP agent how to drive the restaurant (ADR 0013). $context->skills()->register('Restaurant', Guide::text()); } diff --git a/plugin/src/RestaurantToolset.php b/plugin/src/RestaurantToolset.php index f62b68e..ad6b7a3 100644 --- a/plugin/src/RestaurantToolset.php +++ b/plugin/src/RestaurantToolset.php @@ -146,9 +146,24 @@ protected function tools(): array 'required' => ['id'], 'properties' => ['id' => ['type' => 'integer', 'description' => 'The order id.']], ], $this->orderDelete(...)), + + new PluginTool('kitchen', 'read', 'The kitchen queue: tickets in the kitchen (sent/preparing/ready), oldest first, each with its items. Advance one with order_status.', [ + 'type' => 'object', + 'properties' => new \stdClass(), + ], $this->kitchen(...)), ]; } + /** + * @param array $a + * @return array + */ + private function kitchen(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $tickets = $this->orders->ticketsByStatus(['sent', 'preparing', 'ready']); + return ['tickets' => $tickets, 'count' => count($tickets)]; + } + /** * @param array $a * @return array diff --git a/plugin/tests/KitchenAdminTest.php b/plugin/tests/KitchenAdminTest.php new file mode 100644 index 0000000..e3a7f5c --- /dev/null +++ b/plugin/tests/KitchenAdminTest.php @@ -0,0 +1,95 @@ + 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::tables(), ...Schema::orders()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + $db->execute('TRUNCATE ' . Schema::ORDER); + $db->execute('TRUNCATE ' . Schema::ORDER_ITEM); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + $this->orders = new Orders(static fn (): PluginStorage => $storage, $this->tables, static fn (int $id): ?array => null); + $this->admin = new KitchenAdmin($this->orders); + } + + private function sentOrderWithItem(string $itemName): int + { + $t = $this->tables->save(null, ['label' => '1'], '2026-01-01 12:00:00'); + $id = $this->orders->open($t, '2026-01-01 12:00:00'); + $this->orders->addItem($id, null, $itemName, '5', 1, '2026-01-01 12:00:00'); + $this->orders->setStatus($id, 'sent', '2026-01-01 12:00:00'); + return $id; + } + + public function test_a_new_ticket_shows_in_the_new_column_with_a_start_action(): void + { + $this->sentOrderWithItem('Chowder'); + + $html = $this->admin->render('CSRF123', null, 'n'); + + self::assertStringContainsString('Chowder', $html); + self::assertStringContainsString('action="/admin/restaurant-kitchen/advance"', $html); + self::assertStringContainsString('value="preparing"', $html, 'a New ticket advances to preparing'); + self::assertStringContainsString('Start', $html); + self::assertStringContainsString('value="CSRF123"', $html); + } + + public function test_it_escapes_a_hostile_item_name(): void + { + $this->sentOrderWithItem(''); + + $html = $this->admin->render('CSRF123', null, 'n'); + + self::assertStringNotContainsString('', $html); + self::assertStringContainsString('<script>', $html); + } + + public function test_a_ready_ticket_has_no_further_kitchen_action(): void + { + $id = $this->sentOrderWithItem('Soup'); + $this->orders->setStatus($id, 'ready', '2026-01-01 12:05:00'); + + $html = $this->admin->render('CSRF123', null, 'n'); + // The Ready column shows it, but offers no advance (the floor serves it). + self::assertStringContainsString('Ready', $html); + self::assertStringNotContainsString('value="served"', $html, 'the kitchen never serves; the floor does'); + } + + public function test_the_page_auto_refreshes_with_a_nonced_script(): void + { + self::assertStringContainsString(''], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, null, 'n'); + + self::assertStringNotContainsString('', $html); + self::assertStringContainsString('<script>', $html); + self::assertStringContainsString('value="CSRF123"', $html); + } + + public function test_a_linked_booking_links_out_to_the_crm_but_shows_no_crm_data(): void + { + $this->reservations->save(null, ['party_name' => 'Regular', 'contact_id' => '4242'], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, null, 'n'); + // Links to the CRM's own (separately gated) contact page — no contact data here. + self::assertStringContainsString('href="/admin/crm?edit=4242"', $html); + self::assertStringContainsString('Guest in CRM', $html); + } + + public function test_an_unlinked_booking_shows_no_crm_link(): void + { + $this->reservations->save(null, ['party_name' => 'Walk-in'], '2026-01-01 09:00:00'); + + $html = $this->admin->render('CSRF123', null, null, null, 'n'); + self::assertStringNotContainsString('/admin/crm?edit=', $html); + } +} diff --git a/plugin/tests/ReservationsTest.php b/plugin/tests/ReservationsTest.php new file mode 100644 index 0000000..b618211 --- /dev/null +++ b/plugin/tests/ReservationsTest.php @@ -0,0 +1,133 @@ + 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::tables(), ...Schema::reservations()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + $db->execute('TRUNCATE ' . Schema::RESERVATION); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + $this->reservations = new Reservations(static fn (): PluginStorage => $storage, $this->tables); + } + + private const NOW = '2026-01-01 09:00:00'; + + public function test_create_get_and_update_round_trip(): void + { + $id = $this->reservations->save(null, ['party_name' => 'Smith', 'party_size' => '4', 'reserved_at' => '2026-06-01 19:30:00'], self::NOW); + $r = $this->reservations->get($id); + self::assertNotNull($r); + self::assertSame('Smith', $r['party_name']); + self::assertSame(4, $r['party_size']); + self::assertSame('2026-06-01 19:30:00', $r['reserved_at']); + self::assertSame('booked', $r['status']); + + $this->reservations->save($id, ['party_size' => '6'], '2026-01-02 09:00:00'); + self::assertSame(6, $this->reservations->get($id)['party_size']); + self::assertSame('Smith', $this->reservations->get($id)['party_name'], 'unsent fields unchanged'); + } + + public function test_a_reservation_needs_a_party_name(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->reservations->save(null, ['party_size' => '2'], self::NOW); + } + + public function test_reserved_at_accepts_datetime_local_and_rejects_junk(): void + { + $id = $this->reservations->save(null, ['party_name' => 'A', 'reserved_at' => '2026-06-01T19:30'], self::NOW); + self::assertSame('2026-06-01 19:30:00', $this->reservations->get($id)['reserved_at']); + + $this->expectException(\InvalidArgumentException::class); + $this->reservations->save(null, ['party_name' => 'B', 'reserved_at' => 'friday-ish'], self::NOW); + } + + public function test_a_table_link_must_exist(): void + { + $tableId = $this->tables->save(null, ['label' => '7'], self::NOW); + $id = $this->reservations->save(null, ['party_name' => 'Jones', 'table_id' => (string) $tableId], self::NOW); + self::assertSame($tableId, $this->reservations->get($id)['table_id']); + self::assertSame('7', $this->reservations->get($id)['table_label']); + + $this->expectException(\InvalidArgumentException::class); + $this->reservations->save(null, ['party_name' => 'Ghost', 'table_id' => '99999'], self::NOW); + } + + public function test_contact_id_is_stored_as_a_bare_link_not_resolved(): void + { + // A positive int is accepted and stored verbatim — no CRM read, no existence + // check (that would breach the CRM's gate). A non-positive value is rejected. + $id = $this->reservations->save(null, ['party_name' => 'Regular', 'contact_id' => '4242'], self::NOW); + self::assertSame(4242, $this->reservations->get($id)['contact_id']); + + $blank = $this->reservations->save(null, ['party_name' => 'Walkin', 'contact_id' => ''], self::NOW); + self::assertNull($this->reservations->get($blank)['contact_id']); + } + + public function test_a_bad_contact_id_is_rejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->reservations->save(null, ['party_name' => 'X', 'contact_id' => '-1'], self::NOW); + } + + public function test_status_is_an_allow_list_and_set_status_moves_it(): void + { + $id = $this->reservations->save(null, ['party_name' => 'X'], self::NOW); + self::assertSame(1, $this->reservations->setStatus($id, 'seated', self::NOW)); + self::assertSame('seated', $this->reservations->get($id)['status']); + + $this->expectException(\InvalidArgumentException::class); + $this->reservations->setStatus($id, 'teleported', self::NOW); + } + + public function test_all_orders_soonest_first_and_filters_by_status(): void + { + $this->reservations->save(null, ['party_name' => 'Late', 'reserved_at' => '2026-06-01 21:00:00'], self::NOW); + $this->reservations->save(null, ['party_name' => 'Early', 'reserved_at' => '2026-06-01 18:00:00'], self::NOW); + $cancel = $this->reservations->save(null, ['party_name' => 'Gone', 'reserved_at' => '2026-06-01 19:00:00', 'status' => 'cancelled'], self::NOW); + + $names = array_column($this->reservations->all(), 'party_name'); + self::assertSame(['Early', 'Gone', 'Late'], $names, 'soonest first'); + self::assertCount(1, $this->reservations->all('cancelled')); + self::assertSame($cancel, $this->reservations->all('cancelled')[0]['id']); + } + + public function test_delete_removes_it(): void + { + $id = $this->reservations->save(null, ['party_name' => 'X'], self::NOW); + self::assertSame(1, $this->reservations->delete($id)); + self::assertNull($this->reservations->get($id)); + } +} diff --git a/plugin/tests/RestaurantPluginTest.php b/plugin/tests/RestaurantPluginTest.php index 60596c7..6af9a47 100644 --- a/plugin/tests/RestaurantPluginTest.php +++ b/plugin/tests/RestaurantPluginTest.php @@ -48,5 +48,6 @@ public function test_each_terminal_is_gated_on_the_right_action(): void self::assertSame('danmat.restaurant:floor', $gate['restaurant'], 'the floor is floor-staff only'); self::assertSame('danmat.restaurant:floor', $gate['restaurant-orders'], 'orders + payment are floor-staff'); self::assertSame('danmat.restaurant:kitchen', $gate['restaurant-kitchen'], 'the kitchen is cooks only'); + self::assertSame('danmat.restaurant:floor', $gate['restaurant-reservations'], 'the book is floor-staff'); } } diff --git a/plugin/tests/RestaurantToolsetTest.php b/plugin/tests/RestaurantToolsetTest.php index 959eb38..ef92d0b 100644 --- a/plugin/tests/RestaurantToolsetTest.php +++ b/plugin/tests/RestaurantToolsetTest.php @@ -6,6 +6,7 @@ use DanMat\Restaurant\Menu; use DanMat\Restaurant\Orders; +use DanMat\Restaurant\Reservations; use DanMat\Restaurant\RestaurantToolset; use DanMat\Restaurant\Schema; use DanMat\Restaurant\Tables; @@ -37,21 +38,23 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...Schema::tables(), ...Schema::orders()] as $sql) { + foreach ([...Schema::tables(), ...Schema::orders(), ...Schema::reservations()] as $sql) { $db->execute($sql); } $db->execute('TRUNCATE ' . Schema::TABLE); $db->execute('TRUNCATE ' . Schema::ORDER); $db->execute('TRUNCATE ' . Schema::ORDER_ITEM); + $db->execute('TRUNCATE ' . Schema::RESERVATION); - $storage = new PluginStorage($db); - $tables = new Tables(static fn (): PluginStorage => $storage); - $orders = new Orders(static fn (): PluginStorage => $storage, $tables, static fn (int $id): ?array => null); + $storage = new PluginStorage($db); + $tables = new Tables(static fn (): PluginStorage => $storage); + $orders = new Orders(static fn (): PluginStorage => $storage, $tables, static fn (int $id): ?array => null); + $reservations = new Reservations(static fn (): PluginStorage => $storage, $tables); // The menu reader is never exercised here (order lines are manual), so a // reader that would need core content is fine left unbuilt. $menu = new Menu(static fn () => throw new \RuntimeException('no content reader in this test')); - $this->toolset = new RestaurantToolset($tables, $orders, $menu); + $this->toolset = new RestaurantToolset($tables, $orders, $menu, $reservations); $this->toolset->bindTo('danmat.restaurant'); $this->ctx = new EntryOpContext('127.0.0.1', '/api/v1/mcp'); @@ -76,13 +79,41 @@ public function test_the_tools_are_namespaced_and_split_read_from_write(): void 'restaurant_menu', 'restaurant_order_open', 'restaurant_orders', 'restaurant_order_get', 'restaurant_order_status', 'restaurant_order_add_item', 'restaurant_order_set_item_qty', 'restaurant_order_remove_item', 'restaurant_order_pay', 'restaurant_order_delete', 'restaurant_kitchen', + 'restaurant_reservations', 'restaurant_reservation_get', 'restaurant_reservation_set', 'restaurant_reservation_status', 'restaurant_reservation_delete', ], $names); } public function test_a_read_only_token_sees_only_the_read_tools(): void { $names = array_column($this->toolset->definitions($this->principal('danmat.restaurant:read')), 'name'); - self::assertSame(['restaurant_tables', 'restaurant_table_get', 'restaurant_menu', 'restaurant_orders', 'restaurant_order_get', 'restaurant_kitchen'], $names); + self::assertSame([ + 'restaurant_tables', 'restaurant_table_get', 'restaurant_menu', 'restaurant_orders', 'restaurant_order_get', + 'restaurant_kitchen', 'restaurant_reservations', 'restaurant_reservation_get', + ], $names); + } + + public function test_a_reservation_round_trips_over_mcp_and_carries_only_the_crm_link(): void + { + $write = $this->principal('danmat.restaurant:read', 'danmat.restaurant:write'); + + $out = $this->toolset->call('restaurant_reservation_set', ['party_name' => 'Smith', 'party_size' => 4, 'contact_id' => 4242], $write, $this->ctx); + self::assertTrue($out['ok']); + self::assertSame('Smith', $out['reservation']['party_name']); + self::assertSame(4242, $out['reservation']['contact_id'], 'the CRM link is a bare id — never resolved here'); + self::assertArrayNotHasKey('guest_name', $out['reservation'], 'the restaurant surfaces no CRM contact data'); + $id = $out['reservation']['id']; + + $this->toolset->call('restaurant_reservation_status', ['id' => $id, 'status' => 'seated'], $write, $this->ctx); + self::assertSame('seated', $this->toolset->call('restaurant_reservation_get', ['id' => $id], $write, $this->ctx)['reservation']['status']); + self::assertSame(1, $this->toolset->call('restaurant_reservations', [], $write, $this->ctx)['count']); + self::assertTrue($this->toolset->call('restaurant_reservation_delete', ['id' => $id], $write, $this->ctx)['deleted']); + } + + public function test_a_content_token_cannot_reach_reservations(): void + { + $this->expectException(McpError::class); + $this->expectExceptionMessage('Unknown tool "restaurant_reservation_set"'); + $this->toolset->call('restaurant_reservation_set', ['party_name' => 'x'], $this->principal('*:read', '*:write'), $this->ctx); } public function test_the_kitchen_queue_lists_sent_and_preparing_tickets(): void From 796213d1060a1a6ee9f56d7051d80f1e41458b4b Mon Sep 17 00:00:00 2001 From: DanMat Date: Sat, 5 Sep 2026 21:12:08 -0400 Subject: [PATCH 15/32] docs: Reservations vertical done (CRM link, PII boundary honored) Co-Authored-By: Claude Opus 4.8 --- README-NIMBUS.md | 4 +++- docs/PLATFORM-VALIDATION.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README-NIMBUS.md b/README-NIMBUS.md index 6e0188c..2f28d58 100644 --- a/README-NIMBUS.md +++ b/README-NIMBUS.md @@ -33,7 +33,9 @@ collections. Zero Nimbus core change. - ✅ **Staff & roles** — fine-grained capabilities (floor / kitchen / manage) gate the terminals, so a cook can't take payment and a waiter can't be handed the books. Forced the core capability behind ADR 0030 (closing F4). -- ⬜ Reservations, Reports — next. +- ✅ **Reservations** — a booking book that links guests to their **CRM** records by + id, without the restaurant ever reading CRM data (the PII boundary held). +- ⬜ Reports, then theme + deploy — next. ## Layout diff --git a/docs/PLATFORM-VALIDATION.md b/docs/PLATFORM-VALIDATION.md index b6a3c56..05a8f33 100644 --- a/docs/PLATFORM-VALIDATION.md +++ b/docs/PLATFORM-VALIDATION.md @@ -28,7 +28,7 @@ logic landing in Nimbus core.** | **Orders** | ✅ done (plugin) | **Yes — the plugin content-read capability (ADR 0029 in core), which closed F1/A2** | | **Kitchen display** | ✅ done (plugin) | No — an admin page (routes are public; not used) | | **Payment & turn** | ✅ done (plugin) | No — server-computed amount on the order | -| Reservations | ⬜ not started | tbd (CRM guest link + PII gate) | +| **Reservations** | ✅ done (plugin) | No — links to CRM guests by id, never reads CRM (PII gate honored) | | Reports | ⬜ not started | likely: dashboard widgets / aggregation | | **Staff & roles** | ✅ done (plugin) | **Yes — fine-grained plugin capabilities (ADR 0030 in core), which closed F4** | From 8a68a8857b1707d6ce392896ea331a205368ca58 Mon Sep 17 00:00:00 2001 From: Danny Matthew Date: Sat, 5 Sep 2026 21:31:16 -0400 Subject: [PATCH 16/32] Slice 7: Reports (manager dashboard) (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Slice 7: Reports (manager dashboard) Read-only revenue reporting over paid orders — the last operational vertical. - Reports service: revenueBetween / revenueByDay / topItems / activeOrders, over paid orders (revenue = recorded amount_paid, never recomputed). Callers pass explicit [from,to) bounds, so it's deterministic and testable; bound SQL. - Admin: a manager dashboard (today + last-7-days revenue cards, active orders, a 7-day breakdown, week's top items). Read-only, gated on the manage action (danmat.restaurant:manage) — the first use of that grant. Mobile cards. - MCP: a reports read tool (today/last_7_days/active_orders/top_items). - Guide: reports section. - Tests: ReportsTest (windowed revenue, unpaid≠revenue but active, by-day grouping, top-items ranking), ReportsAdminTest (today's revenue + escapes item names + empty period), RestaurantToolsetTest (reports tool listed), RestaurantPluginTest (reports page gated :manage; manage label). No new tables, no core change. cs-fixer + PHPStan green locally; phpunit in CI. Co-Authored-By: Claude Opus 4.8 * Reports: rename card to 'Revenue today' (avoid apostrophe-escape mismatch) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- plugin/src/Guide.php | 7 ++ plugin/src/Reports.php | 96 +++++++++++++++++++++ plugin/src/ReportsAdmin.php | 109 ++++++++++++++++++++++++ plugin/src/RestaurantPlugin.php | 14 +++- plugin/src/RestaurantToolset.php | 25 ++++++ plugin/tests/ReportsAdminTest.php | 75 +++++++++++++++++ plugin/tests/ReportsTest.php | 110 +++++++++++++++++++++++++ plugin/tests/RestaurantPluginTest.php | 2 + plugin/tests/RestaurantToolsetTest.php | 7 +- 9 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 plugin/src/Reports.php create mode 100644 plugin/src/ReportsAdmin.php create mode 100644 plugin/tests/ReportsAdminTest.php create mode 100644 plugin/tests/ReportsTest.php diff --git a/plugin/src/Guide.php b/plugin/src/Guide.php index 9a95f14..8f24c62 100644 --- a/plugin/src/Guide.php +++ b/plugin/src/Guide.php @@ -107,6 +107,13 @@ public static function text(): string To see a linked guest's contact details, use the CRM's own tools with that `contact_id` (they require the CRM capability). + + ## Reports + + - `restaurant_reports` — a revenue summary from **paid** orders: `today` and + `last_7_days` (each `{revenue, orders}`), `active_orders` (not yet closed), + and `top_items` (the week's best sellers). Revenue is the settled amount, not + a live recomputation. MD; } } diff --git a/plugin/src/Reports.php b/plugin/src/Reports.php new file mode 100644 index 0000000..16d8dff --- /dev/null +++ b/plugin/src/Reports.php @@ -0,0 +1,96 @@ +storage()->selectOne( + 'SELECT COALESCE(SUM(amount_paid), 0) AS revenue, COUNT(*) AS orders + FROM ' . Schema::ORDER . ' WHERE paid = 1 AND paid_at >= :from AND paid_at < :to', + ['from' => $from, 'to' => $to], + ); + return [ + 'revenue' => number_format((float) ($row['revenue'] ?? 0), 2, '.', ''), + 'orders' => (int) ($row['orders'] ?? 0), + ]; + } + + /** + * Revenue per calendar day across `[from, to)`, oldest first. + * + * @return list + */ + public function revenueByDay(string $from, string $to): array + { + $rows = $this->storage()->select( + 'SELECT DATE(paid_at) AS day, COALESCE(SUM(amount_paid), 0) AS revenue, COUNT(*) AS orders + FROM ' . Schema::ORDER . ' WHERE paid = 1 AND paid_at >= :from AND paid_at < :to + GROUP BY DATE(paid_at) ORDER BY day', + ['from' => $from, 'to' => $to], + ); + return array_map(static fn (array $r): array => [ + 'day' => (string) $r['day'], + 'revenue' => number_format((float) $r['revenue'], 2, '.', ''), + 'orders' => (int) $r['orders'], + ], $rows); + } + + /** + * Best-selling items by quantity, over paid orders settled in `[from, to)`. + * + * @return list + */ + public function topItems(string $from, string $to, int $limit = 5): array + { + $limit = max(1, min($limit, 100)); + $rows = $this->storage()->select( + 'SELECT i.name, SUM(i.qty) AS qty, SUM(i.unit_price * i.qty) AS revenue + FROM ' . Schema::ORDER_ITEM . ' i JOIN ' . Schema::ORDER . ' o ON o.id = i.order_id + WHERE o.paid = 1 AND o.paid_at >= :from AND o.paid_at < :to + GROUP BY i.name ORDER BY qty DESC, revenue DESC LIMIT ' . $limit, + ['from' => $from, 'to' => $to], + ); + return array_map(static fn (array $r): array => [ + 'name' => (string) $r['name'], + 'qty' => (int) $r['qty'], + 'revenue' => number_format((float) $r['revenue'], 2, '.', ''), + ], $rows); + } + + /** Orders not yet closed (still on the floor / in the kitchen / awaiting payment). */ + public function activeOrders(): int + { + $row = $this->storage()->selectOne( + 'SELECT COUNT(*) AS c FROM ' . Schema::ORDER . " WHERE status <> 'closed'", + ); + return (int) ($row['c'] ?? 0); + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/plugin/src/ReportsAdmin.php b/plugin/src/ReportsAdmin.php new file mode 100644 index 0000000..fa88396 --- /dev/null +++ b/plugin/src/ReportsAdmin.php @@ -0,0 +1,109 @@ +reports->revenueBetween($todayStart, $tomorrow); + $week = $this->reports->revenueBetween($weekStart, $tomorrow); + $byDay = $this->reports->revenueByDay($weekStart, $tomorrow); + $top = $this->reports->topItems($weekStart, $tomorrow, 5); + $active = $this->reports->activeOrders(); + + return $this->styles($nonce) + . '

    Reports

    ' + . '

    How service is going — revenue and what is selling. Figures are from settled (paid) orders.

    ' + . '
    ' + . $this->card('Revenue today', $today['revenue'], $today['orders'] . ' paid') + . $this->card('Last 7 days', $week['revenue'], $week['orders'] . ' paid') + . $this->card('Active orders', (string) $active, 'open on the floor') + . '
    ' + . $this->byDay($byDay) + . $this->topItems($top); + } + + private function card(string $label, string $big, string $sub): string + { + return '
    ' . self::e($label) . '
    ' + . '
    ' . self::e($big) . '
    ' + . '
    ' . self::e($sub) . '
    '; + } + + /** @param list $byDay */ + private function byDay(array $byDay): string + { + if ($byDay === []) { + return '

    Revenue by day

    No paid orders in the last 7 days.

    '; + } + $rows = ''; + foreach ($byDay as $d) { + $rows .= '' . self::e($d['day']) . '' + . '' . self::e((string) $d['orders']) . '' + . '' . self::e($d['revenue']) . ''; + } + return '

    Revenue by day

    ' . $rows . '
    DayOrdersRevenue
    '; + } + + /** @param list $top */ + private function topItems(array $top): string + { + if ($top === []) { + return '

    Top items (7 days)

    Nothing sold yet.

    '; + } + $rows = ''; + foreach ($top as $t) { + $rows .= '' . self::e($t['name']) . '' + . '' . self::e((string) $t['qty']) . '' + . '' . self::e($t['revenue']) . ''; + } + return '

    Top items (7 days)

    ' . $rows . '
    ItemSoldRevenue
    '; + } + + private function styles(string $nonce): string + { + return ''; + } + + private static function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/plugin/src/RestaurantPlugin.php b/plugin/src/RestaurantPlugin.php index bb08017..85f6eda 100644 --- a/plugin/src/RestaurantPlugin.php +++ b/plugin/src/RestaurantPlugin.php @@ -23,7 +23,7 @@ * payment & turn. Slice 5: staff roles — the terminals are gated on fine-grained * actions (ADR 0030): floor staff reach tables/orders/payment, cooks the kitchen, * managers everything. Slice 6: reservations, which link to CRM guests without the - * restaurant ever reading CRM data. Reports follow. + * restaurant ever reading CRM data. Slice 7: the manager reports dashboard. */ final class RestaurantPlugin implements Plugin { @@ -52,9 +52,10 @@ public function register(PluginContext $context): void $menu = new Menu(static fn () => $context->content()); $orders = new Orders($storage, $tables, static fn (int $menuItemId): ?array => $menu->snapshot($menuItemId)); $reservations = new Reservations($storage, $tables); + $reports = new Reports($storage); // The agent surface — every tool gates on danmat.restaurant:read|write (ADR 0016). - $context->mcp()->register(new RestaurantToolset($tables, $orders, $menu, $reservations)); + $context->mcp()->register(new RestaurantToolset($tables, $orders, $menu, $reservations, $reports)); // 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 @@ -282,6 +283,15 @@ public function register(PluginContext $context): void return Response::redirect('/admin/restaurant-reservations?ok=deleted'); }); + // Reports — the manager dashboard. Read-only, gated on the manage action. + $context->adminPages()->register( + 'restaurant-reports', + 'Reports', + '📈', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new ReportsAdmin($reports))->render($csrf, null, $nonce), + self::ID . ':manage', + ); + // Teach an MCP agent how to drive the restaurant (ADR 0013). $context->skills()->register('Restaurant', Guide::text()); } diff --git a/plugin/src/RestaurantToolset.php b/plugin/src/RestaurantToolset.php index 913bec5..193a707 100644 --- a/plugin/src/RestaurantToolset.php +++ b/plugin/src/RestaurantToolset.php @@ -30,6 +30,7 @@ public function __construct( private Orders $orders, private MenuSource $menu, private Reservations $reservations, + private Reports $reports, ) { } @@ -203,6 +204,30 @@ protected function tools(): array 'required' => ['id'], 'properties' => ['id' => ['type' => 'integer', 'description' => 'The reservation id.']], ], $this->reservationDelete(...)), + + new PluginTool('reports', 'read', 'A revenue summary: today and the last 7 days (from paid orders), active orders, and the week\'s best-selling items.', [ + 'type' => 'object', + 'properties' => new \stdClass(), + ], $this->reports(...)), + ]; + } + + /** + * @param array $a + * @return array + */ + private function reports(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $ref = time(); + $todayStart = date('Y-m-d 00:00:00', $ref); + $tomorrow = date('Y-m-d 00:00:00', $ref + 86400); + $weekStart = date('Y-m-d 00:00:00', $ref - 6 * 86400); + + return [ + 'today' => $this->reports->revenueBetween($todayStart, $tomorrow), + 'last_7_days' => $this->reports->revenueBetween($weekStart, $tomorrow), + 'active_orders' => $this->reports->activeOrders(), + 'top_items' => $this->reports->topItems($weekStart, $tomorrow, 5), ]; } diff --git a/plugin/tests/ReportsAdminTest.php b/plugin/tests/ReportsAdminTest.php new file mode 100644 index 0000000..55cc8cd --- /dev/null +++ b/plugin/tests/ReportsAdminTest.php @@ -0,0 +1,75 @@ + 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::tables(), ...Schema::orders()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + $db->execute('TRUNCATE ' . Schema::ORDER); + $db->execute('TRUNCATE ' . Schema::ORDER_ITEM); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + $this->orders = new Orders(static fn (): PluginStorage => $storage, $this->tables, static fn (int $id): ?array => null); + $this->admin = new ReportsAdmin(new Reports(static fn (): PluginStorage => $storage)); + } + + private function paidOrder(string $label, string $item, string $price, int $qty, string $paidAt): void + { + $t = $this->tables->save(null, ['label' => $label], $paidAt); + $id = $this->orders->open($t, $paidAt); + $this->orders->addItem($id, null, $item, $price, $qty, $paidAt); + $this->orders->pay($id, 'card', $paidAt); + } + + public function test_it_shows_todays_revenue_and_escapes_item_names(): void + { + $this->paidOrder('1', 'Special', '10', 2, '2026-06-01 12:00:00'); + + $html = $this->admin->render('', null, 'n', '2026-06-01 20:00:00'); + + self::assertStringContainsString('Revenue today', $html); + self::assertStringContainsString('20.00', $html, 'the settled revenue shows'); + self::assertStringNotContainsString('Special', $html, 'a hostile item name is escaped'); + self::assertStringContainsString('<b>Special</b>', $html); + } + + public function test_an_empty_period_reads_cleanly(): void + { + $html = $this->admin->render('', null, 'n', '2026-06-01 20:00:00'); + self::assertStringContainsString('0.00', $html); + self::assertStringContainsString('No paid orders in the last 7 days.', $html); + } +} diff --git a/plugin/tests/ReportsTest.php b/plugin/tests/ReportsTest.php new file mode 100644 index 0000000..4617696 --- /dev/null +++ b/plugin/tests/ReportsTest.php @@ -0,0 +1,110 @@ + 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::tables(), ...Schema::orders()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + $db->execute('TRUNCATE ' . Schema::ORDER); + $db->execute('TRUNCATE ' . Schema::ORDER_ITEM); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + $this->orders = new Orders(static fn (): PluginStorage => $storage, $this->tables, static fn (int $id): ?array => null); + $this->reports = new Reports(static fn (): PluginStorage => $storage); + } + + /** Open a fresh order (own table), add one manual line, and pay it at $paidAt. */ + private function paidOrder(string $label, string $item, string $price, int $qty, string $paidAt): void + { + $t = $this->tables->save(null, ['label' => $label], $paidAt); + $id = $this->orders->open($t, $paidAt); + $this->orders->addItem($id, null, $item, $price, $qty, $paidAt); + $this->orders->pay($id, 'card', $paidAt); + } + + public function test_revenue_between_sums_paid_orders_in_the_window(): void + { + $this->paidOrder('1', 'Steak', '20', 2, '2026-06-01 12:00:00'); // 40 today + $this->paidOrder('2', 'Soup', '5', 1, '2026-06-01 19:00:00'); // 5 today + $this->paidOrder('3', 'Wine', '8', 1, '2026-05-31 20:00:00'); // 8 yesterday + + $today = $this->reports->revenueBetween('2026-06-01 00:00:00', '2026-06-02 00:00:00'); + self::assertSame('45.00', $today['revenue']); + self::assertSame(2, $today['orders']); + + $week = $this->reports->revenueBetween('2026-05-26 00:00:00', '2026-06-02 00:00:00'); + self::assertSame('53.00', $week['revenue'], 'includes yesterday'); + self::assertSame(3, $week['orders']); + } + + public function test_an_unpaid_order_is_not_revenue_but_is_active(): void + { + $t = $this->tables->save(null, ['label' => '9'], '2026-06-01 12:00:00'); + $this->orders->open($t, '2026-06-01 12:00:00'); // open, unpaid + + self::assertSame('0.00', $this->reports->revenueBetween('2026-06-01 00:00:00', '2026-06-02 00:00:00')['revenue']); + self::assertSame(1, $this->reports->activeOrders(), 'the open order is active'); + + $this->paidOrder('10', 'X', '5', 1, '2026-06-01 13:00:00'); + self::assertSame(1, $this->reports->activeOrders(), 'a paid (closed) order is not active'); + } + + public function test_revenue_by_day_groups_and_orders(): void + { + $this->paidOrder('1', 'A', '10', 1, '2026-06-01 12:00:00'); + $this->paidOrder('2', 'B', '10', 1, '2026-06-02 12:00:00'); + $this->paidOrder('3', 'C', '5', 1, '2026-06-02 18:00:00'); + + $byDay = $this->reports->revenueByDay('2026-06-01 00:00:00', '2026-06-03 00:00:00'); + self::assertSame('2026-06-01', $byDay[0]['day']); + self::assertSame('10.00', $byDay[0]['revenue']); + self::assertSame('2026-06-02', $byDay[1]['day']); + self::assertSame('15.00', $byDay[1]['revenue']); + self::assertSame(2, $byDay[1]['orders']); + } + + public function test_top_items_ranks_by_quantity(): void + { + $this->paidOrder('1', 'Fries', '4', 5, '2026-06-01 12:00:00'); + $this->paidOrder('2', 'Steak', '20', 2, '2026-06-01 13:00:00'); + $this->paidOrder('3', 'Fries', '4', 1, '2026-06-01 14:00:00'); + + $top = $this->reports->topItems('2026-06-01 00:00:00', '2026-06-02 00:00:00', 5); + self::assertSame('Fries', $top[0]['name']); + self::assertSame(6, $top[0]['qty']); + self::assertSame('24.00', $top[0]['revenue']); + self::assertSame('Steak', $top[1]['name']); + } +} diff --git a/plugin/tests/RestaurantPluginTest.php b/plugin/tests/RestaurantPluginTest.php index 6af9a47..6b6b7e1 100644 --- a/plugin/tests/RestaurantPluginTest.php +++ b/plugin/tests/RestaurantPluginTest.php @@ -32,6 +32,7 @@ public function test_it_declares_the_fine_grained_staff_actions_as_grants(): voi self::assertArrayHasKey('danmat.restaurant:floor', $grantable); self::assertArrayHasKey('danmat.restaurant:kitchen', $grantable); self::assertArrayHasKey('danmat.restaurant:manage', $grantable); + self::assertSame('Restaurant: manage', $grantable['danmat.restaurant:manage']); // read/write remain for the MCP/agent surface. self::assertArrayHasKey('danmat.restaurant:read', $grantable); self::assertArrayHasKey('danmat.restaurant:write', $grantable); @@ -49,5 +50,6 @@ public function test_each_terminal_is_gated_on_the_right_action(): void self::assertSame('danmat.restaurant:floor', $gate['restaurant-orders'], 'orders + payment are floor-staff'); self::assertSame('danmat.restaurant:kitchen', $gate['restaurant-kitchen'], 'the kitchen is cooks only'); self::assertSame('danmat.restaurant:floor', $gate['restaurant-reservations'], 'the book is floor-staff'); + self::assertSame('danmat.restaurant:manage', $gate['restaurant-reports'], 'reports are manager-only'); } } diff --git a/plugin/tests/RestaurantToolsetTest.php b/plugin/tests/RestaurantToolsetTest.php index ef92d0b..87050f9 100644 --- a/plugin/tests/RestaurantToolsetTest.php +++ b/plugin/tests/RestaurantToolsetTest.php @@ -6,6 +6,7 @@ use DanMat\Restaurant\Menu; use DanMat\Restaurant\Orders; +use DanMat\Restaurant\Reports; use DanMat\Restaurant\Reservations; use DanMat\Restaurant\RestaurantToolset; use DanMat\Restaurant\Schema; @@ -50,11 +51,12 @@ protected function setUp(): void $tables = new Tables(static fn (): PluginStorage => $storage); $orders = new Orders(static fn (): PluginStorage => $storage, $tables, static fn (int $id): ?array => null); $reservations = new Reservations(static fn (): PluginStorage => $storage, $tables); + $reports = new Reports(static fn (): PluginStorage => $storage); // The menu reader is never exercised here (order lines are manual), so a // reader that would need core content is fine left unbuilt. $menu = new Menu(static fn () => throw new \RuntimeException('no content reader in this test')); - $this->toolset = new RestaurantToolset($tables, $orders, $menu, $reservations); + $this->toolset = new RestaurantToolset($tables, $orders, $menu, $reservations, $reports); $this->toolset->bindTo('danmat.restaurant'); $this->ctx = new EntryOpContext('127.0.0.1', '/api/v1/mcp'); @@ -80,6 +82,7 @@ public function test_the_tools_are_namespaced_and_split_read_from_write(): void 'restaurant_order_add_item', 'restaurant_order_set_item_qty', 'restaurant_order_remove_item', 'restaurant_order_pay', 'restaurant_order_delete', 'restaurant_kitchen', 'restaurant_reservations', 'restaurant_reservation_get', 'restaurant_reservation_set', 'restaurant_reservation_status', 'restaurant_reservation_delete', + 'restaurant_reports', ], $names); } @@ -88,7 +91,7 @@ public function test_a_read_only_token_sees_only_the_read_tools(): void $names = array_column($this->toolset->definitions($this->principal('danmat.restaurant:read')), 'name'); self::assertSame([ 'restaurant_tables', 'restaurant_table_get', 'restaurant_menu', 'restaurant_orders', 'restaurant_order_get', - 'restaurant_kitchen', 'restaurant_reservations', 'restaurant_reservation_get', + 'restaurant_kitchen', 'restaurant_reservations', 'restaurant_reservation_get', 'restaurant_reports', ], $names); } From 2e8039a295011561d8769d7e0c088cd4a40c32de Mon Sep 17 00:00:00 2001 From: DanMat Date: Sat, 5 Sep 2026 21:31:40 -0400 Subject: [PATCH 17/32] =?UTF-8?q?docs:=20Reports=20vertical=20done=20?= =?UTF-8?q?=E2=80=94=20all=20operational=20verticals=20rebuilt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- README-NIMBUS.md | 8 +++++++- docs/PLATFORM-VALIDATION.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README-NIMBUS.md b/README-NIMBUS.md index 2f28d58..164a228 100644 --- a/README-NIMBUS.md +++ b/README-NIMBUS.md @@ -35,7 +35,13 @@ collections. Zero Nimbus core change. books. Forced the core capability behind ADR 0030 (closing F4). - ✅ **Reservations** — a booking book that links guests to their **CRM** records by id, without the restaurant ever reading CRM data (the PII boundary held). -- ⬜ Reports, then theme + deploy — next. +- ✅ **Reports** — a manager dashboard (revenue today / 7 days, active orders, top + items), read-only and gated on `:manage`. +- ⬜ Theme + public menu + deploy — the finale. + +**Every operational vertical of the legacy system is now rebuilt on Nimbus**, and +the rebuild drove two reusable core capabilities (ADR 0029 content-read, ADR 0030 +fine-grained capabilities) — with no restaurant-specific logic in Nimbus core. ## Layout diff --git a/docs/PLATFORM-VALIDATION.md b/docs/PLATFORM-VALIDATION.md index 05a8f33..65e2019 100644 --- a/docs/PLATFORM-VALIDATION.md +++ b/docs/PLATFORM-VALIDATION.md @@ -29,7 +29,7 @@ logic landing in Nimbus core.** | **Kitchen display** | ✅ done (plugin) | No — an admin page (routes are public; not used) | | **Payment & turn** | ✅ done (plugin) | No — server-computed amount on the order | | **Reservations** | ✅ done (plugin) | No — links to CRM guests by id, never reads CRM (PII gate honored) | -| Reports | ⬜ not started | likely: dashboard widgets / aggregation | +| **Reports** | ✅ done (plugin) | No — read-only aggregation over the plugin's own orders | | **Staff & roles** | ✅ done (plugin) | **Yes — fine-grained plugin capabilities (ADR 0030 in core), which closed F4** | --- From a9a788a34bca6f8409ac24f196295f3ee1f2a21e Mon Sep 17 00:00:00 2001 From: Danny Matthew Date: Sat, 5 Sep 2026 22:02:30 -0400 Subject: [PATCH 18/32] Slice 8a: RAS design uplift for the staff terminals (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain the feel of the original Restaurant Automation System, uplifted. - Branding::head — a shared "RAS · Restaurant Automation System" eyebrow + title + subtitle on every terminal (Floor, Orders, Kitchen, Reservations, Reports), so they read as one system. Nonce'd styles (admin CSP drops inline style=). - Floor: the signature circular table tokens are back — a grid of status- coloured circles (open green / occupied amber / dirty red / reserved blue, modernized from the harsh originals), each linking to the table with seats + contextual quick actions beneath, plus a colour legend. Replaces the rectangular cards; keeps the same status classes, actions and behaviour. - Shared status palette via a --rs custom property (legend dots + circles from one source). No behaviour change; markup/CSS only. Tests updated to assert the circular tokens; all terminal tests still green. cs-fixer + PHPStan green locally. Co-authored-by: Claude Opus 4.8 --- plugin/src/Branding.php | 43 +++++++++++++++++++++ plugin/src/KitchenAdmin.php | 2 +- plugin/src/OrdersAdmin.php | 2 +- plugin/src/ReportsAdmin.php | 2 +- plugin/src/ReservationsAdmin.php | 2 +- plugin/src/TablesAdmin.php | 64 ++++++++++++++++++-------------- plugin/tests/TablesAdminTest.php | 3 +- 7 files changed, 85 insertions(+), 33 deletions(-) create mode 100644 plugin/src/Branding.php diff --git a/plugin/src/Branding.php b/plugin/src/Branding.php new file mode 100644 index 0000000..85b134d --- /dev/null +++ b/plugin/src/Branding.php @@ -0,0 +1,43 @@ +` block because the admin CSP drops inline `style=`. + */ +final class Branding +{ + /** The plugin's shared status palette (uplifted from the original RAS colours). */ + public const STATUS_COLORS = [ + 'open' => '#3d8b40', + 'occupied' => '#b8860b', + 'dirty' => '#c0392b', + 'reserved' => '#2471a3', + ]; + + /** A page header with the RAS eyebrow, a title, and a one-line subtitle. */ + public static function head(string $title, string $subtitle, string $nonce): string + { + return '' + . '
    ' + . '

    RAS · Restaurant Automation System

    ' + . '

    ' . self::e($title) . '

    ' + . '

    ' . self::e($subtitle) . '

    ' + . '
    '; + } + + private static function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/plugin/src/KitchenAdmin.php b/plugin/src/KitchenAdmin.php index 104a6a7..043ad03 100644 --- a/plugin/src/KitchenAdmin.php +++ b/plugin/src/KitchenAdmin.php @@ -48,7 +48,7 @@ public function render(string $csrf = '', ?string $notice = null, string $nonce } return $this->styles($nonce) - . '

    Kitchen

    ' + . Branding::head('Kitchen', 'Tickets on the line, oldest first.', $nonce) . $this->notice($notice) . '

    Tickets on the line. Start a ticket when you begin it, mark it ready when it is up for the pass.

    ' . '
    ' . $cols . '
    ' diff --git a/plugin/src/OrdersAdmin.php b/plugin/src/OrdersAdmin.php index 853de82..c523e75 100644 --- a/plugin/src/OrdersAdmin.php +++ b/plugin/src/OrdersAdmin.php @@ -46,7 +46,7 @@ public function render(string $csrf = '', ?string $notice = null, ?string $view $viewId = ($view !== null && preg_match('/^\d+$/', trim($view)) === 1) ? (int) trim($view) : null; $viewOrder = $viewId !== null ? $this->orders->get($viewId) : null; - $html = $this->styles($nonce) . '

    Orders

    ' . $this->notice($notice); + $html = $this->styles($nonce) . Branding::head('Orders', 'Open a table, build the order, settle up.', $nonce) . $this->notice($notice); if ($viewOrder !== null) { return $html . $this->orderScreen($csrf, $viewOrder); diff --git a/plugin/src/ReportsAdmin.php b/plugin/src/ReportsAdmin.php index fa88396..711910e 100644 --- a/plugin/src/ReportsAdmin.php +++ b/plugin/src/ReportsAdmin.php @@ -31,7 +31,7 @@ public function render(string $csrf = '', ?string $notice = null, string $nonce $active = $this->reports->activeOrders(); return $this->styles($nonce) - . '

    Reports

    ' + . Branding::head('Reports', 'Revenue and what is selling.', $nonce) . '

    How service is going — revenue and what is selling. Figures are from settled (paid) orders.

    ' . '
    ' . $this->card('Revenue today', $today['revenue'], $today['orders'] . ' paid') diff --git a/plugin/src/ReservationsAdmin.php b/plugin/src/ReservationsAdmin.php index 9694db3..c28673c 100644 --- a/plugin/src/ReservationsAdmin.php +++ b/plugin/src/ReservationsAdmin.php @@ -40,7 +40,7 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit $filter = ($status !== null && in_array(trim($status), Reservations::STATUSES, true)) ? trim($status) : null; return $this->styles($nonce) - . '

    Reservations

    ' + . Branding::head('Reservations', 'The book — upcoming bookings.', $nonce) . $this->notice($notice) . '

    Upcoming bookings. Linking a guest connects the booking to their record in the CRM — opening it needs CRM access.

    ' . $this->form($csrf, $editRes) diff --git a/plugin/src/TablesAdmin.php b/plugin/src/TablesAdmin.php index da21965..ac55f9e 100644 --- a/plugin/src/TablesAdmin.php +++ b/plugin/src/TablesAdmin.php @@ -43,10 +43,10 @@ public function render(string $csrf = '', ?string $notice = null, ?string $edit $filter = ($status !== null && in_array(trim($status), Tables::STATUSES, true)) ? trim($status) : null; return $this->styles($nonce) - . '

    Floor

    ' + . Branding::head('Floor', 'The room at a glance — seat, clear and turn tables.', $nonce) . $this->notice($notice) - . '

    Your tables and their status. Seat guests, send tables for cleaning, and turn them for the next party.

    ' . $this->form($csrf, $editTable) + . $this->legend() . $this->filterBar($filter) . $this->board($csrf, $this->tables->all($filter), $filter); } @@ -85,7 +85,21 @@ private function filterBar(?string $active): string return '
    ' . $chips . '
    '; } + /** A key to the status colours — the circular tokens read at a glance. */ + private function legend(): string + { + $items = ''; + foreach (self::STATUS_LABELS as $s => $label) { + $items .= '' . self::e($label) . ''; + } + return '
    ' . $items . '
    '; + } + /** + * The floor as a grid of circular table tokens, coloured by status — the RAS + * signature, uplifted. The circle links to the table; the seats and the + * contextual quick actions sit beneath it. + * * @param list> $tables */ private function board(string $csrf, array $tables, ?string $filter): string @@ -95,20 +109,18 @@ private function board(string $csrf, array $tables, ?string $filter): string return '

    ' . $msg . '

    '; } - $cards = ''; + $tokens = ''; foreach ($tables as $t) { $status = (string) $t['status']; - $cards .= '
  • ' - . '
    ' - . '' . self::e((string) $t['label']) . '' - . '' . self::e(self::STATUS_LABELS[$status] ?? $status) . '' - . '
    ' - . '
    ' . self::e((string) $t['seats']) . ' seats
    ' - . '
    ' . $this->actions($csrf, (int) $t['id'], $status) . '
    ' + $tokens .= '
  • ' + . '' + . '' . self::e((string) $t['label']) . '' + . '
    ' . self::e((string) $t['seats']) . ' seats · ' . self::e(self::STATUS_LABELS[$status] ?? $status) . '
    ' + . '
    ' . $this->actions($csrf, (int) $t['id'], $status) . '
    ' . '
  • '; } - return '
      ' . $cards . '
    '; + return '
      ' . $tokens . '
    '; } /** The status quick-actions relevant to a table's current state. */ @@ -166,24 +178,20 @@ private function styles(string $nonce): string . '.rz-filter{display:flex;flex-wrap:wrap;gap:.4rem;margin:0 0 1rem}' . '.rz-chip{text-decoration:none;background:rgba(128,128,128,.12);border-radius:999px;padding:.3rem .8rem;font-size:.82rem;color:inherit;min-height:32px;display:inline-flex;align-items:center}' . '.rz-chip.is-active{background:rgba(52,152,219,.22);color:#2471a3;font-weight:700}' - . '.rz-board{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(9.5rem,1fr));gap:.75rem}' - . '.rz-table{border:1px solid rgba(128,128,128,.2);border-left:4px solid rgba(128,128,128,.4);border-radius:10px;padding:.6rem .7rem;display:flex;flex-direction:column;gap:.4rem}' - . '.rz-status-open{border-left-color:#27ae60}' - . '.rz-status-occupied{border-left-color:#2980b9}' - . '.rz-status-dirty{border-left-color:#c0392b}' - . '.rz-status-reserved{border-left-color:#b8860b}' - . '.rz-table-top{display:flex;justify-content:space-between;align-items:center;gap:.4rem}' - . '.rz-table-label{font-weight:800;font-size:1.15rem;text-decoration:none}' - . '.rz-table-seats{font-size:.8rem;opacity:.7}' - . '.rz-badge{font-size:.65rem;font-weight:700;padding:.1rem .4rem;border-radius:999px;text-transform:uppercase;letter-spacing:.03em}' - . '.rz-badge-open{background:rgba(39,174,96,.18);color:#1e8449}' - . '.rz-badge-occupied{background:rgba(41,128,185,.18);color:#2471a3}' - . '.rz-badge-dirty{background:rgba(192,57,43,.15);color:#c0392b}' - . '.rz-badge-reserved{background:rgba(184,134,11,.18);color:#9a7d0a}' - . '.rz-table-acts{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center;margin-top:.1rem}' + . '.rz-status-open{--rs:#3d8b40}.rz-status-occupied{--rs:#b8860b}.rz-status-dirty{--rs:#c0392b}.rz-status-reserved{--rs:#2471a3}' + . '.rz-legend{display:flex;flex-wrap:wrap;gap:.9rem;margin:0 0 1rem;font-size:.8rem;color:var(--nb-muted,#6b7280)}' + . '.rz-key{display:inline-flex;align-items:center;gap:.4rem}' + . '.rz-dot{width:.75rem;height:.75rem;border-radius:50%;background:var(--rs,#888);display:inline-block}' + . '.rz-board{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(6.5rem,1fr));gap:1rem .75rem}' + . '.rz-token{display:flex;flex-direction:column;align-items:center;gap:.4rem;text-align:center}' + . '.rz-circle{width:76px;height:76px;border-radius:50%;background:var(--rs,#888);color:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;padding:.35rem;box-sizing:border-box;transition:transform .08s ease}' + . '.rz-circle:hover{transform:scale(1.05)}' + . '.rz-circle-label{font-weight:700;font-size:1.05rem;line-height:1.1;overflow-wrap:anywhere}' + . '.rz-token-seats{font-size:.72rem;color:var(--nb-muted,#6b7280);line-height:1.2}' + . '.rz-token-acts{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center;justify-content:center}' . '.rz-act{display:inline}' - . '.rz-act-btn{min-height:36px;padding:.25rem .6rem;font-size:.8rem}' - . '.rz-link-danger{background:none;border:0;color:#c0392b;font:inherit;cursor:pointer;text-decoration:underline;padding:.25rem 0;min-height:36px}' + . '.rz-act-btn{min-height:32px;padding:.2rem .55rem;font-size:.75rem}' + . '.rz-link-danger{background:none;border:0;color:#c0392b;font:inherit;cursor:pointer;text-decoration:underline;padding:.2rem 0;min-height:32px;font-size:.75rem}' . ''; } diff --git a/plugin/tests/TablesAdminTest.php b/plugin/tests/TablesAdminTest.php index 9897a33..b63fb61 100644 --- a/plugin/tests/TablesAdminTest.php +++ b/plugin/tests/TablesAdminTest.php @@ -58,7 +58,8 @@ public function test_the_board_shows_status_and_quick_actions(): void $html = $this->admin->render('CSRF123', null, null, null, 'n'); - self::assertStringContainsString('rz-status-occupied', $html, 'the card is marked with its status'); + self::assertStringContainsString('rz-status-occupied', $html, 'the token is marked with its status'); + self::assertStringContainsString('rz-circle', $html, 'tables render as circular tokens (the RAS signature)'); self::assertStringContainsString('action="/admin/restaurant/table-status"', $html, 'quick-action posts to the status action'); // An occupied table offers "Clear" (→ dirty), not "Seat". self::assertStringContainsString('value="dirty"', $html); From 801d32d4f3a7ddacfc73873e83b9196e1f30b1cd Mon Sep 17 00:00:00 2001 From: DanMat Date: Sat, 5 Sep 2026 22:02:48 -0400 Subject: [PATCH 19/32] docs: RAS design uplift shipped Co-Authored-By: Claude Opus 4.8 --- README-NIMBUS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README-NIMBUS.md b/README-NIMBUS.md index 164a228..05c6ef7 100644 --- a/README-NIMBUS.md +++ b/README-NIMBUS.md @@ -37,7 +37,11 @@ collections. Zero Nimbus core change. id, without the restaurant ever reading CRM data (the PII boundary held). - ✅ **Reports** — a manager dashboard (revenue today / 7 days, active orders, top items), read-only and gated on `:manage`. -- ⬜ Theme + public menu + deploy — the finale. +- ✅ **RAS design uplift** — the staff terminals wear the original "Restaurant + Automation System" identity, uplifted: the signature **circular table tokens** are + back on the Floor (modernized status colours), with a shared RAS header across + every terminal. +- ⬜ Public menu theme, then deploy — the rest of the finale. **Every operational vertical of the legacy system is now rebuilt on Nimbus**, and the rebuild drove two reusable core capabilities (ADR 0029 content-read, ADR 0030 From 30ac7580dc06890ed3b85ba499de0303debc7d5e Mon Sep 17 00:00:00 2001 From: Danny Matthew Date: Sat, 5 Sep 2026 23:00:45 -0400 Subject: [PATCH 20/32] Slice 8b: RAS public theme (guest menu) (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest-facing side of the rebuild — the public menu, in the RAS identity. - theme/ (source co-located in the app repo; deploy drops it into the site's themes/): a dark, committed restaurant look uplifted from the original RAS #222, with a warm gold accent and an elegant printed-menu feel. - Templates: layout / header (RAS wordmark) / footer / entry / 404, and collection-menu_items — the menu grouped by its category relation (expanded by EntryView) with prices and dotted leaders. theme.json declares it + nav. - assets/app.css — one stylesheet, responsive, no build step. - Theme CI: php -l the templates on 8.2 + 8.3 (view files, no test DB). Reads the same menu_items collection the Orders picker uses; no core change. Templates lint clean locally; rendered a standalone preview to verify the look. Co-authored-by: Claude Opus 4.8 --- .github/workflows/theme-ci.yml | 27 ++++++ theme/assets/app.css | 107 ++++++++++++++++++++++ theme/templates/404.php | 15 +++ theme/templates/collection-menu_items.php | 57 ++++++++++++ theme/templates/entry.php | 17 ++++ theme/templates/footer.php | 17 ++++ theme/templates/header.php | 20 ++++ theme/templates/layout.php | 44 +++++++++ theme/theme.json | 16 ++++ 9 files changed, 320 insertions(+) create mode 100644 .github/workflows/theme-ci.yml create mode 100644 theme/assets/app.css create mode 100644 theme/templates/404.php create mode 100644 theme/templates/collection-menu_items.php create mode 100644 theme/templates/entry.php create mode 100644 theme/templates/footer.php create mode 100644 theme/templates/header.php create mode 100644 theme/templates/layout.php create mode 100644 theme/theme.json diff --git a/.github/workflows/theme-ci.yml b/.github/workflows/theme-ci.yml new file mode 100644 index 0000000..670bd29 --- /dev/null +++ b/.github/workflows/theme-ci.yml @@ -0,0 +1,27 @@ +name: Theme CI + +# The RAS public theme is plain PHP templates (no build step, no test DB), so CI +# just lints them for syntax on the supported PHP versions. + +on: + push: + branches: [nimbus-rebuild, 'slice/**'] + paths: ['theme/**', '.github/workflows/theme-ci.yml'] + pull_request: + paths: ['theme/**', '.github/workflows/theme-ci.yml'] + +jobs: + lint: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.2', '8.3'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Lint templates + run: | + for f in theme/templates/*.php; do php -l "$f"; done diff --git a/theme/assets/app.css b/theme/assets/app.css new file mode 100644 index 0000000..67f00f2 --- /dev/null +++ b/theme/assets/app.css @@ -0,0 +1,107 @@ +/* + * RAS — the public face of the Restaurant Automation System. + * A single, committed dark restaurant look (uplifted from the original #222), + * with a warm gold accent and an elegant printed-menu feel. + */ + +:root { + --ground: #1a1c20; + --surface: #23262c; + --text: #ecedee; + --muted: #9aa0a8; + --gold: #d4a017; + --gold-bright: #e7b62f; + --rule: rgba(255, 255, 255, 0.09); + --serif: Georgia, "Iowan Old Style", "Times New Roman", serif; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--ground); + color: var(--text); + font-family: var(--sans); + line-height: 1.6; + font-size: 17px; +} + +a { color: var(--gold); text-decoration: none; } +a:hover { color: var(--gold-bright); } + +img { max-width: 100%; height: auto; } + +.wrap { width: 100%; max-width: 900px; margin: 0 auto; padding: 0 1.25rem; } +.measure { max-width: 40rem; } + +.skip-link { + position: absolute; left: -999px; top: 0; + background: var(--gold); color: #1a1c20; padding: .6rem 1rem; border-radius: 6px; +} +.skip-link:focus { left: 1rem; top: 1rem; z-index: 10; } + +/* Header */ +.site-header { + position: sticky; top: 0; z-index: 5; + background: rgba(20, 22, 26, 0.92); + backdrop-filter: saturate(140%) blur(6px); + border-bottom: 1px solid var(--rule); +} +.header-inner { display: flex; align-items: center; justify-content: space-between; min-height: 64px; gap: 1rem; } +.brand { display: inline-flex; align-items: center; gap: .7rem; color: var(--text); } +.brand:hover { color: var(--text); } +.brand-mark { + font-weight: 800; letter-spacing: .12em; font-size: .82rem; + background: var(--gold); color: #1a1c20; + padding: .3rem .5rem; border-radius: 5px; +} +.brand-name { font-family: var(--serif); font-size: 1.15rem; letter-spacing: .01em; } +.site-nav a { font-size: .95rem; letter-spacing: .04em; text-transform: uppercase; } + +/* Menu page */ +.menu-page { padding: 3rem 0 4rem; } +.menu-head { text-align: center; margin-bottom: 2.75rem; } +.eyebrow { + margin: 0 0 .6rem; font-size: .72rem; font-weight: 700; + letter-spacing: .22em; text-transform: uppercase; color: var(--gold); +} +.menu-head h1 { font-family: var(--serif); font-size: 2.6rem; font-weight: 400; margin: 0 0 .5rem; } +.lede { color: var(--muted); margin: 0; } + +.menu-group { margin: 0 0 2.75rem; } +.menu-group h2 { + font-family: var(--serif); font-weight: 400; font-size: 1.5rem; + margin: 0 0 1.1rem; padding-bottom: .5rem; border-bottom: 1px solid var(--rule); + color: var(--text); +} +.menu-list { list-style: none; margin: 0; padding: 0; } +.menu-item { padding: .7rem 0; border-bottom: 1px dashed rgba(255, 255, 255, 0.05); } +.menu-item:last-child { border-bottom: 0; } +.menu-row { display: flex; align-items: baseline; gap: .35rem; } +.menu-name { font-weight: 500; } +.menu-leader { flex: 1; border-bottom: 1px dotted rgba(255, 255, 255, 0.28); transform: translateY(-.25rem); } +.menu-price { font-variant-numeric: tabular-nums; color: var(--gold); font-weight: 600; white-space: nowrap; } +.menu-desc { margin: .3rem 0 0; color: var(--muted); font-size: .92rem; max-width: 46ch; } +.empty { color: var(--muted); text-align: center; padding: 2rem 0; } + +/* Generic page + 404 */ +.page { padding: 3rem 0 4rem; } +.page h1 { font-family: var(--serif); font-weight: 400; font-size: 2.2rem; margin: 0 0 1rem; } +.prose { color: var(--text); } +.notfound { text-align: center; } + +/* Footer */ +.site-footer { border-top: 1px solid var(--rule); padding: 2.5rem 0; margin-top: 2rem; } +.footer-inner { text-align: center; } +.footer-name { font-family: var(--serif); font-size: 1.15rem; margin: 0 0 .3rem; } +.footer-meta { color: var(--muted); margin: 0 0 .6rem; } +.footer-fine { color: var(--muted); font-size: .8rem; margin: 0; opacity: .8; } + +@media (max-width: 40rem) { + .menu-head h1 { font-size: 2.1rem; } + .brand-name { display: none; } + body { font-size: 16px; } +} diff --git a/theme/templates/404.php b/theme/templates/404.php new file mode 100644 index 0000000..23621d5 --- /dev/null +++ b/theme/templates/404.php @@ -0,0 +1,15 @@ + +
    +
    +

    RAS

    +

    Not on the menu

    +

    That page isn’t here. Have a look at the menu instead.

    +
    +
    diff --git a/theme/templates/collection-menu_items.php b/theme/templates/collection-menu_items.php new file mode 100644 index 0000000..53d32e4 --- /dev/null +++ b/theme/templates/collection-menu_items.php @@ -0,0 +1,57 @@ +> $entries + * @var callable $e + */ +$groups = []; +foreach ($entries as $item) { + $fields = is_array($item['fields'] ?? null) ? $item['fields'] : []; + $rel = $fields['category'] ?? null; + $cat = is_array($rel) && isset($rel[0]['title']) && (string) $rel[0]['title'] !== '' + ? (string) $rel[0]['title'] + : 'More'; + $groups[$cat][] = $item; +} +?> + diff --git a/theme/templates/entry.php b/theme/templates/entry.php new file mode 100644 index 0000000..943588b --- /dev/null +++ b/theme/templates/entry.php @@ -0,0 +1,17 @@ + $entry + * @var callable $e + */ +$fields = is_array($entry['fields'] ?? null) ? $entry['fields'] : []; +$body = trim((string) ($fields['body'] ?? '')); +?> +
    +
    +

    +
    +
    +
    diff --git a/theme/templates/footer.php b/theme/templates/footer.php new file mode 100644 index 0000000..f313681 --- /dev/null +++ b/theme/templates/footer.php @@ -0,0 +1,17 @@ + +
    + +
    diff --git a/theme/templates/header.php b/theme/templates/header.php new file mode 100644 index 0000000..0c33a1b --- /dev/null +++ b/theme/templates/header.php @@ -0,0 +1,20 @@ + + diff --git a/theme/templates/layout.php b/theme/templates/layout.php new file mode 100644 index 0000000..3bcae66 --- /dev/null +++ b/theme/templates/layout.php @@ -0,0 +1,44 @@ + $meta + * @var string $head extra HTML contributed by plugins (already-rendered, trusted) + */ +$pageTitle = isset($title) && $title !== '' ? $title . ' · ' . $appName : $appName; +$meta = $meta ?? []; +$cssVer = substr((string) @hash_file('crc32b', __DIR__ . '/../assets/app.css'), 0, 8); +?> + + + + + + <?= $e($pageTitle) ?> + + + + + + + + + + + + + + + +
    + +
    + + + diff --git a/theme/theme.json b/theme/theme.json new file mode 100644 index 0000000..d754a80 --- /dev/null +++ b/theme/theme.json @@ -0,0 +1,16 @@ +{ + "name": "RAS", + "version": "0.1.0", + "description": "The public face of the Restaurant Automation System — a dark, elegant guest menu carrying the RAS identity. Plain PHP templates, one stylesheet, no build step.", + "nav": ["menu_items"], + "templates": { + "layout": "HTML shell; includes header and footer.", + "header": "Site header with the RAS wordmark and the menu link.", + "footer": "Footer with hours and contact.", + "collection-menu_items": "The guest menu (the `menu_items` collection), grouped by category with prices.", + "entry": "A single info page.", + "404": "Themed not-found page." + }, + "specialization": "collection-menu_items renders the `menu_items` collection provisioned by the restaurant app — each item has a `price` number and a `category` relation (expanded). Grouped by category name, priced, with dotted leaders.", + "assets": "Files under assets/ are served at /theme/assets/ (e.g. assets/app.css -> /theme/assets/app.css)." +} From 6bcfd84f10572c70190b4992dd0b89e27a698b29 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sat, 5 Sep 2026 23:01:01 -0400 Subject: [PATCH 21/32] docs: public RAS theme shipped Co-Authored-By: Claude Opus 4.8 --- README-NIMBUS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README-NIMBUS.md b/README-NIMBUS.md index 05c6ef7..dd10f81 100644 --- a/README-NIMBUS.md +++ b/README-NIMBUS.md @@ -41,7 +41,9 @@ collections. Zero Nimbus core change. Automation System" identity, uplifted: the signature **circular table tokens** are back on the Floor (modernized status colours), with a shared RAS header across every terminal. -- ⬜ Public menu theme, then deploy — the rest of the finale. +- ✅ **Public menu theme** — a dark, elegant guest menu in the RAS identity + (`theme/`), rendering the `menu_items` collection grouped by category with prices. +- ⬜ Deploy live + create staff logins, then merge to `master` — the last step. **Every operational vertical of the legacy system is now rebuilt on Nimbus**, and the rebuild drove two reusable core capabilities (ADR 0029 content-read, ADR 0030 From 47aa6519518e3426cf32609bb32521f48dbd00e2 Mon Sep 17 00:00:00 2001 From: Danny Matthew Date: Sat, 5 Sep 2026 23:19:09 -0400 Subject: [PATCH 22/32] Slice 8c (kit): demo deploy runbook + seed + hourly reset (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifacts to stand up the RAS public demo (explorable multi-role logins, data reset hourly), mirroring the Foodmart deploy. Repo-side only — no live changes. - deploy/DEPLOY.md — runbook: site image (Nimbus core + this plugin via a Composer path repo + CRM + theme), compose + Caddy/Cloudflare for a subdomain, first deploy, the hourly-reset cron, and the public demo-login table. - deploy/seed-demo.php — seeds capability roles (Waiter/Host/Busboy→floor, Cook→kitchen, Manager→floor+kitchen+manage+crm:read/write), one login per role (public password "demopass"), the menu (categories + items), and live sample data (tables in mixed states, open + paid orders, reservations incl. one linked to a CRM guest). Manager holds crm:read so the reservation→CRM PII gate is visible: a floor login can't open the guest, a manager can. - deploy/reset-demo.sh — the hourly reset: drop DB, migrate (core + plugin), make admin, re-seed. Fresh DB each run, so the seed needs no idempotency. seed lints clean (php -l); reset lints clean (sh -n). Live execution (box build, DNS, first reset, cron) is the go-live step. Co-authored-by: Claude Opus 4.8 --- deploy/DEPLOY.md | 85 +++++++++++++++++++++ deploy/reset-demo.sh | 30 ++++++++ deploy/seed-demo.php | 174 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 deploy/DEPLOY.md create mode 100755 deploy/reset-demo.sh create mode 100644 deploy/seed-demo.php diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..17878ad --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,85 @@ +# Deploying the RAS demo + +The Restaurant Automation System runs as a public **demo**: anyone can log in as +any role and explore the admin, and the data resets every hour. It is deployed the +same way as Foodmart — a co-located site on the existing platform box (one box, +one bill), behind Cloudflare. + +Target hostname (example): **`ras.danmat.dev`** — substitute your chosen subdomain +throughout. + +## What ships + +- **NimbusCMS core** (`nimbuscms/nimbus`, dev-main). +- **The restaurant plugin** — this repo's `plugin/`, consumed via a Composer + **path repository** (ADR-0001); no Packagist. +- **The CRM plugin** (`nimbuscms/crm`, dev-main) — guests. +- **The RAS theme** — this repo's `theme/`, copied to the site's `themes/restaurant/`. +- **The menu collections + demo data** — via `deploy/seed-demo.php`. + +## Site image + +Mirror the Foodmart demo image. A `Dockerfile` for the site (built from a checkout +of this repo alongside a Nimbus checkout) should: + +1. Start from the Nimbus base (PHP 8.2/8.3 + extensions), app at `/var/www/html`. +2. Add a path repository to this repo's `plugin/` and `composer require danmat/restaurant:@dev`. +3. `composer require nimbuscms/crm:dev-main`. +4. Copy this repo's `theme/` to `/var/www/html/themes/restaurant/`. +5. Set config: + - `config/theme.php` → `return 'restaurant';` + - `config/plugins.php` → enable `nimbuscms.crm` and `danmat.restaurant` + (plugins are enabled by default; no action usually needed). + - Optionally render the menu at `/` (site config `homeCollection` → `menu_items`), + else the public menu is at `/menu_items` and the header links to it. +6. Copy `deploy/seed-demo.php` and `deploy/reset-demo.sh` into the image. + +## Compose + edge + +Add a site to the `nimbus-platform` project (as with Foodmart): + +- `docker-compose.ras.yml` — the `ras` app service (image above) + its own MySQL + (`db-ras`), on the `nimbus-platform_web` network, with `DB_*` env. +- Caddy: a per-host block for `ras.danmat.dev` → the `ras` service, `import + cloudflare_only`, `tls /certs/ras.danmat.dev.pem`. +- Cloudflare: a proxied DNS record for `ras.danmat.dev` → the box, and an origin + cert in `/certs/` (same as the other sites). + +## First deploy + +From the box, in the platform project: + +```sh +docker compose -f docker-compose.yml -f docker-compose.ras.yml up -d --build ras db-ras +docker compose -f docker-compose.ras.yml exec ras sh deploy/reset-demo.sh +``` + +`reset-demo.sh` migrates (core + plugin + CRM), creates the admin, and seeds the +demo. Visit `https://ras.danmat.dev/menu_items` (public menu) and +`https://ras.danmat.dev/admin` (staff). + +## Hourly reset + +A cron on the box re-runs the reset each hour: + +```cron +0 * * * * cd /opt/nimbus-platform && docker compose -f docker-compose.ras.yml exec -T ras sh deploy/reset-demo.sh >> /var/log/ras-reset.log 2>&1 +``` + +## The demo logins (public, on purpose) + +Every staff account uses the password **`demopass`**. Each is a Nimbus user in a +capability role, so each sees only what their role allows: + +| Login | Role | Sees | Can it take payment? Open a guest's CRM record? | +|-------|------|------|--------------------------------------------------| +| `waiter@ras.demo` | Waiter (`:floor`) | Floor, Orders, Reservations | Takes payment · **cannot** open CRM guest | +| `host@ras.demo` | Host (`:floor`) | Floor, Orders, Reservations | same as waiter | +| `busboy@ras.demo` | Busboy (`:floor`) | Floor, Orders, Reservations | same as waiter | +| `cook@ras.demo` | Cook (`:kitchen`) | Kitchen only | **cannot** take payment | +| `manager@ras.demo` | Manager (`:floor` + `:kitchen` + `:manage` + `crm:read/write`) | Everything incl. Reports | Takes payment · **can** open CRM guest | +| `admin@ras.demo` | Admin | The whole CMS | — | + +Logging in as a waiter vs a manager shows the capability model live: the cook has +no payment button, and a floor login opening a reservation's "Guest in CRM" link +is refused while the manager's is not — the cross-plugin PII gate, visible. diff --git a/deploy/reset-demo.sh b/deploy/reset-demo.sh new file mode 100755 index 0000000..d58a180 --- /dev/null +++ b/deploy/reset-demo.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Reset the RAS demo to its seeded state — run hourly by cron (see deploy/DEPLOY.md). +# +# Runs INSIDE the RAS site's app container (it has vendor/, bin/nimbus, the plugin +# + CRM + theme, and the DB_* env). A full rebuild is the simplest guaranteed-fresh +# reset: drop the database, re-run migrations (core + plugin), recreate the admin, +# and re-seed. Because the DB is empty each time, the seed needs no idempotency. +set -eu + +: "${DB_HOST:=db}" +: "${DB_NAME:=nimbus}" +: "${DB_USER:=nimbus}" +: "${DB_PASS:=}" +: "${ADMIN_EMAIL:=admin@ras.demo}" +: "${ADMIN_PASSWORD:=demopass}" + +echo "[ras-reset] dropping and recreating ${DB_NAME}…" +mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" \ + -e "DROP DATABASE IF EXISTS \`${DB_NAME}\`; CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + +echo "[ras-reset] migrating (core + plugins)…" +php bin/nimbus migrate + +echo "[ras-reset] creating admin…" +php bin/nimbus install --email="$ADMIN_EMAIL" --password="$ADMIN_PASSWORD" --name="RAS Admin" || true + +echo "[ras-reset] seeding demo…" +php deploy/seed-demo.php + +echo "[ras-reset] done." diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php new file mode 100644 index 0000000..ae0aca3 --- /dev/null +++ b/deploy/seed-demo.php @@ -0,0 +1,174 @@ + getenv('DB_HOST') ?: 'db', + 'port' => (int) (getenv('DB_PORT') ?: 3306), + 'name' => getenv('DB_NAME') ?: 'nimbus', + 'user' => getenv('DB_USER') ?: 'nimbus', + 'pass' => (string) (getenv('DB_PASS') ?: ''), +]); +$pdo = $db->pdo(); +$now = date('Y-m-d H:i:s'); + +// The one public demo password. These accounts exist to be logged into by anyone +// exploring the demo, and the site resets hourly — so this is intentionally not a +// secret. +$demoPassword = 'demopass'; + +echo "RAS demo seed…\n"; + +// --- 1) Capability roles --------------------------------------------------- +// Floor staff run the room; the cook runs the kitchen; the manager runs +// everything AND holds nimbuscms.crm:read so opening a reservation's guest +// record works for them — and visibly does not for floor staff (the PII gate). +$roles = new RoleRepository($db); +$roleId = [ + 'Waiter' => $roles->create('Waiter', ['danmat.restaurant:floor'], false), + 'Host' => $roles->create('Host', ['danmat.restaurant:floor'], false), + 'Busboy' => $roles->create('Busboy', ['danmat.restaurant:floor'], false), + 'Cook' => $roles->create('Cook', ['danmat.restaurant:kitchen'], false), + 'Manager' => $roles->create('Manager', [ + 'danmat.restaurant:floor', + 'danmat.restaurant:kitchen', + 'danmat.restaurant:manage', + 'nimbuscms.crm:read', + 'nimbuscms.crm:write', + ], false), +]; +echo " roles: " . implode(', ', array_keys($roleId)) . "\n"; + +// --- 2) One demo user per role -------------------------------------------- +$makeUser = static function (string $name, string $email) use ($pdo, $now, $demoPassword): int { + $pdo->prepare('INSERT INTO nb_users (name, email, password, role, created_at, updated_at) VALUES (:n,:e,:p,:r,:c,:u)') + ->execute(['n' => $name, 'e' => $email, 'p' => Password::hash($demoPassword), 'r' => 'editor', 'c' => $now, 'u' => $now]); + return (int) $pdo->lastInsertId(); +}; +$staff = [ + ['Wendy Waiter', 'waiter@ras.demo', 'Waiter'], + ['Hank Host', 'host@ras.demo', 'Host'], + ['Bianca Busboy', 'busboy@ras.demo', 'Busboy'], + ['Cody Cook', 'cook@ras.demo', 'Cook'], + ['Morgan Manager', 'manager@ras.demo', 'Manager'], +]; +foreach ($staff as [$name, $email, $role]) { + $roles->assignToUser($makeUser($name, $email), $roleId[$role]); + echo " user: {$email} ({$role})\n"; +} + +// --- 3) The menu (collections + entries) ----------------------------------- +$collections = new CollectionService($db, new CollectionRepository($db)); +$collections->create('categories', 'Categories', '#', '', ['kind' => 'collection', 'permissions' => []], [ + ['handle' => 'name', 'label' => 'Name', 'type' => 'text', 'required' => false, 'options' => []], +]); +$collections->create('menu_items', 'Menu Items', '#', '', ['kind' => 'collection', 'permissions' => []], [ + ['handle' => 'price', 'label' => 'Price', 'type' => 'number', 'required' => false, 'options' => []], + ['handle' => 'category', 'label' => 'Category', 'type' => 'relation', 'required' => false, 'options' => ['target' => 'categories']], + ['handle' => 'body', 'label' => 'Description', 'type' => 'textarea', 'required' => false, 'options' => []], +]); +$repo = new CollectionRepository($db); +$entries = new EntryService($db, new EntryRepository($db), new RelationRepository($db), new FieldTypeRegistry(), new EventDispatcher()); +$catCol = $repo->findByHandle('categories'); +$menuCol = $repo->findByHandle('menu_items'); + +$slug = static fn (string $s): string => trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($s)) ?? '', '-'); +$catIds = []; +foreach (['Soup', 'Appetizer', 'Main Course', 'Dessert'] as $cat) { + $r = $entries->save($catCol, new EntryInput($cat, $slug($cat), 'published', ['name' => $cat], '2024-01-01 00:00:00'), null, null); + $catIds[$cat] = (int) $r->entryId; +} +$menu = [ + ['Miso Soup', 3.50, 'Soup', 'Dashi, silken tofu, spring onion.'], + ['Chicken Soup', 4.99, 'Soup', ''], + ['Goulash Soup', 4.00, 'Soup', ''], + ['Guacamole', 5.50, 'Appetizer', 'Hand-mashed, lime, warm corn chips.'], + ['Pepperoni Bread', 6.25, 'Appetizer', ''], + ['Artichoke Spinach Dip', 4.00, 'Appetizer', ''], + ['Grilled Salmon', 9.99, 'Main Course', 'Seasonal greens, brown butter.'], + ['Chicken Marsala', 8.21, 'Main Course', ''], + ['Salsa Chicken', 10.99, 'Main Course', ''], + ['Fudge', 4.99, 'Dessert', ''], + ['Apple Crisp', 4.25, 'Dessert', 'Oat crumble, vanilla cream.'], + ['Parfait', 3.99, 'Dessert', ''], +]; +foreach ($menu as [$name, $price, $cat, $desc]) { + $entries->save($menuCol, new EntryInput($name, $slug($name), 'published', [ + 'price' => $price, 'category' => [$catIds[$cat]], 'body' => $desc, + ], '2024-01-01 00:00:00'), null, null); +} +echo " menu: " . count($catIds) . " categories, " . count($menu) . " items\n"; + +// --- 4) Live floor / orders / reservations --------------------------------- +$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); + +$t = []; +foreach ([['1', 2], ['2', 4], ['3', 4], ['4', 2], ['5', 6], ['6', 2], ['Patio 1', 4], ['Patio 2', 4]] as [$label, $seats]) { + $t[$label] = $tables->save(null, ['label' => $label, 'seats' => (string) $seats], $now); +} +$tables->setStatus($t['4'], 'dirty', $now); +$tables->setStatus($t['5'], 'reserved', $now); + +// An open order on table 2, mid-service and sent to the kitchen. +$o1 = $orders->open($t['2'], $now); +$orders->addItem($o1, null, 'Grilled Salmon', '9.99', 2, $now); +$orders->addItem($o1, null, 'Guacamole', '5.50', 1, $now); +$orders->setStatus($o1, 'sent', $now); +// A second order on table 3, ready for the pass. +$o2 = $orders->open($t['3'], $now); +$orders->addItem($o2, null, 'Chicken Marsala', '8.21', 1, $now); +$orders->setStatus($o2, 'ready', $now); + +// A couple of settled orders today, so Reports has revenue. +foreach ([['Fudge', '4.99', 2], ['Miso Soup', '3.50', 3]] as $i => [$name, $price, $qty]) { + $tid = $tables->save(null, ['label' => 'H' . $i, 'seats' => '2'], $now); + $paid = $orders->open($tid, $now); + $orders->addItem($paid, null, $name, $price, $qty, $now); + $orders->pay($paid, 'card', $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); +$contactId = $crm->save(null, ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'email' => 'ada@example.test', 'phone' => '555-0101'], $now); +$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 open orders, 2 paid, 2 reservations, 1 CRM guest\n"; +echo "Done. Demo password for every staff login: {$demoPassword}\n"; From 1d3a12ce464a4a5a2a327a218a2d4cb27cf62307 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 00:40:20 -0400 Subject: [PATCH 23/32] deploy: robust autoload path in seed (app at /app on the platform image) Co-Authored-By: Claude Opus 4.8 --- deploy/seed-demo.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php index ae0aca3..13f3807 100644 --- a/deploy/seed-demo.php +++ b/deploy/seed-demo.php @@ -16,7 +16,15 @@ * php deploy/seed-demo.php */ -require '/var/www/html/vendor/autoload.php'; +// The Nimbus app root differs by image (/app on the platform image); find autoload. +$__autoload = null; +foreach (['/app/vendor/autoload.php', __DIR__ . '/../vendor/autoload.php', '/var/www/html/vendor/autoload.php'] as $__p) { + if (is_file($__p)) { + $__autoload = $__p; + break; + } +} +require $__autoload ?? throw new RuntimeException('Could not locate vendor/autoload.php'); use DanMat\Restaurant\Orders; use DanMat\Restaurant\Reservations; From 117f23a336816e712e9fbb7224d1c0abc05822cf Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 00:41:44 -0400 Subject: [PATCH 24/32] deploy: use a policy-compliant demo password (restaurant-demo, >=12 chars) Co-Authored-By: Claude Opus 4.8 --- deploy/DEPLOY.md | 2 +- deploy/reset-demo.sh | 2 +- deploy/seed-demo.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 17878ad..3848964 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -68,7 +68,7 @@ A cron on the box re-runs the reset each hour: ## The demo logins (public, on purpose) -Every staff account uses the password **`demopass`**. Each is a Nimbus user in a +Every staff account uses the password **`restaurant-demo`**. Each is a Nimbus user in a capability role, so each sees only what their role allows: | Login | Role | Sees | Can it take payment? Open a guest's CRM record? | diff --git a/deploy/reset-demo.sh b/deploy/reset-demo.sh index d58a180..330b223 100755 --- a/deploy/reset-demo.sh +++ b/deploy/reset-demo.sh @@ -12,7 +12,7 @@ set -eu : "${DB_USER:=nimbus}" : "${DB_PASS:=}" : "${ADMIN_EMAIL:=admin@ras.demo}" -: "${ADMIN_PASSWORD:=demopass}" +: "${ADMIN_PASSWORD:=restaurant-demo}" echo "[ras-reset] dropping and recreating ${DB_NAME}…" mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" \ diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php index 13f3807..b637d69 100644 --- a/deploy/seed-demo.php +++ b/deploy/seed-demo.php @@ -56,7 +56,7 @@ // The one public demo password. These accounts exist to be logged into by anyone // exploring the demo, and the site resets hourly — so this is intentionally not a // secret. -$demoPassword = 'demopass'; +$demoPassword = 'restaurant-demo'; echo "RAS demo seed…\n"; From 348458eb029a35e48e252b51d0ad59a22c29b989 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 01:16:50 -0400 Subject: [PATCH 25/32] deploy: seed paid demo orders on named tables (drop throwaway H0/H1) Co-Authored-By: Claude Opus 4.8 --- deploy/seed-demo.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php index b637d69..8fe9799 100644 --- a/deploy/seed-demo.php +++ b/deploy/seed-demo.php @@ -150,8 +150,7 @@ foreach ([['1', 2], ['2', 4], ['3', 4], ['4', 2], ['5', 6], ['6', 2], ['Patio 1', 4], ['Patio 2', 4]] as [$label, $seats]) { $t[$label] = $tables->save(null, ['label' => $label, 'seats' => (string) $seats], $now); } -$tables->setStatus($t['4'], 'dirty', $now); -$tables->setStatus($t['5'], 'reserved', $now); +$tables->setStatus($t['5'], 'reserved', $now); // held for tonight's booking // An open order on table 2, mid-service and sent to the kitchen. $o1 = $orders->open($t['2'], $now); @@ -163,13 +162,14 @@ $orders->addItem($o2, null, 'Chicken Marsala', '8.21', 1, $now); $orders->setStatus($o2, 'ready', $now); -// A couple of settled orders today, so Reports has revenue. -foreach ([['Fudge', '4.99', 2], ['Miso Soup', '3.50', 3]] as $i => [$name, $price, $qty]) { - $tid = $tables->save(null, ['label' => 'H' . $i, 'seats' => '2'], $now); - $paid = $orders->open($tid, $now); - $orders->addItem($paid, null, $name, $price, $qty, $now); - $orders->pay($paid, 'card', $now); -} +// Two just-settled tables — paying turns each to `dirty` (awaiting bussing), which +// also gives Reports some revenue. No throwaway tables: it happens on real ones. +$paid1 = $orders->open($t['6'], $now); +$orders->addItem($paid1, null, 'Fudge', '4.99', 2, $now); +$orders->pay($paid1, 'card', $now); +$paid2 = $orders->open($t['Patio 1'], $now); +$orders->addItem($paid2, null, 'Miso Soup', '3.50', 3, $now); +$orders->pay($paid2, 'cash', $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. @@ -178,5 +178,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 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, 2 reservations, 1 CRM guest\n"; echo "Done. Demo password for every staff login: {$demoPassword}\n"; From b09533a939fa4b5dee54f9e3a41e410fd0e43fe3 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 01:23:49 -0400 Subject: [PATCH 26/32] deploy: seed site.home=menu_items + brand so root shows the menu, not the Nimbus placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest-facing root (/) was rendering the bare 'No home page configured' placeholder because site.home lives in nb_settings (which shadows config/site.php at runtime) and was never seeded. Set it — plus the site title/description brand — via SettingsRepository so a from-scratch rebuild matches the golden restore now serving the branded menu at /. Co-Authored-By: Claude Opus 4.8 --- deploy/seed-demo.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php index 8fe9799..d178d4f 100644 --- a/deploy/seed-demo.php +++ b/deploy/seed-demo.php @@ -40,6 +40,7 @@ use Nimbus\Content\RelationRepository; use Nimbus\Database\Connection; use Nimbus\Plugin\PluginStorage; +use Nimbus\Settings\SettingsRepository; use Nimbus\Support\EventDispatcher; use NimbusCMS\Crm\Contacts; @@ -140,6 +141,17 @@ } echo " menu: " . count($catIds) . " categories, " . count($menu) . " items\n"; +// --- 3b) Site settings: brand + render the menu at the root ---------------- +// These are DB settings (nb_settings), which shadow config/site.php at runtime — +// so the guest-facing root ("/") shows the branded menu, not the bare Nimbus +// placeholder. Seeded here so a from-scratch rebuild matches the golden restore. +(new SettingsRepository($db))->setMany([ + 'site.title' => 'The Copper Table', + 'site.description' => 'A restaurant running on the Restaurant Automation System — a live NimbusCMS demo.', + 'site.home' => 'menu_items', +]); +echo " settings: home -> menu_items, brand -> The Copper Table\n"; + // --- 4) Live floor / orders / reservations --------------------------------- $storage = static fn (): PluginStorage => new PluginStorage($db); $tables = new Tables($storage); From a97a241bd8e9e53a7773ca3028b55719a8b6827e Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 02:05:53 -0400 Subject: [PATCH 27/32] deploy: grant the Manager role menu content caps (edit items/categories) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restaurant manager owns the menu, so grant menu_items:write + categories:write (write implies read) on the Manager role — scoped to those collections only (no schema:write). Now the manager can edit the menu from the CMS, and the admin dashboard's Collections/Entries tiles are meaningful for them rather than linking to an empty list. Co-Authored-By: Claude Opus 4.8 --- deploy/DEPLOY.md | 2 +- deploy/seed-demo.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 3848964..2d96a09 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -77,7 +77,7 @@ capability role, so each sees only what their role allows: | `host@ras.demo` | Host (`:floor`) | Floor, Orders, Reservations | same as waiter | | `busboy@ras.demo` | Busboy (`:floor`) | Floor, Orders, Reservations | same as waiter | | `cook@ras.demo` | Cook (`:kitchen`) | Kitchen only | **cannot** take payment | -| `manager@ras.demo` | Manager (`:floor` + `:kitchen` + `:manage` + `crm:read/write`) | Everything incl. Reports | Takes payment · **can** open CRM guest | +| `manager@ras.demo` | Manager (`:floor` + `:kitchen` + `:manage` + `crm:read/write` + `menu_items/categories:write`) | Everything incl. Reports **and the menu** (edit items/categories) | Takes payment · **can** open CRM guest | | `admin@ras.demo` | Admin | The whole CMS | — | Logging in as a waiter vs a manager shows the capability model live: the cook has diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php index d178d4f..8e6e786 100644 --- a/deploy/seed-demo.php +++ b/deploy/seed-demo.php @@ -77,6 +77,11 @@ 'danmat.restaurant:manage', 'nimbuscms.crm:read', 'nimbuscms.crm:write', + // The manager owns the menu — grant content write on the menu collections + // (write implies read) so they can edit items/categories from the CMS. + // Scoped to these collections only: no schema:write, no other content. + 'menu_items:write', + 'categories:write', ], false), ]; echo " roles: " . implode(', ', array_keys($roleId)) . "\n"; From 50089527cb7e1234cbad87d06563bf5221c63f38 Mon Sep 17 00:00:00 2001 From: Danny Matthew Date: Sun, 6 Sep 2026 07:03:25 -0400 Subject: [PATCH 28/32] Slice B: public homepage + denser guest site (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Slice B: public homepage + denser guest site The 2014 original had no public site; the guest site was a single menu page. Add a real restaurant homepage in the RAS identity — hero, about, live featured dishes, hours/location, and a call-to-reserve CTA — with the menu as its own page. - Homepage modeled as a `single`-kind collection `home` (editable CMS content: hero/about/hours/address/phone); site.home -> home. Theme entry-home.php renders the five sections and degrades gracefully when a field is blank. - Live featured dishes via the ADR-0027 view-data hinge: the plugin's new HomeViewData contributor returns a handful of menu items (description-first, deterministic, visitor-independent) only on the home page; the theme reads contrib['danmat.restaurant']['featured'] and escapes on render. Menu::featured() added behind the MenuSource seam. - Header nav -> Home · Menu. New homepage CSS in the existing token system, responsive at 375px. Seed creates the home collection + entry. No core change. Design + 3-hat review in docs/design/slice-b-homepage.md. Co-Authored-By: Claude Opus 4.8 * theme: avoid duplicated title on the homepage (Name · Name) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- deploy/seed-demo.php | 35 +++++++- docs/design/slice-b-homepage.md | 140 ++++++++++++++++++++++++++++++ plugin/src/HomeViewData.php | 36 ++++++++ plugin/src/Menu.php | 31 +++++++ plugin/src/MenuSource.php | 19 ++-- plugin/src/RestaurantPlugin.php | 5 ++ plugin/tests/HomeViewDataTest.php | 82 +++++++++++++++++ plugin/tests/OrdersAdminTest.php | 5 ++ theme/assets/app.css | 57 ++++++++++++ theme/templates/entry-home.php | 115 ++++++++++++++++++++++++ theme/templates/header.php | 1 + theme/templates/layout.php | 4 +- theme/theme.json | 7 +- 13 files changed, 524 insertions(+), 13 deletions(-) create mode 100644 docs/design/slice-b-homepage.md create mode 100644 plugin/src/HomeViewData.php create mode 100644 plugin/tests/HomeViewDataTest.php create mode 100644 theme/templates/entry-home.php diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php index 8e6e786..d15da05 100644 --- a/deploy/seed-demo.php +++ b/deploy/seed-demo.php @@ -146,16 +146,43 @@ } echo " menu: " . count($catIds) . " categories, " . count($menu) . " items\n"; -// --- 3b) Site settings: brand + render the menu at the root ---------------- +// --- 3b) The homepage (a `single`-kind collection: one editable entry) ------ +// The public root ("/") renders this as a restaurant front page (theme +// `entry-home.php`); featured dishes come live from the menu via the plugin's +// view-data contributor. Modeled as content so the copy is editable in the CMS. +$collections->create('home', 'Home', '#', '', ['kind' => 'single', 'permissions' => []], [ + ['handle' => 'hero_kicker', 'label' => 'Hero kicker', 'type' => 'text', 'required' => false, 'options' => []], + ['handle' => 'hero_title', 'label' => 'Hero title', 'type' => 'text', 'required' => false, 'options' => []], + ['handle' => 'hero_tagline', 'label' => 'Hero tagline', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'about_title', 'label' => 'About title', 'type' => 'text', 'required' => false, 'options' => []], + ['handle' => 'about_body', 'label' => 'About body', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'hours', 'label' => 'Hours', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'address', 'label' => 'Address', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'phone', 'label' => 'Phone', 'type' => 'text', 'required' => false, 'options' => []], +]); +$homeCol = $repo->findByHandle('home'); +$entries->save($homeCol, new EntryInput('The Copper Table', 'home', 'published', [ + 'hero_kicker' => 'Est. 2014 · Modern American', + 'hero_title' => 'The Copper Table', + 'hero_tagline' => 'A neighbourhood kitchen for lunch and dinner — seasonal plates, an easy room, and a short list done well.', + 'about_title' => 'About the table', + 'about_body' => "We opened on a corner in 2014 with a wood-topped bar and a small menu that changes with the season. Everything is cooked to order; nothing leaves the pass we wouldn't eat ourselves.\n\nToday the room runs on the Restaurant Automation System — a live NimbusCMS demo.", + 'hours' => "Mon–Thu · 11:00–22:00\nFri–Sat · 11:00–23:00\nSunday · 10:00–21:00", + 'address' => "18 Copper Lane\nOld Town\nEC1 4RS", + 'phone' => '020 7946 0142', +], '2024-01-01 00:00:00'), null, null); +echo " homepage: 1 single collection + entry\n"; + +// --- 3c) Site settings: brand + render the homepage at the root ------------ // These are DB settings (nb_settings), which shadow config/site.php at runtime — -// so the guest-facing root ("/") shows the branded menu, not the bare Nimbus +// so the guest-facing root ("/") shows the branded homepage, not the bare Nimbus // placeholder. Seeded here so a from-scratch rebuild matches the golden restore. (new SettingsRepository($db))->setMany([ 'site.title' => 'The Copper Table', 'site.description' => 'A restaurant running on the Restaurant Automation System — a live NimbusCMS demo.', - 'site.home' => 'menu_items', + 'site.home' => 'home', ]); -echo " settings: home -> menu_items, brand -> The Copper Table\n"; +echo " settings: home -> home, brand -> The Copper Table\n"; // --- 4) Live floor / orders / reservations --------------------------------- $storage = static fn (): PluginStorage => new PluginStorage($db); diff --git a/docs/design/slice-b-homepage.md b/docs/design/slice-b-homepage.md new file mode 100644 index 0000000..305a650 --- /dev/null +++ b/docs/design/slice-b-homepage.md @@ -0,0 +1,140 @@ +# Slice B — Public homepage + a denser guest site + +**Status:** design (pre-build) · **Branch:** `nimbus-rebuild` · **Depends on:** the RAS +theme (Slice 8b), the menu collections, the ADR-0027 view-data hinge (core, already +shipped). + +## Why + +The 2014 original had **no public website** — it was a staff dashboard (RAS). The +guest-facing site is therefore new work, and today it is a single page: the menu at +`/`. Dan's ask: *"make the public-facing site more dense with a homepage direction +and all the normal stuff"*, keeping the uplifted RAS identity (dark `#1a1c20` + +gold `#d4a017`, serif display, printed-menu feel). + +So: a real restaurant homepage — hero, a short about, a few **featured dishes +pulled live from the menu**, hours & location, and a reservations call-to-action — +with the full menu remaining its own page. + +## Non-goals (this slice) + +- Online **ordering / payment** — that is Slice C2 (its own design + security pass). +- Online **reservations** — booking stays staff-side (the Reservations terminal). + The homepage CTA is *"call to reserve"* (a real `tel:` link), not a public form. + (A public booking form is a candidate future slice; it is a public write surface + and would need the same security treatment as ordering.) +- No new **core** capability. Everything here rides existing hinges. + +## Content model — the homepage is editable CMS content + +Model the homepage as a **`single`-kind collection `home`** (one entry, no index), +so the copy is editable in the admin and the slice also demonstrates Nimbus as a +CMS rather than hard-coding strings in a template. + +`home` fields (all optional; the template degrades gracefully when blank): + +| handle | type | purpose | +|--------|------|---------| +| `hero_kicker` | text | eyebrow, e.g. "Est. 2014 · Modern American" | +| `hero_title` | text | large display line, e.g. "The Copper Table" | +| `hero_tagline` | textarea | one or two sentences under the title | +| `about_title` | text | section heading | +| `about_body` | textarea | a short paragraph | +| `hours` | textarea | one "Day · time" per line | +| `address` | textarea | postal address, one line per row | +| `phone` | text | used for the `tel:` reservations CTA | + +Wire it with the existing home mechanism: set the **`site.home` setting** to +`home` (DB setting — see the deploy gotcha; seeded via `SettingsRepository`). The +router then renders the single entry through `entry-home.php` +(`specialize('entry','home')`), falling back to `entry.php` if the theme lacked it. + +> The menu stays at `/menu_items`. `site.home` moves from `menu_items` → `home`. + +## Live "featured dishes" — the plugin feeds the theme (ADR 0027) + +A restaurant homepage should show a few real dishes, and they must stay live as the +menu changes. The platform-honest way (no core change, no restaurant logic leaking +into core) is the **view-data hinge**: + +- The restaurant plugin registers a `ViewDataContributor` via + `PluginContext::viewData()`. +- Its `data(PageContext $page)` returns `[]` unless `$page->kind === 'home'`; on the + home page it returns `['featured' => [ {title, price, category, blurb}, … ]]` — a + **handful** of published menu items read through the plugin's existing + `ContentReader` (`$context->content()`), which is **published-only** and + **visitor-independent** (safe to bake into the by-path page cache). +- The result reaches the theme namespaced as + `contrib['danmat.restaurant']['featured']`. The theme **escapes every value on + render** (it is data, not HTML). + +Selection rule for "featured": the first *N* (=3) published menu items that have a +non-empty description (`body`), falling back to the first *N* items — deterministic, +cache-stable, no per-visitor state. + +Why not just query the menu in the template? Themes get only the current page's +view-model; cross-collection reads are a plugin concern. The hinge is exactly this +seam, and using it keeps the theme dumb and the data live. + +## Theme changes + +- **`templates/entry-home.php`** (new) — the homepage, sections in order: + 1. **Hero** — kicker, title, tagline; full-width dark band, gold rule, a "View + the menu" button (→ `/menu_items`) and a "Call to reserve" button (→ `tel:`). + 2. **About** — `about_title` + `about_body`, measure-width column. + 3. **Featured** — 2–3 cards from `contrib['danmat.restaurant']['featured']` + (name · price · one-line blurb), a "See the full menu →" link. + 4. **Visit** — a two-column block: **Hours** (from `hours`) and **Find us** + (`address` + a `tel:` phone), on one column at ≤ 640px. + 5. **Reserve** — a closing CTA band (call to reserve). +- **`templates/header.php`** — nav becomes **Home · Menu** (and later Order). The + brand already links to `/`. +- **`assets/app.css`** — sections styled in the existing token system (no new + colours beyond the current palette); hero, cards grid, the two-column Visit block, + responsive at `40rem`. Reuse `.wrap`, `.eyebrow`, serif headings, dotted leaders. +- **`theme.json`** — document the new template + `nav`. + +## Seed (`deploy/seed-demo.php`) + +- Create the `home` single collection + its fields. +- Save the one home entry with real copy for "The Copper Table". +- Set `site.home` → `home` (was `menu_items`); keep title/description. +- Golden re-dump on the box so the hourly reset serves it. + +## Three-hat review (proportionate — no core change) + +**🧑‍💼 Product** — Real problem (the demo needs a credible restaurant front page), +for the demo's guests; keeps the menu live. Not over-built (no booking/ordering +here). ✅ + +**🏗️ Architect** — Classification: **theme + plugin + seed** in the app repo; zero +core change. The one design choice — homepage as a `single` collection + featured +via the view-data hinge — reuses shipped seams (ADR 0027, single-kind home) exactly +as intended, and is the smallest thing that keeps featured dishes live. No new +capability, no API surface frozen. A reusable *pattern* (a plugin lighting up a +theme homepage) but implemented entirely with existing hooks. ✅ + +**👷 Principal engineer** — Correctness: template degrades when any field/contrib is +empty; featured selection is deterministic (cache-stable). Perf: featured is a +handful, read once per cached page; no N+1. Testability: the contributor is a pure +`PageContext → array` unit (test: returns `[]` off-home, returns ≤3 published items +on-home, never a draft). Mobile: verify at 375px (hero, cards, two-column Visit +collapse). ✅ + +**🔒 Security lens (light — public read surface only):** all output is +escape-on-render (data, not HTML, per the hinge contract); the contributor is +**visitor-independent** (no `$_COOKIE`/`$_SESSION`/user) so nothing per-visitor is +baked into the shared page cache; ContentReader is published-only (no draft leak); +`tel:` uses an admin-entered phone, escaped. No write surface, no new route. Nothing +to block. (The real security work lands in Slice C2, which *does* add a public +write.) + +## Definition of done + +- `entry-home.php` renders all five sections; blanks degrade gracefully. +- Featured dishes come **live** from the menu via the contributor; editing a menu + item is reflected on the homepage after the page-cache TTL. +- Header nav = Home · Menu; menu still at `/menu_items`. +- Verified at desktop **and 375px** (no horizontal scroll; Visit collapses). +- Plugin CI green (contributor unit test); theme CI green (`php -l`). +- Seed creates `home` + sets `site.home`; box golden re-dumped. diff --git a/plugin/src/HomeViewData.php b/plugin/src/HomeViewData.php new file mode 100644 index 0000000..4199d82 --- /dev/null +++ b/plugin/src/HomeViewData.php @@ -0,0 +1,36 @@ + */ + public function data(PageContext $page): array + { + if ($page->kind !== 'home') { + return []; + } + $featured = $this->menu->featured($this->limit); + return $featured === [] ? [] : ['featured' => $featured]; + } +} diff --git a/plugin/src/Menu.php b/plugin/src/Menu.php index 7445d1e..6d7b1be 100644 --- a/plugin/src/Menu.php +++ b/plugin/src/Menu.php @@ -45,6 +45,37 @@ public function items(): array return $out; } + /** + * A handful of items for the public homepage: name, price, category and a + * short blurb (the item's `body`). Items that HAVE a blurb come first (a + * homepage reads better with descriptions), then the rest, capped at $limit. + * Order within each group follows the collection read order, so the result is + * deterministic and visitor-independent — safe to bake into the page cache + * (ADR 0027). + * + * @return list + */ + public function featured(int $limit = 3): array + { + $blurbed = []; + $plain = []; + foreach (($this->reader)()->entries(self::COLLECTION, 500) as $entry) { + $fields = is_array($entry['fields'] ?? null) ? $entry['fields'] : []; + $row = [ + 'name' => (string) ($entry['title'] ?? ''), + 'price' => $this->price($entry), + 'category' => $this->category($entry), + 'blurb' => trim((string) ($fields['body'] ?? '')), + ]; + if ($row['blurb'] !== '') { + $blurbed[] = $row; + } else { + $plain[] = $row; + } + } + return array_slice(array_merge($blurbed, $plain), 0, max(0, $limit)); + } + /** * The name + unit price to snapshot onto an order line, for one menu item id, * or null if there is no such published item. diff --git a/plugin/src/MenuSource.php b/plugin/src/MenuSource.php index 36d5077..392b270 100644 --- a/plugin/src/MenuSource.php +++ b/plugin/src/MenuSource.php @@ -5,17 +5,26 @@ namespace DanMat\Restaurant; /** - * The menu as the order surfaces need it — a pickable list. A seam over {@see Menu} - * (which reads the menu collection through the core content-read capability), so the - * admin and MCP surfaces depend on this, not on core content, and a test can supply - * a canned menu without a collection. + * The menu as the app's surfaces need it. A seam over {@see Menu} (which reads the + * menu collection through the core content-read capability), so the admin, MCP and + * public surfaces depend on this, not on core content, and a test can supply a + * canned menu without a collection. */ interface MenuSource { /** - * The menu items available to order. + * The menu items available to order — a pickable list. * * @return list */ public function items(): array; + + /** + * A handful of items to feature on the public homepage — items with a + * description first, then others, capped at $limit. Deterministic and + * visitor-independent (safe for the page cache; see ADR 0027). + * + * @return list + */ + public function featured(int $limit = 3): array; } diff --git a/plugin/src/RestaurantPlugin.php b/plugin/src/RestaurantPlugin.php index 85f6eda..c7c04e1 100644 --- a/plugin/src/RestaurantPlugin.php +++ b/plugin/src/RestaurantPlugin.php @@ -57,6 +57,11 @@ public function register(PluginContext $context): void // The agent surface — every tool gates on danmat.restaurant:read|write (ADR 0016). $context->mcp()->register(new RestaurantToolset($tables, $orders, $menu, $reservations, $reports)); + // The public homepage's live "featured dishes" — the plugin feeds the theme + // a handful of menu items via the view-data hinge (ADR 0027). Data only, + // home page only, visitor-independent (cache-safe); the theme escapes it. + $context->viewData()->register(new HomeViewData($menu)); + // 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/tests/HomeViewDataTest.php b/plugin/tests/HomeViewDataTest.php new file mode 100644 index 0000000..433ed5b --- /dev/null +++ b/plugin/tests/HomeViewDataTest.php @@ -0,0 +1,82 @@ + $featured */ + private function menu(array $featured): MenuSource + { + return new class ($featured) implements MenuSource { + /** @param list $featured */ + public function __construct(private array $featured) + { + } + + public function items(): array + { + return []; + } + + public function featured(int $limit = 3): array + { + return array_slice($this->featured, 0, $limit); + } + }; + } + + private function context(string $kind): PageContext + { + return new PageContext($kind, 'https://x.test/', 'T', 'Site', 'nonce'); + } + + public function test_it_contributes_featured_dishes_on_the_home_page(): void + { + $menu = $this->menu([ + ['name' => 'Grilled Salmon', 'price' => '9.99', 'category' => 'Main Course', 'blurb' => 'Seasonal greens.'], + ]); + $data = (new HomeViewData($menu))->data($this->context('home')); + + self::assertArrayHasKey('featured', $data); + self::assertSame('Grilled Salmon', $data['featured'][0]['name']); + } + + public function test_it_contributes_nothing_off_the_home_page(): void + { + $menu = $this->menu([ + ['name' => 'Grilled Salmon', 'price' => '9.99', 'category' => 'Main Course', 'blurb' => 'x'], + ]); + $contributor = new HomeViewData($menu); + + self::assertSame([], $contributor->data($this->context('collection')), 'no data on a collection page'); + self::assertSame([], $contributor->data($this->context('entry')), 'no data on an entry page'); + } + + public function test_an_empty_menu_contributes_nothing_even_on_home(): void + { + $data = (new HomeViewData($this->menu([])))->data($this->context('home')); + self::assertSame([], $data, 'no featured key when there is nothing to feature'); + } + + public function test_the_limit_is_honoured(): void + { + $items = []; + foreach (['A', 'B', 'C', 'D', 'E'] as $n) { + $items[] = ['name' => $n, 'price' => '1.00', 'category' => null, 'blurb' => '']; + } + $data = (new HomeViewData($this->menu($items), 3))->data($this->context('home')); + self::assertCount(3, $data['featured']); + } +} diff --git a/plugin/tests/OrdersAdminTest.php b/plugin/tests/OrdersAdminTest.php index f19dcb9..663e820 100644 --- a/plugin/tests/OrdersAdminTest.php +++ b/plugin/tests/OrdersAdminTest.php @@ -49,6 +49,11 @@ public function items(): array { return [['id' => 101, 'name' => 'Margherita', 'price' => '12.50', 'category' => 'Mains']]; } + + public function featured(int $limit = 3): array + { + return []; + } }; $this->admin = new OrdersAdmin($this->orders, $this->tables, $menu); } diff --git a/theme/assets/app.css b/theme/assets/app.css index 67f00f2..ab640ea 100644 --- a/theme/assets/app.css +++ b/theme/assets/app.css @@ -59,6 +59,7 @@ img { max-width: 100%; height: auto; } padding: .3rem .5rem; border-radius: 5px; } .brand-name { font-family: var(--serif); font-size: 1.15rem; letter-spacing: .01em; } +.site-nav { display: flex; gap: 1.4rem; } .site-nav a { font-size: .95rem; letter-spacing: .04em; text-transform: uppercase; } /* Menu page */ @@ -87,6 +88,62 @@ img { max-width: 100%; height: auto; } .menu-desc { margin: .3rem 0 0; color: var(--muted); font-size: .92rem; max-width: 46ch; } .empty { color: var(--muted); text-align: center; padding: 2rem 0; } +/* Homepage */ +.btn { + display: inline-block; padding: .7rem 1.3rem; border-radius: 6px; + font-size: .82rem; letter-spacing: .12em; text-transform: uppercase; font-weight: 700; + border: 1px solid transparent; transition: background-color .15s, color .15s, border-color .15s; +} +.btn-gold { background: var(--gold); color: #1a1c20; } +.btn-gold:hover { background: var(--gold-bright); color: #1a1c20; } +.btn-ghost { border-color: var(--rule); color: var(--text); } +.btn-ghost:hover { border-color: var(--gold); color: var(--gold-bright); } + +.hero { + border-bottom: 1px solid var(--rule); + background: + radial-gradient(1200px 400px at 50% -120px, rgba(212, 160, 23, 0.10), transparent 70%), + var(--ground); +} +.hero-inner { text-align: center; padding: 5.5rem 0 4.5rem; } +.hero-title { font-family: var(--serif); font-weight: 400; font-size: 3.4rem; line-height: 1.05; margin: 0 0 1rem; text-wrap: balance; } +.hero-tagline { color: var(--muted); font-size: 1.15rem; max-width: 34rem; margin: 0 auto 1.9rem; } +.hero-actions { display: flex; gap: .8rem; justify-content: center; flex-wrap: wrap; margin: 0; } + +.home-section { padding: 3.75rem 0; border-bottom: 1px solid var(--rule); } +.section-head { text-align: center; margin-bottom: 2.25rem; } +.section-title { font-family: var(--serif); font-weight: 400; font-size: 1.9rem; margin: 0 0 1rem; } +.about { text-align: center; } +.about .section-title { margin-bottom: 1rem; } +.about-body { color: var(--muted); font-size: 1.08rem; margin: 0; } + +.dish-grid { + list-style: none; margin: 0; padding: 0; + display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; +} +.dish { background: var(--surface); border: 1px solid var(--rule); border-radius: 10px; padding: 1.4rem; } +.dish-cat { margin: 0 0 .5rem; font-size: .68rem; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; color: var(--gold); } +.dish-row { display: flex; align-items: baseline; justify-content: space-between; gap: .6rem; } +.dish-name { font-family: var(--serif); font-size: 1.2rem; } +.dish-price { font-variant-numeric: tabular-nums; color: var(--gold); font-weight: 600; white-space: nowrap; } +.dish-blurb { margin: .55rem 0 0; color: var(--muted); font-size: .92rem; } +.section-more { text-align: center; margin: 2rem 0 0; } +.section-more a { letter-spacing: .04em; text-transform: uppercase; font-size: .9rem; } + +.visit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2.5rem; } +.visit-col .section-title { font-size: 1.5rem; } +.visit-body { color: var(--muted); margin: 0 0 .5rem; } + +.reserve { text-align: center; border-bottom: 0; } +.reserve-lede { color: var(--muted); margin: 0 0 1.6rem; } + +@media (max-width: 40rem) { + .hero-title { font-size: 2.5rem; } + .hero-inner { padding: 4rem 0 3.25rem; } + .dish-grid { grid-template-columns: 1fr; } + .visit-grid { grid-template-columns: 1fr; gap: 1.75rem; } +} + /* Generic page + 404 */ .page { padding: 3rem 0 4rem; } .page h1 { font-family: var(--serif); font-weight: 400; font-size: 2.2rem; margin: 0 0 1rem; } diff --git a/theme/templates/entry-home.php b/theme/templates/entry-home.php new file mode 100644 index 0000000..5eeaf5f --- /dev/null +++ b/theme/templates/entry-home.php @@ -0,0 +1,115 @@ + $entry the home entry view-model + * @var array> $contrib namespaced plugin view-data + * @var string $appName the site name + * @var callable $e escape a value for output + */ +$fields = is_array($entry['fields'] ?? null) ? $entry['fields'] : []; +$f = static fn (string $k): string => trim((string) ($fields[$k] ?? '')); + +$heroKicker = $f('hero_kicker'); +$heroTitle = $f('hero_title') !== '' ? $f('hero_title') : $appName; +$heroTagline = $f('hero_tagline'); +$aboutTitle = $f('about_title'); +$aboutBody = $f('about_body'); +$hours = $f('hours'); +$address = $f('address'); +$phone = $f('phone'); + +$featured = $contrib['danmat.restaurant']['featured'] ?? []; +$featured = is_array($featured) ? $featured : []; + +// tel: href — digits and a leading + only, so an admin-entered phone is a safe URL. +$telHref = $phone !== '' ? 'tel:' . preg_replace('/[^0-9+]/', '', $phone) : ''; +?> +
    +
    +

    +

    +

    +

    + View the menu + Call to reserve +

    +
    +
    + + +
    +
    +

    +

    +
    +
    + + + + + + + +
    +
    + +
    +

    Hours

    +

    +
    + + +
    +

    Find us

    +

    +

    +
    + +
    +
    + + +
    +
    +

    Join us

    +

    Walk-ins welcome. For a table.

    +

    + Call to reserve + Browse the menu +

    +
    +
    diff --git a/theme/templates/header.php b/theme/templates/header.php index 0c33a1b..b203f6e 100644 --- a/theme/templates/header.php +++ b/theme/templates/header.php @@ -14,6 +14,7 @@
    diff --git a/theme/templates/layout.php b/theme/templates/layout.php index 3bcae66..a7de0dd 100644 --- a/theme/templates/layout.php +++ b/theme/templates/layout.php @@ -11,7 +11,9 @@ * @var array $meta * @var string $head extra HTML contributed by plugins (already-rendered, trusted) */ -$pageTitle = isset($title) && $title !== '' ? $title . ' · ' . $appName : $appName; +// Append the site name unless the page title already is it (the homepage entry is +// titled after the restaurant, so this avoids "Name · Name"). +$pageTitle = isset($title) && $title !== '' && $title !== $appName ? $title . ' · ' . $appName : $appName; $meta = $meta ?? []; $cssVer = substr((string) @hash_file('crc32b', __DIR__ . '/../assets/app.css'), 0, 8); ?> diff --git a/theme/theme.json b/theme/theme.json index d754a80..4babccd 100644 --- a/theme/theme.json +++ b/theme/theme.json @@ -2,15 +2,16 @@ "name": "RAS", "version": "0.1.0", "description": "The public face of the Restaurant Automation System — a dark, elegant guest menu carrying the RAS identity. Plain PHP templates, one stylesheet, no build step.", - "nav": ["menu_items"], + "nav": ["home", "menu_items"], "templates": { "layout": "HTML shell; includes header and footer.", - "header": "Site header with the RAS wordmark and the menu link.", + "header": "Site header with the RAS wordmark and the Home/Menu links.", "footer": "Footer with hours and contact.", + "entry-home": "The homepage: the `home` single-collection entry, rendered as a restaurant front page (hero, about, featured dishes, hours/location, reservations CTA).", "collection-menu_items": "The guest menu (the `menu_items` collection), grouped by category with prices.", "entry": "A single info page.", "404": "Themed not-found page." }, - "specialization": "collection-menu_items renders the `menu_items` collection provisioned by the restaurant app — each item has a `price` number and a `category` relation (expanded). Grouped by category name, priced, with dotted leaders.", + "specialization": "entry-home renders the `home` single collection (fields: hero_kicker/hero_title/hero_tagline, about_title/about_body, hours, address, phone) and shows live featured dishes contributed by the restaurant plugin under `contrib['danmat.restaurant']['featured']` (ADR 0027). collection-menu_items renders the `menu_items` collection — each item has a `price` number and a `category` relation (expanded), grouped by category name, priced, with dotted leaders.", "assets": "Files under assets/ are served at /theme/assets/ (e.g. assets/app.css -> /theme/assets/app.css)." } From d26526adfb3770331bb5a1bdd2df8e9d3d005da8 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 07:15:23 -0400 Subject: [PATCH 29/32] deploy: add demo.php (6 public logins) for the login role-picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the account list for the NimbusCMS demo-mode 'Explore as …' picker (config/demo.php). Manager first (backs the pre-fill). Documented in DEPLOY.md; mounted read-only into the ras service. Co-Authored-By: Claude Opus 4.8 --- deploy/DEPLOY.md | 15 +++++++++++++++ deploy/demo.php | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 deploy/demo.php diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 2d96a09..dbe20db 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -83,3 +83,18 @@ capability role, so each sees only what their role allows: Logging in as a waiter vs a manager shows the capability model live: the cook has no payment button, and a floor login opening a reservation's "Guest in CRM" link is refused while the manager's is not — the cross-plugin PII gate, visible. + +### The "Explore as …" picker + +So visitors can switch roles without retyping addresses, the sign-in page shows a +role dropdown (NimbusCMS demo mode, `Config::demoAccounts()`). Deploy the account +list to the site's `config/demo.php` and mount it read-only: + +```sh +cp deploy/demo.php /opt/ras/config/demo.php # the 6 public logins above +# docker-compose.ras.yml → the ras service volumes: +# - /opt/ras/config/demo.php:/app/config/demo.php:ro +``` + +The picker appears only in demo mode (`NIMBUS_DEMO=1`); the first entry (Manager) +backs the one-click pre-fill. On a non-demo install the file is ignored entirely. diff --git a/deploy/demo.php b/deploy/demo.php new file mode 100644 index 0000000..ca939a5 --- /dev/null +++ b/deploy/demo.php @@ -0,0 +1,21 @@ + [ + ['label' => 'Manager — everything + menu + guests', 'email' => 'manager@ras.demo', 'password' => 'restaurant-demo'], + ['label' => 'Waiter — the floor', 'email' => 'waiter@ras.demo', 'password' => 'restaurant-demo'], + ['label' => 'Host — the floor', 'email' => 'host@ras.demo', 'password' => 'restaurant-demo'], + ['label' => 'Busboy — the floor', 'email' => 'busboy@ras.demo', 'password' => 'restaurant-demo'], + ['label' => 'Cook — the kitchen', 'email' => 'cook@ras.demo', 'password' => 'restaurant-demo'], + ['label' => 'Admin — the whole CMS', 'email' => 'admin@ras.demo', 'password' => 'restaurant-demo'], + ], +]; From a22b79acd96183d418d2d6b5f6057fb64c6c0681 Mon Sep 17 00:00:00 2001 From: Danny Matthew Date: Sun, 6 Sep 2026 08:20:35 -0400 Subject: [PATCH 30/32] Slice C2: online ordering + simulated checkout (#17) 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 @@
    From b7fb4de2b56d669080116375e96f6fa8f4c87c00 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 08:46:11 -0400 Subject: [PATCH 31/32] fix(admin): stop the form select overflowing its column on mobile The reservations 'Add a reservation' form (and the orders/tables forms) had a