From 42a8b08a5b359412536f4032bd879ea0848a0275 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 09:34:07 +0200 Subject: [PATCH] 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. --- README.md | 14 ++++ docs/api-reference.md | 109 +++++++++++++++++++++++++++ docs/architecture.md | 89 ++++++++++++++++++++++ docs/cms.md | 88 ++++++++++++++++++++++ docs/data-model.md | 149 +++++++++++++++++++++++++++++++++++++ docs/glossary.md | 80 ++++++++++++++++++++ docs/index.md | 50 +++++++++++++ docs/menu-tree.md | 91 ++++++++++++++++++++++ docs/nostr-layer.md | 110 +++++++++++++++++++++++++++ docs/order-flow.md | 108 +++++++++++++++++++++++++++ docs/webapp-integration.md | 127 +++++++++++++++++++++++++++++++ 11 files changed, 1015 insertions(+) create mode 100644 docs/api-reference.md create mode 100644 docs/architecture.md create mode 100644 docs/cms.md create mode 100644 docs/data-model.md create mode 100644 docs/glossary.md create mode 100644 docs/index.md create mode 100644 docs/menu-tree.md create mode 100644 docs/nostr-layer.md create mode 100644 docs/order-flow.md create mode 100644 docs/webapp-integration.md diff --git a/README.md b/README.md index d142033..dc1567d 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,20 @@ without the Nostr layer. - **Image upload pipeline** (today images are URLs; a CDN integration belongs in the AIO webapp, not here). +## Documentation + +Deeper docs live in [`docs/`](docs/) as an Obsidian-style vault. Start +at [`docs/index.md`](docs/index.md) (Map of Content) and follow the +`[[wikilinks]]`. Highlights: + +- [`architecture`](docs/architecture.md) — layered overview +- [`data-model`](docs/data-model.md) — every table, every relationship +- [`menu-tree`](docs/menu-tree.md) — the tree as a concept +- [`order-flow`](docs/order-flow.md) — state machine + payment + print +- [`nostr-layer`](docs/nostr-layer.md) — kinds, tags, signing +- [`webapp-integration`](docs/webapp-integration.md) — multi-restaurant cart pattern +- [`adr-0001-menu-tree`](docs/adr-0001-menu-tree.md) — why adjacency + materialized path + ## License MIT. diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..442a9b3 --- /dev/null +++ b/docs/api-reference.md @@ -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: `) + +### 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": ""}`. 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]] diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..15fb925 --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/cms.md b/docs/cms.md new file mode 100644 index 0000000..119be3f --- /dev/null +++ b/docs/cms.md @@ -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 `` 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 5–8 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]] diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..fc5a23c --- /dev/null +++ b/docs/data-model.md @@ -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/` | +| `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:` 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]] diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..cf511be --- /dev/null +++ b/docs/glossary.md @@ -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]]. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d8bf7f2 --- /dev/null +++ b/docs/index.md @@ -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. diff --git a/docs/menu-tree.md b/docs/menu-tree.md new file mode 100644 index 0000000..a8fdf7b --- /dev/null +++ b/docs/menu-tree.md @@ -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 (5–50 nodes, 10–200 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 diff --git a/docs/nostr-layer.md b/docs/nostr-layer.md new file mode 100644 index 0000000..6711e82 --- /dev/null +++ b/docs/nostr-layer.md @@ -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` | `` 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:` | structured allergens | +| `t` | `ingr:` | structured ingredients | +| `l` | `"restaurant:"` | 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 diff --git a/docs/order-flow.md b/docs/order-flow.md new file mode 100644 index 0000000..ffa2888 --- /dev/null +++ b/docs/order-flow.md @@ -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": }` 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) diff --git a/docs/webapp-integration.md b/docs/webapp-integration.md new file mode 100644 index 0000000..4c0a925 --- /dev/null +++ b/docs/webapp-integration.md @@ -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", ""], + ["p", ""], + ["p", ""] + ], + ... +} +``` + +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]]