Commit graph

27 commits

Author SHA1 Message Date
d29d4dbec9 feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#11)
Closes aiolabs/restaurant#11. Pre-cascade prerequisite for
aiolabs/lnbits#17 (signer abstraction phase 1), which lands an m002
startup job that NULLs the legacy `accounts.prvkey` column. After
this migration, the restaurant extension reads no plaintext nsec and
works with any NostrSigner backend (LocalSigner / RemoteBunkerSigner
/ ClientSideOnlySigner).

## What changed

### views_api.py — _resolve_signing_keypair → _resolve_signer

Was: `_resolve_signing_keypair(restaurant)` returned `(pubkey, prvkey)`
read directly from `account.pubkey` / `account.prvkey` after walking
wallet → account.

Now: `_resolve_signer(restaurant)` returns `NostrSigner | None`.
Precedence order preserved:

  1. `restaurant.nostr_pubkey` set → per-restaurant identity. Still
     a no-op TODO returning None until a per-restaurant signer /
     vault ships (separate concern, future work).
  2. fallback → `resolve_for_wallet(restaurant.wallet)` (the DRY
     helper from aiolabs/lnbits#23 — wallet → account → signer →
     can_sign-check in one call, returns None on any soft-fail).

Three call sites updated (`_publish_restaurant`, `_publish_menu_item`,
`_publish_menu_item_delete`): each now passes the resolved `signer`
to `publish_event` instead of the keypair tuple, and uses
`signer.pubkey` for tag construction. The discovery-echo line in
`_publish_restaurant` (`restaurant.nostr_pubkey = signer.pubkey`)
preserves prior behavior.

Dropped now-unused imports: `get_account`, `get_wallet`.

### nostr_publisher.py — publish_event

Was: `publish_event(client, event, private_key_hex)` called a local
`sign_nostr_event` helper that signed in place via
`coincurve.PrivateKey.sign_schnorr`.

Now: `publish_event(client, event, signer: NostrSigner)` builds the
unsigned dict (`kind`/`created_at`/`tags`/`content`), hands it to
`await signer.sign_event(...)`, and writes `id`/`pubkey`/`sig` back
onto the local `NostrEvent` model before publishing. The signer
backend (LocalSigner / RemoteBunkerSigner) is transparent.

Removed the `sign_nostr_event` helper entirely — the signer
abstraction handles all signing now.

Dropped the `coincurve` import; no direct crypto in this extension.

### docs/nostr-layer.md — signing prose

Updated the Signing section to reflect the signer-abstraction model:
`resolve_for_wallet` resolves a `NostrSigner`, the extension no
longer touches `account.prvkey` or calls `coincurve.sign_schnorr`
directly. The per-restaurant-identity TODO is preserved.

## Acceptance

- [x] `_resolve_signing_keypair` replaced with `_resolve_signer` returning NostrSigner
- [x] `sign_nostr_event` helper removed (signer handles it internally)
- [x] `publish_event` accepts a NostrSigner instead of private_key_hex
- [x] all three call sites updated to pass the signer
- [x] re-grep `restaurant/`: zero `account.prvkey` references
- [x] coincurve import dropped
- [x] docs/nostr-layer.md updated in the same commit

Manual smoke testing + tag + catalog entry follow the migration
landing; will run against the regtest stack with lnbits on
`issue-18-phase-2.3` (which validates both LocalSigner and
RemoteBunkerSigner signing paths end-to-end).

## Cross-references

- aiolabs/restaurant#11 — issue this commit closes
- aiolabs/lnbits#17 — the cascading signer-abstraction PR
- aiolabs/lnbits#23 — the resolve_for_wallet helper this uses
- aiolabs/lnbits#21 — umbrella audit (5 affected extensions)
- aiolabs/events#23 / aiolabs/tasks#3 — sister migrations (already on signer-abstraction branches)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:26:41 +02:00
cbbb3c743b feat(scripts): add seed_lightning_cafe.py — sats-priced demo restaurant
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>
2026-05-11 23:21:41 +02:00
42746d7321 fix(cms): KDS card text legible on dark mode v0.1.0
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.
2026-05-11 19:18:33 +02:00
638f36e945 fix(services): convert fiat menu prices to sat via exchange rates
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.
2026-05-11 19:18:16 +02:00
6dae57f3f4 feat(api): public GET /restaurants/by-slug/{slug}
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.
2026-05-11 19:17:35 +02:00
Padreug
dd756ecfc3 docs: update NIPs path to ~/dev/refs/repos/nostr-protocol/nips 2026-05-09 07:11:06 +02:00
30754bfa8f migrations: make m002_menu_tree idempotent
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>
2026-05-09 07:11:06 +02:00
e2a2f4a633 docs: rename castle → libra in design-conversation example
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>
2026-05-09 07:11:06 +02:00
42a8b08a5b 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.
2026-05-09 07:11:06 +02:00
7f7915a041 docs: README + ADR for menu tree refactor
README.md
  - Update intro: 'menu tree' is now arbitrary-depth (cap 4
    levels), items can attach to any node.
  - Update Nostr publisher description to mention ancestor 't'
    tags (slugified, root-first) so clients can filter on
    #t=hot-beverages, #t=coffee-based, etc.
  - Replace the Data model table's categories/subcategories rows
    with a single menu_nodes row that explains the adjacency-list
    + materialized-path + depth shape and points at the ADR.
  - Replace the boilerplate 'full CRUD for categories,
    subcategories, ...' line with a real menu_nodes API list,
    including the cascade-detach behavior on delete and the
    rename-triggers-subtree-republish behavior on update.

