When a card hits `accepted` the items section bumps base font to
1.25rem and modifier/note lines to 1.15rem + medium weight; the
muted grey on modifiers drops to inherited color. All via Vue
`:style` bindings — class-based CSS rules lose to lnbits' upstream
`!important` on Quasar typography utilities (even with our own
`!important`), so inline wins without an arms race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The canonical demo seed for the restaurant extension + webapp
bundle. Drops a "Lightning Cafe" restaurant at slug=lightning-cafe
with currency='sat', exercising every feature surface so it
doubles as a visual smoke-test fixture:
- Menu tree (depth 2: Drinks→{Coffee,Cold,Tea}, Food→{Breakfast,
Lunch,Snacks})
- 19 items with descriptions, dietary tags, allergens, ingredients,
calorie counts
- Featured items (is_featured=1) — Latte, Avocado Toast, Lightning
Burger
- Sold-out item (is_available=0) — Daily Special
- Low-stock item (stock=3, low_stock_threshold=5) — Fresh OJ
- Multiple modifier groups per item showing all three patterns:
required/one (radio, e.g. Milk choice)
required/many (limited multi, e.g. Burger extras max 4)
optional/many (free multi, e.g. Avocado Toast toppings)
- Modifier price deltas in sats (+50 for oat milk, +200 for egg, …)
The /var/lib/lnbits/data path is in the auto-detect list so the
script Just Works on the standard NixOS lnbits service layout.
Usage:
python3 scripts/seed_lightning_cafe.py --wallet <wallet_id> --force
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The age-escalation highlights (bg-amber-1 / bg-orange-1 /
bg-red-1) are very pale Quasar shades. On LNbits dark mode the
q-card inherits a near-white text color from the theme — paired
with the pale background that's white-on-cream, which is what
the user reported: the '1x Coffee' on a ready-card was barely
visible.
Pin an explicit text-grey-9 alongside each pale bg so dark text
on light background renders in both themes. The 'no highlight'
branch returns '' unchanged, so non-aged orders still use the
q-card's theme-aware default text color.
Before this fix `_to_msat(item.price)` blindly did `price * 1000`,
treating any menu price as sat-denominated regardless of the item's
`currency` field. Quote and bolt11 were internally consistent but
charged ~0.1% of the real price for fiat-priced menus.
Big Jay's seeded with GTQ:
2× Tacos (Maíz, +Brisket, +Chicken)
pre-fix: 170 GTQ → 170000 msat → 170 sat invoice (~$0.14)
post-fix: 170 GTQ → 26968000 msat → 26968 sat invoice (~$22)
services.py:
- Drop `_to_msat` in favor of a `_price_to_msat(amount, currency)`
helper. Sat-aliased currencies ("sat", "sats", "satoshi",
"msat", …) take the flat ×1000 path; everything else round-
trips through lnbits.utils.exchange_rates.fiat_amount_as_satoshis
(same pool the events extension uses).
- Update _price_line_item: item.price AND each modifier.price_delta
are converted using item.currency. Modifier deltas inherit the
parent item's currency since we don't carry a per-modifier
currency field.
- Update quote_balance_required: same conversion via the item's
currency.
Verified live against the seeded "Big Jay's Bustaurant":
GTQ → sat conversion matches LNbits's bitcoin-price aggregate
(Binance / Blockchain / Bitfinex / Bitstamp / Coinbase / yadio).
Quote returns 26968 sats for 170 GTQ — within ~2% of expected
rate from external sources.
Prerequisite for the customer webapp module (aiolabs/webapp,
branch feat/restaurant-bundle): the webapp's /r/:slug route needs
to resolve a slug to a Restaurant payload without an admin key.
crud.get_restaurant_by_slug already exists (used by the server-
rendered CMS routes in views.py); just expose it as a public REST
endpoint. Mirrors api_get_restaurant by id and is declared before
the bare-id route so the static prefix wins FastAPI's path match.
Verified live against seeded 'Big Jay's Bustaurant':
GET /restaurant/api/v1/restaurants/by-slug/big-jays-bustaurant
-> 200 with the Restaurant payload.
Every step now uses CREATE [TABLE|INDEX] IF NOT EXISTS or is wrapped
via a _safe(stmt) helper that swallows OperationalError, and
backfill INSERTs become INSERT OR IGNORE. So a partially-applied
m002 (interrupted by a crash before the dbversion bump) re-runs
cleanly on next startup instead of failing on duplicate-table /
duplicate-index / duplicate-PK errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The castle LNbits extension was renamed to libra. Updating the
parallel-extension reference in the design-conversation notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
- Update intro: 'menu tree' is now arbitrary-depth (cap 4
levels), items can attach to any node.
- Update Nostr publisher description to mention ancestor 't'
tags (slugified, root-first) so clients can filter on
#t=hot-beverages, #t=coffee-based, etc.
- Replace the Data model table's categories/subcategories rows
with a single menu_nodes row that explains the adjacency-list
+ materialized-path + depth shape and points at the ADR.
- Replace the boilerplate 'full CRUD for categories,
subcategories, ...' line with a real menu_nodes API list,
including the cascade-detach behavior on delete and the
rename-triggers-subtree-republish behavior on update.
docs/adr-0001-menu-tree.md
- New ADR explaining the storage choice (adjacency list +
materialized path + denormalized depth), the alternatives
considered (closure table, Postgres ltree, pure adjacency,
nested set), and the consequences. Provides the rationale
so future contributors don't relitigate the decision.
When a menu item's NIP-99 kind-30402 listing is published, the
extension now emits one 't' tag per ancestor node name (root-first,
slugified to lowercase ASCII). This lets Nostr clients filter the
global listing stream by category — e.g.
{"#t": ["hot-beverages"]}
{"#t": ["coffee-based"]}
without having to know the publisher's pubkey or pull markdown
content. The 'menu' anchor stays first so subscribers can still
get the universal stream. Allergen / ingredient prefixes
(allergen:<x>, ingr:<x>) and dietary tags are unchanged.
nostr_publisher.py:
- Add _slugify(name) -> str (lowercase, [^a-z0-9]+ -> '-', strip).
- build_menu_item_event takes ancestor_names: tuple[str, ...] kw
and emits dedup'd slugs. Stays DB-free; the caller does the
walk.
views_api.py:
- _ancestor_names_for_node walks the materialized path of an
item's node to (root.name, ..., leaf.name).
- _publish_menu_item passes them to the builder.
- api_update_menu_node detects a name change and calls
_republish_subtree_items(node_id), which re-publishes every
menu_item in the subtree so the new ancestor slug lands on
each listing. <=50 items per restaurant in practice; eager
re-publish keeps the relay state consistent without a
background sync.
Replace the flat sidebar + dead-subcategory-modal with a real
arbitrary-depth tree builder using Quasar's q-tree.
templates/restaurant/menu.html:
Three-pane layout (sidebar / tree / items).
q-tree binds to the hydrated tree returned by
GET /api/v1/restaurants/{id}/menu. Custom default-header slot
renders the node name + an item-count badge + a child-count
hint, with inline buttons:
add (disabled at depth 3),
edit, drive_file_move, delete (with cascade prompt).
Top-level button above the tree adds root nodes.
Items pane filters to the selected node, with a 'New item' that
opens the item dialog with node_id pre-selected. The item
dialog's node_id picker is a flat-indented q-select of every
node in the restaurant (em-space indentation per depth level).
A dedicated Move dialog uses the same flat-indented picker, but
filters out the moved node + its descendants and any depth-3
candidate (cycle / depth pre-checks; server enforces both too).
static/js/menu.js:
Vue 3 + Quasar 2 UMD. Loads {tree, items} once, builds a
flatNodes index for the option lists, and refetches after every
mutation (≤50 nodes per restaurant — trivial; SSE/Nostr push is
v2). Helpers:
_findNode — recursive lookup by id
_flatten — depth-first walk producing the option list
selectedNode / filteredItems / allNodeOptions /
moveTargetOptions / adminkey computeds.
Delete prompts surface child-count + item-count and pass
cascade=true when needed.
CMS now lets the operator build menus like
Drinks
├─ Hot Beverages
│ ├─ Coffee-based
│ └─ Cacao-based
└─ Cold (with its own items)
including items at any non-leaf level, satisfying the design
constraint.
Remove the transitional layer added in commits 1+2:
models.py
- Drop Category, Subcategory, CreateCategory, CreateSubcategory.
crud.py
- Drop create_category / update_category / get_category /
get_categories / delete_category and the subcategory variants
along with the _node_row_to_category / _node_row_to_subcategory
helpers. Tree state is owned exclusively by menu_node CRUD now.
views_api.py
- Remove old endpoints:
GET /api/v1/restaurants/{id}/categories
POST /api/v1/categories
DELETE /api/v1/categories/{id}
GET /api/v1/categories/{id}/subcategories
POST /api/v1/subcategories
DELETE /api/v1/subcategories/{id}
Hits return 404 now.
- GET /api/v1/restaurants/{id}/menu loses the synthetic
'categories' projection. Response is {restaurant, tree, items}.
static/js/api.js
- Drop listCategories / createCategory / deleteCategory and the
subcategory wrappers.
The CMS menu builder is broken between this commit and commit 4.
The plan acknowledged this trade-off: keeping commits revertible
beats the cost of an unshipped UI page rendering a stale empty
sidebar for one commit's lifetime.
views_api.py:
- New endpoints (admin-key-gated, ownership-checked):
* GET /api/v1/restaurants/{id}/menu_nodes flat list of nodes
* GET /api/v1/menu_nodes/{id} single node
* POST /api/v1/menu_nodes create
* PUT /api/v1/menu_nodes/{id} edit name / desc /
sort_order /
image_url
* PUT /api/v1/menu_nodes/{id}/move body
{new_parent_id}
* DELETE /api/v1/menu_nodes/{id}?cascade=true|false
- ValueError from CRUD (depth, cycle, has-children-without-cascade)
surfaces as 400 (creates / moves) or 409 (delete blocked).
- GET /api/v1/restaurants/{id}/menu now returns three views in
one round trip:
tree: hydrated tree (root nodes -> children + items)
items: flat enriched list (modifiers + availability)
categories: transitional projection of depth-0 nodes with
their immediate items, in the legacy shape — kept
for one commit's lifetime so the existing CMS
keeps rendering. Drops in commit 3.
static/js/api.js:
- listMenuNodes / getMenuNode / createMenuNode / updateMenuNode /
moveMenuNode / deleteMenuNode added.
- Old category/subcategory methods marked transitional in
comments (drop in commit 3).
No JS / template churn — the CMS still reads from menu.categories
which is now produced from menu_nodes via the synthetic projection.
Commit 4 replaces the CMS with q-tree.
Replace the fixed two-level (categories + subcategories) menu shape
with an arbitrary-depth tree, capped at 4 levels. The legacy
Atitlan.io project this carries forward already used a self-FK tree;
real menus need the depth (Drinks -> Hot Beverages -> Coffee-based
-> Espressos). As a side benefit, the current CMS has no UI for
subcategories at all, so this refactor incidentally fixes that gap.
Pattern: adjacency list (parent_id self-FK) + denormalized
materialized path (TEXT, '/'-separated) + denormalized depth.
Rejected closure table (overkill at n=5..50) and Postgres ltree
(not portable to SQLite). Subtree queries become
'WHERE path LIKE :p || '%''; subtree moves are a single
SUBSTR + concat UPDATE; max-depth and cycle checks are O(1).
migrations.py
m002_menu_tree:
- CREATE TABLE menu_nodes (id, restaurant_id, parent_id,
name, description, sort_order, image_url, depth, path, time)
with indexes on (restaurant_id), (parent_id), (path).
- Backfill depth-0 from categories; depth-1 from subcategories
with path = parent.id || '/' || own.id.
- ALTER menu_items ADD COLUMN node_id; backfill via
COALESCE(subcategory_id, category_id). Index on node_id.
- DROP subcategory_id, category_id; DROP TABLE subcategories,
categories.
models.py
- New MAX_MENU_DEPTH = 3 (zero-indexed; 4 levels total).
- New MenuNodeRow (DB I/O shape) + MenuNode (extends with
children + items for hydrated tree responses; never persisted).
- New CreateMenuNode.
- MenuItem.node_id is Optional (orphans allowed when a parent
is deleted with cascade=False); CreateMenuItem.node_id is
required (newly created items must land somewhere).
- Category / Subcategory / Create* kept temporarily as
transitional shim shapes for the old endpoints; dropped in
commit 3.
crud.py
- New: create/update/get/get_all/move/delete_menu_node and
get_menu_tree. move_menu_node uses single-statement subtree
rewrite (path = new_prefix || SUBSTR(path, old_len + 1)).
Cycle check: new_parent's path must not contain node_id.
Depth check: max descendant depth + delta_depth <= MAX_MENU_DEPTH.
delete_menu_node default cascade=False (block on children/items);
cascade=True detaches items (sets node_id NULL) rather than
hard-deletes, since items carry nostr_event_ids and are
revenue-bearing.
- get_menu_tree fetches nodes + items in two queries and assembles
the tree in O(n+m) Python — no recursive CTEs, identical on
SQLite + Postgres.
- Old create_category / get_categories / create_subcategory etc.
rewritten as thin shims that translate to/from menu_nodes.
Old endpoints keep working.
- delete_restaurant cascade now deletes from menu_nodes
(single statement) instead of categories + subcategories.
views_api.py
- GET /restaurants/{id}/menu temporarily sources from menu_nodes
via the shims; surfaces items only at depth-0 nodes for now
(commit 2 replaces the whole block with a real tree response).
static/js/menu.js + templates/restaurant/menu.html
- Rename category_id -> node_id in the item dialog payload so
POST /menu_items satisfies the new CreateMenuItem schema.
The CMS still renders against the depth-0 'categories'
projection; full q-tree rewrite lands in commit 4.
Drop the 'What this extension is not' negation list and absorb
its content into 'What this extension is' as positive descriptions
under five headings: a CMS for operators, a REST API, a Nostr
publisher, an order pipeline, and a single-tenant view of the
world (the last folds in customer-UI-lives-elsewhere, festivals-
are-external-NIP-51-lists, and per-restaurant-invoices-no-
splitter).
Trimmed render of the Claude Code session that produced the initial
scaffold. Tool calls, agent sub-conversations, system reminders, and
diagnostics stripped; only user prompts + assistant prose remain
(31 user / 30 assistant turns, ~32 KB).
Captures the design rationale that doesn't otherwise live in commit
messages:
- why per-restaurant invoices instead of split settlement (each
item linked to its restaurant; each restaurant issues its own
bolt11; webapp pre-flights total balance before paying any)
- why 'festival' is not an entity in the data model (curated
NIP-51 lists are emergent from outside the extension)
- why menus are NIP-99 listings (parameterized replaceable; tags
carry price, dietary, allergens, ingredients)
- why the customer kiosk lives in ~/dev/webapp, not in this
extension's static dir
- the 'nostrize everything' direction from the OmniXY stack
overview, and how it shapes the publisher/sync split here
README covers:
- What the extension is / isn't (CMS only; customer UI in webapp;
no festival entity; no central splitter)
- Architecture diagram
- Data model summary
- Order state machine
- Nostr (kind 0 / 30402 / 5; NIP-17 stub)
- Public vs owner-write API surface
- A worked-out webapp integration snippet showing the multi-
restaurant cart flow (group by restaurant -> per-restaurant
quote -> sufficient-balance check -> N place_order calls ->
pay each bolt11)
- Install instructions for development
- Roadmap of explicitly-deferred items (NIP-44 unwrap, per-
restaurant secret storage, SSE/push, HODL atomicity, foreign
menu cache, image upload pipeline)
LNbits convention: extends base.html, declares window.app =
Vue.createApp({mixins: [windowMixin], data, methods, created}); the
LNbits init-app.js loads after extension scripts and finishes the
mount with app.use(Quasar) + app.mount('#vue').
Pages
- index.html restaurant list / dashboard with create dialog;
scoped to the logged-in user's wallets.
- menu.html category sidebar + items grid; full item dialog
with price/currency/images/dietary/allergens/
ingredients/calories/stock/availability/featured.
Modifier groups managed in a separate dialog
with required|optional + one|many semantics.
- orders.html filterable q-table with status colors and inline
state-machine actions (accept/ready/complete/
cancel). Polls every 8s.
- kds.html kitchen display: card-per-order, items + selected
modifiers + notes, age-based color escalation
(>5min orange, >15min red), polls every 5s. The
poll loop is a stand-in until SSE/Nostr push
lands.
- settings.html restaurant profile editor + delete + per-instance
ext settings panel (Nostr publish toggle, auto-
accept, invoice expiry).
Static
- js/api.js single REST client (LNbits.api.request wrapper)
used by all pages.
- js/index.js dashboard logic.
- js/menu.js menu CRUD.
- js/orders.js order monitor.
- js/kds.js kitchen display.
- js/settings.js settings persistence.
Customer kiosk UI lives in ~/dev/webapp; this extension only ships
the operator console.
nostr/event.py
Bare NIP-01 NostrEvent with canonical id computation.
nostr/nostr_client.py
Bidirectional WebSocket client (lifted from events ext, kept
local). Connects to nostrclient ext's internal relay endpoint,
dedups by event id (LRU 1000).
nostr_publisher.py
Builders for:
* kind 0 — restaurant profile (NIP-01 metadata)
* kind 30402 — menu item (NIP-99 classified listing,
parameterized replaceable by item.id)
* kind 5 — deletion request (NIP-09)
Schnorr signing via coincurve (BIP-340).
Menu listings carry structured price tags (["price", n, currency]),
status (active|sold) so customers see sold-out items, and 't' tags
for category, dietary, allergens (allergen:<x>) and ingredients
(ingr:<x>) so webapps can filter without parsing markdown.
Restaurants can sign with their own keypair (per-restaurant Nostr
identity) or fall back to the LNbits Account keypair.
nostr_sync.py
Subscribes to:
* kind 30402 #t=menu — backfill 200 + live (echo confirmation
for now; foreign-menu indexing deferred until we settle on a
federated cache table).
* kind 1059 — NIP-17 gift-wrapped DMs, only when
settings.nostr_orders_enabled. Decryption stubbed (needs
NIP-44 v2 unwrap); REST stays the supported transport
until that's wired up. _place_order_from_dm is complete and
ready for the decryption hook.
services.py
- place_order: validates against live menu, prices line items
authoritatively from DB (modifier ids resolved server-side, not
trusted from input), creates LNbits invoice, persists order +
items. Order id := payment_hash for zero-metadata listener
lookups.
- mark_order_paid: idempotent paid -> [accepted if auto-accept] +
stock decrement + queues a print job.
- transition_order: explicit state-machine guard for accept/ready/
complete/cancel/refund.
- quote_balance_required: pre-flight total for the webapp's
multi-restaurant balance check (per the user's requirement to
verify funds before opening any per-restaurant invoice).
tasks.py
- Single invoice listener filtered on extra.tag == 'restaurant',
looks up order by payment_hash, delegates to mark_order_paid.
Wrapped in try/except so one bad payment doesn't kill the loop.
- Restaurants: create / update / get / get_by_slug / get_by_wallets /
get_all / delete (with ordered cascade through dependent rows)
- Categories + subcategories with cascade
- Menu items with adjust_stock helper for atomic decrement
- Modifier groups + modifiers with cascade
- Availability windows
- Orders + order items (id := payment_hash so the invoice listener
can look up by payment_hash with zero metadata round-trip)
- Print jobs queue
- Settings (single-row config table)
JSON columns are passed through pydantic pre-validators on read so
nested models (OpenHours, lists, etc.) round-trip cleanly across
SQLite + Postgres.
Tables: restaurants, categories, subcategories, menu_items,
modifier_groups, modifiers, availability_windows, orders,
order_items, print_jobs, settings.
Design notes:
- One wallet -> N restaurants (no 1:1 assumption).
- Each restaurant carries its own Nostr identity (pubkey + relays).
- Publishable rows have nostr_event_id + nostr_event_created_at
for cheap reconciliation against relay state.
- No umbrella/festival concept stored here; cross-restaurant
grouping is the customer/webapp's concern.
- Modifiers and addons unified under modifier_groups.kind +
selection (one|many).
- Money as msat throughout orders for precision.
- Availability windows per item with optional weekday.