docs: Obsidian-style vault under docs/

Add a navigable Obsidian vault as the project's first-class
technical documentation. Notes cross-reference with [[wikilinks]];
docs/index.md is the Map of Content.

New notes:
  index.md             MOC, entry point
  architecture.md      what the extension owns vs what lives outside
  data-model.md        entity-by-entity schema reference
  menu-tree.md         the arbitrary-depth tree concept
  order-flow.md        state machine + invoice listener + print
  nostr-layer.md       kinds 0/30402/5/1059, signing, t-tags
  api-reference.md     endpoint catalog by audience
  cms.md               Vue 3 + Quasar 2 UMD conventions, q-tree
  webapp-integration.md  multi-restaurant cart pattern + atomicity
  glossary.md          domain terms

Existing notes (kept as-is):
  adr-0001-menu-tree.md  the storage choice rationale
  design-conversation.md trimmed transcript

README.md adds a Documentation section pointing at docs/index.md
with the headline note list. Each note links to ~3-5 others; the
vault forms a connected graph.

A project-level memory rule (saved outside the repo) commits us to
keeping these docs in sync as the code evolves: any commit that
materially changes schema, API, order flow, Nostr surface, CMS
conventions, or webapp integration must update the relevant note(s)
in the same commit.
This commit is contained in:
Padreug 2026-05-02 09:34:07 +02:00
commit 42a8b08a5b
11 changed files with 1015 additions and 0 deletions

109
docs/api-reference.md Normal file
View file

@ -0,0 +1,109 @@
# API reference
All routes live under `/restaurant/api/v1`. Three audiences:
- **Public** — no auth, used by customer webapps to read menus.
- **Customer** — no auth, places orders and queries them.
- **Owner** — wallet admin key in `X-Api-Key`, ownership-checked by
matching `restaurant.wallet`.
For exhaustive per-endpoint detail open `views_api.py`; this note is
the catalog.
## Public reads
| Method | Path | Notes |
|---|---|---|
| `GET` | `/restaurants/{id}` | Restaurant profile |
| `GET` | `/restaurants/{id}/menu` | `{restaurant, tree, items}` — the canonical [[menu-tree|menu tree]] (hydrated children + items per node) plus a flat enriched items list with modifier groups + availability windows pre-joined |
| `GET` | `/menu_items/{id}` | Single item |
| `GET` | `/menu_nodes/{id}` | Single node row |
| `GET` | `/restaurants/{id}/menu_nodes` | Flat list of all nodes — useful for parent pickers |
| `GET` | `/menu_items/{id}/modifier_groups` | Groups for an item |
| `GET` | `/modifier_groups/{id}/modifiers` | Modifiers for a group |
| `GET` | `/menu_items/{id}/availability_windows` | Availability rules |
## Customer order placement
| Method | Path | Notes |
|---|---|---|
| `POST` | `/orders/quote` | Pre-flight balance check; body is a list of `CreateOrderItem`. Returns `{required_msat}`. See [[order-flow]] |
| `POST` | `/orders` | Place an order on one restaurant; returns `{order, invoice}` where `invoice` is the bolt11 + payment_hash |
| `GET` | `/orders/{id}` | Order + items |
For multi-restaurant carts the webapp posts `/orders` once per
restaurant; see [[webapp-integration]].
## Owner CRUD (`X-Api-Key: <admin>`)
### Restaurants
| Method | Path |
|---|---|
| `GET` | `/restaurants?all_wallets=true` |
| `POST` | `/restaurants` |
| `PUT` | `/restaurants/{id}` |
| `DELETE` | `/restaurants/{id}` |
### Menu nodes (the tree)
| Method | Path | Notes |
|---|---|---|
| `POST` | `/menu_nodes` | depth + path derived from parent; HTTP 400 if creates would exceed cap |
| `PUT` | `/menu_nodes/{id}` | rename / desc / sort / image. **Rename re-publishes every item in the subtree** so [[nostr-layer\|ancestor `t` tags]] update |
| `PUT` | `/menu_nodes/{id}/move` | body `{new_parent_id}`; HTTP 400 on cycle / depth violation |
| `DELETE` | `/menu_nodes/{id}?cascade=true\|false` | default blocks (HTTP 409) if children/items exist; cascade=true detaches items and deletes the subtree of nodes |
### Menu items
| Method | Path | Notes |
|---|---|---|
| `POST` | `/menu_items` | Re-publishes to Nostr |
| `PUT` | `/menu_items/{id}` | Re-publishes |
| `DELETE` | `/menu_items/{id}` | Sends NIP-09 deletion |
### Modifier groups + modifiers
`POST` / `DELETE` for `/modifier_groups` and `/modifiers`.
### Availability windows
`POST` / `DELETE` for `/availability_windows`.
### Orders (operator)
| Method | Path | Notes |
|---|---|---|
| `GET` | `/restaurants/{id}/orders?statuses=...&limit=...` | invoice key acceptable — used by the [[cms\|order monitor + KDS]] |
| `PUT` | `/orders/{id}/status/{new_status}` | Manual transitions (`accepted`, `ready`, `completed`, `canceled`, `refunded`); admin key required |
### Print jobs
| Method | Path | Notes |
|---|---|---|
| `GET` | `/restaurants/{id}/print_jobs?status=...` | invoice key |
| `PUT` | `/print_jobs/{id}/ack` | Called by `printer-pi` after a successful print; admin key |
### Settings (LNbits admin only)
| Method | Path |
|---|---|
| `GET` | `/settings` |
| `PUT` | `/settings` |
## Error shapes
Standard FastAPI `{"detail": "<msg>"}`. Status codes:
- `400` — validation, depth / cycle on menu node ops, balance precheck failures
- `403` — owner check failed
- `404` — entity missing
- `409` — node delete blocked by children / items (pass `?cascade=true`)
- `500` — server-side, with the exception captured in logs
## See also
- [[architecture]]
- [[order-flow]]
- [[menu-tree]]
- [[webapp-integration]]

