Skip to content

Rebuild the Restaurant Management System on NimbusCMS - #18

Merged
DanMat merged 32 commits into
masterfrom
nimbus-rebuild
Sep 6, 2026
Merged

DanMat merged 32 commits into
masterfrom
nimbus-rebuild

Conversation

@DanMat

@DanMat DanMat commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Replaces the 2014 hand-rolled PHP app with a full rebuild as an application on NimbusCMS — and makes it the default branch.

What's here

  • The app — floor, orders, kitchen, payments, staff roles, reservations, reports, a public homepage, the guest menu, and online ordering (simulated checkout) — as a Nimbus plugin co-located in this repo (plugin/) + the official CRM + a theme (theme/). Zero Nimbus core change for app logic.
  • Live demo: https://ras.nimbuscms.dev (resets hourly; "Explore as …" role logins on /admin).
  • Platform validation — drove two reusable Nimbus core capabilities (ADR 0029 content-read, ADR 0030 fine-grained plugin capabilities); ledger in docs/PLATFORM-VALIDATION.md.
  • The original 2014 app is preserved in archive/, and made runnable again via Docker (PHP 5.6) — see archive/README.md.
  • README promoted to the Nimbus rebuild (run-locally instructions + live demo); tree cleaned up.

Verification

All work shipped as reviewed slices (design + 3-hat + security review where relevant), each CI-green. Every operational vertical rebuilt and verified live; the online-ordering security review is in docs/design/slice-c2-online-ordering.md.

Legacy issues #1#4 will be closed as superseded after this merges (the "won't start" one is now answered by archive/).

🤖 Generated with Claude Code

DanMat and others added 30 commits August 2, 2026 20:08
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.
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 <noreply@anthropic.com>
Ran the platform (nimbus-review-loop) and security (nimbus-security-review)
disciplines on the architecture before any code. Corrections:

- Capability model: stock Nimbus gates admin pages only on {pluginId}:read|write,
  so the floor/kitchen/manage split is not expressible today. v1 uses a single
  coarse danmat.restaurant:read|write; fine-grained roles logged as finding F4
  (a core PR at the Staff & roles slice). Finer logic enforced in-app meanwhile.
- Terminals are capability-gated ADMIN PAGES, not plugin routes — routes are
  public (no auth/CSRF), so a kitchen/floor route would be unauthenticated.
- Added §6a Security controls (merge bar per slice): capability-gate every
  surface incl. MCP, server-computed money + manager-checked voids, cross-plugin
  CRM PII resolved only for crm:read holders, escape-on-render, bound SQL + enum
  allow-lists, graceful dangling contact refs.
- Ledger: F4 recorded; Staff & roles row marked as a confirmed core gap.

Verdict: security-green to build Slice 1 (Tables) — no money/PII/CRM link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first operational vertical, and the first code of the co-located restaurant
plugin (plugin/, type: nimbuscms-plugin, id danmat.restaurant) — mirroring the
official Inventory/Commerce/CRM plugin pattern, zero Nimbus core change.

- Schema: rest_table (label unique, seats, status ENUM open/occupied/dirty/
  reserved) + migration 001_tables.
- Tables service: field allow-list; status is a write-time allow-list; label
  required + unique; bounded seats; bound SQL; total delete; setStatus quick move.
- MCP (namespace restaurant): tables / table_get / table_set / table_status /
  table_delete, gated on the wildcard-immune danmat.restaurant capability
  (read/write), non-enumerating — a content *:write token can't reach the floor.
- Admin: a mobile-first floor board (tables by status, per-card quick actions
  seat/clear/open/reserve, status filter, create/edit), a capability-gated ADMIN
  PAGE (not a public route, per the security review) + CSRF.
- Guide: tables section for agents.
- Tests: TablesTest, RestaurantToolsetTest (authz matrix), TablesAdminTest
  (escape-on-render + board + quick actions).
- CI: .github/workflows/plugin-ci.yml runs cs-fixer + PHPStan L6 + phpunit in
  plugin/ against MySQL, on PHP 8.2 + 8.3.

cs-fixer + PHPStan green locally (borrowed-vendor); phpunit runs in CI.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the service loop — settle a bill, close the order, turn the table.

- Schema: payment columns on rest_order (amount_paid, payment_method, paid_at)
  folded into its migration (unreleased; no deployed install to ALTER).
- Orders::pay(orderId, method, now): the AMOUNT is the server-computed order
  total — never a caller value; only the method (allow-list cash/card/other) is
  chosen. Marks paid + closed and sets the table `dirty`, atomically. Rejects a
  bad method, a missing order, and a double payment. get()/all() now surface
  amount_paid / payment_method / paid_at.
