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

365
crud.py
View file

@ -19,16 +19,20 @@ from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import (
MAX_MENU_DEPTH,
AvailabilityWindow,
Category,
CreateAvailabilityWindow,
CreateCategory,
CreateMenuItem,
CreateMenuNode,
CreateModifier,
CreateModifierGroup,
CreateRestaurant,
CreateSubcategory,
MenuItem,
MenuNode,
MenuNodeRow,
Modifier,
ModifierGroup,
Order,
@ -156,16 +160,7 @@ async def delete_restaurant(restaurant_id: str) -> None:
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.subcategories
WHERE category_id IN (
SELECT id FROM restaurant.categories WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.categories WHERE restaurant_id = :rid",
"DELETE FROM restaurant.menu_nodes WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
await db.execute(
@ -175,88 +170,354 @@ async def delete_restaurant(restaurant_id: str) -> None:
# --------------------------------------------------------------------- #
# Categories / subcategories #
# Menu nodes (tree) #
# --------------------------------------------------------------------- #
async def create_category(data: CreateCategory) -> Category:
cat = Category(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
async def create_menu_node(data: CreateMenuNode) -> MenuNode:
"""
Insert a node. Depth + path are derived from parent.
Raises ValueError if parent doesn't exist, lives on a different
restaurant, or sits at MAX_MENU_DEPTH (would push the new node
past the cap).
"""
new_id = urlsafe_short_hash()
if data.parent_id:
parent = await get_menu_node(data.parent_id)
if not parent or parent.restaurant_id != data.restaurant_id:
raise ValueError("Parent not found or in another restaurant")
if parent.depth >= MAX_MENU_DEPTH:
raise ValueError(
f"Cannot create node: depth {MAX_MENU_DEPTH + 1} exceeds "
f"max depth ({MAX_MENU_DEPTH + 1})"
)
await db.insert("restaurant.categories", cat)
return cat
depth = parent.depth + 1
path = f"{parent.path}/{new_id}"
else:
depth, path = 0, new_id
row = MenuNodeRow(
id=new_id,
restaurant_id=data.restaurant_id,
parent_id=data.parent_id,
name=data.name,
description=data.description,
sort_order=data.sort_order,
image_url=data.image_url,
depth=depth,
path=path,
time=datetime.now(timezone.utc),
)
await db.insert("restaurant.menu_nodes", row)
return MenuNode(**row.dict())
async def update_menu_node(node: MenuNodeRow | MenuNode) -> MenuNodeRow:
"""Update name / description / sort_order / image_url. Tree
position changes go through move_menu_node."""
row = MenuNodeRow(**{k: v for k, v in node.dict().items()
if k in MenuNodeRow.__fields__})
await db.update("restaurant.menu_nodes", row)
return row
async def get_menu_node(node_id: str) -> Optional[MenuNodeRow]:
return await db.fetchone(
"SELECT * FROM restaurant.menu_nodes WHERE id = :id",
{"id": node_id},
MenuNodeRow,
)
async def get_menu_nodes(restaurant_id: str) -> list[MenuNodeRow]:
return await db.fetchall(
"""
SELECT * FROM restaurant.menu_nodes
WHERE restaurant_id = :rid
ORDER BY depth, sort_order, time
""",
{"rid": restaurant_id},
model=MenuNodeRow,
)
async def get_menu_tree(restaurant_id: str) -> list[MenuNode]:
"""
Build the full hydrated tree for a restaurant: every node + every
item, in one pair of queries, assembled in O(n+m) Python. For
n=5..50 nodes and m=10..200 items this is microseconds far
simpler than recursive CTEs and identical on SQLite + Postgres.
"""
rows = await get_menu_nodes(restaurant_id)
items = await get_menu_items(restaurant_id)
by_id: dict[str, MenuNode] = {
r.id: MenuNode(**r.dict()) for r in rows
}
roots: list[MenuNode] = []
for r in rows:
node = by_id[r.id]
if r.parent_id and r.parent_id in by_id:
by_id[r.parent_id].children.append(node)
else:
roots.append(node)
for it in items:
if it.node_id and it.node_id in by_id:
by_id[it.node_id].items.append(it)
return roots
async def move_menu_node(
node_id: str, new_parent_id: Optional[str]
) -> MenuNodeRow:
"""
Move a node (and its entire subtree) under a new parent, or to
the root if new_parent_id is None.
Single-statement subtree path rewrite using SUBSTR + concat:
`path = new_prefix || SUBSTR(path, len(old_path) + 1)`. SQLite's
SUBSTR is 1-indexed (matches Postgres).
Raises ValueError on:
* missing node / parent
* cross-restaurant move
* cycle (new_parent_id is in the moved node's subtree)
* any descendant would exceed MAX_MENU_DEPTH after the move
"""
node = await get_menu_node(node_id)
if not node:
raise ValueError("Node not found")
if new_parent_id:
parent = await get_menu_node(new_parent_id)
if not parent or parent.restaurant_id != node.restaurant_id:
raise ValueError("Parent not found or in another restaurant")
# Cycle prevention: parent must not be inside node's subtree.
if node_id in parent.path.split("/"):
raise ValueError("Cannot move a node into its own subtree")
new_depth = parent.depth + 1
new_prefix = f"{parent.path}/{node_id}"
else:
new_depth = 0
new_prefix = node_id
# Reject if any descendant would exceed MAX_MENU_DEPTH.
max_d_row = await db.fetchone(
"""
SELECT MAX(depth) AS max_depth FROM restaurant.menu_nodes
WHERE path = :p OR path LIKE :p || '/%'
""",
{"p": node.path},
)
max_d = (max_d_row["max_depth"] if max_d_row else None) or node.depth
delta_depth = new_depth - node.depth
if max_d + delta_depth > MAX_MENU_DEPTH:
raise ValueError(
f"Move would exceed max depth ({MAX_MENU_DEPTH + 1})"
)
old_path = node.path
await db.execute(
"""
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 || '/%'
""",
{
"new_prefix": new_prefix,
"old_len": len(old_path),
"delta": delta_depth,
"old_path": old_path,
},
)
await db.execute(
"UPDATE restaurant.menu_nodes SET parent_id = :pid WHERE id = :id",
{"pid": new_parent_id, "id": node_id},
)
refreshed = await get_menu_node(node_id)
assert refreshed
return refreshed
async def delete_menu_node(node_id: str, cascade: bool = False) -> None:
"""
Delete a node. If it has children or items:
* cascade=False (default): raise ValueError. The CMS prompts
the operator to confirm before passing cascade=True.
* cascade=True: delete the entire subtree of nodes, but
DETACH items (set node_id NULL) rather than wipe them.
Items carry nostr_event_ids and are revenue-bearing
orphaning them so the operator can re-home is friendlier
than deleting.
"""
node = await get_menu_node(node_id)
if not node:
return
has_children_row = await db.fetchone(
"SELECT 1 AS one FROM restaurant.menu_nodes WHERE parent_id = :id LIMIT 1",
{"id": node_id},
)
has_items_row = await db.fetchone(
"SELECT 1 AS one FROM restaurant.menu_items WHERE node_id = :id LIMIT 1",
{"id": node_id},
)
if (has_children_row or has_items_row) and not cascade:
raise ValueError(
"Node has children or items; pass cascade=true to delete"
)
if cascade:
# Detach items in the entire subtree.
await db.execute(
"""
UPDATE restaurant.menu_items
SET node_id = NULL
WHERE node_id IN (
SELECT id FROM restaurant.menu_nodes
WHERE path = :p OR path LIKE :p || '/%'
)
""",
{"p": node.path},
)
await db.execute(
"""
DELETE FROM restaurant.menu_nodes
WHERE path = :p OR path LIKE :p || '/%'
""",
{"p": node.path},
)
else:
await db.execute(
"DELETE FROM restaurant.menu_nodes WHERE id = :id",
{"id": node_id},
)
# --------------------------------------------------------------------- #
# Categories / subcategories — transitional shims (drop in commit 3) #
# --------------------------------------------------------------------- #
# These keep the old /categories and /subcategories REST endpoints
# working over the new menu_nodes table for one commit's lifetime.
# Drop entirely in the next commit once the new endpoints are live.
def _node_row_to_category(row: MenuNodeRow) -> Category:
return Category(
id=row.id,
restaurant_id=row.restaurant_id,
name=row.name,
description=row.description,
sort_order=row.sort_order,
image_url=row.image_url,
time=row.time,
)
def _node_row_to_subcategory(row: MenuNodeRow) -> Subcategory:
# Subcategory carries the parent category id, not its own restaurant.
return Subcategory(
id=row.id,
category_id=row.parent_id or "",
name=row.name,
sort_order=row.sort_order,
time=row.time,
)
async def create_category(data: CreateCategory) -> Category:
node = await create_menu_node(
CreateMenuNode(
restaurant_id=data.restaurant_id,
parent_id=None,
name=data.name,
description=data.description,
sort_order=data.sort_order,
image_url=data.image_url,
)
)
return _node_row_to_category(node)
async def update_category(category: Category) -> Category:
await db.update("restaurant.categories", category)
row = await get_menu_node(category.id)
if not row:
raise ValueError("Category not found")
row.name = category.name
row.description = category.description
row.sort_order = category.sort_order
row.image_url = category.image_url
await update_menu_node(row)
return category
async def get_category(category_id: str) -> Optional[Category]:
return await db.fetchone(
"SELECT * FROM restaurant.categories WHERE id = :id",
{"id": category_id},
Category,
)
row = await get_menu_node(category_id)
if not row or row.depth != 0:
return None
return _node_row_to_category(row)
async def get_categories(restaurant_id: str) -> list[Category]:
return await db.fetchall(
rows = await db.fetchall(
"""
SELECT * FROM restaurant.categories
WHERE restaurant_id = :rid
SELECT * FROM restaurant.menu_nodes
WHERE restaurant_id = :rid AND depth = 0
ORDER BY sort_order, time
""",
{"rid": restaurant_id},
model=Category,
model=MenuNodeRow,
)
return [_node_row_to_category(r) for r in rows]
async def delete_category(category_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.subcategories WHERE category_id = :cid",
{"cid": category_id},
)
await db.execute(
"DELETE FROM restaurant.categories WHERE id = :id",
{"id": category_id},
)
await delete_menu_node(category_id, cascade=True)
async def create_subcategory(data: CreateSubcategory) -> Subcategory:
sub = Subcategory(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
parent = await get_menu_node(data.category_id)
if not parent:
raise ValueError("Category not found")
node = await create_menu_node(
CreateMenuNode(
restaurant_id=parent.restaurant_id,
parent_id=parent.id,
name=data.name,
sort_order=data.sort_order,
)
await db.insert("restaurant.subcategories", sub)
return sub
)
return _node_row_to_subcategory(node)
async def update_subcategory(subcategory: Subcategory) -> Subcategory:
await db.update("restaurant.subcategories", subcategory)
row = await get_menu_node(subcategory.id)
if not row:
raise ValueError("Subcategory not found")
row.name = subcategory.name
row.sort_order = subcategory.sort_order
await update_menu_node(row)
return subcategory
async def get_subcategories(category_id: str) -> list[Subcategory]:
return await db.fetchall(
rows = await db.fetchall(
"""
SELECT * FROM restaurant.subcategories
WHERE category_id = :cid
SELECT * FROM restaurant.menu_nodes
WHERE parent_id = :pid
ORDER BY sort_order, time
""",
{"cid": category_id},
model=Subcategory,
{"pid": category_id},
model=MenuNodeRow,
)
return [_node_row_to_subcategory(r) for r in rows]
async def delete_subcategory(subcategory_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.subcategories WHERE id = :id",
{"id": subcategory_id},
)
await delete_menu_node(subcategory_id, cascade=True)
# --------------------------------------------------------------------- #

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;")

View file

@ -162,8 +162,63 @@ class Restaurant(BaseModel):
# --------------------------------------------------------------------- #
# Categories / subcategories #
# Menu nodes (arbitrary-depth tree, max depth 4) #
# --------------------------------------------------------------------- #
#
# Adjacency list (parent_id self-FK) plus denormalized materialized
# `path` ('rootid' or 'rootid/childid' / ...) and `depth` (0..3).
#
# * MenuNodeRow is the persistence shape (no nested fields).
# * MenuNode extends it with `children` and `items` populated only
# by the tree builder (get_menu_tree). Never persist these — db
# writes go through MenuNodeRow.
#
# The legacy two-table category/subcategory shape is gone; we keep
# Category / Subcategory as transitional read-only projections for
# the shim commit, defined further down.
MAX_MENU_DEPTH = 3 # zero-indexed; 4 levels total
class CreateMenuNode(BaseModel):
restaurant_id: str
parent_id: Optional[str] = None
name: str
description: Optional[str] = None
sort_order: int = 0
image_url: Optional[str] = None
class MenuNodeRow(BaseModel):
"""Plain row mapping for db.insert / db.update."""
id: str
restaurant_id: str
parent_id: Optional[str] = None
name: str
description: Optional[str] = None
sort_order: int = 0
image_url: Optional[str] = None
depth: int = 0
path: str
time: datetime
class MenuNode(MenuNodeRow):
"""Hydrated tree node — adds `children` and `items` for the tree
response. Never persisted."""
children: list["MenuNode"] = Field(default_factory=list)
items: list["MenuItem"] = Field(default_factory=list)
# --------------------------------------------------------------------- #
# Transitional shims (kept until commit 3) #
# --------------------------------------------------------------------- #
# These let the old /categories and /subcategories endpoints keep
# working over the new menu_nodes table for one commit's lifetime.
# Drop in commit 3.
class CreateCategory(BaseModel):
@ -213,8 +268,10 @@ class MenuItemExtra(BaseModel):
class CreateMenuItem(BaseModel):
restaurant_id: str
category_id: Optional[str] = None
subcategory_id: Optional[str] = None
# Required at create time so newly-created items always land
# somewhere in the tree. Stored items can become orphaned later
# (cascade=False on parent delete) — see MenuItem.node_id below.
node_id: str
name: str
description: Optional[str] = None
price: float = 0
@ -236,8 +293,10 @@ class CreateMenuItem(BaseModel):
class MenuItem(BaseModel):
id: str
restaurant_id: str
category_id: Optional[str] = None
subcategory_id: Optional[str] = None
# Optional in the persisted shape: lets a node be deleted with
# cascade=False, leaving its items orphaned for the operator to
# re-home via the CMS instead of wiping revenue-bearing rows.
node_id: Optional[str] = None
name: str
description: Optional[str] = None
price: float = 0
@ -269,6 +328,10 @@ class MenuItem(BaseModel):
return v or MenuItemExtra()
# Resolve the forward references on MenuNode (declared above MenuItem).
MenuNode.update_forward_refs(MenuItem=MenuItem)
# --------------------------------------------------------------------- #
# Modifier groups + modifiers #
# --------------------------------------------------------------------- #

View file

@ -33,7 +33,7 @@ window.app = Vue.createApp({
},
filteredItems() {
if (!this.selectedCategoryId) return this.items
return this.items.filter((i) => i.category_id === this.selectedCategoryId)
return this.items.filter((i) => i.node_id === this.selectedCategoryId)
},
categoryOptions() {
return this.categories.map((c) => ({label: c.name, value: c.id}))
@ -48,8 +48,7 @@ window.app = Vue.createApp({
_blankItem() {
return {
restaurant_id: '',
category_id: null,
subcategory_id: null,
node_id: null,
name: '',
description: '',
price: 0,
@ -123,8 +122,8 @@ window.app = Vue.createApp({
const item = existing
? {...existing}
: {...this._blankItem(), restaurant_id: this.restaurant.id}
if (!item.category_id && this.selectedCategoryId) {
item.category_id = this.selectedCategoryId
if (!item.node_id && this.selectedCategoryId) {
item.node_id = this.selectedCategoryId
}
this.itemDialog.data = item
this.itemDialog.imagesText = (item.images || []).join(', ')

View file

@ -182,7 +182,7 @@
<q-select
filled
dense
v-model="itemDialog.data.category_id"
v-model="itemDialog.data.node_id"
:options="categoryOptions"
emit-value
map-options

View file

@ -398,8 +398,13 @@ async def api_get_menu(restaurant_id: str) -> dict:
w.dict() for w in await get_availability_windows(item.id)
]
enriched_items.append(item_dict)
if item.category_id and item.category_id in cat_map:
cat_map[item.category_id]["items"].append(item_dict)
# Backed by menu_nodes now: an item's node_id may be a depth-0
# node (legacy "category") or deeper. For this transitional
# endpoint we surface items only when they sit at depth-0 so
# the existing CMS keeps rendering. Commit 2 replaces this
# whole block with a real tree.
if item.node_id and item.node_id in cat_map:
cat_map[item.node_id]["items"].append(item_dict)
return {
"restaurant": restaurant.dict(),