89
docs/architecture.md Normal file
View file

@ -0,0 +1,89 @@
# Architecture
The restaurant extension is the **operator's CMS** for one or many
restaurants on a single LNbits account. Customer-facing UIs (kiosks,
mobile apps, the AIO webapp) live outside the extension and consume it
over REST + Nostr.
## What this extension owns
- Restaurant profile rows and per-restaurant Nostr identity.
- The [[menu-tree]] (`menu_nodes` + `menu_items` + `modifier_groups` +
`modifiers` + `availability_windows`).
- The [[order-flow|order pipeline]] (`orders`, `order_items`,
`print_jobs`).
- Publishing the [[nostr-layer|menu to Nostr]] as NIP-99 listings.
- The [[cms|operator console]] under `/restaurant/...` (Jinja +
Quasar 2 UMD).
- A REST [[api-reference|API]] under `/restaurant/api/v1/...`.
## What lives outside the extension
| Concern | Where |
|---|---|
| Customer kiosk / mobile / web | `~/dev/webapp` ([[webapp-integration]]) |
| Multi-restaurant aggregation (festivals, food courts, collective spaces) | NIP-51 lists, curated externally |
| Lightning wallet, payment routing, user auth | LNbits core |
| Nostr relay connection | `nostrclient` extension |
| Thermal printer | `printer-pi` (subscribes to a webhook or Nostr event) |
## High-level topology
```
LNbits instance
┌────────────────────────────────┐
│ Restaurant ext │
│ ├── REST /restaurant/api/v1│
│ ├── CMS /restaurant/... │
│ ├── Nostr publisher │──────┐
│ └── Invoice listener │ │
│ (settle, decrement, │ │
│ queue print) │ │
└─────────┬──────────────────────┘ │
│ ▼
│ ┌─────────────────────────┐
│ │ nostrclient ext │──→ relays
│ └─────────────────────────┘
┌──────────────┐ ┌─────────────────────┐
│ printer-pi │ ◀──────│ webapp / AIO │
│ (subscribes) │ │ (customer, multi- │
└──────────────┘ │ restaurant cart) │
└─────────────────────┘
```
## Lifecycle
`__init__.py` registers three permanent tasks on extension start
(`create_permanent_unique_task`):
1. **Invoice listener**`tasks.wait_for_paid_invoices` consumes
LNbits' global payment queue, filters on
`payment.extra.tag == "restaurant"`, and dispatches to
[[order-flow|services.mark_order_paid]].
2. **NostrClient bootstrap**`nostr.nostr_client.NostrClient`
connects to the `nostrclient` extension's internal WebSocket
after a 10s grace.
3. **Nostr sync**`nostr_sync.wait_for_nostr_events` subscribes
to the relevant filters once the client is up. NIP-17 unwrap is
stubbed.
`restaurant_stop` cancels the three tasks and closes the WebSocket.
If `nostrclient` isn't enabled, the publisher / sync no-op gracefully
and the extension still works — it just operates as REST-only without
the [[nostr-layer]].
## Boundaries we keep
The extension only ever knows about **its own restaurant's** data.
There is no global "festival" or "marketplace" entity stored anywhere
in `restaurant.*` tables. Cross-restaurant grouping is the customer
webapp's concern; see [[webapp-integration]].
## See also
- [[data-model]] — the entity catalog
- [[order-flow]] — payment lands → print job
- [[nostr-layer]] — what propagates outward
- [[adr-0001-menu-tree]] — why the menu storage choice

88
docs/cms.md Normal file
View file

