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.
This commit is contained in:
Padreug 2026-05-02 09:12:19 +02:00
commit cedd548963
2 changed files with 109 additions and 6 deletions

View file

@ -157,6 +157,29 @@ async def _publish_restaurant(restaurant: Restaurant) -> None:
await update_restaurant(restaurant)
async def _ancestor_names_for_node(node_id: Optional[str]) -> tuple[str, ...]:
"""
Walk the materialized `path` of a node, returning the chain of
node names root-first (including the leaf node itself).
Returns () if node_id is None or path can't be resolved.
"""
if not node_id:
return ()
leaf = await get_menu_node(node_id)
if not leaf:
return ()
ancestor_ids = leaf.path.split("/")
if not ancestor_ids:
return ()
# One round-trip per node — at most MAX_MENU_DEPTH+1 calls (≤4).
names: list[str] = []
for nid in ancestor_ids:
n = await get_menu_node(nid)
if n:
names.append(n.name)
return tuple(names)
async def _publish_menu_item(item: MenuItem) -> None:
settings = await get_settings()
if not settings.nostr_publish_enabled:
@ -171,7 +194,10 @@ async def _publish_menu_item(item: MenuItem) -> None:
from . import nostr_client
event = build_menu_item_event(item, restaurant, pubkey)
ancestors = await _ancestor_names_for_node(item.node_id)
event = build_menu_item_event(
item, restaurant, pubkey, ancestor_names=ancestors
)
published = await publish_event(nostr_client, event, prvkey)
if published:
item.nostr_event_id = published.id
@ -342,7 +368,12 @@ async def api_update_menu_node(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> MenuNodeRow:
"""Update editable fields (name, description, sort_order, image_url).
Tree position changes go through PUT /menu_nodes/{id}/move."""
Tree position changes go through PUT /menu_nodes/{id}/move.
A name change triggers re-publishing every item in the subtree
so their NIP-99 listings carry the new ancestor `t` tag. 50
items per restaurant in practice eager re-publish is cheap.
"""
node = await get_menu_node(node_id)
if not node:
raise HTTPException(
@ -351,11 +382,41 @@ async def api_update_menu_node(
restaurant = await get_restaurant(node.restaurant_id)
if restaurant:
_require_owner(restaurant, wallet)
name_changed = node.name != data.name
node.name = data.name
node.description = data.description
node.sort_order = data.sort_order
node.image_url = data.image_url
return await update_menu_node(node)
updated = await update_menu_node(node)
if name_changed:
await _republish_subtree_items(node_id)
return updated
async def _republish_subtree_items(node_id: str) -> None:
"""Re-publish every menu item under the given node's subtree,
so its kind-30402 events carry the updated ancestor `t` tag set."""
from .crud import db
rows = await db.fetchall(
"""
SELECT mi.* FROM restaurant.menu_items mi
JOIN restaurant.menu_nodes mn ON mn.id = mi.node_id
WHERE mn.path = (SELECT path FROM restaurant.menu_nodes WHERE id = :nid)
OR mn.path LIKE (SELECT path FROM restaurant.menu_nodes WHERE id = :nid) || '/%'
""",
{"nid": node_id},
model=MenuItem,
)
for it in rows:
try:
await _publish_menu_item(it)
except Exception as ex:
logger.warning(
f"[RESTAURANT] re-publish failed for item {it.id[:12]}..: {ex}"
)
@restaurant_api_router.put("/api/v1/menu_nodes/{node_id}/move")