Navigation Module
Admin-managed storefront navigation menus. A menu is a slug-addressed tree of links rendered as the header bar, mobile drawer, or any other navigation slot; items may point at a literal URL or at a catalog entity that keeps resolving after a slug rename.
Admin-managed storefront navigation. A Menu is a slug-addressed tree of links; a MenuItem is one entry in that tree, nested under another item via parentId. An operator can create as many menus as they need — a header bar, a mobile drawer, footer columns, an app tab bar — and bind each one to a storefront slot from settings.
Source:
api-modules/navigation(registered viaNavigationModule.forRoot()inapps/api/src/app.module.ts).The module is pluggable: removing the
NavigationModule.forRoot()line disables the admin and store routes, and the storefront falls back to an empty nav. Schema lives inapi-modules/db/src/schema/navigation.ts. Consumers integrate via theMENU_PORT/MENU_ITEM_PORT/STORE_MENU_PORTtokens — concrete service classes are not exported.
Role-scoped HTTP references: admin/navigation.md (CRUD, reorder, duplicate) and store/navigation.md (public tree read).
Why a tree table rather than a settings blob
The storefront navbar was hardcoded in the web app, and the desktop and mobile trees had already drifted apart — the same label pointing at different URLs. Storing the nav as one JSON setting would have kept that fragility: no per-item validation, no entity pickers, no reorder API. A real table gives each entry its own row, its own permissions, and a foreign key to the entity it points at.
Data model
menu
| Column | Notes |
|---|---|
id | uuid |
title | operator-facing name |
slug | unique across menus — the storefront's lookup key |
platform | APP / WEB / BOTH (default BOTH) |
is_active | hides the menu from the storefront without deleting it |
metadata | jsonb, free-form render hints |
Slug is globally unique rather than unique per platform: if a WEB row and a BOTH row shared a slug, a WEB caller's lookup would be ambiguous. An operator who wants platform-specific headers creates header-web and header-app.
menu_item
| Column | Notes |
|---|---|
menu_id | FK → menu.id, ON DELETE CASCADE |
parent_id | self FK, ON DELETE CASCADE — null for a root entry |
position | order among siblings, not globally |
label | the rendered text |
link_type | CUSTOM / CATEGORY / TAG / BRAND / PRODUCT / PAGE |
target_id | the linked entity's id (required unless CUSTOM) |
url | literal destination for CUSTOM; a resolved snapshot otherwise |
badge | small pill next to the label, e.g. "Trending" |
image | storage key, for image tiles in a mega menu |
platform | APP / WEB / BOTH (default BOTH) |
is_active, open_in_new_tab | booleans |
metadata | jsonb render hints — { "column": 2 } places a mega-menu section, { "badgeVariant": "highlight" } switches the badge pill from the default blue to yellow |
An adjacency list, not a materialized path: the tree is small enough to load in one query and nest in memory, and a reparent is a single-column write rather than a subtree rewrite.
Rules the service enforces
- Depth cap of 5 levels. The schema allows any depth; the service rejects deeper writes so an operator can't create a level the storefront could never render.
- No cycles. An item cannot be its own parent, nor be moved beneath one of its own descendants.
- Typed links need a target. Anything other than
CUSTOMrequirestarget_id. - Cross-menu ids are rejected on both create and reorder.
Platform and visibility filtering
Both filters apply to the tree, not to a flat list: an item whose parent is filtered out disappears with it, along with the rest of that subtree. That is the intuitive behaviour for a nav, but it means an item marked APP under a WEB-only parent can never appear anywhere.
Link resolution
For a typed item, the storefront read recomputes url from the target entity's current slug and the operator's store/storefront_urls path patterns (/product-category/:slug, /product/:slug, …). A category rename therefore fixes every menu that points at it. When the target has been deleted, the stored url snapshot is returned unchanged rather than dropping the entry.
Moving a menu between deployments
A menu is content, not configuration, so it isn't seeded into production. GET /admin/menus/:id/export produces a portable JSON document — hierarchy and order preserved, no ids — and POST /admin/menus/:id/import loads it into any menu on another deployment, replacing or appending. Typed links travel as target slugs and are re-resolved locally; an unmatched slug degrades that entry to a plain URL and is reported rather than failing the whole import. The admin UI exposes both as Export / Import buttons on the menu page.
Caching and revalidation
Store reads are cached in Redis behind a generation counter — every write bumps one key and orphans the previous generation wholesale. The same write pushes the menus and menu/<slug> tags through STOREFRONT_REVALIDATION_PORT so the Next.js ISR cache flushes too; a slug rename pushes the old slug's tag as well. Both are best-effort: a failed revalidation degrades to TTL-bounded staleness, never a failed write.
Storefront wiring
The storefront doesn't hardcode slugs. GET /store/settings/navigation supplies header_menu_slug (default header) and mobile_menu_slug (default mobile); the web app fetches both menus server-side in the root layout and hands them to the navbar through a context provider, so the nav renders with the first paint rather than popping in after hydration.
Permissions
menu: create|read|update|delete and menuItem: create|read|update|delete — see admin/admin-rbac.md. The admin UI (@sc/admin-navigation) gates its nav entry on menu:read and every write action on the matching resource/action.
Inventory Module
Vendor-facing HTTP endpoints for managing product-variant stock levels, policies, manual adjustments, audit trails, and bulk CSV imports.
Notifications Module
HTTP surface for the event-driven notification system — customer + vendor mobile-device registration for FCM push, admin broadcasts (one-off email or push to a defined audience),…