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.
323 lines
15 KiB
Markdown
323 lines
15 KiB
Markdown
# Restaurant — LNbits extension
|
||
|
||
A Nostr-native restaurant CMS for LNbits. The operator (the person who
|
||
enables this extension on their LNbits account) builds menus, manages
|
||
modifiers and inventory, watches orders in real time, and routes paid
|
||
tickets to a thermal printer. Each restaurant's data is owned by that
|
||
restaurant's wallet; menus are published to Nostr so any client — from
|
||
a single venue's customer kiosk to a webapp aggregating dozens of
|
||
restaurants for a festival — can subscribe and stay live.
|
||
|
||
## What this extension is
|
||
|
||
**A CMS for restaurant operators.** One LNbits account can host one or
|
||
many restaurants under the same login. Each restaurant carries its own
|
||
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
|
||
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, 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
|
||
modifiers. The invoice listener settles `pending → paid` on payment;
|
||
the operator (or auto-accept) walks it through `accepted → ready →
|
||
completed`. Stock decrements on settlement, a print job lands in the
|
||
queue, and the Kitchen Display picks it up.
|
||
|
||
**A single-tenant view of the world.** Customer-facing UIs (kiosks,
|
||
mobile apps, the AIO webapp at `~/dev/webapp`) live outside this
|
||
extension and connect via REST and Nostr. When a customer wants to
|
||
order across multiple restaurants — at a festival, in a collective
|
||
space, across a food court — that grouping is curated externally
|
||
(typically as a NIP-51 list of restaurant pubkeys), the webapp fetches
|
||
each menu independently, builds a unified cart, and sends one order
|
||
per restaurant. Each restaurant issues its own bolt11 invoice; the
|
||
customer pays N invoices to complete the cart. No central wallet
|
||
holds the float, no splitter divides the payment, and each operator
|
||
sees their own sats land directly. The webapp pre-flights the total
|
||
via `POST /api/v1/orders/quote` so a customer with insufficient
|
||
balance gets one clean error rather than a partially-paid cart.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
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 UI, │
|
||
└──────────────┘ │ multi-rest │
|
||
│ cart) │
|
||
└────────────────┘
|
||
```
|
||
|
||
A customer's webapp:
|
||
|
||
1. Discovers a restaurant (directly, or via a NIP-51 list curated for a
|
||
festival/collective space).
|
||
2. Loads the menu via `GET /api/v1/restaurants/{id}/menu` (one-shot
|
||
tree fetch) and subscribes to the restaurant's Nostr pubkey for
|
||
live updates.
|
||
3. Builds a cart that may span multiple restaurants.
|
||
4. Calls `POST /api/v1/orders/quote` (per restaurant) to get the total
|
||
msat needed; sums them and verifies the wallet has enough.
|
||
5. Calls `POST /api/v1/orders` once per restaurant; gets back N
|
||
`OrderInvoice` payloads (`{order_id, payment_hash, bolt11,
|
||
amount_msat, expires_at}`).
|
||
6. Pays each bolt11 from the customer's LNbits wallet.
|
||
|
||
Each restaurant's LNbits instance:
|
||
|
||
7. Receives the payment via its own invoice listener
|
||
(`tag == "restaurant"`), looks up the order by `payment_hash`,
|
||
transitions the order to `paid` (or `accepted` if auto-accept is
|
||
set), decrements stock, and queues a print job.
|
||
8. Optionally, when wired up, sends NIP-17 status DMs back to the
|
||
customer's pubkey: `paid → preparing → ready`.
|
||
|
||
## Data model
|
||
|
||
| Table | Purpose |
|
||
| --------------------- | ------------------------------------------------------ |
|
||
| `restaurants` | One row per restaurant. Owns a wallet + Nostr pubkey. |
|
||
| `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. |
|
||
| `orders` | Per-restaurant order with state machine. |
|
||
| `order_items` | Snapshot of price + selected modifiers at order time. |
|
||
| `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
|
||
go to msat at order time and snapshots that into `unit_price_msat`.
|
||
|
||
## Order state machine
|
||
|
||
```
|
||
pending ──pay──▶ paid ──accept──▶ accepted ──ready──▶ ready ──serve──▶ completed
|
||
│ │ │
|
||
└─cancel────────────┴──────────────────┴─▶ canceled
|
||
└─refund────────────────────────────────▶ refunded
|
||
```
|
||
|
||
`pending → paid` is the *only* transition driven by money movement
|
||
(the invoice listener). All others are explicit calls to
|
||
`PUT /api/v1/orders/{id}/status/{new_status}` from the CMS.
|
||
|
||
## Nostr
|
||
|
||
- **Restaurant profile** is published as **kind 0** (NIP-01 metadata)
|
||
whenever the restaurant is created or updated.
|
||
- **Menu items** are published as **kind 30402** (NIP-99 classified
|
||
listings, parameterized replaceable by `item.id`). Tags: `d`,
|
||
`title`, `summary`, `price` (as `[price, n, currency]`), `image*`,
|
||
`t` (category, dietary, `allergen:<x>`, `ingr:<x>`), `l`
|
||
(`restaurant:<id>`), `location`, `g` (geohash), `status`
|
||
(`active`/`sold`).
|
||
- **Deletions** use **kind 5** (NIP-09) referencing the addressable
|
||
event via `["a", "30402:<pubkey>:<d-tag>"]`.
|
||
- **Inbound order DMs** are scaffolded as **NIP-17 gift-wrapped DMs**
|
||
(kind 1059). The unwrap step (NIP-44 v2) is a stub; until it lands
|
||
REST is the supported transport. The dispatcher
|
||
(`_place_order_from_dm`) is complete and ready to wire in.
|
||
|
||
A restaurant signs with `restaurant.nostr_pubkey` if set (per-restaurant
|
||
identity), else with the LNbits Account keypair of the wallet owner.
|
||
|
||
## API surface
|
||
|
||
Reading menus is **public** (no auth):
|
||
|
||
- `GET /restaurant/api/v1/restaurants/{id}` — profile
|
||
- `GET /restaurant/api/v1/restaurants/{id}/menu` — full menu tree
|
||
- `GET /restaurant/api/v1/menu_items/{id}` — single item
|
||
|
||
Customers placing orders need no auth (the customer pubkey is
|
||
optional metadata):
|
||
|
||
- `POST /restaurant/api/v1/orders/quote` — pre-flight balance check
|
||
- `POST /restaurant/api/v1/orders` — place an order, get bolt11
|
||
- `GET /restaurant/api/v1/orders/{id}` — order + items
|
||
|
||
Owners write with their wallet's admin key:
|
||
|
||
- `POST /restaurant/api/v1/restaurants` — create
|
||
- `PUT /restaurant/api/v1/restaurants/{id}` — update
|
||
- `POST /restaurant/api/v1/menu_items` — create
|
||
- `PUT /restaurant/api/v1/menu_items/{id}` — update (re-publishes
|
||
to Nostr; kind 30402 is replaceable)
|
||
- `DELETE /restaurant/api/v1/menu_items/{id}` — delete (sends NIP-09
|
||
deletion to Nostr)
|
||
- `PUT /restaurant/api/v1/orders/{id}/status/{new_status}` — manual
|
||
state transitions
|
||
- `PUT /restaurant/api/v1/print_jobs/{id}/ack` — printer-pi
|
||
acknowledgement
|
||
|
||
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
|
||
|
||
The webapp's multi-restaurant cart flow:
|
||
|
||
```js
|
||
// 1. Resolve restaurant pubkeys (e.g. from a NIP-51 festival list).
|
||
const restaurants = await fetchRestaurantsForFestival(festivalId)
|
||
|
||
// 2. Pull each menu in parallel; subscribe to Nostr for live updates.
|
||
await Promise.all(restaurants.map(r =>
|
||
fetch(`/restaurant/api/v1/restaurants/${r.id}/menu`).then(r => r.json())
|
||
))
|
||
|
||
// 3. User builds a cart spanning N restaurants.
|
||
// Group lines by restaurant_id.
|
||
const cartByRestaurant = groupBy(cart.lines, line => line.restaurant_id)
|
||
|
||
// 4. Pre-flight: ask each restaurant for the msat total.
|
||
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}))
|
||
)
|
||
)
|
||
|
||
const totalMsat = quotes.reduce((s, q) => s + q.msat, 0)
|
||
if (walletBalanceMsat < totalMsat) {
|
||
alert('Insufficient balance — top up first')
|
||
return
|
||
}
|
||
|
||
// 5. Open one order per restaurant. Each returns its own bolt11.
|
||
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(...),
|
||
customer_pubkey: window.user.nostrPubkey,
|
||
parent_order_ref: cart.id,
|
||
tip_msat: q.tipMsat,
|
||
payment_method: 'lightning'
|
||
})
|
||
}).then(r => r.json())
|
||
orders.push(res)
|
||
}
|
||
|
||
// 6. Pay each bolt11 in sequence from the user's wallet.
|
||
// The restaurant's invoice listener marks each as paid + queues
|
||
// its print job independently.
|
||
for (const o of orders) {
|
||
await payInvoice(o.invoice.bolt11)
|
||
}
|
||
```
|
||
|
||
## Install (dev)
|
||
|
||
```sh
|
||
cd ~/dev/lnbits/main
|
||
# Drop a clone of this repo into the extensions dir LNbits expects:
|
||
ln -s ~/dev/shared/extensions/restaurant lnbits/extensions/restaurant
|
||
poetry run lnbits # or whatever your dev runner is
|
||
```
|
||
|
||
Then enable the extension from the LNbits admin UI. The
|
||
`nostrclient` extension must also be enabled for the publisher and
|
||
sync to function — without it, the extension still works, just
|
||
without the Nostr layer.
|
||
|
||
## Roadmap (not implemented yet)
|
||
|
||
- **NIP-44 v2 unwrap** for NIP-17 gift-wrapped order DMs.
|
||
- **Per-restaurant Nostr keypair** secret storage (currently the
|
||
fallback to the LNbits Account keypair is the only working path).
|
||
- **SSE / push** for orders + KDS (today the CMS polls every 5–8 s).
|
||
- **HODL invoices** for atomic multi-restaurant cart settlement (the
|
||
scaffold accepts that best-effort sequential payment is enough for
|
||
internal LNbits transfers; HODL would harden the rare external case).
|
||
- **Foreign menu cache** so a single LNbits instance can serve a
|
||
webapp that aggregates restaurants from many other instances. The
|
||
`nostr_sync` skeleton currently only echoes our own published items.
|
||
- **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.
|