docs/adr-0001-menu-tree.md
  - New ADR explaining the storage choice (adjacency list +
    materialized path + denormalized depth), the alternatives
    considered (closure table, Postgres ltree, pure adjacency,
    nested set), and the consequences. Provides the rationale
    so future contributors don't relitigate the decision.
2026-05-09 07:11:06 +02:00
cedd548963 feat(nostr): ancestor 't' tags on menu listings
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.
2026-05-09 07:11:06 +02:00
4827f5e10f feat(cms): q-tree menu builder
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.
2026-05-09 07:11:06 +02:00
b7fa1aec4a refactor(http): drop categories/subcategories shim
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.
2026-05-09 07:11:06 +02:00
ab87ddb2da feat(http): /menu_nodes endpoints + tree-shaped /menu response
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.
2026-05-09 07:11:06 +02:00
6272df1288 feat(db,models,crud): m002 menu tree + node CRUD with shim
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.
2026-05-09 07:11:06 +02:00
c2ea0297f9 docs(README): rewrite intro as positive framing
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).
2026-05-09 07:11:06 +02:00
60d59c0370 docs: add design-conversation transcript
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
2026-05-09 07:11:06 +02:00
027db9cad2 docs: README + description.md
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)
2026-05-09 07:11:06 +02:00
3382462af4 feat(cms): Vue 3 + Quasar 2 UMD CMS templates
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.
2026-05-09 07:11:06 +02:00
c37b17d474 feat(http): CMS pages + REST API for owners and customers
views.py (Jinja CMS pages, /restaurant/...):
  - /                   restaurant list / dashboard
  - /{slug}             menu builder
  - /{slug}/orders      order monitor
  - /{slug}/kds         kitchen display
  - /{slug}/settings    restaurant + Nostr settings

views_api.py (REST under /restaurant/api/v1/):

  Owner write-side (require_admin_key, ownership-checked):
    - restaurants CRUD (publishes kind 0 metadata to Nostr on
      create/update; signs with restaurant.nostr_pubkey override
      or LNbits Account fallback)
    - categories + subcategories CRUD
    - menu_items CRUD (publishes/replaces kind 30402 NIP-99
      listings on create/update; sends kind 5 NIP-09 deletion on
      delete)
    - modifier_groups + modifiers CRUD
    - availability_windows CRUD
    - orders status transitions (PUT /api/v1/orders/{id}/status/{new})
    - print_jobs/{id}/ack
    - settings (admin-only)

  Customer-facing (no auth, customer pubkey optional):
    - GET /api/v1/restaurants/{id}                profile
    - GET /api/v1/restaurants/{id}/menu           full menu tree
                                                  (categories +
                                                  subcategories +
                                                  items + modifiers +
                                                  availability) in
                                                  one round trip
    - POST /api/v1/orders/quote                   pre-flight balance
                                                  check; webapp calls
                                                  this *before* opening
                                                  any per-restaurant
                                                  invoice
    - POST /api/v1/orders                         place an order on
                                                  one restaurant,
                                                  returns bolt11

  KDS / order monitor (require_invoice_key, ownership-checked):
    - GET /api/v1/restaurants/{id}/orders
    - GET /api/v1/restaurants/{id}/print_jobs

crud.py: added get_print_job(job_id) helper used by the ack endpoint.
2026-05-09 07:11:06 +02:00
b155548036 feat(nostr): NIP-99 menu listings, NIP-01 profile, NIP-17 stub
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.
2026-05-09 07:11:06 +02:00
201c387722 feat(services,tasks): order placement, settlement, invoice listener
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.
2026-05-09 07:11:06 +02:00
5f4b416f5f feat(crud): async CRUD layer for all entities
- 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.
2026-05-09 07:11:06 +02:00
52f1ad1bb1 feat(models): pydantic v1 models for all entities
- Restaurant + nested OpenHours / SocialLinks / RestaurantExtra
- Category, Subcategory
- MenuItem with structured dietary, allergens, ingredients lists
- ModifierGroup (required/optional) + Modifier with price_delta
- AvailabilityWindow (weekday + HH:MM range)
- Order + OrderItemRow with SelectedModifier snapshot
- OrderInvoice (returned to client after order creation)
- PrintJob, RestaurantSettings

JSON list/dict columns are parsed in pre-validators so callers
always see structured types.
2026-05-09 07:11:06 +02:00
5255a99f1a feat(db): m001_initial schema
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.
2026-05-09 07:11:06 +02:00
3b046276f6 feat: extension lifecycle hooks (__init__.py)
- restaurant_start spawns three permanent tasks:
    1. invoice listener (LNBits payment settlement)
    2. NostrClient bootstrap (after 10s grace for nostrclient ext)
    3. Nostr sync loop (after 15s)
- restaurant_stop cancels tasks and closes the WS.
- Module-level nostr_client = None when nostrclient unavailable;
  publishing helpers no-op gracefully in that case.
2026-05-09 07:11:06 +02:00
5c19cf6691 chore: initial extension manifest, config, gitignore 2026-05-09 07:11:06 +02:00