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.
This commit is contained in:
Padreug 2026-05-02 09:03:40 +02:00
commit 6272df1288
6 changed files with 516 additions and 63 deletions

View file

@ -331,3 +331,128 @@ async def m001_initial(db):
ON CONFLICT (id) DO NOTHING;
"""
)
async def m002_menu_tree(db):
"""
Replace the fixed `categories` + `subcategories` two-level model
with a single self-referential `menu_nodes` table (adjacency list
+ denormalized materialized path).
Why adjacency + path (not closure table, not Postgres ltree):
* Scale: 550 nodes per restaurant, depth 4. Closure table
is overhead at this size.
* Backend portability: works identically on SQLite + Postgres
with no extensions. ltree is Postgres-only.
* `path` ('rootid' or 'rootid/childid' / ...) gives O(1)
subtree queries (`WHERE path LIKE :p || '%'`), trivial
cycle detection on move, and a single-statement subtree
rewrite (substring + concat).
* `depth` is denormalized so we can reject "would exceed 4"
without walking the tree.
Items can attach to ANY node (not just leaves). On node delete,
the default cascade detaches items (sets node_id NULL) rather
than hard-deleting them; items are revenue-bearing and carry
nostr_event_ids, so orphaning them so the operator can re-home
via the CMS is friendlier than wiping.
"""
# ---------------------------------------------------------------- #
# New menu_nodes table #
# ---------------------------------------------------------------- #
await db.execute(
f"""
CREATE TABLE restaurant.menu_nodes (
id TEXT PRIMARY KEY,
restaurant_id TEXT NOT NULL,
parent_id TEXT,
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
image_url TEXT,
depth INTEGER NOT NULL DEFAULT 0,
path TEXT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
"CREATE INDEX restaurant.idx_menu_nodes_restaurant "
"ON menu_nodes(restaurant_id);"
)
await db.execute(
"CREATE INDEX restaurant.idx_menu_nodes_parent "
"ON menu_nodes(parent_id);"
)
await db.execute(
"CREATE INDEX restaurant.idx_menu_nodes_path "
"ON menu_nodes(path);"
)
# ---------------------------------------------------------------- #
# Backfill: top-level (depth 0) from categories #
# ---------------------------------------------------------------- #
await db.execute(
"""
INSERT INTO restaurant.menu_nodes
(id, restaurant_id, parent_id, name, description, sort_order,
image_url, depth, path, time)
SELECT id, restaurant_id, NULL, name, description, sort_order,
image_url, 0, id, time
FROM restaurant.categories;
"""
)
# ---------------------------------------------------------------- #
# Backfill: depth-1 from subcategories #
# ---------------------------------------------------------------- #
await db.execute(
"""
INSERT INTO restaurant.menu_nodes
(id, restaurant_id, parent_id, name, description, sort_order,
image_url, depth, path, time)
SELECT s.id, c.restaurant_id, s.category_id, s.name, NULL,
s.sort_order, NULL, 1, c.id || '/' || s.id, s.time
FROM restaurant.subcategories s
JOIN restaurant.categories c ON c.id = s.category_id;
"""
)
# ---------------------------------------------------------------- #
# Add menu_items.node_id and backfill #
# subcategory wins if both set #
# ---------------------------------------------------------------- #
await db.execute(
"ALTER TABLE restaurant.menu_items ADD COLUMN node_id TEXT;"
)
await db.execute(
"""
UPDATE restaurant.menu_items
SET node_id = COALESCE(subcategory_id, category_id);
"""
)
await db.execute(
"CREATE INDEX restaurant.idx_menu_items_node "
"ON menu_items(node_id);"
)
# ---------------------------------------------------------------- #
# Drop old columns + tables #
# #
# `ALTER TABLE ... DROP COLUMN` requires SQLite ≥ 3.35 (2021-03). #
# LNbits's pinned dependencies are on a modern SQLite, but if a #
# downstream user is on something older the column drops will #
# fail loudly and they'll need to upgrade SQLite — preferable to #
# the table-rebuild dance which has more failure modes. #
# ---------------------------------------------------------------- #
await db.execute(
"ALTER TABLE restaurant.menu_items DROP COLUMN subcategory_id;"
)
await db.execute(
"ALTER TABLE restaurant.menu_items DROP COLUMN category_id;"
)
await db.execute("DROP INDEX restaurant.idx_menu_items_category;")
await db.execute("DROP TABLE restaurant.subcategories;")
await db.execute("DROP TABLE restaurant.categories;")