- Admin: a payment section on the order screen (shows total due + method select,
  no amount input; a receipt once settled). "served" no longer offers a Close
  button — payment closes the order. The floor's existing dirty→open quick action
  finishes the turn.
- MCP: order_pay (id + method only).
- Guide: payment note.
- Tests: OrdersTest (computed-total charge, close, table turn, method allow-list,
  missing/double-pay rejected), OrdersAdminTest (pay form has total + no amount
  input; receipt after), RestaurantToolsetTest (order_pay listed; pays the
  computed total over MCP; bad method as data).

Security: amount is never client-set (structurally — pay() takes no amount); the
payment record carries no PII.

cs-fixer + PHPStan green locally; phpunit runs in CI.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Real role separation, now that fine-grained plugin capabilities exist in core.

- Declares fine-grained actions on the capability: read, write, floor, kitchen,
  manage (was read/write only).
- Re-gates the terminals: Floor (tables) and Orders (incl. taking payment) →
  danmat.restaurant:floor; Kitchen display → danmat.restaurant:kitchen. Each
  admin action inherits its page's gate, so a cook (:kitchen) cannot take payment
  and a waiter (:floor) cannot advance kitchen tickets; a manager holds all three.
  read/write remain for the MCP/agent surface. `manage` is declared for reports
  (a later slice).
- Guide: staff-roles section (which grant does what). Legacy roles map:
  waiter/host/busboy→floor, cook→kitchen, manager/admin→manage(+floor+kitchen).
  Staff users are granted these in Nimbus's own roles/tokens admin.
- Test: RestaurantPluginTest (no DB) — register() declares the fine actions as
  grants and gates each terminal on the right one.

cs-fixer + PHPStan green locally; phpunit runs in CI.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lights up the CRM integration without ever reading CRM data.

- Schema: rest_reservation (003_reservations) — party_name/size/reserved_at/
  notes are the restaurant's own first-party data; table_id (validated,
  same-plugin) + contact_id (a bare link to a CRM contact).
- Reservations service: allow-list, party name required, allow-listed status
  (booked/seated/cancelled/no_show), strict datetime (accepts datetime-local),
  validated table ref. contact_id is stored as given and NOT resolved or
  existence-checked — reading the CRM would breach its capability gate.
- Admin: the book (list + status filter + create/edit). Shows only the booking's
  own data; a CRM-linked booking renders a "Guest in CRM" LINK to
  /admin/crm?edit=<id> (core gates that page) — never CRM data itself. Gated on
  danmat.restaurant:floor + CSRF, mobile-reflow.
- MCP: reservations / _get / _set / _status / _delete; contact_id passes through
  as a bare link.
- Guide: reservations section (+ the CRM boundary).
- Tests: ReservationsTest, ReservationsAdminTest (escape + gated CRM link, no
  CRM data), RestaurantToolsetTest (reservation tools + round-trip carrying only
  the link), RestaurantPluginTest (reservations page gated :floor).

Security: the restaurant stores only contact_id and links out to the CRM's own
gated page; no CRM PII is read, resolved or copied into rest_* tables. A proper
in-process guest picker would need a CRM read service port (ADR 0019) — a
documented follow-up.

cs-fixer + PHPStan green locally; phpunit runs in CI.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>

* Reports: rename card to 'Revenue today' (avoid apostrophe-escape mismatch)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hars)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… the Nimbus placeholder

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>

* theme: avoid duplicated title on the homepage (Name · Name)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
DanMat and others added 2 commits September 6, 2026 08:46
The reservations 'Add a reservation' form (and the orders/tables forms) had a
<select> with no width constraint inside a flex column, so a long option (e.g. a
table label, a menu item) pushed it past its column and overlapped the next field
on narrow screens. Add width:100%/max-width:100% to the fields and min-width:0 to
the flex label so it can shrink. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clean up the folder tree now that the rebuild is the project:

- Move the original 2014 app (procedural PHP, oose.sql, css/js/font-awesome, the
  design doc, the old README) into archive/, unchanged. The old README is marked
  as the origin 2014 version.
- Promote the Nimbus rebuild README to the root README.md (was README-NIMBUS.md),
  rewritten as the primary readme with the live demo, status, layout, and
  run-locally instructions; it points to archive/ for the origin.
- Make the archived app runnable again: it wouldn't start on a modern PHP (it uses
  the removed mysql_* extension), so add archive/Dockerfile (PHP 5.6 + mysql ext,
  output_buffering on to restore the 2014 shared-hosting default its session/header
  flow assumes) + archive/docker-compose.yml (MySQL 5.7, auto-imports oose.sql).
  The only source concession: settings.inc.php reads the DB host from env (original
  values as fallback). Login (waiter/123) works; screenshots committed as proof.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DanMat
DanMat merged commit 19bd345 into master Sep 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant