diff --git a/README.md b/README.md index 01151d2..d142033 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ restaurants for a festival — can subscribe and stay live. **A CMS for restaurant operators.** One LNbits account can host one or many restaurants under the same login. Each restaurant carries its own -profile, menu tree (categories → subcategories → items), modifier -groups (required choices and optional addons, single- or multi-select), -per-item availability windows, inventory, and Nostr identity. +profile, an arbitrary-depth **menu tree** (capped at 4 levels — e.g. +*Drinks → Hot Beverages → Coffee-based → Espressos*) where items can +attach to **any** node (not just leaves), modifier groups (required +choices and optional addons, single- or multi-select), per-item +availability windows, inventory, and Nostr identity. **A REST API.** Public read endpoints serve menu trees and item details; gated write endpoints (admin key) handle CRUD; an unauthenticated @@ -23,8 +25,10 @@ order placement endpoint accepts carts and returns a Lightning invoice. **A Nostr publisher.** Menu items are published as NIP-99 classified listings (kind 30402, parameterized replaceable) every time they're created or edited; restaurant profiles are kind 0 metadata; deletions -are NIP-09. Tags carry structured price, dietary flags, allergens, and -ingredients so subscribers can filter without parsing markdown. +are NIP-09. Tags carry structured price, the ancestor category chain +(slugified, root-first, so clients can filter on `#t=hot-beverages` +etc.), dietary flags, allergens, and ingredients so subscribers can +filter without parsing markdown. **An order pipeline.** Every cart placed against this restaurant becomes one order with snapshotted line-item prices and selected @@ -102,10 +106,13 @@ Each restaurant's LNbits instance: | Table | Purpose | | --------------------- | ------------------------------------------------------ | | `restaurants` | One row per restaurant. Owns a wallet + Nostr pubkey. | -| `categories` | Top-level menu sections. | -| `subcategories` | Optional second level under a category. | -| `menu_items` | Items, with structured dietary/allergens/ingredients, | -| | images, stock, availability, Nostr event id. | +| `menu_nodes` | The menu tree. Self-referential (`parent_id`); carries | +| | denormalized `path` + `depth` for cheap subtree ops. | +| | Capped at 4 levels. | +| `menu_items` | Items keyed to a node (`node_id`). Structured | +| | dietary / allergens / ingredients, images, stock, | +| | Nostr event id. Items can attach to **any** node, not | +| | just leaves. | | `modifier_groups` | Choice groups (`required`/`optional`, `one`/`many`). | | `modifiers` | Individual options with `price_delta`. | | `availability_windows`| Per-item time-of-day + weekday availability. | @@ -114,6 +121,15 @@ Each restaurant's LNbits instance: | `print_jobs` | Thermal printer queue with retry tracking. | | `settings` | Per-instance toggles (Nostr publish, auto-accept, …). | +The menu is an **adjacency list with denormalized materialized path**: +each node has a `parent_id` self-FK, plus a `path` TEXT column +(`'rootid'` or `'rootid/childid/...'`) and an integer `depth`. This +gives cheap subtree queries (`WHERE path LIKE :p || '%'`), trivial +cycle detection on move, and a single-statement subtree path rewrite — +identical on SQLite + Postgres, no `ltree` extension needed. See +[`docs/adr-0001-menu-tree.md`](docs/adr-0001-menu-tree.md) for the +trade-off vs. a closure table. + Money amounts on `orders`/`order_items` are stored as integer **msat** for precision. Item prices are floats in their declared currency (`sat`, `USD`, `GTQ`, etc.); the order pipeline multiplies by 1000 to @@ -181,8 +197,21 @@ Owners write with their wallet's admin key: - `PUT /restaurant/api/v1/print_jobs/{id}/ack` — printer-pi acknowledgement -Plus full CRUD for categories, subcategories, modifier groups, -modifiers, and availability windows. +Plus full CRUD for menu nodes (the tree), modifier groups, modifiers, +and availability windows. Menu node operations: + +- `POST /restaurant/api/v1/menu_nodes` — create (depth + path + derived from parent; rejected at depth > 4) +- `PUT /restaurant/api/v1/menu_nodes/{id}` — rename / desc / sort / + image. Rename re-publishes every item in the subtree so their + ancestor `t` tags update on Nostr. +- `PUT /restaurant/api/v1/menu_nodes/{id}/move` — body + `{new_parent_id}`; single-statement subtree rewrite, cycle-checked +- `DELETE /restaurant/api/v1/menu_nodes/{id}?cascade=true|false` — + default blocks if the node has children or items; cascade deletes + the subtree and **detaches** items (sets `node_id` to null) rather + than wiping them, since items carry `nostr_event_id`s and revenue + history. ## Customer-facing webapp integration diff --git a/docs/adr-0001-menu-tree.md b/docs/adr-0001-menu-tree.md new file mode 100644 index 0000000..3c2a115 --- /dev/null +++ b/docs/adr-0001-menu-tree.md @@ -0,0 +1,146 @@ +# ADR 0001 — Menu storage: adjacency list with materialized path + +**Status:** Accepted +**Date:** 2026-04-29 +**Supersedes:** initial scaffold's flat `categories` + `subcategories` model. + +## Context + +Real restaurant menus are nested: +*Drinks → Hot Beverages → Coffee-based → Espressos*. The initial +scaffold pinned a fixed two-level shape (categories + subcategories +tables). That was a transcription of the LNbits "category + +subcategory" idiom rather than a real data-model decision. The +legacy Atitlan.io project we're carrying forward already used a +self-FK tree (`Category.parentId` in +`Atitlan.io/Legacy/server-fastify/prisma/schema.prisma`). + +We also need: +- Items attaching to **any** node, not just leaves (a "Drinks" node + can carry both children and its own items). +- A small **maximum depth** so the UI stays navigable (we picked 4 + levels — *root → kid → grandkid → great-grandkid*). +- Cheap "subtree of X" reads (the customer webapp asks for an entire + menu in one round trip). +- Cheap "move subtree" writes (operators reorganize menus). +- Cheap cycle + depth validation on move. +- Identical behavior on **SQLite + Postgres**, which LNbits both + support. + +## Decision + +Store the tree as an **adjacency list** (`parent_id` self-FK) plus +denormalized **materialized path** (`path` TEXT, `'/'`-separated +node ids) and **depth** (INTEGER, 0..3). + +Indexes: `(restaurant_id)`, `(parent_id)`, `(path)`. + +``` +menu_nodes +├── id TEXT PK +├── restaurant_id TEXT +├── parent_id TEXT NULL -- NULL = root of restaurant +├── name TEXT +├── description TEXT +├── sort_order INTEGER +├── image_url TEXT +├── depth INTEGER -- 0..3 +├── path TEXT -- 'rootid' or 'rootid/childid/...' +└── time TIMESTAMP +``` + +Menu items get `node_id` (replacing `category_id` + `subcategory_id`). +`MenuItem.node_id` is **Optional** in the persisted shape (orphans +allowed when a parent is deleted with `cascade=False`); the +`CreateMenuItem` request body requires it (newly-created items must +land somewhere). + +### What this gives us + +| Operation | Cost | +| -------------------------------- | ---------------------------------------- | +| Children of node X | `WHERE parent_id = X` — index hit | +| Subtree of node X | `WHERE path LIKE X.path \|\| '%'` — index hit | +| Ancestors of node X | split `path` into ids, fetch by id (≤4) | +| Cycle check on move | `node_id in new_parent.path.split('/')` — O(depth) | +| Max-depth check on create / move | compare integers — O(1) | +| Move subtree (rewrite paths) | one `UPDATE … SET path = new_prefix \|\| SUBSTR(path, len(old)+1)` | +| Build full tree | one `SELECT *` ordered by `(depth, sort_order)`, assemble in O(n) Python | + +For the realistic scale (5–50 nodes per restaurant, depth ≤ 4), the +"build full tree" pass takes microseconds. We never reach for +recursive CTEs. + +## Alternatives considered + +### Closure table + +A separate `menu_node_paths` table holding every (ancestor, +descendant) pair. Best read characteristics for very deep trees +with thousands of nodes — cheap descendant queries via a single +join, no string matching. Rejected because: + +- **Maintenance overhead.** Every insert writes one row per + ancestor; every move deletes and rewrites the entire subtree's + rows; every delete is a fan-out. At our scale (depth ≤ 4) this + is pure overhead. +- **Two sources of truth.** The closure table can drift from + `parent_id` on bugs. We'd have to test and lock both. +- **No real win.** Subtree queries on the path column are already + index-backed and fast at this scale. + +We'd revisit if a single instance ever hosted thousands of nodes per +restaurant. Today it doesn't. + +### Postgres `ltree` + +A first-class materialized-path type with GiST indexes. Lovely on +Postgres. **Rejected** because LNbits also supports SQLite, which +has no `ltree`. We don't want a per-backend code path. + +A `path` TEXT column gives us the same query shape (`LIKE prefix || +'%'`) on both backends. If a deployment ever wanted GiST-indexed +performance, an opt-in migration to `ltree` could be added later +without changing the model API. + +### Pure adjacency list (no path / no depth) + +Keep `parent_id`, drop the denormalized columns. Subtree queries +require recursive CTEs (Postgres + SQLite both support them). +**Rejected** because: + +- Recursive CTE syntax is *almost* identical between SQLite and + Postgres but not quite, and writing portable migrations becomes + fiddly. +- Cycle detection on move requires walking with another CTE. +- Move's path rewrite isn't a single statement; you'd have to + recompute every descendant's depth in app code. + +The denormalized columns are cheap (one `path: TEXT`, one `depth: +INT` per node) and remove all of these papercuts. + +### Nested set (lft / rgt) + +Optimal subtree reads, terrible writes (every insert / move shifts +half the tree's `lft`/`rgt` values). **Rejected** as obviously +wrong-shaped for an interactive CMS where operators reorganize +menus often. + +## Consequences + +- Operators can build menus of any shape up to 4 levels, with items + attachable at any depth. +- Subtree moves are a single SQL statement. +- The CMS uses Quasar's `q-tree` directly off the hydrated tree + returned by `GET /api/v1/restaurants/{id}/menu`. +- Items can be orphaned (their `node_id` is nullable). The CMS UI + surfaces orphans as "unfiled" so operators can re-home them. +- Nostr listings (NIP-99 kind 30402) carry one `t` tag per ancestor + name (slugified, root-first). Renaming a node re-publishes every + item in its subtree so the new tag set lands. + +## Migration + +`m002_menu_tree` (shipped) backfills `menu_nodes` from the prior +`categories` + `subcategories` tables, then drops them. See +`migrations.py` for the SQL.