@ -0,0 +1,88 @@
# CMS
The operator-facing console — what the restaurant owner sees when
they enable the extension. Lives under `/restaurant/...` and uses
LNbits' built-in **Vue 3.5 + Quasar 2.18 UMD** runtime: no build
step, no bundler, just Jinja templates including a per-page JS file.
## Pages
All pages extend `base.html` (provided by LNbits core) and require a
logged-in user (`check_user_exists`).
| Path | Template | Script | Purpose |
|---|---|---|---|
| `/restaurant/` | `restaurant/index.html` | `static/js/index.js` | Restaurant list / dashboard |
| `/restaurant/{slug}` | `restaurant/menu.html` | `static/js/menu.js` | [[menu-tree\|menu]] builder (q-tree) |
| `/restaurant/{slug}/orders` | `restaurant/orders.html` | `static/js/orders.js` | Order monitor |
| `/restaurant/{slug}/kds` | `restaurant/kds.html` | `static/js/kds.js` | Kitchen Display |
| `/restaurant/{slug}/settings` | `restaurant/settings.html` | `static/js/settings.js` | Restaurant + extension settings |
## Conventions
- `{% extends "base.html" %}` and `{% from "macros.jinja" import window_vars with context %}`.
- Page content goes in `{% block page %}`. Scripts in `{% block scripts %}`, after `{{ window_vars(user) }}`.
- The page JS sets `window.app = Vue.createApp({mixins: [windowMixin], data, methods, created})`. LNbits' `init-app.js` runs after the extension scripts and finishes the mount with `app.use(Quasar)` + `app.mount('#vue')`**don't call `.mount()` yourself**.
- Bootstrap data is injected via `<script>window.X = {{ thing | tojson | safe }}</script>` between the macro and the per-page script.
- The shared REST client is `static/js/api.js`, exposing `window.RestaurantAPI` (one method per resource).
## Menu builder (q-tree)
The menu page uses Quasar's `q-tree` directly off the hydrated tree
returned by `GET /api/v1/restaurants/{id}/menu`. Three-pane layout:
```
+--------------------+----------------+----------------------------+
| sidebar nav | q-tree | Items panel |
| (orders / KDS / | with inline | (filtered by selected |
| settings links) | edit buttons | tree node) |
+--------------------+----------------+----------------------------+
```
Custom `default-header` slot renders:
- node name + item-count badge + child-count hint
- inline buttons: `add` (disabled at depth 3), `edit`,
`drive_file_move`, `delete` (with cascade prompt)
Add-root button sits above the tree (`+ New top-level`).
The Move dialog uses a flat-indented `q-select` of all nodes,
filtered to exclude the moved node + its descendants and any
depth-3 candidate. (The server enforces both checks too — see
[[menu-tree]].)
Drag-drop reorder is **v2**; v1 uses the explicit Move dialog.
## Item dialog
The item dialog includes a flat-indented `q-select` for `node_id`,
populated by walking the tree with em-space indentation per depth
level. An item can land on any node, not just leaves.
Modifier groups + modifiers live in a separate dialog (a child of
the item dialog) with the `chooseOne / chooseMany / required /
optional` semantics from [[data-model|the data model]].
## Order monitor + KDS
Both use the same data source (`GET /restaurants/{id}/orders`)
filtered by status. The KDS view escalates color by age (`>5min`
orange, `>15min` red) and offers one-tap state transitions.
Today the monitor + KDS poll every 58 s. SSE / Nostr push is on
the roadmap.
## Settings
`settings.html` saves restaurant fields via
`PUT /restaurants/{id}` and (for LNbits admins) extension-wide
toggles via `PUT /settings`. NIP-17 orders toggle is currently
disabled because the unwrap step is stubbed — see [[nostr-layer]].
## See also
- [[architecture]]
- [[menu-tree]]
- [[order-flow]]
- [[api-reference]]

149
docs/data-model.md Normal file
View file

