docs: README + ADR for menu tree refactor

README.md
  - Update intro: 'menu tree' is now arbitrary-depth (cap 4
    levels), items can attach to any node.
  - Update Nostr publisher description to mention ancestor 't'
    tags (slugified, root-first) so clients can filter on
    #t=hot-beverages, #t=coffee-based, etc.
  - Replace the Data model table's categories/subcategories rows
    with a single menu_nodes row that explains the adjacency-list
    + materialized-path + depth shape and points at the ADR.
  - Replace the boilerplate 'full CRUD for categories,
    subcategories, ...' line with a real menu_nodes API list,
    including the cascade-detach behavior on delete and the
    rename-triggers-subtree-republish behavior on update.

docs/adr-0001-menu-tree.md
  - New ADR explaining the storage choice (adjacency list +
    materialized path + denormalized depth), the alternatives
    considered (closure table, Postgres ltree, pure adjacency,
    nested set), and the consequences. Provides the rationale
    so future contributors don't relitigate the decision.
This commit is contained in:
Padreug 2026-05-02 09:14:26 +02:00
commit 7f7915a041
2 changed files with 186 additions and 11 deletions

View file

@ -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