diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml new file mode 100644 index 0000000..fb54a42 --- /dev/null +++ b/.github/workflows/plugin-ci.yml @@ -0,0 +1,62 @@ +name: Plugin CI + +# The restaurant plugin is co-located in this repo under plugin/. This runs its +# checks (composer validate, cs-fixer, PHPStan, phpunit) against a MySQL service, +# exactly as the official Nimbus plugins do in their own repos. + +on: + push: + branches: [nimbus-rebuild, 'slice/**'] + paths: ['plugin/**', '.github/workflows/plugin-ci.yml'] + pull_request: + paths: ['plugin/**', '.github/workflows/plugin-ci.yml'] + +defaults: + run: + working-directory: plugin + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.2', '8.3'] + + services: + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: nimbus_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot" + --health-interval=5s --health-timeout=5s --health-retries=20 + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo_mysql, mbstring + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Formatting + run: vendor/bin/php-cs-fixer fix --dry-run --diff + + - name: Static analysis + run: vendor/bin/phpstan analyse --no-progress --memory-limit=512M + + - name: Run tests + run: vendor/bin/phpunit + env: + TEST_DB_HOST: 127.0.0.1 + TEST_DB_PORT: '3306' 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/README.md b/README.md index 25039c5..a1ff6cb 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,120 @@ -# Restaurant-Management-System -A restaurant management system based on PHP - -Restaurant Management System was developed using PHP as backend and Bootstrap as frontend. The system allows the waiter to take orders/payments from customers and maintain table status. The cook can see the list of orders made by different waiters and notify the same once the food is prepared. The system allows the manager to see the monthly revenue of the restaurant and the inventory. The admin user can maintain the different roles of the system. - -Credentials - -Username: waiter - -Password: 123 - - - -Username: cook - -Password: 123 - - - -Username: host - -Password: 123 - - - -Username: busboy - -Password: 123 - - - -Note: includes/settings.inc.php has the DB connection settings. - -oose.sql has the sample DB +# Restaurant Management System — on NimbusCMS + +The Restaurant Management System, **rebuilt as an application on +[NimbusCMS](https://github.com/NimbusCMS/nimbus)** instead of the original 2014 +hand-rolled PHP. It runs a full restaurant — floor, orders, kitchen, payments, +staff roles, reservations, reports — plus a public site with online ordering, as a +Nimbus **plugin co-located in this repo** (`plugin/`) composed with the official +CRM (guests) and a theme (`theme/`). **Zero Nimbus core change** for app logic. + +> The original 2014 version is preserved in **[`archive/`](archive/)** (and still +> runnable — see its README). This is the current system. + +## Try the live demo + +**** — a public sandbox that resets hourly. + +- **Guests:** the homepage, the menu, and **online ordering** (`/order`) with a + *simulated* checkout (no real payment). +- **Staff:** sign in at **`/admin`** — the login page has an *“Explore as …”* + picker for each role. Every login uses the password **`restaurant-demo`**: + `waiter@ras.demo` (floor), `cook@ras.demo` (kitchen), `manager@ras.demo` + (everything + menu + guests), `admin@ras.demo` (the whole CMS), and more. + +Signing in as a waiter vs. a cook vs. a manager shows the capability model live: a +cook has no payment button, a floor login can’t open a guest’s CRM record, and only +the manager sees Reports. + +## Why this exists (platform validation) + +Beyond the app, this rebuild is the **first real validation of Nimbus as a +platform**: if a whole restaurant can be built on Nimbus without pushing +restaurant-specific logic into the CMS core, the platform is proven. It drove two +reusable **core** capabilities — a read-only content capability for plugins +(ADR 0029) and fine-grained plugin capabilities (ADR 0030) — with nothing +restaurant-shaped added to core. The running ledger is in +[`docs/PLATFORM-VALIDATION.md`](docs/PLATFORM-VALIDATION.md); the architecture is in +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and +[`docs/adr/0001`](docs/adr/0001-restaurant-as-a-colocated-nimbus-plugin.md). + +## Status — complete + +- ✅ **Menu** — categories and priced items, as Nimbus collections. +- ✅ **Tables / floor** — `rest_table`, live status, mobile floor board (the RAS + circular table tokens), capability-gated admin + MCP. +- ✅ **Orders** — open on a table, pick from the menu (snapshotting name + price), + server-computed totals, workflow (ADR 0029). +- ✅ **Kitchen display** — a cook’s New → Preparing → Ready screen. +- ✅ **Payment & turn** — settle (server-computed amount), close, turn the table. +- ✅ **Staff & roles** — floor / kitchen / manage capabilities gate the terminals + (ADR 0030). +- ✅ **Reservations** — a booking book linked to **CRM** guests by id, without the + restaurant ever reading CRM data (the PII boundary holds). +- ✅ **Reports** — a manager dashboard (revenue, active orders, top items). +- ✅ **Public site** — a homepage (hero, about, live featured dishes, hours), the + guest menu, and **online ordering** with a simulated checkout, in the dark RAS + identity (`theme/`). +- ✅ **Deployed** — live at , resets hourly. + +## Layout + +``` +plugin/ the restaurant plugin (type: nimbuscms-plugin) + src/ RestaurantPlugin, Schema, Tables, Orders, Kitchen, Reservations, + Reports, admin pages, MCP toolset, online-order view-data + routes + templates/ default templates for the public order page (theme-overridable) + tests/ DB-backed test suites (run in CI) +theme/ the public "RAS" theme (homepage, menu, order pages) +app/collections.php the menu content model, declared as data +bin/provision-menu.sh installs the menu onto a running Nimbus via its public API +deploy/ deploy kit — DEPLOY.md runbook, seed-demo.php, reset scripts, demo logins +docs/ ARCHITECTURE, PLATFORM-VALIDATION, ADRs, per-slice design docs +archive/ the original 2014 app (unmodified; runnable via Docker) +``` + +## Run it locally + +The app is a Nimbus **plugin**; a site consumes it via a Composer **path +repository** (Nimbus discovers plugins by `type: nimbuscms-plugin`), alongside the +CRM and the theme. The full, reproducible recipe — a Docker image built from a +Nimbus base + this repo’s `plugin/` + the CRM + the `theme/`, then migrate + seed — +is documented in **[`deploy/DEPLOY.md`](deploy/DEPLOY.md)**, and the demo data +(roles, one login per role, the menu, sample floor/orders/reservations) is created +by **[`deploy/seed-demo.php`](deploy/seed-demo.php)**. + +In short: + +1. Build a site image: the Nimbus base image, plus `composer require danmat/restaurant` + (via a path repo pointing at this repo’s `plugin/`) and `nimbuscms/crm`, and copy + `theme/` into the site’s `themes/restaurant/`. (See `deploy/DEPLOY.md`.) +2. Bring up the app + a MySQL, then: + ```bash + php bin/nimbus migrate + php bin/nimbus install --email=admin@ras.demo --password= --name="RAS Admin" + php deploy/seed-demo.php # roles, logins, menu, sample data + ``` +3. Visit `/` (homepage), `/menu_items` (menu), `/order` (online ordering), and + `/admin` (staff). + +Just the **Menu** vertical against an already-running Nimbus: + +```bash +NIMBUS_URL=http://localhost:8080 \ +ADMIN_EMAIL=admin@nimbus.test ADMIN_PASSWORD=password \ + bin/provision-menu.sh +``` + +## The original 2014 app + +Preserved in [`archive/`](archive/) — procedural PHP + Bootstrap 3. It wouldn’t +start on a modern PHP (it uses the removed `mysql_*` extension), so it ships with a +Docker setup that runs it authentically on PHP 5.6: + +```bash +cd archive && docker compose up --build # then http://localhost:8090 +``` + +The waiter's floor from the 2014 app — the original circular table tokens that the +rebuild's RAS uplift brought back: + +![The original 2014 app running](archive/screenshots/02-floor.png) 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/Analysis and Design Document/SE 6329-OOADocument.doc b/archive/Analysis and Design Document/SE 6329-OOADocument.doc similarity index 100% rename from Analysis and Design Document/SE 6329-OOADocument.doc rename to archive/Analysis and Design Document/SE 6329-OOADocument.doc diff --git a/archive/Dockerfile b/archive/Dockerfile new file mode 100644 index 0000000..5a487b9 --- /dev/null +++ b/archive/Dockerfile @@ -0,0 +1,18 @@ +# The original 2014 "Restaurant Automation System", run as it was written — on +# PHP 5.6 with the long-removed `mysql_*` extension. This is why the app wouldn't +# start on a modern PHP: it predates mysqli/PDO-only runtimes. Nothing here is +# modernised; it exists so you can see where the project began. The rebuilt app +# lives at the repository root (see the top-level README). +FROM php:5.6-apache + +# The ancient `mysql` extension the 2014 code calls (mysql_connect/mysql_query). +RUN docker-php-ext-install mysql + +# Restore the 2014 shared-hosting PHP defaults the code assumes: output_buffering +# ON (so its `session_start()`/`header()` calls after output still work — the real +# reason it "wouldn't start" on a modern default config), and errors hidden as in a +# production host. No application code is modified. +RUN { echo "output_buffering = 4096"; echo "display_errors = Off"; } > /usr/local/etc/php/conf.d/ras-legacy.ini + +# Serve the app from the Apache document root. +COPY . /var/www/html/ diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..7c770d4 --- /dev/null +++ b/archive/README.md @@ -0,0 +1,78 @@ +> # 🗄️ Archive — the original 2014 version +> +> This folder is the **origin of the project**: the Restaurant Management System as +> it was first written in **2014** (procedural PHP + the long-removed `mysql_*` +> extension + Bootstrap 3). It is kept **unmodified** for posterity — it is *not* +> the current app. +> +> The project has since been **rebuilt as an application on +> [NimbusCMS](https://github.com/NimbusCMS/nimbus)**. For the current system, see +> the [repository root README](../README.md). +> +> **Concessions to age (no app logic changed):** `includes/settings.inc.php` reads +> its DB host from environment variables (original values kept as the fallback) so it +> can reach the database container; and the Docker image restores the 2014 +> shared-hosting PHP defaults the code assumes — `output_buffering` on (so its +> `session_start()`/`header()` calls after page output still work, which is the real +> reason it "wouldn't start" on a modern default config) and errors hidden as on a +> production host. The application source itself is untouched. + +## Run the original app (Docker) + +It wouldn't start on a modern PHP because it calls the `mysql_*` functions removed +in PHP 7. The included Docker setup runs it authentically on **PHP 5.6 + MySQL 5.7**: + +```bash +cd archive +docker compose up --build +``` + +Then open and sign in. Stop it with `docker compose down` +(add `-v` to also drop the database). + +### It runs — proof + +The login screen, and the waiter's floor after signing in — the original signature +**circular table tokens** (green = open, yellow = occupied, red = needs bussing) +that the rebuilt app's RAS uplift brought back: + +![The 2014 login screen](screenshots/01-login.png) + +![The waiter's floor — the original circular table tokens](screenshots/02-floor.png) + +--- + +# Restaurant-Management-System +A restaurant management system based on PHP + +Restaurant Management System was developed using PHP as backend and Bootstrap as frontend. The system allows the waiter to take orders/payments from customers and maintain table status. The cook can see the list of orders made by different waiters and notify the same once the food is prepared. The system allows the manager to see the monthly revenue of the restaurant and the inventory. The admin user can maintain the different roles of the system. + +Credentials + +Username: waiter + +Password: 123 + + + +Username: cook + +Password: 123 + + + +Username: host + +Password: 123 + + + +Username: busboy + +Password: 123 + + + +Note: includes/settings.inc.php has the DB connection settings. + +oose.sql has the sample DB diff --git a/add items.php b/archive/add items.php similarity index 100% rename from add items.php rename to archive/add items.php diff --git a/change table low.php b/archive/change table low.php similarity index 100% rename from change table low.php rename to archive/change table low.php diff --git a/change table.php b/archive/change table.php similarity index 100% rename from change table.php rename to archive/change table.php diff --git a/confirmation.php b/archive/confirmation.php similarity index 100% rename from confirmation.php rename to archive/confirmation.php diff --git a/css/bootstrap.css b/archive/css/bootstrap.css similarity index 100% rename from css/bootstrap.css rename to archive/css/bootstrap.css diff --git a/css/bootstrap.min.css b/archive/css/bootstrap.min.css similarity index 100% rename from css/bootstrap.min.css rename to archive/css/bootstrap.min.css diff --git a/css/custom.css b/archive/css/custom.css similarity index 100% rename from css/custom.css rename to archive/css/custom.css diff --git a/css/plugins/bootstrap-clockpicker.min.css b/archive/css/plugins/bootstrap-clockpicker.min.css similarity index 100% rename from css/plugins/bootstrap-clockpicker.min.css rename to archive/css/plugins/bootstrap-clockpicker.min.css diff --git a/archive/docker-compose.yml b/archive/docker-compose.yml new file mode 100644 index 0000000..559ad6e --- /dev/null +++ b/archive/docker-compose.yml @@ -0,0 +1,29 @@ +# Run the original 2014 app locally: docker compose up --build +# Then open http://localhost:8090 and sign in (see archive/README.md for logins). +services: + web: + build: . + ports: + - "8090:80" + environment: + DB_HOST: db + DB_NAME: oose + DB_USER: root + DB_PASS: "" + depends_on: + db: + condition: service_healthy + + db: + # MySQL 5.7 — the old `mysql` extension can't speak MySQL 8's default auth. + image: mysql:5.7 + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" # the 2014 code connects as root with no password + MYSQL_DATABASE: oose + volumes: + - ./oose.sql:/docker-entrypoint-initdb.d/oose.sql:ro # auto-imported on first boot + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"] + interval: 3s + timeout: 5s + retries: 20 diff --git a/font-awesome-4.2.0/css/font-awesome.css b/archive/font-awesome-4.2.0/css/font-awesome.css similarity index 100% rename from font-awesome-4.2.0/css/font-awesome.css rename to archive/font-awesome-4.2.0/css/font-awesome.css diff --git a/font-awesome-4.2.0/css/font-awesome.min.css b/archive/font-awesome-4.2.0/css/font-awesome.min.css similarity index 100% rename from font-awesome-4.2.0/css/font-awesome.min.css rename to archive/font-awesome-4.2.0/css/font-awesome.min.css diff --git a/font-awesome-4.2.0/fonts/FontAwesome.otf b/archive/font-awesome-4.2.0/fonts/FontAwesome.otf similarity index 100% rename from font-awesome-4.2.0/fonts/FontAwesome.otf rename to archive/font-awesome-4.2.0/fonts/FontAwesome.otf diff --git a/font-awesome-4.2.0/fonts/fontawesome-webfont.eot b/archive/font-awesome-4.2.0/fonts/fontawesome-webfont.eot similarity index 100% rename from font-awesome-4.2.0/fonts/fontawesome-webfont.eot rename to archive/font-awesome-4.2.0/fonts/fontawesome-webfont.eot diff --git a/font-awesome-4.2.0/fonts/fontawesome-webfont.svg b/archive/font-awesome-4.2.0/fonts/fontawesome-webfont.svg similarity index 100% rename from font-awesome-4.2.0/fonts/fontawesome-webfont.svg rename to archive/font-awesome-4.2.0/fonts/fontawesome-webfont.svg diff --git a/font-awesome-4.2.0/fonts/fontawesome-webfont.ttf b/archive/font-awesome-4.2.0/fonts/fontawesome-webfont.ttf similarity index 100% rename from font-awesome-4.2.0/fonts/fontawesome-webfont.ttf rename to archive/font-awesome-4.2.0/fonts/fontawesome-webfont.ttf diff --git a/font-awesome-4.2.0/fonts/fontawesome-webfont.woff b/archive/font-awesome-4.2.0/fonts/fontawesome-webfont.woff similarity index 100% rename from font-awesome-4.2.0/fonts/fontawesome-webfont.woff rename to archive/font-awesome-4.2.0/fonts/fontawesome-webfont.woff diff --git a/font-awesome-4.2.0/less/bordered-pulled.less b/archive/font-awesome-4.2.0/less/bordered-pulled.less similarity index 100% rename from font-awesome-4.2.0/less/bordered-pulled.less rename to archive/font-awesome-4.2.0/less/bordered-pulled.less diff --git a/font-awesome-4.2.0/less/core.less b/archive/font-awesome-4.2.0/less/core.less similarity index 100% rename from font-awesome-4.2.0/less/core.less rename to archive/font-awesome-4.2.0/less/core.less diff --git a/font-awesome-4.2.0/less/fixed-width.less b/archive/font-awesome-4.2.0/less/fixed-width.less similarity index 100% rename from font-awesome-4.2.0/less/fixed-width.less rename to archive/font-awesome-4.2.0/less/fixed-width.less diff --git a/font-awesome-4.2.0/less/font-awesome.less b/archive/font-awesome-4.2.0/less/font-awesome.less similarity index 100% rename from font-awesome-4.2.0/less/font-awesome.less rename to archive/font-awesome-4.2.0/less/font-awesome.less diff --git a/font-awesome-4.2.0/less/icons.less b/archive/font-awesome-4.2.0/less/icons.less similarity index 100% rename from font-awesome-4.2.0/less/icons.less rename to archive/font-awesome-4.2.0/less/icons.less diff --git a/font-awesome-4.2.0/less/larger.less b/archive/font-awesome-4.2.0/less/larger.less similarity index 100% rename from font-awesome-4.2.0/less/larger.less rename to archive/font-awesome-4.2.0/less/larger.less diff --git a/font-awesome-4.2.0/less/list.less b/archive/font-awesome-4.2.0/less/list.less similarity index 100% rename from font-awesome-4.2.0/less/list.less rename to archive/font-awesome-4.2.0/less/list.less diff --git a/font-awesome-4.2.0/less/mixins.less b/archive/font-awesome-4.2.0/less/mixins.less similarity index 100% rename from font-awesome-4.2.0/less/mixins.less rename to archive/font-awesome-4.2.0/less/mixins.less diff --git a/font-awesome-4.2.0/less/path.less b/archive/font-awesome-4.2.0/less/path.less similarity index 100% rename from font-awesome-4.2.0/less/path.less rename to archive/font-awesome-4.2.0/less/path.less diff --git a/font-awesome-4.2.0/less/rotated-flipped.less b/archive/font-awesome-4.2.0/less/rotated-flipped.less similarity index 100% rename from font-awesome-4.2.0/less/rotated-flipped.less rename to archive/font-awesome-4.2.0/less/rotated-flipped.less diff --git a/font-awesome-4.2.0/less/spinning.less b/archive/font-awesome-4.2.0/less/spinning.less similarity index 100% rename from font-awesome-4.2.0/less/spinning.less rename to archive/font-awesome-4.2.0/less/spinning.less diff --git a/font-awesome-4.2.0/less/stacked.less b/archive/font-awesome-4.2.0/less/stacked.less similarity index 100% rename from font-awesome-4.2.0/less/stacked.less rename to archive/font-awesome-4.2.0/less/stacked.less diff --git a/font-awesome-4.2.0/less/variables.less b/archive/font-awesome-4.2.0/less/variables.less similarity index 100% rename from font-awesome-4.2.0/less/variables.less rename to archive/font-awesome-4.2.0/less/variables.less diff --git a/font-awesome-4.2.0/scss/_bordered-pulled.scss b/archive/font-awesome-4.2.0/scss/_bordered-pulled.scss similarity index 100% rename from font-awesome-4.2.0/scss/_bordered-pulled.scss rename to archive/font-awesome-4.2.0/scss/_bordered-pulled.scss diff --git a/font-awesome-4.2.0/scss/_core.scss b/archive/font-awesome-4.2.0/scss/_core.scss similarity index 100% rename from font-awesome-4.2.0/scss/_core.scss rename to archive/font-awesome-4.2.0/scss/_core.scss diff --git a/font-awesome-4.2.0/scss/_fixed-width.scss b/archive/font-awesome-4.2.0/scss/_fixed-width.scss similarity index 100% rename from font-awesome-4.2.0/scss/_fixed-width.scss rename to archive/font-awesome-4.2.0/scss/_fixed-width.scss diff --git a/font-awesome-4.2.0/scss/_icons.scss b/archive/font-awesome-4.2.0/scss/_icons.scss similarity index 100% rename from font-awesome-4.2.0/scss/_icons.scss rename to archive/font-awesome-4.2.0/scss/_icons.scss diff --git a/font-awesome-4.2.0/scss/_larger.scss b/archive/font-awesome-4.2.0/scss/_larger.scss similarity index 100% rename from font-awesome-4.2.0/scss/_larger.scss rename to archive/font-awesome-4.2.0/scss/_larger.scss diff --git a/font-awesome-4.2.0/scss/_list.scss b/archive/font-awesome-4.2.0/scss/_list.scss similarity index 100% rename from font-awesome-4.2.0/scss/_list.scss rename to archive/font-awesome-4.2.0/scss/_list.scss diff --git a/font-awesome-4.2.0/scss/_mixins.scss b/archive/font-awesome-4.2.0/scss/_mixins.scss similarity index 100% rename from font-awesome-4.2.0/scss/_mixins.scss rename to archive/font-awesome-4.2.0/scss/_mixins.scss diff --git a/font-awesome-4.2.0/scss/_path.scss b/archive/font-awesome-4.2.0/scss/_path.scss similarity index 100% rename from font-awesome-4.2.0/scss/_path.scss rename to archive/font-awesome-4.2.0/scss/_path.scss diff --git a/font-awesome-4.2.0/scss/_rotated-flipped.scss b/archive/font-awesome-4.2.0/scss/_rotated-flipped.scss similarity index 100% rename from font-awesome-4.2.0/scss/_rotated-flipped.scss rename to archive/font-awesome-4.2.0/scss/_rotated-flipped.scss diff --git a/font-awesome-4.2.0/scss/_spinning.scss b/archive/font-awesome-4.2.0/scss/_spinning.scss similarity index 100% rename from font-awesome-4.2.0/scss/_spinning.scss rename to archive/font-awesome-4.2.0/scss/_spinning.scss diff --git a/font-awesome-4.2.0/scss/_stacked.scss b/archive/font-awesome-4.2.0/scss/_stacked.scss similarity index 100% rename from font-awesome-4.2.0/scss/_stacked.scss rename to archive/font-awesome-4.2.0/scss/_stacked.scss diff --git a/font-awesome-4.2.0/scss/_variables.scss b/archive/font-awesome-4.2.0/scss/_variables.scss similarity index 100% rename from font-awesome-4.2.0/scss/_variables.scss rename to archive/font-awesome-4.2.0/scss/_variables.scss diff --git a/font-awesome-4.2.0/scss/font-awesome.scss b/archive/font-awesome-4.2.0/scss/font-awesome.scss similarity index 100% rename from font-awesome-4.2.0/scss/font-awesome.scss rename to archive/font-awesome-4.2.0/scss/font-awesome.scss diff --git a/head.php b/archive/head.php similarity index 100% rename from head.php rename to archive/head.php diff --git a/header.php b/archive/header.php similarity index 100% rename from header.php rename to archive/header.php diff --git a/includes/category.php b/archive/includes/category.php similarity index 100% rename from includes/category.php rename to archive/includes/category.php diff --git a/includes/connectdb.inc.php b/archive/includes/connectdb.inc.php similarity index 100% rename from includes/connectdb.inc.php rename to archive/includes/connectdb.inc.php diff --git a/includes/employee.php b/archive/includes/employee.php similarity index 100% rename from includes/employee.php rename to archive/includes/employee.php diff --git a/includes/floorplan.php b/archive/includes/floorplan.php similarity index 100% rename from includes/floorplan.php rename to archive/includes/floorplan.php diff --git a/includes/item.php b/archive/includes/item.php similarity index 100% rename from includes/item.php rename to archive/includes/item.php diff --git a/includes/notify.php b/archive/includes/notify.php similarity index 100% rename from includes/notify.php rename to archive/includes/notify.php diff --git a/includes/order.php b/archive/includes/order.php similarity index 100% rename from includes/order.php rename to archive/includes/order.php diff --git a/includes/orderline.php b/archive/includes/orderline.php similarity index 100% rename from includes/orderline.php rename to archive/includes/orderline.php diff --git a/includes/orderqueue.php b/archive/includes/orderqueue.php similarity index 100% rename from includes/orderqueue.php rename to archive/includes/orderqueue.php diff --git a/includes/payment.php b/archive/includes/payment.php similarity index 100% rename from includes/payment.php rename to archive/includes/payment.php diff --git a/archive/includes/settings.inc.php b/archive/includes/settings.inc.php new file mode 100644 index 0000000..d3f1914 --- /dev/null +++ b/archive/includes/settings.inc.php @@ -0,0 +1,9 @@ + diff --git a/includes/sql.php b/archive/includes/sql.php similarity index 100% rename from includes/sql.php rename to archive/includes/sql.php diff --git a/index.php b/archive/index.php similarity index 100% rename from index.php rename to archive/index.php diff --git a/js/bootstrap.js b/archive/js/bootstrap.js similarity index 100% rename from js/bootstrap.js rename to archive/js/bootstrap.js diff --git a/js/bootstrap.min.js b/archive/js/bootstrap.min.js similarity index 100% rename from js/bootstrap.min.js rename to archive/js/bootstrap.min.js diff --git a/js/custom.js b/archive/js/custom.js similarity index 100% rename from js/custom.js rename to archive/js/custom.js diff --git a/js/jquery-1.11.0.js b/archive/js/jquery-1.11.0.js similarity index 100% rename from js/jquery-1.11.0.js rename to archive/js/jquery-1.11.0.js diff --git a/js/plugins/calender/MonthPicker.min.js b/archive/js/plugins/calender/MonthPicker.min.js similarity index 100% rename from js/plugins/calender/MonthPicker.min.js rename to archive/js/plugins/calender/MonthPicker.min.js diff --git a/js/plugins/calender/jquery-ui.js b/archive/js/plugins/calender/jquery-ui.js similarity index 100% rename from js/plugins/calender/jquery-ui.js rename to archive/js/plugins/calender/jquery-ui.js diff --git a/js/plugins/chart/Chart.min.js b/archive/js/plugins/chart/Chart.min.js similarity index 100% rename from js/plugins/chart/Chart.min.js rename to archive/js/plugins/chart/Chart.min.js diff --git a/js/plugins/chart/Chart_data.js b/archive/js/plugins/chart/Chart_data.js similarity index 100% rename from js/plugins/chart/Chart_data.js rename to archive/js/plugins/chart/Chart_data.js diff --git a/js/plugins/chart/Chart_db.php b/archive/js/plugins/chart/Chart_db.php similarity index 100% rename from js/plugins/chart/Chart_db.php rename to archive/js/plugins/chart/Chart_db.php diff --git a/js/plugins/time/bootstrap-clockpicker.min.js b/archive/js/plugins/time/bootstrap-clockpicker.min.js similarity index 100% rename from js/plugins/time/bootstrap-clockpicker.min.js rename to archive/js/plugins/time/bootstrap-clockpicker.min.js diff --git a/js/simulation.js b/archive/js/simulation.js similarity index 100% rename from js/simulation.js rename to archive/js/simulation.js diff --git a/login.php b/archive/login.php similarity index 100% rename from login.php rename to archive/login.php diff --git a/logout.php b/archive/logout.php similarity index 100% rename from logout.php rename to archive/logout.php diff --git a/menu.php b/archive/menu.php similarity index 100% rename from menu.php rename to archive/menu.php diff --git a/oose.sql b/archive/oose.sql similarity index 100% rename from oose.sql rename to archive/oose.sql diff --git a/order.php b/archive/order.php similarity index 100% rename from order.php rename to archive/order.php diff --git a/payment.php b/archive/payment.php similarity index 100% rename from payment.php rename to archive/payment.php diff --git a/archive/screenshots/01-login.png b/archive/screenshots/01-login.png new file mode 100644 index 0000000..901c399 Binary files /dev/null and b/archive/screenshots/01-login.png differ diff --git a/archive/screenshots/02-floor.png b/archive/screenshots/02-floor.png new file mode 100644 index 0000000..63229a0 Binary files /dev/null and b/archive/screenshots/02-floor.png differ diff --git a/select category.php b/archive/select category.php similarity index 100% rename from select category.php rename to archive/select category.php diff --git a/table.php b/archive/table.php similarity index 100% rename from table.php rename to archive/table.php diff --git a/tablelow.php b/archive/tablelow.php similarity index 100% rename from tablelow.php rename to archive/tablelow.php 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/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..dbe20db --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,100 @@ +# 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 **`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? | +|-------|------|------|--------------------------------------------------| +| `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` + `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 +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'], + ], +]; diff --git a/deploy/reset-demo.sh b/deploy/reset-demo.sh new file mode 100755 index 0000000..330b223 --- /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:=restaurant-demo}" + +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..8edd5f3 --- /dev/null +++ b/deploy/seed-demo.php @@ -0,0 +1,248 @@ + 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 = 'restaurant-demo'; + +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', + // 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"; + +// --- 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"; + +// --- 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 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' => 'home', +]); +echo " settings: home -> home, brand -> The Copper Table\n"; + +// --- 4) Live floor / orders / reservations --------------------------------- +$storage = static fn (): PluginStorage => new PluginStorage($db); +$tables = new Tables($storage); +$reservations = new Reservations($storage, $tables); +// 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]) { + $t[$label] = $tables->save(null, ['label' => $label, 'seats' => (string) $seats], $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); +$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); + +// 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 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); +$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 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/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e4fc8ed --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,288 @@ +# 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 `' + . '
' + . '

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/Guide.php b/plugin/src/Guide.php new file mode 100644 index 0000000..8f24c62 --- /dev/null +++ b/plugin/src/Guide.php @@ -0,0 +1,119 @@ + */ + 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/KitchenAdmin.php b/plugin/src/KitchenAdmin.php new file mode 100644 index 0000000..b548853 --- /dev/null +++ b/plugin/src/KitchenAdmin.php @@ -0,0 +1,173 @@ + ['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) + . 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 . '
' + . $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 = '
    ' + . '' + . '' + . '' + . '
    '; + } + + // 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 + . '
    '; + } + + /** 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/Menu.php b/plugin/src/Menu.php new file mode 100644 index 0000000..6d7b1be --- /dev/null +++ b/plugin/src/Menu.php @@ -0,0 +1,113 @@ + + */ + 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; + } + + /** + * 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. + * + * @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..392b270 --- /dev/null +++ b/plugin/src/MenuSource.php @@ -0,0 +1,30 @@ + + */ + 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/Orders.php b/plugin/src/Orders.php new file mode 100644 index 0000000..8ba0b25 --- /dev/null +++ b/plugin/src/Orders.php @@ -0,0 +1,525 @@ + the order workflow, in order */ + public const STATUSES = ['open', 'sent', 'preparing', 'ready', 'served', 'closed']; + + /** @var list how a bill can be settled */ + public const PAYMENT_METHODS = ['cash', 'card', 'other']; + + 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 + */ + 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; + }); + } + + /** + * 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,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.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], + ); + 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.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'; + 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' => ($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'], + 'amount_paid' => ($r['amount_paid'] ?? null) === null ? null : number_format((float) $r['amount_paid'], 2, '.', ''), + 'payment_method' => ($r['payment_method'] ?? null) === null ? null : (string) $r['payment_method'], + '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)); + } + + /** + * 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.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, + ); + 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' => ($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); + } + + /** + * Take payment on an order and turn its table. The **amount is computed + * server-side** from the order's line items — never passed in, so a client can + * never dictate what is charged; only the `method` (an allow-list) is chosen. + * 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,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 + { + if (!in_array($method, self::PAYMENT_METHODS, true)) { + throw new \InvalidArgumentException('"method" must be one of: ' . implode(', ', self::PAYMENT_METHODS) . '.'); + } + $order = $this->get($orderId); + if ($order === null) { + throw new \InvalidArgumentException("No order with id {$orderId}."); + } + if ($order['paid']) { + throw new \InvalidArgumentException('That order is already paid.'); + } + + $amount = $order['total']; // computed from the lines, authoritative + + $this->storage()->transaction(function () use ($orderId, $order, $amount, $method, $now): void { + $this->storage()->execute( + '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. 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); + assert($settled !== null); + return $settled; + } + + /** 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,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 + { + $total = 0.0; + foreach ($items as $item) { + $total += (float) $item['line_total']; + } + return [ + 'id' => (int) $row['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'], + 'amount_paid' => ($row['amount_paid'] ?? null) === null ? null : number_format((float) $row['amount_paid'], 2, '.', ''), + 'payment_method' => ($row['payment_method'] ?? null) === null ? null : (string) $row['payment_method'], + 'paid_at' => ($row['paid_at'] ?? null) === null ? null : (string) $row['paid_at'], + '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..2e04fd6 --- /dev/null +++ b/plugin/src/OrdersAdmin.php @@ -0,0 +1,267 @@ +` 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.'], + 'paid' => ['ok', 'Payment taken — table sent for bussing.'], + '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) . Branding::head('Orders', 'Open a table, build the order, settle up.', $nonce) . $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']) + . $this->paymentBlock($csrf, $order) + . '
    ' + . '' + . '' + . '
    '; + } + + 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 + { + // "served" is not offered a Close button — taking payment closes the order. + $moves = match ($status) { + 'open' => [['sent', 'Send to kitchen']], + 'sent', 'preparing', 'ready' => [['served', 'Mark served']], + default => [], + }; + if ($moves === []) { + return ''; + } + $html = '
    '; + foreach ($moves as [$to, $verb]) { + $html .= '
    ' + . '' + . '' + . '' + . '' + . '
    '; + } + return $html . '
    '; + } + + /** @param array $order */ + private function paymentBlock(string $csrf, array $order): string + { + if ((bool) $order['paid']) { + $method = self::e((string) ($order['payment_method'] ?? '')); + $amount = self::e((string) ($order['amount_paid'] ?? $order['total'])); + $when = self::e((string) ($order['paid_at'] ?? '')); + return '
    ✓ Paid ' . $amount . ($method !== '' ? ' by ' . ucfirst($method) : '') . ($when !== '' ? ' · ' . $when : '') . '
    '; + } + + $options = ''; + foreach (Orders::PAYMENT_METHODS as $m) { + $options .= ''; + } + // The amount is not an input — it is the computed total, charged server-side. + return '

    Take payment

    ' + . '

    Total due ' . self::e((string) $order['total']) . '

    ' + . '
    ' + . '' + . '' + . '' + . '' + . '
    ' + . '
    '; + } + + 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/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/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..711910e --- /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) + . 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') + . $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/Reservations.php b/plugin/src/Reservations.php new file mode 100644 index 0000000..a7dab55 --- /dev/null +++ b/plugin/src/Reservations.php @@ -0,0 +1,313 @@ + the reservation lifecycle */ + public const STATUSES = ['booked', 'seated', 'cancelled', 'no_show']; + + private const MAX_NAME = 120; + private const MAX_NOTES = 10000; + private const MAX_PARTY = 99; + + /** @param \Closure():PluginStorage $storage resolved lazily, so construction runs no query */ + public function __construct(private \Closure $storage, private Tables $tables) + { + } + + /** + * Create (id null) or update (id given) a reservation from an allow-listed field + * set. Returns the reservation id. + * + * @param array $fields + */ + public function save(?int $id, array $fields, string $now): int + { + $existing = $id !== null ? $this->get($id) : null; + if ($id !== null && $existing === null) { + throw new \InvalidArgumentException("No reservation with id {$id}."); + } + + $name = $this->name($fields, $existing); + $size = $this->partySize($fields, $existing); + $at = $this->reservedAt($fields, $existing, $now); + $status = $this->status($fields, $existing); + $tableId = $this->tableId($fields, $existing); + $contactId = $this->contactId($fields, $existing); + $notes = $this->optStr($fields, 'notes', $existing, self::MAX_NOTES); + + $params = ['name' => $name, 'size' => $size, 'at' => $at, 'status' => $status, 'table' => $tableId, 'contact' => $contactId, 'notes' => $notes]; + + if ($id === null) { + return $this->storage()->insert( + 'INSERT INTO ' . Schema::RESERVATION . ' (party_name, party_size, reserved_at, status, table_id, contact_id, notes, created_at, updated_at) + VALUES (:name, :size, :at, :status, :table, :contact, :notes, :created, :updated)', + $params + ['created' => $now, 'updated' => $now], + ); + } + + $this->storage()->execute( + 'UPDATE ' . Schema::RESERVATION . ' SET party_name = :name, party_size = :size, reserved_at = :at, status = :status, table_id = :table, contact_id = :contact, notes = :notes, updated_at = :updated WHERE id = :id', + $params + ['updated' => $now, 'id' => $id], + ); + return $id; + } + + /** Move a reservation to an allow-listed 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::RESERVATION . ' SET status = :status, updated_at = :now WHERE id = :id', + ['status' => $status, 'now' => $now, 'id' => $id], + ); + } + + /** + * @return array{id:int,table_id:?int,table_label:?string,contact_id:?int,party_name:string,party_size:int,reserved_at:string,status:string,notes:?string,created_at:string,updated_at:string}|null + */ + public function get(int $id): ?array + { + $row = $this->storage()->selectOne( + $this->selectExpr() . ' WHERE r.id = :id', + ['id' => $id], + ); + return $row === null ? null : $this->hydrate($row); + } + + /** + * Reservations for the book / MCP, soonest first, optionally filtered by an + * allow-listed status. + * + * @return list + */ + public function all(?string $status = null): array + { + if ($status !== null && $status !== '' && in_array($status, self::STATUSES, true)) { + $rows = $this->storage()->select( + $this->selectExpr() . ' WHERE r.status = :status ORDER BY r.reserved_at ASC, r.id ASC', + ['status' => $status], + ); + return array_map($this->hydrate(...), $rows); + } + return array_map($this->hydrate(...), $this->storage()->select($this->selectExpr() . ' ORDER BY r.reserved_at ASC, r.id ASC')); + } + + /** Delete a reservation outright by id; returns rows removed. */ + public function delete(int $id): int + { + return $this->storage()->execute('DELETE FROM ' . Schema::RESERVATION . ' WHERE id = :id', ['id' => $id]); + } + + // --- validation / hydration ----------------------------------------- + + private function selectExpr(): string + { + return 'SELECT r.id, r.table_id, r.contact_id, r.party_name, r.party_size, r.reserved_at, r.status, r.notes, r.created_at, r.updated_at, + t.label AS table_label + FROM ' . Schema::RESERVATION . ' r LEFT JOIN ' . Schema::TABLE . ' t ON t.id = r.table_id'; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function name(array $fields, ?array $existing): string + { + if (!array_key_exists('party_name', $fields)) { + if ($existing !== null) { + return (string) $existing['party_name']; + } + throw new \InvalidArgumentException('A reservation needs a party name.'); + } + $name = trim((string) $fields['party_name']); + if ($name === '') { + throw new \InvalidArgumentException('A reservation needs a party name.'); + } + if (mb_strlen($name) > self::MAX_NAME) { + throw new \InvalidArgumentException('A party name must be ' . self::MAX_NAME . ' characters or fewer.'); + } + return $name; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function partySize(array $fields, ?array $existing): int + { + if (!array_key_exists('party_size', $fields)) { + return $existing !== null ? (int) $existing['party_size'] : 2; + } + $raw = trim((string) $fields['party_size']); + if ($raw === '') { + return 2; + } + if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1 || (int) $raw > self::MAX_PARTY) { + throw new \InvalidArgumentException('"party_size" must be a whole number between 1 and ' . self::MAX_PARTY . '.'); + } + return (int) $raw; + } + + /** + * When the booking is for. Accepts a full datetime or an `datetime-local` value; + * absent on a create defaults to now. A sloppy value is rejected. + * + * @param array $fields + * @param array|null $existing + */ + private function reservedAt(array $fields, ?array $existing, string $now): string + { + if (!array_key_exists('reserved_at', $fields)) { + return $existing !== null ? (string) $existing['reserved_at'] : $now; + } + $raw = str_replace('T', ' ', trim((string) $fields['reserved_at'])); + if ($raw === '') { + return $existing !== null ? (string) $existing['reserved_at'] : $now; + } + foreach (['Y-m-d H:i:s', 'Y-m-d H:i'] as $fmt) { + $d = \DateTimeImmutable::createFromFormat($fmt, $raw); + if ($d !== false && $d->format($fmt) === $raw) { + return $d->format('Y-m-d H:i:s'); + } + } + throw new \InvalidArgumentException('"reserved_at" must be a valid date and time.'); + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function status(array $fields, ?array $existing): string + { + if (!array_key_exists('status', $fields)) { + return $existing !== null ? (string) $existing['status'] : 'booked'; + } + $status = trim((string) $fields['status']); + if ($status === '') { + return $existing !== null ? (string) $existing['status'] : 'booked'; + } + if (!in_array($status, self::STATUSES, true)) { + throw new \InvalidArgumentException('"status" must be one of: ' . implode(', ', self::STATUSES) . '.'); + } + return $status; + } + + /** + * The table a reservation holds: null, or an id that must exist (same-plugin soft + * ref, validated at write). Absent on update keeps the stored value. + * + * @param array $fields + * @param array|null $existing + */ + private function tableId(array $fields, ?array $existing): ?int + { + if (!array_key_exists('table_id', $fields)) { + return $existing !== null ? ($existing['table_id'] === null ? null : (int) $existing['table_id']) : null; + } + $raw = trim((string) $fields['table_id']); + if ($raw === '') { + return null; + } + if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1) { + throw new \InvalidArgumentException('"table_id" must be a positive whole number or blank.'); + } + $tableId = (int) $raw; + if ($this->tables->get($tableId) === null) { + throw new \InvalidArgumentException("No table with id {$tableId}."); + } + return $tableId; + } + + /** + * The guest's CRM contact id: null, or a positive int. **Not** validated to exist + * — that lives in the CRM plugin, and reading it would breach the CRM's capability + * gate (see the class docblock). It is a link, resolved (or not) on the CRM side. + * + * @param array $fields + * @param array|null $existing + */ + private function contactId(array $fields, ?array $existing): ?int + { + if (!array_key_exists('contact_id', $fields)) { + return $existing !== null ? ($existing['contact_id'] === null ? null : (int) $existing['contact_id']) : null; + } + $raw = trim((string) $fields['contact_id']); + if ($raw === '') { + return null; + } + if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1) { + throw new \InvalidArgumentException('"contact_id" must be a positive whole number or blank.'); + } + return (int) $raw; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function optStr(array $fields, string $key, ?array $existing, int $max): ?string + { + if (!array_key_exists($key, $fields)) { + return $existing !== null ? ($existing[$key] === null ? null : (string) $existing[$key]) : null; + } + $v = trim((string) $fields[$key]); + if ($v === '') { + return null; + } + if (mb_strlen($v) > $max) { + throw new \InvalidArgumentException("\"{$key}\" must be {$max} characters or fewer."); + } + return $v; + } + + /** + * @param array $row + * @return array{id:int,table_id:?int,table_label:?string,contact_id:?int,party_name:string,party_size:int,reserved_at:string,status:string,notes:?string,created_at:string,updated_at:string} + */ + private function hydrate(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'table_id' => $row['table_id'] === null ? null : (int) $row['table_id'], + 'table_label' => ($row['table_label'] ?? null) === null ? null : (string) $row['table_label'], + 'contact_id' => $row['contact_id'] === null ? null : (int) $row['contact_id'], + 'party_name' => (string) $row['party_name'], + 'party_size' => (int) $row['party_size'], + 'reserved_at' => (string) $row['reserved_at'], + 'status' => (string) $row['status'], + 'notes' => $row['notes'] === null ? null : (string) $row['notes'], + 'created_at' => (string) $row['created_at'], + 'updated_at' => (string) $row['updated_at'], + ]; + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/plugin/src/ReservationsAdmin.php b/plugin/src/ReservationsAdmin.php new file mode 100644 index 0000000..9053feb --- /dev/null +++ b/plugin/src/ReservationsAdmin.php @@ -0,0 +1,169 @@ +`, `danmat.restaurant:floor` + CSRF). + */ +final class ReservationsAdmin +{ + private const NOTICES = [ + 'saved' => ['ok', 'Reservation saved.'], + 'updated' => ['ok', 'Reservation updated.'], + 'deleted' => ['ok', 'Reservation deleted.'], + 'noname' => ['err', 'A reservation needs a party name.'], + 'invalid' => ['err', 'Check the details and try again.'], + ]; + + private const STATUS_LABELS = [ + 'booked' => 'Booked', + 'seated' => 'Seated', + 'cancelled' => 'Cancelled', + 'no_show' => 'No-show', + ]; + + public function __construct(private Reservations $reservations, private Tables $tables) + { + } + + public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $status = null, string $nonce = ''): string + { + $editId = ($edit !== null && preg_match('/^\d+$/', trim($edit)) === 1) ? (int) trim($edit) : null; + $editRes = $editId !== null ? $this->reservations->get($editId) : null; + $filter = ($status !== null && in_array(trim($status), Reservations::STATUSES, true)) ? trim($status) : null; + + return $this->styles($nonce) + . 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) + . $this->filterBar($filter) + . $this->list($csrf, $this->reservations->all($filter)); + } + + /** @param array|null $edit */ + private function form(string $csrf, ?array $edit): string + { + $val = static fn (string $k): string => $edit !== null && $edit[$k] !== null ? self::e((string) $edit[$k]) : ''; + $idField = $edit !== null ? '' : ''; + // reserved_at into a datetime-local value (drop seconds). + $at = $edit !== null ? substr(str_replace(' ', 'T', (string) $edit['reserved_at']), 0, 16) : ''; + + $statusOptions = ''; + $current = $edit !== null ? (string) $edit['status'] : 'booked'; + foreach (Reservations::STATUSES as $s) { + $statusOptions .= ''; + } + + $tableOptions = ''; + $curTable = $edit !== null && $edit['table_id'] !== null ? (int) $edit['table_id'] : null; + foreach ($this->tables->all() as $t) { + $tableOptions .= ''; + } + + return '

    ' . ($edit !== null ? 'Edit reservation' : 'Add a reservation') . '

    ' + . '
    ' + . '' . $idField + . '
    ' + . '' + . '' + . '
    ' + . '
    ' + . '' + . '' + . '' + . '
    ' + . '' + . '' + . '
    ' + . ($edit !== null ? ' Cancel' : '') + . '
    '; + } + + private function filterBar(?string $active): string + { + $chips = 'All'; + foreach (Reservations::STATUSES as $s) { + $chips .= '' . self::e(self::STATUS_LABELS[$s]) . ''; + } + return '
    ' . $chips . '
    '; + } + + /** @param list> $reservations */ + private function list(string $csrf, array $reservations): string + { + if ($reservations === []) { + return '

    No reservations.

    '; + } + $rows = ''; + foreach ($reservations as $r) { + $guest = ($r['contact_id'] ?? null) !== null + ? 'Guest in CRM →' + : ''; + $rows .= '' + . '' . self::e((string) $r['reserved_at']) . '' + . '' . self::e((string) $r['party_name']) . ' · ' . self::e((string) $r['party_size']) . '' + . '' . self::e((string) ($r['table_label'] ?? '—')) . '' + . '' . self::e(self::STATUS_LABELS[(string) $r['status']] ?? (string) $r['status']) . '' + . '' . $guest . '' + . '' + . '
    ' + . '' + . '' + . '
    ' + . ''; + } + return '' . $rows . '
    WhenPartyTableStatusGuest
    '; + } + + 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 new file mode 100644 index 0000000..c51c4f4 --- /dev/null +++ b/plugin/src/RestaurantPlugin.php @@ -0,0 +1,360 @@ +migrations()->register('001_tables', Schema::tables()); + $context->migrations()->register('002_orders', Schema::orders()); + $context->migrations()->register('003_reservations', Schema::reservations()); + + // Wildcard-immune capability with fine-grained staff actions (ADR 0030, F4): + // floor — waiters/hosts/busboys: tables, orders, payment + // kitchen — cooks: the kitchen display + // manage — managers/admins: reports & settings (reports land later) + // read/write — the agent/integration surface over MCP + // Legacy roles map to grants of these; a manager holds floor+kitchen+manage. + $context->capabilities()->declare('Restaurant', ['read', 'write', 'floor', 'kitchen', 'manage']); + + // 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)); + $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, $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)); + + // --- 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). + $context->adminPages()->register( + 'restaurant', + 'Floor', + '🍽️', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new TablesAdmin($tables))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('status'), $nonce), + self::ID . ':floor', + ); + + $context->adminPages()->action('restaurant', 'table-save', static function (Request $r) use ($tables): Response { + $fields = [ + 'label' => (string) ($r->input('label') ?? ''), + 'seats' => (string) ($r->input('seats') ?? ''), + 'status' => (string) ($r->input('status') ?? ''), + ]; + $idIn = trim((string) ($r->input('id') ?? '')); + $id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null; + try { + $tables->save($id, $fields, date('Y-m-d H:i:s')); + return Response::redirect('/admin/restaurant?ok=saved'); + } catch (\InvalidArgumentException $e) { + $msg = $e->getMessage(); + $code = str_contains($msg, 'already exists') ? 'dupe' : (str_contains($msg, 'label') ? 'nolabel' : 'invalid'); + return Response::redirect('/admin/restaurant?err=' . $code); + } catch (\Throwable) { + return Response::redirect('/admin/restaurant?err=invalid'); + } + }); + + $context->adminPages()->action('restaurant', 'table-status', static function (Request $r) use ($tables): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + $status = (string) ($r->input('status') ?? ''); + if ($idIn !== '' && ctype_digit($idIn)) { + try { + $tables->setStatus((int) $idIn, $status, date('Y-m-d H:i:s')); + } catch (\Throwable) { + return Response::redirect('/admin/restaurant?err=invalid'); + } + } + return Response::redirect('/admin/restaurant?ok=seated'); + }); + + $context->adminPages()->action('restaurant', 'table-delete', static function (Request $r) use ($tables): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn !== '' && ctype_digit($idIn)) { + $tables->delete((int) $idIn); + } + 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 . ':floor', + ); + + // 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-pay', static function (Request $r) use ($orders, $backToOrder): Response { + $base = $backToOrder($r); + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn === '' || !ctype_digit($idIn)) { + return Response::redirect($base . 'err=invalid'); + } + try { + // Only the method comes from the request — the amount is computed. + $orders->pay((int) $idIn, (string) ($r->input('method') ?? ''), date('Y-m-d H:i:s')); + return Response::redirect($base . 'ok=paid'); + } catch (\Throwable) { + return Response::redirect($base . 'err=invalid'); + } + }); + + $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'); + }); + + // 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 . ':kitchen', + ); + + $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'); + }); + + // Reservations — the book. Floor staff manage bookings; a booking may link to + // a CRM guest, but this page only links out (the CRM page is separately gated). + $context->adminPages()->register( + 'restaurant-reservations', + 'Reservations', + '📅', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new ReservationsAdmin($reservations, $tables))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('status'), $nonce), + self::ID . ':floor', + ); + + $context->adminPages()->action('restaurant-reservations', 'reservation-save', static function (Request $r) use ($reservations): Response { + $fields = [ + 'party_name' => (string) ($r->input('party_name') ?? ''), + 'party_size' => (string) ($r->input('party_size') ?? ''), + 'reserved_at' => (string) ($r->input('reserved_at') ?? ''), + 'table_id' => (string) ($r->input('table_id') ?? ''), + 'contact_id' => (string) ($r->input('contact_id') ?? ''), + 'status' => (string) ($r->input('status') ?? ''), + 'notes' => (string) ($r->input('notes') ?? ''), + ]; + $idIn = trim((string) ($r->input('id') ?? '')); + $id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null; + try { + $reservations->save($id, $fields, date('Y-m-d H:i:s')); + return Response::redirect('/admin/restaurant-reservations?ok=saved'); + } catch (\InvalidArgumentException $e) { + $code = str_contains($e->getMessage(), 'party name') ? 'noname' : 'invalid'; + return Response::redirect('/admin/restaurant-reservations?err=' . $code); + } catch (\Throwable) { + return Response::redirect('/admin/restaurant-reservations?err=invalid'); + } + }); + + $context->adminPages()->action('restaurant-reservations', 'reservation-delete', static function (Request $r) use ($reservations): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn !== '' && ctype_digit($idIn)) { + $reservations->delete((int) $idIn); + } + 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 new file mode 100644 index 0000000..193a707 --- /dev/null +++ b/plugin/src/RestaurantToolset.php @@ -0,0 +1,533 @@ + 'integer', 'description' => 'The table id.']; + + return [ + new PluginTool('tables', 'read', 'List tables on the floor, optionally filtered by status.', [ + 'type' => 'object', + 'properties' => [ + 'status' => ['type' => 'string', 'enum' => Tables::STATUSES, 'description' => 'Optional filter: open / occupied / dirty / reserved.'], + ], + ], $this->tables(...)), + + new PluginTool('table_get', 'read', 'One table by id, or none.', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => $id], + ], $this->tableGet(...)), + + new PluginTool('table_set', 'write', 'Create a table (omit id) or update one (with id). Only the fields you send change.', [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'Existing table id to update; omit to create.'], + 'label' => ['type' => 'string', 'description' => 'The table label/number (required to create; unique).'], + 'seats' => ['type' => 'integer', 'description' => 'How many it seats. Defaults to 2.'], + 'status' => ['type' => 'string', 'enum' => Tables::STATUSES, 'description' => 'Table status. Defaults to open.'], + ], + ], $this->tableSet(...)), + + new PluginTool('table_status', 'write', 'Move a table to a status (seat = occupied, clear = dirty, clean = open, reserve = reserved).', [ + 'type' => 'object', + 'required' => ['id', 'status'], + 'properties' => [ + 'id' => $id, + 'status' => ['type' => 'string', 'enum' => Tables::STATUSES, 'description' => 'The new status.'], + ], + ], $this->tableStatus(...)), + + new PluginTool('table_delete', 'write', 'Delete a table outright by id.', [ + 'type' => 'object', + '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_pay', 'write', 'Take payment on an order and turn its table. The amount is the computed order total (never passed in); choose only the method. Closes the order.', [ + 'type' => 'object', + 'required' => ['id', 'method'], + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'The order id.'], + 'method' => ['type' => 'string', 'enum' => Orders::PAYMENT_METHODS, 'description' => 'How it was paid: cash / card / other.'], + ], + ], $this->orderPay(...)), + + 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(...)), + + 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(...)), + + new PluginTool('reservations', 'read', 'List reservations, soonest first, optionally filtered by status.', [ + 'type' => 'object', + 'properties' => [ + 'status' => ['type' => 'string', 'enum' => Reservations::STATUSES, 'description' => 'Optional: booked / seated / cancelled / no_show.'], + ], + ], $this->reservations(...)), + + new PluginTool('reservation_get', 'read', 'One reservation by id, or none. (contact_id links to a CRM guest; open it in the CRM, which is separately gated.)', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer', 'description' => 'The reservation id.']], + ], $this->reservationGet(...)), + + new PluginTool('reservation_set', 'write', 'Create a reservation (omit id) or update one (with id). Only the fields you send change.', [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'Existing reservation id to update; omit to create.'], + 'party_name' => ['type' => 'string', 'description' => 'The booking name (required to create).'], + 'party_size' => ['type' => 'integer', 'description' => 'How many. Defaults to 2.'], + 'reserved_at' => ['type' => 'string', 'description' => 'When, "YYYY-MM-DD HH:MM[:SS]". Defaults to now.'], + 'table_id' => ['type' => 'integer', 'description' => 'An existing table to hold. Optional; blank to unassign.'], + 'contact_id' => ['type' => 'integer', 'description' => 'The guest\'s CRM contact id to link. Optional; not resolved here — the restaurant never reads CRM data.'], + 'status' => ['type' => 'string', 'enum' => Reservations::STATUSES, 'description' => 'Reservation status. Defaults to booked.'], + 'notes' => ['type' => 'string', 'description' => 'Free-text notes. Optional.'], + ], + ], $this->reservationSet(...)), + + new PluginTool('reservation_status', 'write', 'Set a reservation status (booked/seated/cancelled/no_show).', [ + 'type' => 'object', + 'required' => ['id', 'status'], + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'The reservation id.'], + 'status' => ['type' => 'string', 'enum' => Reservations::STATUSES, 'description' => 'The new status.'], + ], + ], $this->reservationStatus(...)), + + new PluginTool('reservation_delete', 'write', 'Delete a reservation by id.', [ + 'type' => 'object', + '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), + ]; + } + + /** + * @param array $a + * @return array + */ + private function reservations(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $list = $this->reservations->all($this->nullableStr($a, 'status')); + return ['reservations' => $list, 'count' => count($list)]; + } + + /** + * @param array $a + * @return array + */ + private function reservationGet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['id' => $id, 'reservation' => $this->reservations->get($id)]; + } + + /** + * @param array $a + * @return array + */ + private function reservationSet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->reservations->save($this->nullableInt($a, 'id'), $a, $this->now()); + return ['ok' => true, 'reservation' => $this->reservations->get($id)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function reservationStatus(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->requireInt($a, 'id'); + $changed = $this->reservations->setStatus($id, (string) ($a['status'] ?? ''), $this->now()); + return ['ok' => true, 'changed' => $changed > 0, 'reservation' => $this->reservations->get($id)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function reservationDelete(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['ok' => true, 'deleted' => $this->reservations->delete($id) > 0]; + } + + /** + * @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 + */ + 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 + */ + private function orderPay(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $order = $this->orders->pay($this->requireInt($a, 'id'), (string) ($a['method'] ?? ''), $this->now()); + return ['ok' => true, 'order' => $order]; + }); + } + + /** + * @param array $a + * @return array + */ + private function tables(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $list = $this->tables->all($this->nullableStr($a, 'status')); + return ['tables' => $list, 'count' => count($list)]; + } + + /** + * @param array $a + * @return array + */ + private function tableGet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['id' => $id, 'table' => $this->tables->get($id)]; + } + + /** + * @param array $a + * @return array + */ + private function tableSet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->tables->save($this->nullableInt($a, 'id'), $a, $this->now()); + return ['ok' => true, 'table' => $this->tables->get($id)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function tableStatus(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->requireInt($a, 'id'); + $status = (string) ($a['status'] ?? ''); + $changed = $this->tables->setStatus($id, $status, $this->now()); + return ['ok' => true, 'changed' => $changed > 0, 'table' => $this->tables->get($id)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function tableDelete(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->requireInt($a, 'id'); + return ['ok' => true, 'deleted' => $this->tables->delete($id) > 0]; + } + + // --- helpers --------------------------------------------------------- + + /** + * @param \Closure():array $work + * @return array + */ + private function guard(\Closure $work): array + { + try { + return $work(); + } catch (\InvalidArgumentException $e) { + return ['ok' => false, 'error' => 'invalid', 'message' => $e->getMessage()]; + } + } + + private function now(): string + { + return date('Y-m-d H:i:s'); + } + + /** @param array $a */ + private function requireInt(array $a, string $key): int + { + $v = $this->nullableInt($a, $key); + if ($v === null) { + throw new \InvalidArgumentException("\"{$key}\" is required."); + } + return $v; + } + + /** @param array $a */ + private function nullableInt(array $a, string $key): ?int + { + $v = $a[$key] ?? null; + if ($v === null || $v === '') { + return null; + } + if (is_int($v)) { + return $v; + } + if (is_string($v) && preg_match('/^\d+$/', trim($v)) === 1) { + return (int) trim($v); + } + throw new \InvalidArgumentException("\"{$key}\" must be a whole number."); + } + + /** @param array $a */ + private function nullableStr(array $a, string $key): ?string + { + $v = $a[$key] ?? null; + if (!is_string($v) && !is_int($v) && !is_float($v)) { + return null; + } + $s = trim((string) $v); + return $s === '' ? null : $s; + } +} diff --git a/plugin/src/Schema.php b/plugin/src/Schema.php new file mode 100644 index 0000000..ef73bfb --- /dev/null +++ b/plugin/src/Schema.php @@ -0,0 +1,131 @@ + each statement individually idempotent (ADR 0005) */ + public static function tables(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . " ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + label VARCHAR(40) NOT NULL, + seats SMALLINT UNSIGNED NOT NULL DEFAULT 2, + status ENUM('open','occupied','dirty','reserved') NOT NULL DEFAULT 'open', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uniq_table_label (label), + INDEX idx_table_status (status) + ) 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 [ + // `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 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, + payment_method VARCHAR(20) NULL, + paid_at DATETIME NULL, + 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', + + // 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', + ]; + } + + /** + * Reservations — a booking of a table, at a time, for a party. `party_name` and + * `notes` are the restaurant's **own** first-party data (what the host types when + * taking the booking); `contact_id` is an optional link to the guest's full record + * in the CRM (a separate plugin). The restaurant stores only that id and links out + * to the CRM's own capability-gated page — it never reads or copies CRM contact + * PII, so the CRM's gate is respected by construction. `table_id` is a soft ref to + * a table (validated at write, same plugin). + * + * @return list each statement individually idempotent (ADR 0005) + */ + public static function reservations(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::RESERVATION . " ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + table_id BIGINT UNSIGNED NULL, + contact_id BIGINT UNSIGNED NULL, + party_name VARCHAR(120) NOT NULL, + party_size SMALLINT UNSIGNED NOT NULL DEFAULT 2, + reserved_at DATETIME NOT NULL, + status ENUM('booked','seated','cancelled','no_show') NOT NULL DEFAULT 'booked', + notes TEXT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_res_at (reserved_at), + INDEX idx_res_status (status), + INDEX idx_res_table (table_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + ]; + } +} diff --git a/plugin/src/Tables.php b/plugin/src/Tables.php new file mode 100644 index 0000000..0d54431 --- /dev/null +++ b/plugin/src/Tables.php @@ -0,0 +1,218 @@ + the statuses a table can hold */ + public const STATUSES = ['open', 'occupied', 'dirty', 'reserved']; + + private const MAX_LABEL = 40; + private const MAX_SEATS = 999; + + /** @param \Closure():PluginStorage $storage resolved lazily, so construction runs no query */ + public function __construct(private \Closure $storage) + { + } + + /** + * Create (id null) or update (id given) a table from an allow-listed field set; + * unknown keys are ignored. Returns the table id. + * + * @param array $fields + */ + public function save(?int $id, array $fields, string $now): int + { + $existing = $id !== null ? $this->get($id) : null; + if ($id !== null && $existing === null) { + throw new \InvalidArgumentException("No table with id {$id}."); + } + + $label = $this->label($fields, $existing); + $seats = $this->seats($fields, $existing); + $status = $this->status($fields, $existing); + $this->requireUniqueLabel($label, $id); + + if ($id === null) { + return $this->storage()->insert( + 'INSERT INTO ' . Schema::TABLE . ' (label, seats, status, created_at, updated_at) + VALUES (:label, :seats, :status, :created, :updated)', + ['label' => $label, 'seats' => $seats, 'status' => $status, 'created' => $now, 'updated' => $now], + ); + } + + $this->storage()->execute( + 'UPDATE ' . Schema::TABLE . ' SET label = :label, seats = :seats, status = :status, updated_at = :now WHERE id = :id', + ['label' => $label, 'seats' => $seats, 'status' => $status, 'now' => $now, 'id' => $id], + ); + return $id; + } + + /** + * Move a table to an allow-listed status (the floor's quick action). Returns the + * number of rows changed (0 if the table doesn't exist). + */ + 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::TABLE . ' SET status = :status, updated_at = :now WHERE id = :id', + ['status' => $status, 'now' => $now, 'id' => $id], + ); + } + + /** + * @return array{id:int,label:string,seats:int,status:string,created_at:string,updated_at:string}|null + */ + public function get(int $id): ?array + { + $row = $this->storage()->selectOne( + 'SELECT id, label, seats, status, created_at, updated_at FROM ' . Schema::TABLE . ' WHERE id = :id', + ['id' => $id], + ); + return $row === null ? null : $this->hydrate($row); + } + + /** + * Tables for the floor board / MCP, optionally filtered to an allow-listed + * `$status`. Ordered by label (natural-ish: shorter labels, then alpha). + * + * @return list + */ + public function all(?string $status = null): array + { + if ($status !== null && $status !== '' && in_array($status, self::STATUSES, true)) { + $rows = $this->storage()->select( + 'SELECT id, label, seats, status, created_at, updated_at FROM ' . Schema::TABLE . ' + WHERE status = :status ORDER BY LENGTH(label), label', + ['status' => $status], + ); + return array_map($this->hydrate(...), $rows); + } + $rows = $this->storage()->select( + 'SELECT id, label, seats, status, created_at, updated_at FROM ' . Schema::TABLE . ' ORDER BY LENGTH(label), label', + ); + return array_map($this->hydrate(...), $rows); + } + + /** Delete a table outright by id; returns the number of rows removed (0 if none). */ + public function delete(int $id): int + { + return $this->storage()->execute('DELETE FROM ' . Schema::TABLE . ' WHERE id = :id', ['id' => $id]); + } + + // --- validation / hydration ----------------------------------------- + + /** + * @param array $fields + * @param array|null $existing + */ + private function label(array $fields, ?array $existing): string + { + if (!array_key_exists('label', $fields)) { + if ($existing !== null) { + return (string) $existing['label']; + } + throw new \InvalidArgumentException('A table needs a label.'); + } + $label = trim((string) $fields['label']); + if ($label === '') { + throw new \InvalidArgumentException('A table needs a label.'); + } + if (mb_strlen($label) > self::MAX_LABEL) { + throw new \InvalidArgumentException('A table label must be ' . self::MAX_LABEL . ' characters or fewer.'); + } + return $label; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function seats(array $fields, ?array $existing): int + { + if (!array_key_exists('seats', $fields)) { + return $existing !== null ? (int) $existing['seats'] : 2; + } + $raw = trim((string) $fields['seats']); + if ($raw === '') { + return 2; + } + if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1 || (int) $raw > self::MAX_SEATS) { + throw new \InvalidArgumentException('"seats" must be a whole number between 1 and ' . self::MAX_SEATS . '.'); + } + return (int) $raw; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function status(array $fields, ?array $existing): string + { + if (!array_key_exists('status', $fields)) { + return $existing !== null ? (string) $existing['status'] : 'open'; + } + $status = trim((string) $fields['status']); + if ($status === '') { + return $existing !== null ? (string) $existing['status'] : 'open'; + } + if (!in_array($status, self::STATUSES, true)) { + throw new \InvalidArgumentException('"status" must be one of: ' . implode(', ', self::STATUSES) . '.'); + } + return $status; + } + + /** A label is unique across the floor; reject a clash (excluding the row being updated). */ + private function requireUniqueLabel(string $label, ?int $id): void + { + $clash = $this->storage()->selectOne( + 'SELECT id FROM ' . Schema::TABLE . ' WHERE label = :label' . ($id !== null ? ' AND id <> :id' : ''), + $id !== null ? ['label' => $label, 'id' => $id] : ['label' => $label], + ); + if ($clash !== null) { + throw new \InvalidArgumentException("A table labelled \"{$label}\" already exists."); + } + } + + /** + * @param array $row + * @return array{id:int,label:string,seats:int,status:string,created_at:string,updated_at:string} + */ + private function hydrate(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'label' => (string) $row['label'], + 'seats' => (int) $row['seats'], + 'status' => (string) $row['status'], + 'created_at' => (string) $row['created_at'], + 'updated_at' => (string) $row['updated_at'], + ]; + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/plugin/src/TablesAdmin.php b/plugin/src/TablesAdmin.php new file mode 100644 index 0000000..abaa949 --- /dev/null +++ b/plugin/src/TablesAdmin.php @@ -0,0 +1,203 @@ +` block (the admin CSP is nonce-only), and the page + its POST actions + * gated on `danmat.restaurant:write` + CSRF by core (ADR 0020). Built mobile-first + * — staff work this on a phone. + */ +final class TablesAdmin +{ + private const NOTICES = [ + 'saved' => ['ok', 'Table saved.'], + 'deleted' => ['ok', 'Table deleted.'], + 'seated' => ['ok', 'Table updated.'], + 'nolabel' => ['err', 'A table needs a label.'], + 'dupe' => ['err', 'A table with that label already exists.'], + 'invalid' => ['err', 'Check the details and try again.'], + ]; + + /** status => [label, quick-action verb offered on the card] */ + private const STATUS_LABELS = [ + 'open' => 'Open', + 'occupied' => 'Occupied', + 'dirty' => 'Needs cleaning', + 'reserved' => 'Reserved', + ]; + + public function __construct(private Tables $tables) + { + } + + public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $status = null, string $nonce = ''): string + { + $editId = ($edit !== null && preg_match('/^\d+$/', trim($edit)) === 1) ? (int) trim($edit) : null; + $editTable = $editId !== null ? $this->tables->get($editId) : null; + $filter = ($status !== null && in_array(trim($status), Tables::STATUSES, true)) ? trim($status) : null; + + return $this->styles($nonce) + . Branding::head('Floor', 'The room at a glance — seat, clear and turn tables.', $nonce) + . $this->notice($notice) + . $this->form($csrf, $editTable) + . $this->legend() + . $this->filterBar($filter) + . $this->board($csrf, $this->tables->all($filter), $filter); + } + + /** @param array|null $edit */ + private function form(string $csrf, ?array $edit): string + { + $val = static fn (string $k): string => $edit !== null && $edit[$k] !== null ? self::e((string) $edit[$k]) : ''; + $idField = $edit !== null ? '' : ''; + + $statusOptions = ''; + $current = $edit !== null ? (string) $edit['status'] : 'open'; + foreach (Tables::STATUSES as $s) { + $statusOptions .= ''; + } + + return '

    ' . ($edit !== null ? 'Edit table' : 'Add a table') . '

    ' + . '
    ' + . '' . $idField + . '
    ' + . '' + . '' + . '' + . '
    ' + . '
    ' + . ($edit !== null ? ' Cancel' : '') + . '
    '; + } + + private function filterBar(?string $active): string + { + $chips = 'All'; + foreach (Tables::STATUSES as $s) { + $chips .= '' . self::e(self::STATUS_LABELS[$s]) . ''; + } + 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 + { + if ($tables === []) { + $msg = $filter !== null ? 'No ' . self::e(self::STATUS_LABELS[$filter]) . ' tables.' : 'No tables yet — add one above.'; + return '

    ' . $msg . '

    '; + } + + $tokens = ''; + foreach ($tables as $t) { + $status = (string) $t['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 '
      ' . $tokens . '
    '; + } + + /** The status quick-actions relevant to a table's current state. */ + private function actions(string $csrf, int $id, string $status): string + { + // What each button moves the table to, shown only when it makes sense. + $moves = []; + if ($status !== 'occupied') { + $moves[] = ['occupied', 'Seat']; + } + if ($status === 'occupied') { + $moves[] = ['dirty', 'Clear']; + } + if ($status === 'dirty' || $status === 'reserved') { + $moves[] = ['open', 'Open']; + } + if ($status === 'open') { + $moves[] = ['reserved', 'Reserve']; + } + + $html = ''; + foreach ($moves as [$to, $verb]) { + $html .= '
    ' + . '' + . '' + . '' + . '
    '; + } + + $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 ''; + } + + /** 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/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/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/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 new file mode 100644 index 0000000..6b6b7e1 --- /dev/null +++ b/plugin/tests/RestaurantPluginTest.php @@ -0,0 +1,55 @@ +caps = new PluginCapabilities(); + (new RestaurantPlugin())->register(new PluginContext($this->caps, RestaurantPlugin::ID)); + } + + public function test_it_declares_the_fine_grained_staff_actions_as_grants(): void + { + $grantable = $this->caps->capabilities->grantable(); + + 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); + self::assertSame('Restaurant: kitchen', $grantable['danmat.restaurant:kitchen'], 'a finer action labels as itself'); + } + + public function test_each_terminal_is_gated_on_the_right_action(): void + { + $gate = []; + foreach ($this->caps->adminPages->all() as $page) { + $gate[$page['slug']] = $page['capability']; + } + + 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'); + self::assertSame('danmat.restaurant:manage', $gate['restaurant-reports'], 'reports are manager-only'); + } +} diff --git a/plugin/tests/RestaurantToolsetTest.php b/plugin/tests/RestaurantToolsetTest.php new file mode 100644 index 0000000..87050f9 --- /dev/null +++ b/plugin/tests/RestaurantToolsetTest.php @@ -0,0 +1,244 @@ + 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(), ...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); + $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, $reports); + $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', + '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', + 'restaurant_reports', + ], $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', 'restaurant_reservations', 'restaurant_reservation_get', 'restaurant_reports', + ], $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 + { + $write = $this->principal('danmat.restaurant:read', 'danmat.restaurant:write'); + $tid = $this->toolset->call('restaurant_table_set', ['label' => '5'], $write, $this->ctx)['table']['id']; + $oid = $this->toolset->call('restaurant_order_open', ['table_id' => $tid], $write, $this->ctx)['order']['id']; + $this->toolset->call('restaurant_order_add_item', ['order_id' => $oid, 'name' => 'Fries', 'price' => '3', 'qty' => 1], $write, $this->ctx); + + // Not in the kitchen while open. + self::assertSame(0, $this->toolset->call('restaurant_kitchen', [], $write, $this->ctx)['count']); + + $this->toolset->call('restaurant_order_status', ['id' => $oid, 'status' => 'sent'], $write, $this->ctx); + $queue = $this->toolset->call('restaurant_kitchen', [], $write, $this->ctx); + self::assertSame(1, $queue['count']); + self::assertSame('Fries', $queue['tickets'][0]['items'][0]['name']); + } + + 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_paying_over_mcp_charges_the_computed_total(): void + { + $write = $this->principal('danmat.restaurant:read', 'danmat.restaurant:write'); + $tid = $this->toolset->call('restaurant_table_set', ['label' => '9'], $write, $this->ctx)['table']['id']; + $oid = $this->toolset->call('restaurant_order_open', ['table_id' => $tid], $write, $this->ctx)['order']['id']; + $this->toolset->call('restaurant_order_add_item', ['order_id' => $oid, 'name' => 'Steak', 'price' => '20', 'qty' => 2], $write, $this->ctx); + + // The tool takes only a method — no amount can be supplied. + $out = $this->toolset->call('restaurant_order_pay', ['id' => $oid, 'method' => 'card'], $write, $this->ctx); + self::assertTrue($out['ok']); + self::assertSame('40.00', $out['order']['amount_paid'], 'charged the computed total'); + self::assertSame('closed', $out['order']['status']); + self::assertSame('dirty', $this->toolset->call('restaurant_table_get', ['id' => $tid], $write, $this->ctx)['table']['status']); + } + + public function test_a_bad_payment_method_comes_back_as_data(): void + { + $write = $this->principal('danmat.restaurant:read', 'danmat.restaurant:write'); + $tid = $this->toolset->call('restaurant_table_set', ['label' => '9'], $write, $this->ctx)['table']['id']; + $oid = $this->toolset->call('restaurant_order_open', ['table_id' => $tid], $write, $this->ctx)['order']['id']; + $out = $this->toolset->call('restaurant_order_pay', ['id' => $oid, 'method' => 'iou'], $write, $this->ctx); + self::assertFalse($out['ok']); + self::assertSame('invalid', $out['error']); + } + + 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 + { + 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..b63fb61 --- /dev/null +++ b/plugin/tests/TablesAdminTest.php @@ -0,0 +1,83 @@ + 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 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); + } + + 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); + } +} diff --git a/theme/assets/app.css b/theme/assets/app.css new file mode 100644 index 0000000..304cd76 --- /dev/null +++ b/theme/assets/app.css @@ -0,0 +1,219 @@ +/* + * 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 { display: flex; gap: 1.4rem; } +.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; } + +/* 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; } +.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; } +.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-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/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..ea252a7 --- /dev/null +++ b/theme/templates/header.php @@ -0,0 +1,22 @@ + + diff --git a/theme/templates/layout.php b/theme/templates/layout.php new file mode 100644 index 0000000..a7de0dd --- /dev/null +++ b/theme/templates/layout.php @@ -0,0 +1,46 @@ + $meta + * @var string $head extra HTML contributed by plugins (already-rendered, trusted) + */ +// 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); +?> + + + + + + <?= $e($pageTitle) ?> + + + + + + + + + + + + + + + +
    + +
    + + + diff --git a/theme/theme.json b/theme/theme.json new file mode 100644 index 0000000..4babccd --- /dev/null +++ b/theme/theme.json @@ -0,0 +1,17 @@ +{ + "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": ["home", "menu_items"], + "templates": { + "layout": "HTML shell; includes header and footer.", + "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": "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)." +}