@ -0,0 +1,149 @@
# Data model
Schema-by-schema reference. The migration that creates each table is
in `migrations.py`; the pydantic shapes are in `models.py`; CRUD is
in `crud.py`.
All tables live under the Postgres schema `restaurant.` (or the
SQLite equivalent), keyed off the LNbits `Database("ext_restaurant")`
binding.
---
## `restaurants`
One row per restaurant. **One LNbits wallet can own many.**
| Column | Notes |
|---|---|
| `id` | `urlsafe_short_hash()` |
| `wallet` | LNbits wallet id — payment receiver, signing-key fallback |
| `name`, `slug` | `slug` is the URL segment in `/restaurant/<slug>` |
| `description`, `currency`, `timezone`, `location`, `geohash` | metadata |
| `logo_url`, `banner_url`, `social_links` (JSON) | profile dressing |
| `open_hours` (JSON) | weekly schedule, see [[order-flow]] |
| `is_open`, `accepts_cash`, `accepts_lightning` | runtime toggles |
| `tip_presets` (JSON int[]), `tax_rate` | money / UX hints |
| `printer_endpoint` | URL or `nostr:<pubkey>` for [[order-flow|print jobs]] |
| `nostr_pubkey`, `nostr_relays` (JSON str[]) | per-restaurant Nostr identity (optional override; defaults to the LNbits Account keypair) |
| `nostr_event_id`, `nostr_event_created_at` | last published kind-0 metadata event |
| `extra` (JSON) | free-form |
Published as a [[nostr-layer|kind-0 metadata event]] on create / update.
---
## `menu_nodes`
The [[menu-tree]] — adjacency list with materialized path. Capped
at 4 levels (`MAX_MENU_DEPTH = 3`, zero-indexed).
| Column | Notes |
|---|---|
| `id` | `urlsafe_short_hash()` |
| `restaurant_id` | FK |
| `parent_id` | self-FK, NULL = root |
| `name`, `description`, `image_url`, `sort_order` | display |
| `depth` | denormalized 0..3 — O(1) max-depth checks |
| `path` | denormalized `'rootid'` or `'rootid/childid'` — cheap subtree queries |
| `time` | created_at |
Indexes: `(restaurant_id)`, `(parent_id)`, `(path)`. See
[[adr-0001-menu-tree]] for why this shape and not a closure table.
Not published as a Nostr event itself — internal organizational
structure only. Renames trigger re-publish of every item in the
subtree (so the [[nostr-layer|ancestor `t` tag]] stays current).
---
## `menu_items`
| Column | Notes |
|---|---|
| `id`, `restaurant_id` | |
| `node_id` | nullable — orphans allowed when a node is deleted with `cascade=False` |
| `name`, `description`, `price`, `currency`, `sku` | core |
| `images`, `dietary`, `allergens`, `ingredients` (JSON arrays) | structured tags |
| `calories`, `sort_order`, `is_available`, `is_featured` | display |
| `stock`, `low_stock_threshold` | inventory; nullable = unlimited |
| `nostr_event_id`, `nostr_event_created_at` | last published kind-30402 |
| `extra` (JSON) | free-form |
Published as [[nostr-layer|NIP-99 kind 30402]] on create / update;
deleted via NIP-09 kind 5.
---
## `modifier_groups` + `modifiers`
A menu item can have multiple modifier groups. Each group has a
`kind` (`required` | `optional`) and `selection` (`one` | `many`).
Each modifier carries a `price_delta`.
This unifies "required choices" (e.g. *Choose your protein: Chicken /
Tofu*) with "optional addons" (e.g. *Extra cheese +5*) under one
schema.
---
## `availability_windows`
Per-item time-of-day availability.
| Column | Notes |
|---|---|
| `menu_item_id` | FK |
| `weekday` | 0=Mon..6=Sun, NULL = every day |
| `start_time`, `end_time` | `'HH:MM'` 24h, restaurant timezone |
Used by clients to gray out items outside their window. The extension
itself doesn't auto-disable items — it surfaces the windows in the
[[api-reference|menu response]] so the consumer decides.
---
## `orders` + `order_items`
See [[order-flow]] for the state machine and amounts (msat).
`orders.id` is set to `payment_hash` for Lightning orders, giving the
invoice listener a zero-metadata lookup path. `order_items` snapshot
price + selected modifiers at order time, so subsequent menu edits
don't rewrite history.
---
## `print_jobs`
Created when an order transitions to `paid`. `printer-pi` polls or
subscribes, prints, and acknowledges via `PUT /print_jobs/{id}/ack`.
| Column | Notes |
|---|---|
| `restaurant_id`, `order_id` | FKs |
| `status` | `queued``sent``acknowledged`, or `failed` |
| `attempts`, `last_error` | retry bookkeeping |
---
## `settings`
Single-row table (id=1) for per-instance toggles.
| Toggle | Effect |
|---|---|
| `nostr_publish_enabled` | gate for kind-0 / kind-30402 publishing |
| `nostr_orders_enabled` | enable subscription to kind-1059 DMs (NIP-17 unwrap is currently stubbed) |
| `invoice_expiry_seconds` | LNbits invoice lifetime |
| `auto_accept_orders` | `paid``accepted` automatically (see [[order-flow]]) |
---
## See also
- [[architecture]]
- [[menu-tree]]
- [[order-flow]]
- [[nostr-layer]]
- [[adr-0001-menu-tree]]

80
docs/glossary.md Normal file
View file

@ -0,0 +1,80 @@
# Glossary
Domain terms used throughout the docs. Linked from many notes.
**Aggregator** — a webapp / client that pulls menus from multiple
restaurants and presents them as a single experience (festival, food
court, collective space). Aggregators live outside this extension.
**Ancestor chain** — the ordered list of node names from the root of
the [[menu-tree]] down to (and including) a given node. Slugified
versions of these names ride along on every Nostr menu listing as
`t` tags so [[nostr-layer|clients can filter]] without parsing
markdown.
**Cascade detach** — the default behavior when deleting a
[[menu-tree|menu node]] that has items: the items are
**detached** (their `node_id` is set to NULL) rather than
hard-deleted. They survive as orphans for the operator to re-home
through the [[cms]]. Hard delete is opt-in.
**CMS** — the operator console. Server-rendered Jinja templates +
inline Vue 3 / Quasar 2 UMD. See [[cms]].
**Customer pubkey** — the Nostr pubkey of an ordering customer.
Optional metadata on `orders.customer_pubkey`. Used for sending
status updates back via [[nostr-layer|NIP-17 DMs]] (scaffolded).
**Festival** — common shorthand for a curated multi-restaurant
context. Not an entity stored in this extension; see
[[webapp-integration]].
**Internal payment** — an LNbits invoice paid from another wallet
on the same instance, never touching the Lightning Network. The
extension supports this as `payment_method = "internal"` for testing
and same-instance flows.
**MAX_MENU_DEPTH** — `3` (zero-indexed); 4 levels of nesting total.
Soft-enforced by the API via HTTP 400 on creates / moves.
**msat** — millisatoshi. Money on `orders` and `order_items` is
stored as integer msat for precision; UI / Nostr surfaces convert
back to sat (or fiat) at display time.
**Node** — a row in `menu_nodes`. The unit of organization in the
[[menu-tree]]. Has zero or more children, zero or more items, and
zero or one parent.
**NIP-XX** — a Nostr Implementation Possibility. Reference repo at
`~/dev/nostr-protocol/nips`. Specific NIPs we use:
- **NIP-01** — base event structure; kind 0 metadata.
- **NIP-09** — deletion request (kind 5).
- **NIP-17** — gift-wrapped DMs (kind 1059); planned order intake.
- **NIP-44** — encryption used inside NIP-17.
- **NIP-51** — generic lists; festival aggregator vehicle.
- **NIP-99** — classified listings (kind 30402); how we publish menu items.
**Operator** — the LNbits user who has enabled this extension on
their account. Owns one or more restaurants.
**Parent order ref** — an opaque string on `orders.parent_order_ref`
the webapp can use to correlate its own umbrella-cart id with the
per-restaurant orders. The extension stores it and echoes it back;
never reads it.
**Path** — denormalized materialized path on `menu_nodes`. Either
`'rootid'` (for a root node) or `'rootid/childid/...'` (for deeper
nodes). Underpins [[menu-tree|cheap subtree queries]].
**Restaurant Nostr identity** — each restaurant has an effective
keypair for signing kind-0 / kind-30402 events. If
`restaurant.nostr_pubkey` is set it overrides; otherwise the LNbits
Account keypair of the wallet owner is used. See [[nostr-layer]].
**Slug** — the URL segment under which a restaurant's [[cms|CMS pages]]
live (e.g. `/restaurant/emporium`). Lowercase, dashes, no spaces.
**Webapp** — the customer-facing UI at `~/dev/webapp`. Subscribes
to restaurants over Nostr, posts orders over REST. See
[[webapp-integration]].

50
docs/index.md Normal file
View file

@ -0,0 +1,50 @@
# Restaurant extension — docs
Map of Content for the restaurant LNbits extension.
> Treat this folder as an Obsidian vault: notes use `[[wikilinks]]` to
> cross-reference. Open the folder in Obsidian (or any markdown editor
> that resolves bare-name links) for the full graph.
## Start here
- [[architecture]] — what the extension is and how it sits inside
LNbits, alongside the customer webapp and Nostr.
- [[glossary]] — domain terms used throughout the docs.
## Concepts
- [[data-model]] — every table, every relationship.
- [[menu-tree]] — the arbitrary-depth menu structure (capped at 4
levels), where items can attach to any node.
- [[order-flow]] — the order state machine, the invoice listener,
and the kitchen pipeline.
- [[nostr-layer]] — what we publish to Nostr (kind 0, kind 30402,
kind 5) and what we listen for (kind 1059, scaffolded).
## Surface
- [[api-reference]] — REST endpoints organized by audience
(public read, customer order placement, owner CRUD).
- [[cms]] — the operator UI: Vue 3 + Quasar 2 UMD conventions,
template structure, q-tree menu builder.
- [[webapp-integration]] — how the AIO webapp aggregates multiple
restaurants into a single cart and pays each one's invoice.
## Decisions
- [[adr-0001-menu-tree]] — why an adjacency list with materialized
path, not a closure table or `ltree`.
## Process
- [[design-conversation]] — trimmed transcript of the design
discussion that produced the initial scaffold. Keep for
rationale that didn't make it into commit messages.
## Maintenance
This vault is the project's first-class technical documentation.
Every commit that changes the surface listed in [[architecture]] or
the rules above must update the matching note(s) in the same commit.
Stale docs are worse than no docs.

91
docs/menu-tree.md Normal file
View file

@ -0,0 +1,91 @@
# Menu tree
Real menus are nested:
*Drinks → Hot Beverages → Coffee-based → Espressos*. The extension
models this as a single self-referential `menu_nodes` table.
## Storage shape
Adjacency list (`parent_id` self-FK) plus two denormalized columns:
- `path``'rootid'` or `'rootid/childid/...'` (slash-separated ids).
- `depth` — integer 0..3 (zero-indexed; cap is 4 levels).
See [[data-model]] for the column list and [[adr-0001-menu-tree]] for
why this shape was chosen over a closure table or `ltree`.
## Rules
- **Max depth is 4** (`MAX_MENU_DEPTH = 3`). Creates that would
exceed the cap return HTTP 400. Moves that would push descendants
past the cap also return 400.
- **Items can attach to any node** — the leaf-only constraint of
the legacy two-level shape is gone. A "Drinks" node can hold its
own drinks AND nest sub-categories below it.
- **Cycle prevention** on move: the new parent's path must not
contain the moved node's id.
- **Cascade-delete detaches items** rather than wiping them. Items
carry `nostr_event_id` and revenue history, so the operator
re-homes orphans through the [[cms]] rather than losing them.
## Operations
| Op | Cost | How |
|---|---|---|
| Children of node | O(log n) | `WHERE parent_id = :id` (indexed) |
| Subtree of node | O(log n) | `WHERE path LIKE :p \|\| '%'` (indexed) |
| Ancestors of node | O(depth) | split `path`, fetch by id |
| Cycle check on move | O(depth) | id in new parent's `path.split('/')` |
| Max-depth check | O(1) | integer compare |
| Move subtree | one statement | `path = new_prefix \|\| SUBSTR(path, len(old)+1)` |
| Build full tree for restaurant | O(n+m) Python | one `SELECT *` → assemble in memory |
## Move
The load-bearing operation. Single-statement subtree rewrite:
```sql
UPDATE restaurant.menu_nodes
SET path = :new_prefix || SUBSTR(path, :old_len + 1),
depth = depth + :delta
WHERE path = :old_path
OR path LIKE :old_path || '/%'
```
`SUBSTR` is 1-indexed on both SQLite and Postgres, so `len(old_path)
+ 1` slices the old prefix off correctly. Followed by a separate
`UPDATE menu_nodes SET parent_id = :new_pid WHERE id = :node_id` for
the moved root (descendants keep their parent_id; only paths +
depths shift).
Implementation lives in `crud.move_menu_node`.
## Tree assembly
Customers and the [[cms]] both want the whole tree in one call.
`crud.get_menu_tree(restaurant_id)`:
1. `SELECT * FROM menu_nodes WHERE restaurant_id = :rid ORDER BY depth, sort_order, time`
2. `SELECT * FROM menu_items WHERE restaurant_id = :rid`
3. Build `by_id: dict[id, MenuNode]` in Python.
4. Walk rows, attaching each non-root to `by_id[parent_id].children`.
5. Walk items, attaching each to `by_id[node_id].items`.
For typical restaurant sizes (550 nodes, 10200 items) this is
microseconds. Identical on SQLite + Postgres, no recursive CTE
needed.
## Items at non-leaf levels
A node can have BOTH children AND items attached. The [[cms]]
renders both at each level. The [[nostr-layer]] tags items with
their full ancestor chain (root-first, slugified) so a customer
filtering for `#t=hot-beverages` finds everything under that branch
regardless of how deeply it nests.
## See also
- [[data-model]] — columns + indexes
- [[cms]] — the q-tree builder UI
- [[nostr-layer]] — ancestor `t` tags
- [[adr-0001-menu-tree]] — adjacency vs. closure trade-off

110
docs/nostr-layer.md Normal file
View file

@ -0,0 +1,110 @@
# Nostr layer
Why Nostr at all? Two reasons:
1. **Live menu propagation.** Customer apps subscribe to a
restaurant's pubkey and pick up menu changes (new items, price
updates, sold-out states) without polling.
2. **Cross-instance discoverability.** A festival or food court
curator publishes a [[webapp-integration|NIP-51 list]] of
restaurant pubkeys; any client can resolve it into a unified
menu without needing to know which LNbits instance hosts each
restaurant.
## What gets published
| Kind | Source | When |
|---|---|---|
| `0` (NIP-01 metadata) | restaurant profile | restaurant create / update |
| `30402` (NIP-99 classified listing, parameterized replaceable, `d`-tag = item id) | menu items | item create / update; node rename re-publishes the whole subtree's items |
| `5` (NIP-09 deletion request) | menu items | item delete |
Menu listings carry structured tags so subscribers can filter
without parsing markdown:
| Tag | Format | Purpose |
|---|---|---|
| `d` | item.id | addressable identifier |
| `title` | item.name | listing title |
| `summary` | first 140 chars of description | preview |
| `price` | `["price", n, currency]` | structured price (NIP-99) |
| `image` | url | one per image, repeatable |
| `t` | `"menu"` | universal anchor |
| `t` | `<slug>` per ancestor | root-first, slugified to lowercase ASCII (e.g. `hot-beverages`); lets clients filter by category |
| `t` | dietary tag | `vegan`, `gluten_free`, etc. |
| `t` | `allergen:<x>` | structured allergens |
| `t` | `ingr:<x>` | structured ingredients |
| `l` | `"restaurant:<id>"` | back-link to the operator |
| `location` | restaurant location | physical reference |
| `g` | restaurant geohash | geo-filterable |
| `status` | `"active"` or `"sold"` | NIP-99 sold-out state |
Builders live in `nostr_publisher.py`:
- `build_restaurant_metadata_event`
- `build_menu_item_event(..., ancestor_names=...)`
- `build_delete_event`
`_slugify` produces the ancestor `t` tag values. Renaming a
[[menu-tree|menu node]] re-publishes every item in the subtree so
the new tag set lands.
## Signing
Each restaurant has an effective Nostr identity:
- If `restaurant.nostr_pubkey` is set, that's a per-restaurant
identity (storage of the matching secret key is **out of scope**
in v1; the column is informational until a vault is wired up).
- Otherwise, the LNbits Account keypair of the wallet owner is
used (`account.pubkey` / `account.prvkey`).
`nostr_publisher.publish_event(client, event, prvkey)` signs in
place with `coincurve.PrivateKey.sign_schnorr` (BIP-340) and ships
to the relay via the [[architecture|nostrclient extension's]]
internal WebSocket.
## What gets listened for
`nostr_sync.wait_for_nostr_events` subscribes to:
- `kind 30402` with `#t=menu`, `limit 200` for backfill, then live.
Currently used only as an echo confirmation of our own publishes;
federated foreign-menu indexing is on the roadmap.
- `kind 1059` (NIP-17 gift-wrapped DMs), only when
`settings.nostr_orders_enabled`. The unwrap step (NIP-44 v2) is
**stubbed** — the dispatcher (`_place_order_from_dm`) is complete
and ready for the decryption hook.
## NIP-17 order intake (planned)
The intended flow once unwrap lands:
1. Customer's webapp encrypts an order payload with NIP-44 v2 to
the restaurant's pubkey, gift-wraps it (kind 13 → kind 1059),
and publishes.
2. The restaurant's `nostr_sync` receives the wrap, decrypts
layers, and produces a `CreateOrder`.
3. Order placement goes through the same `services.place_order`
path as REST — including invoice creation. The bolt11 is sent
back to the customer pubkey via another NIP-17 DM.
4. Status updates (`paid → preparing → ready`) flow back the same
way.
REST stays the supported transport until that lands, since LNbits
already has tested invoice plumbing.
## What does NOT get published
[[menu-tree|Menu nodes]] themselves. They're internal organizational
structure; only items and the restaurant profile carry public Nostr
identity. If we ever want categories to be discoverable as
standalone entities, NIP-51 lists are the right vehicle, not a new
kind.
## See also
- [[architecture]] — extension lifecycle starts the NostrClient
- [[menu-tree]] — ancestor names come from here
- [[order-flow]] — what NIP-17 will eventually deliver
- [[webapp-integration]] — clients of this layer

108
docs/order-flow.md Normal file
View file

@ -0,0 +1,108 @@
# Order flow
From "customer adds to cart" to "ticket prints in the kitchen", in
one restaurant's slice. The customer-side aggregation across
multiple restaurants is in [[webapp-integration]].
## State machine
```
pending ──pay──▶ paid ──accept──▶ accepted ──ready──▶ ready ──serve──▶ completed
│ │ │
└─cancel────────────┴──────────────────┴─▶ canceled
└─refund────────────────────────────────▶ refunded
```
`pending → paid` is the **only** transition driven by money. All
others are explicit calls to `PUT /api/v1/orders/{id}/status/{new}`
from the [[cms]].
States and their meaning:
| State | Set by | Meaning |
|---|---|---|
| `pending` | `services.place_order` | Invoice issued, not yet paid |
| `paid` | invoice listener | LNbits payment settled (or cash recorded) |
| `accepted` | operator (or auto-accept) | Restaurant has acknowledged, prep in progress |
| `ready` | operator | Pickup-ready / served |
| `completed` | operator | Finished |
| `canceled` | operator | Pre- or post-payment cancel |
| `refunded` | operator | Paid → refunded |
## Place order
`services.place_order(CreateOrder)`:
1. Resolves the restaurant; rejects if `is_open=False`.
2. Re-prices every line item against the live menu (modifier ids
are matched server-side; the customer's claimed `price_delta`
values are ignored).
3. Sums `subtotal_msat`, applies `tax_rate`, adds `tip_msat`
`total_msat`.
4. For Lightning / internal: calls
`lnbits.core.services.create_invoice` with
`extra={"tag": "restaurant", "restaurant_id": ...}`.
5. Persists the order with `id = payment_hash` so the listener can
look it up cheaply, plus one `order_items` row per line.
6. For cash: `payment_method = "cash"` skips invoice creation and
marks the order `accepted` directly.
Returns `(Order, OrderInvoice | None)`. The webapp pays the bolt11.
## Pre-flight quote
`POST /api/v1/orders/quote` returns `{"required_msat": <int>}` for a
hypothetical cart. The webapp sums the quotes from each restaurant
**before** opening any per-restaurant invoice — so a customer with
insufficient balance sees one error rather than partially-paid carts
across N restaurants.
## Settlement
`tasks.wait_for_paid_invoices` registers an `asyncio.Queue` listener
on LNbits' global payment stream. On each payment:
```python
if payment.extra.get("tag") != "restaurant":
return
order = await get_order_by_payment_hash(payment.payment_hash)
if order:
await mark_order_paid(order.id)
```
`services.mark_order_paid` is idempotent. It:
1. Sets `order.status``"paid"` (or `"accepted"` if
`settings.auto_accept_orders`).
2. Decrements `menu_item.stock` for each line, clamped at 0.
3. Creates a `print_jobs` row.
Stock decrements happen at settlement, not at order placement —
unpaid orders don't lock inventory.
## Print pipeline
`print_jobs` is a queue. `printer-pi` (a separate process / device
running outside this extension) picks up jobs via `GET /restaurants/
{id}/print_jobs?status=queued`, prints, and acknowledges via
`PUT /print_jobs/{id}/ack`. Status flow:
`queued → sent → acknowledged`, or `failed` with a `last_error`.
The roadmap calls for a Nostr-based push: `printer-pi` subscribes to
the restaurant's pubkey for an `order.confirmed` event, removing the
poll loop.
## Multi-restaurant carts
A single customer cart spanning multiple restaurants is handled by
the [[webapp-integration|webapp]], not here. Each restaurant's
extension instance only ever sees its own slice — its own order, its
own invoice, its own print job. There is no umbrella order on the
server side.
## See also
- [[architecture]]
- [[data-model]] — `orders`, `order_items`, `print_jobs`
- [[webapp-integration]] — multi-restaurant aggregation
- [[nostr-layer]] — outbound status DMs (NIP-17, scaffolded)

127
docs/webapp-integration.md Normal file
View file

@ -0,0 +1,127 @@
# Webapp integration
The customer-facing UI lives in `~/dev/webapp` (the AIO webapp).
This note describes the contract between the webapp and any number
of restaurants, especially the multi-restaurant cart pattern.
## Discovery
A webapp can either talk to one restaurant directly or aggregate
many. There's no central directory inside this extension — grouping
("festival", "collective space", "food court") is **emergent** via
NIP-51 list events curated by whoever runs the venue:
```
{
"kind": 30000, // NIP-51 follow set / generic list
"tags": [
["d", "festival-2026"],
["title", "Atitlan Bitcoin Festival 2026"],
["p", "<restaurant1-pubkey>"],
["p", "<restaurant2-pubkey>"],
["p", "<restaurant3-pubkey>"]
],
...
}
```
The webapp:
1. Resolves the list event for the festival / venue.
2. Fans out to fetch each restaurant's profile (kind 0) and menu
(kind 30402) over Nostr, **or** falls back to
`GET /restaurant/api/v1/restaurants/{id}/menu` over REST.
3. Subscribes to each restaurant pubkey for live updates.
No central wallet, no central database, no per-restaurant
onboarding flow specific to "joining a festival". Restaurants
exist; festivals are curated views.
## Multi-restaurant cart
A single customer can put items from multiple restaurants into one
cart. On checkout, the webapp issues **one order per restaurant**;
each restaurant returns its own bolt11; the customer pays N
invoices. There is no payment splitter and no umbrella order on
the server side.
The pattern, in pseudocode (this lives in the webapp, not the
extension):
```js
// 1. Group cart by restaurant.
const cartByRestaurant = groupBy(cart.lines, line => line.restaurant_id)
// 2. Pre-flight quote per restaurant.
const quotes = await Promise.all(
Object.entries(cartByRestaurant).map(([rid, lines]) =>
fetch(`/restaurant/api/v1/orders/quote`, {
method: 'POST',
body: JSON.stringify(lines.map(l => ({
menu_item_id: l.id,
quantity: l.qty,
selected_modifiers: l.modifiers
})))
}).then(r => r.json()).then(j => ({rid, lines, msat: j.required_msat}))
)
)
// 3. Fail fast on insufficient balance.
const totalMsat = quotes.reduce((s, q) => s + q.msat, 0)
if (walletBalanceMsat < totalMsat) {
return showInsufficientBalanceError()
}
// 4. Open one order per restaurant.
const orders = []
for (const q of quotes) {
const res = await fetch(`/restaurant/api/v1/orders`, {
method: 'POST',
body: JSON.stringify({
restaurant_id: q.rid,
items: q.lines.map(asCreateOrderItem),
customer_pubkey: window.user.nostrPubkey,
parent_order_ref: cart.id,
tip_msat: q.tipMsat,
payment_method: 'lightning'
})
}).then(r => r.json())
orders.push(res)
}
// 5. Pay each bolt11 in sequence.
for (const o of orders) {
await payInvoice(o.invoice.bolt11)
}
```
## Atomicity
Sequential per-restaurant payments are best-effort, not atomic. In
practice — for internal LNbits transfers — payment failures are
rare and usually transient. If one of N invoices does fail mid-flow:
- The successful orders are already paid; their restaurants will
print and prepare them.
- The failed order has issued an invoice that simply expires.
- The customer settles the gap in person at the failing restaurant
(cash, retry, or refund of the others).
The pre-flight `/orders/quote` step plus a balance check before
opening any invoice eliminates the most common cause (insufficient
funds). HODL invoices for true atomicity are on the roadmap and
would replace step 4 once they ship.
## `parent_order_ref`
The webapp may pass an opaque `parent_order_ref` (e.g. its own
cart id) when posting `/orders`. The extension stores it on the
`orders` row but never reads it — useful only for the webapp to
correlate its umbrella cart with the per-restaurant orders later.
## See also
- [[architecture]]
- [[order-flow]]
- [[api-reference]]
- [[nostr-layer]]