Menu Builder System - Deep Dive Documentation¶
Generated: 2026-02-25 Scope: Full-stack Menu Builder — backend modules, entities, frontend API/services, v2 components, legacy components Files Analyzed: 38+ (8 backend modules, 2 entities, 6 frontend API/services, 9 v2 components, 12 legacy components, + seeds, routes, helpers) Lines of Code: ~8,900+ Workflow Mode: Exhaustive Deep-Dive
Overview¶
The Menu Builder is a full-stack dynamic navigation management system allowing admin users to create, configure, and organize navigation menus (navbar, footer, topbar, bottombar, etc.) with nested, drag-and-drop reorderable items. Each menu item can link to internal content (blogs, pages, categories, tags, packages) or external URLs, with per-item permission controls and icon/color customization.
Purpose: CRUD management of navigation menus and their hierarchical items, with WordPress-style tree editing, drag-and-drop reordering, and multi-location assignment.
Key Responsibilities: - Menu CRUD with soft-delete, restore, permanent delete, bulk operations - MenuItem CRUD with self-referencing parent-child hierarchy (adjacency list) - Drag-and-drop reordering via @dnd-kit - Multi-content-type linking (blogs, pages, categories, tags, packages, custom URLs) - Per-item permission-based visibility control - Menu location assignment (header, footer, sidebar, topbar, bottombar, etc.)
Integration Points: Auth middleware, permission system (checkPermissionAndThrow), blog/page/category/tag entities (polymorphic postType/postId), frontend navigation rendering, Next.js server action revalidation.
Table of Contents¶
- Database Schema
- Backend API
- Frontend API Layer
- Frontend V2 Components (Active)
- Frontend Legacy Components
- Dead Code Inventory
- User Flows
- Data Flow
- Dependency Graph
- Known Issues
- Modification Guidance
Database Schema¶
Entity: Menu (table: menus)¶
| # | Column | Type | Nullable | Default | Unique | Notes |
|---|---|---|---|---|---|---|
| 1 | id |
integer (auto-increment PK) |
NO | auto | YES | Primary key |
| 2 | name |
varchar(255) |
NO | — | NO | No length/unique constraint. Multiple menus can share names |
| 3 | slug |
varchar(255) |
YES | NULL | YES | Unique constraint. Used for URL-friendly identification |
| 4 | locations |
text (simple-json) |
YES | NULL | NO | Stored as JSON string e.g. ["header","footer"]. NOT native PostgreSQL jsonb |
| 5 | createdAt |
timestamp |
NO | CURRENT_TIMESTAMP | NO | Auto-set |
| 6 | updatedAt |
timestamp |
NO | CURRENT_TIMESTAMP | NO | Auto-set |
| 7 | deletedAt |
timestamp |
YES | NULL | NO | Soft delete via @DeleteDateColumn |
Relations:
- items → @OneToMany(() => MenuItem, item => item.menu) — No cascade defined on ORM side
Entity: MenuItem (table: menu_items)¶
| # | Column | Type | Nullable | Default | Unique | Notes |
|---|---|---|---|---|---|---|
| 1 | id |
integer (auto-increment PK) |
NO | auto | YES | Primary key |
| 2 | menuId |
integer |
YES | NULL | NO | FK → menus.id. Nullable = orphan items possible |
| 3 | title |
varchar(255) |
NO | — | NO | Display label |
| 4 | link |
varchar(255) |
NO | — | NO | URL or path. Required |
| 5 | linkType |
enum("dynamic","static") |
YES | NULL | NO | PostgreSQL enum |
| 6 | target |
varchar(255) |
NO | "_self" |
NO | HTML anchor target |
| 7 | iconClass |
varchar(255) |
YES | NULL | NO | CSS icon class |
| 8 | color |
varchar(255) |
YES | NULL | NO | Color value |
| 9 | parentId |
integer |
YES | NULL | NO | FK → self (menu_items.id). NULL = top-level |
| 10 | order |
integer |
NO | 0 |
NO | Sort order among siblings. order is a SQL reserved word |
| 11 | postType |
enum("blogs","pages","categories","tags","packages") |
YES | NULL | NO | Polymorphic type discriminator |
| 12 | postId |
integer |
YES | NULL | NO | References row in table indicated by postType. No FK constraint |
| 13 | parameters |
text |
YES | NULL | NO | Free-form URL query params |
| 14 | permissions |
text (simple-array) |
YES | NULL | NO | Comma-separated permission strings controlling visibility |
| 15 | createdAt |
timestamp |
NO | CURRENT_TIMESTAMP | NO | Auto-set |
| 16 | updatedAt |
timestamp |
NO | CURRENT_TIMESTAMP | NO | Auto-set |
| 17 | deletedAt |
timestamp |
YES | NULL | NO | Soft delete |
Relations:
- menu → @ManyToOne(() => Menu, { onDelete: "CASCADE" }) with @JoinColumn({ name: "menuId" })
- parent → @ManyToOne(() => MenuItem, item => item.children, { nullable: true, onDelete: "CASCADE" }) with @JoinColumn({ name: "parentId" })
- children → @OneToMany(() => MenuItem, item => item.parent)
Hierarchy Model¶
- Pattern: Self-referencing adjacency list on
parentId - Nesting depth: No limit enforced (no
depth/levelcolumn) - Ordering:
ordercolumn (integer, default 0). No unique constraint on(menuId, parentId, order)— duplicate orders possible - Cascade:
onDelete: CASCADEon bothmenuandparentFKs — but only for hard deletes. Soft-delete does NOT cascade (items become orphans of soft-deleted menus)
Schema Issues¶
| # | Severity | Issue |
|---|---|---|
| S1 | Medium | menuId is nullable — orphan MenuItems with no Menu are possible |
| S2 | Medium | Soft-delete on Menu does NOT cascade to items — items remain visible with menuId pointing to a soft-deleted Menu |
| S3 | Low | order is a SQL reserved word — requires quoting in raw queries |
| S4 | Low | No indexes on menuId, parentId (only implicit unique index on Menu.slug) |
| S5 | Low | simple-json for locations prevents PostgreSQL indexing/querying inside the column |
| S6 | Low | simple-array for permissions is fragile — permission strings containing commas would break |
| S7 | Low | postId/postType polymorphic association has no FK constraint — referential integrity not enforced |
| S8 | Info | GenericPage.ts has dead import of MenuItem (+ unused OneToMany, ManyToMany imports) — planned but never-implemented relation |
Seed Data (Permissions)¶
From saas-boilerplate/src/seed/data/permission.ts, 8 permission entries exist for Menu Builder:
| Permission Route | Display Name | Readonly |
|---|---|---|
menu.view |
Menu Builder Management | false |
menu.create |
Menu Builder Management | false |
menu.update |
Menu Builder Management | false |
menu.delete |
Menu Builder Management | false |
menu-item.view |
Menu Builder Management | false |
menu-item.create |
Menu Builder Management | false |
menu-item.update |
Menu Builder Management | false |
menu-item.delete |
Menu Builder Management | false |
Note: menu.read, menu.restore, menuItem.view, menuItem.read, menu-item.restore are used in service code but NOT seeded — they will always fail checkPermissionAndThrow.
Migration Files¶
None. The project uses TypeORM synchronize: true — no migration files exist anywhere in saas-boilerplate/src/.
Backend API¶
Route Registration¶
From saas-boilerplate/src/app/routes/v1/index.ts:
- /menus → menuRoutes (separate Router())
- /menu-items → menuItemRoutes (separate Router())
Both routers apply auth() + contextMiddleware globally. No hasPermission() route-level middleware — all permission checks happen in service layer via checkPermissionAndThrow().
Menu Endpoints (base: /api/v1/menus)¶
| # | Method | Path | Validation | Permission | Handler | Notes |
|---|---|---|---|---|---|---|
| 1 | GET | / |
— | menu.view |
getMenuList |
Pagination, search on name/slug, trashOnly support |
| 2 | GET | /:id |
— | menu.read |
getMenuById |
Loads items + items.children |
| 3 | POST | / |
createMenuValidation |
menu.create |
createMenu |
Generates unique slug, checks name uniqueness |
| 4 | PATCH | /bulk-delete |
deleteMenuByIds |
menu.delete |
deleteMenuByIds |
Soft-delete by ID array |
| 5 | PATCH | /bulk-restore |
bulkMenuRestore |
menu.restore |
bulkMenuRestore |
Restore with slug conflict check |
| 6 | PATCH | /:id |
updateMenuValidation |
menu.update |
updateMenu |
Updates name + locations (slug ignored!) |
| 7 | PATCH | /:id/restore |
— | menu.restore |
restoreFromTrash |
Single restore |
| 8 | DELETE | /:id |
— | menu.delete |
deleteMenu |
Soft-delete |
| 9 | DELETE | /:id/permanent |
— | menu.delete |
deleteMenuPermanent |
Hard-delete |
MenuItem Endpoints (base: /api/v1/menu-items)¶
| # | Method | Path | Validation | Permission | Handler | Notes |
|---|---|---|---|---|---|---|
| 1 | GET | / |
— | menuItem.view |
getMenuItemList |
Cached (1-day TTL), search on title/link |
| 2 | GET | /:id |
— | menuItem.read |
getMenuItemById |
Loads menu, parent, children relations |
| 3 | POST | / |
createMenuItemValidation |
menu-item.create |
createMenuItem |
Bulk create (expects { items: [...] }) |
| 4 | PATCH | /bulk-update |
bulkUpdateMenuItemValidation |
menu-item.update |
updateMenuItems |
Transactional, validates circular refs (1-level) |
| 5 | PATCH | /bulk-delete |
deleteMenuItemByIds |
menu-item.delete |
deleteMenuItemByIds |
Soft-delete by IDs |
| 6 | PATCH | /bulk-restore |
bulkMenuItemRestore |
menu-item.restore |
bulkMenuItemRestore |
Bulk restore |
| 7 | PATCH | /:id |
updateMenuItemValidation |
menu-item.update |
updateMenuItem |
Single update |
| 8 | PATCH | /:id/restore |
— | menu-item.restore |
restoreFromTrash |
Single restore |
| 9 | DELETE | /:id |
— | menu-item.delete |
deleteMenuItem |
Soft-delete |
| 10 | DELETE | /:id/permanent |
— | menu-item.delete |
deleteMenuItemPermanent |
Hard-delete |
Total: 19 endpoints (9 Menu + 10 MenuItem)
Validation Schemas (Zod)¶
Menu:
- createMenuValidation: { name: string (required), slug?: string, locations?: string[] }
- updateMenuValidation: Identical to create — name is required even for PATCH
- deleteMenuByIds: { ids: number[].min(1) }
- bulkMenuRestore: { ids: number[].min(0) } — BUG: allows ID 0 and empty array
MenuItem:
- createMenuItemValidation: { items: MenuItemSchema[].min(1) } — bulk create
- updateMenuItemValidation: All fields optional (proper PATCH)
- bulkUpdateMenuItemValidation: { items: BulkUpdateSchema[].min(1) } — requires id, rest optional. Missing permissions field
- deleteMenuItemByIds: { ids: number[].positive().min(1) }
- bulkMenuItemRestore: { ids: number[].min(0) } — same min(0) bug
MenuItemSchema fields: menuId, title, link, linkType (enum), target, iconClass, color, parentId, order, permissions (string[]), postType (enum), postId, parameters
Service Layer Details¶
menu.service.ts (313 LOC):
- Uses getQueryBuilder + BaseQueryBuilder for list queries
- Uses checkPermissionAndThrow for auth in every method
- Uses generateUniqueSlug for slug creation
- Does NOT implement caching (dead cacheKey variable)
- ILike prefix match for name uniqueness (buggy — see issues)
menuItem.service.ts (684 LOC — largest file):
- Implements caching with getCache/addCache/deleteCacheByPrefix (1-day TTL)
- Transactional bulk create/update with queryRunner
- validateMenu(), validateParent(), validatePostIfProvided() helpers
- mapMenuItemWithChildren() recursive DTO mapper
- Hidden getMenuList(type) method for public navigation (NOT exposed via any route)
Frontend API Layer¶
Two Separate API Systems¶
The codebase contains two completely independent API systems that do NOT share query keys or cache:
System A: api/menu-builder/ (Old — used by legacy components)¶
File: api/menu-builder/index.js (676 LOC)
| Hook | Method | Endpoint | Backend Exists? |
|---|---|---|---|
useMenuBuilders |
GET | /menu-items/grouped-by-type |
NO — 404 |
useLabelGroups |
GET | /menu-labels?limit=1000 |
NO — no module exists |
useCreateMenu |
POST | /menu-items |
Partial — payload mismatch |
useUpdateMenu |
PATCH | /menu-items/${id} |
YES |
useDeleteMenu |
DELETE | /menu-items/${id} |
YES |
useReorderMenus |
PATCH | /menu-items/bulk-order-update |
NO — route is /bulk-update |
useUpsertLabelGroup |
POST/PUT | /menu-labels or /menu-labels/${id} |
NO |
useDeleteLabelGroup |
DELETE | /menu-labels/${labelId} |
NO |
useDeletePage |
DELETE | /menu-items/${id}/pages/${pageValue} |
NO — fabricated sub-resource |
useReorderPages |
PUT | /menu-items/${id}/pages/reorder |
NO — fabricated |
useReorderItems |
PUT | /menu-items/${id}/items/reorder |
NO — fabricated |
9 of 11 endpoints either don't exist or have payload mismatches.
Composite hook: useMenuBuilder(menuType) combines all hooks + usePages + useLabelGroups.
Issues: 3 console.log debug statements, identity-function memo, no-op labelGroupOptions.
File: api/menu-builder/menu-label.js (68 LOC)
- Uses useState/useEffect pattern (not TanStack Query) — inconsistent
- BUG: Double-prefixed URL: api.get(\${process.env.NEXT_PUBLIC_API_URL}/menu-labels`)—apialready hasbaseURL- Calls non-existent/menu-labels` endpoint
System B: api/menu/ (V2 — used by current components)¶
File: api/menu/index.js (107 LOC)
| Hook | Method | Endpoint | Backend Exists? |
|---|---|---|---|
useMenu |
GET | /menus |
YES |
useMenuById(id) |
GET | /menus/${id} |
YES |
useCreateMenu |
POST | /menus |
YES |
useDeleteMenu |
DELETE | /menus/${menuId} |
YES |
useUpdateMenu |
PATCH | /menus/${id} |
YES |
usePermanentDeleteMenu |
DELETE | /menus/${menuId}/permanent |
YES |
BUG: useMenu() passes { limits: 1000 } instead of { params: { limit: 1000 } } — query param never sent.
File: api/menu/menu-item.js (74 LOC)
| Hook | Method | Endpoint | Backend Exists? |
|---|---|---|---|
useBulkCreateMenuItems |
POST | /menu-items |
YES |
useBulkUpdateMenuItems |
PATCH | /menu-items/bulk-update |
YES |
useDeleteMenuItem |
DELETE | /menu-items/${itemId} |
YES |
All v2 endpoints match the backend. Query keys: ["menu"], ["menu", id].
Hook Name Collisions¶
Both systems export identically-named hooks: useCreateMenu, useUpdateMenu, useDeleteMenu. Importing from both in the same file would cause collisions.
Dead Code: Service Files + useMenus Hook¶
Complete dead code chain with zero consumers:
lib/dummyMenuData.js
→ services/menuService.js (USE_DUMMY_DATA = true, all API calls commented out)
→ services/menuItemService.js (USE_DUMMY_DATA = true, all API calls commented out)
→ hooks/useMenus.js (wraps menuService in TanStack Query — zero imports anywhere)
Total dead code: ~334 LOC across 4 files.
Frontend V2 Components (Active)¶
Directory: Sass-boilerplate-frontend-v1/src/components/pages/menu-builder/
Route: /dashboard/settings/menu-builder
Total LOC: ~2,500+
Component Tree¶
MenuBuilderPageV2 (422 LOC)
├── MenuSelector (134 LOC)
│ └── CreateMenu dialog (inline)
├── MenuSettingsPanel (156 LOC)
│ └── Location checkboxes (8 options)
├── AddItemsSection (290 LOC)
│ ├── Pages accordion (with search)
│ ├── Blogs accordion (with search)
│ ├── Categories accordion (with search)
│ ├── Tags accordion (with search)
│ └── Custom Link form (title + URL)
├── WordPressStyleMenuTree (510 LOC)
│ ├── SortableMenuItem (DnD wrapper)
│ │ └── MenuItemCard (210 LOC)
│ │ ├── Indent/Outdent buttons
│ │ ├── Edit button → EditItemDialog
│ │ └── Delete button → ConfirmationModal
│ └── DndContext + SortableContext
└── EditItemDialog (240 LOC)
└── Form: title, link, linkType, target, iconClass, permissions
Supporting utility: lib/menuHelpers.js (125 LOC) — buildMenuTree(), flattenTree(), calculateDepth(), canIndent(), canOutdent()
Component Summaries¶
1. menu-builder-page-v2.jsx (422 LOC) — Main page orchestrator
- State: selectedMenuId, localItems (flat array), hasUnsavedChanges, editingItem, isEditDialogOpen
- Uses: useMenuById, useBulkCreateMenuItems, useBulkUpdateMenuItems, useDeleteMenuItem, usePages, useBlogs, useBlogCategories, useBlogTags
- Key pattern: Batch save model — local state tracks changes, "Save Changes" button triggers bulk create/update
- Splits items into newItems (no DB id) and existingItems (have DB id) for separate API calls
- handleAddItems: Creates flat items with linkType: "dynamic", auto-assigns order and parentId: null
- handleAddCustomLink: Creates item with linkType: "static", target: "_blank"
2. MenuSelector.jsx (134 LOC)
- Fetches all menus via useMenu(), renders dropdown + "Create New Menu" button
- Inline create dialog: name input → useCreateMenu mutation → auto-selects new menu
- Fires onSelect(menuId) callback to parent
3. MenuSettingsPanel.jsx (156 LOC)
- Shows: menu name (editable), slug (read-only), 8 location checkboxes (Header, Footer, Sidebar, Topbar, Bottom Bar, Mobile Nav, Mega Menu, Social Links)
- Uses useUpdateMenu and usePermanentDeleteMenu
- Permanent delete with confirmation modal
4. AddItemsSection.jsx (290 LOC) - 5 content types in accordions: Pages, Blogs, Categories, Tags, Custom Link - Each accordion fetches its own data independently - Items selected via checkboxes, "Add to Menu" button - Debounced search (300ms) per accordion - Custom link form: title + URL inputs
5. WordPressStyleMenuTree.jsx (510 LOC)
- Renders flat item list with visual depth indentation (left padding per depth level)
- DnD implementation: @dnd-kit/core + @dnd-kit/sortable with verticalListSortingStrategy
- handleDragEnd: Moves item in flat array via arrayMove, auto-adjusts parentId based on position
- Nesting: Items can be indented (make child of previous sibling) or outdented (move to parent's level)
- Uses buildMenuTree only for display calculation, actual state is flat array
6. MenuItemCard.jsx (210 LOC)
- Displays: title, link, linkType badge, depth indicator, drag handle
- Actions: Edit (opens EditItemDialog), Delete (ConfirmationModal), Indent/Outdent arrows
- Wrapped in PermissionWrapper for menu-item.update and menu-item.delete
7. SortableMenuItem.jsx (~60 LOC)
- Thin DnD wrapper around MenuItemCard using useSortable hook
- Provides attributes, listeners, setNodeRef, transform, transition
8. EditItemDialog.jsx (240 LOC)
- Form fields: title, link, linkType (radio: dynamic/static), target (select: _self/_blank/_parent/_top), iconClass, color, permissions (multi-input)
- Pre-fills from selected item
- Saves via callback to parent (which tracks in localItems)
V2 User Flow¶
- Select/Create Menu: User picks existing menu from dropdown or creates new one
- Configure Settings: Set menu name and display locations (checkboxes)
- Add Items: Browse pages/blogs/categories/tags, check items, click "Add to Menu" — or enter custom link
- Organize: Drag-and-drop to reorder; use arrow buttons to indent/outdent for nesting
- Edit Items: Click edit icon to modify title, link, target, icon, permissions
- Delete Items: Click delete icon, confirm in modal
- Save: Click "Save Changes" to persist all local modifications to backend in batch
Frontend Legacy Components¶
Directory: Sass-boilerplate-frontend-v1/src/components/pages/menu-builder-old/
Routes:
- /dashboard/settings/menu-builder-old → MenuBuilderPage
- /dashboard/settings/menu-builder-old/menu-label → MenuLabelPage
Total LOC: 2,821 across 12 files
These routes are still accessible — the old pages are NOT removed, just accessible at -old paths.
Version Evolution (3 Generations)¶
The legacy directory contains 3 visible generations of iterative development:
| Generation | Files | DnD Strategy | Key Difference |
|---|---|---|---|
| Gen 1 | menu-item-old.jsx, menu-list-old.jsx |
Full array reorder (client-side arrayMove) |
Original approach |
| Gen 2 | menu-item-old-v2.jsx, menu-list-old-v2.jsx |
Position-based ({ itemId, position }) |
Thinner API contract |
| Gen 3 (Final) | menu-item.jsx, menu-list.jsx |
Full array + optimistic updates | Added PermissionWrapper, ConfirmationModal, loading states |
Architecture Differences: Old vs V2¶
| Aspect | Old | V2 (Current) |
|---|---|---|
| Data model | MenuItem → LabelGroup, type field |
Menu → MenuItem, locations array |
| Grouping | 4 hardcoded tabs (navbar/footer/topbar/bottombar) | 8 configurable location checkboxes |
| Item structure | Items have pages + children | Items have title, link, target, icon, permissions |
| Add items | Modal form (label + pages + type) | Left panel with search across 5 content types |
| Nesting | Nested cards-within-cards | Flat list with indent/outdent arrows |
| Reorder | DnD on 3-column card grid | DnD on vertical list |
| Save model | Immediate save per action | Batch "Save Changes" button |
| API layer | api/menu-builder/ (9 phantom endpoints) |
api/menu/ (all endpoints exist) |
| Concept | LabelGroups manage grouping | Menu entity manages grouping |
| Edit | Same modal as create | Dedicated EditItemDialog |
| Content types | Pages only | Pages + Blogs + Categories + Tags + Custom Links |
| Per-item permissions | N/A | Supported |
Features in Old Missing from V2¶
- Label Management Page — Full CRUD with search, pagination, trash/restore
- Label DnD Reordering —
label-manager.jsx - Loading overlay during reorder — Blur + spinner
- Order number badges on cards
Features in V2 Missing from Old¶
- WordPress-style tree with indent/outdent
- Batch save with unsaved changes tracking
- Multi-content-type support (5 types vs 1)
- Per-item permissions
- Menu settings panel (name + 8 locations)
- Content search with debounce
- Icon class and color per item
Legacy Bugs (Inherited Across Generations)¶
!depth > 0logic bug in all 3menu-item*.jsx— works accidentally for depths 0-1 onlyEdit2imported but never used in all versions- Multiple
console.logdebug statements useMemoandSkeletonimported but never used inmenu-builder-page.jsx
Dead Code Inventory¶
| File | LOC | Type | Why Dead |
|---|---|---|---|
services/menuService.js |
94 | Dummy data | USE_DUMMY_DATA = true, all API calls commented out |
services/menuItemService.js |
144 | Dummy data | Same — zero consumers |
hooks/useMenus.js |
96 | Query wrapper | Wraps dummy service — zero consumers |
lib/dummyMenuData.js |
~100 | Test data | Only used by dead service files |
api/menu-builder/menu-label.js |
68 | API hook | Calls non-existent /menu-labels endpoint |
menu-builder-old/ (12 files) |
2,821 | Components | Superseded by v2 — still routed but functionally broken |
menuItem.service.ts:getMenuList() |
~45 | Service method | Not exposed via any route |
Menu entity Not import |
— | Unused import | Imported but never used |
MenuItem entity IsNull import |
— | Unused import | Imported but never used |
Total identifiable dead code: ~3,368+ LOC
User Flows¶
V2: Create and Populate a Menu¶
1. User navigates to /dashboard/settings/menu-builder
2. MenuSelector shows "Select a menu" dropdown
3. User clicks "Create New Menu" → dialog opens
4. User types menu name → POST /menus → menu created with auto-slug
5. New menu auto-selected → MenuSettingsPanel and empty tree appear
6. User checks location boxes (e.g., Header, Footer) → PATCH /menus/:id
7. User expands "Pages" accordion in AddItemsSection
8. User searches for "About", checks "About Us" page
9. User clicks "Add to Menu" → item added to local state (unsaved)
10. User drags item to reorder → local state updated
11. User clicks indent arrow → item becomes child of item above
12. User clicks "Save Changes" → POST /menu-items (bulk create for new items)
13. Confirmation toast shown
V2: Edit and Delete Items¶
1. User clicks edit icon on a menu item
2. EditItemDialog opens with pre-filled data
3. User changes title, sets target="_blank", adds permissions
4. User clicks Save → local state updated (unsaved)
5. User clicks "Save Changes" → PATCH /menu-items/bulk-update
6. To delete: user clicks delete icon → ConfirmationModal
7. Confirms → DELETE /menu-items/:id (immediate, not batched)
Data Flow¶
V2 Data Flow¶
[Backend DB] → GET /menus/:id → useMenuById → menuData.items (flat)
↓
buildMenuTree() → tree structure (display only)
↓
localItems (flat state in page component)
↓
┌──────────────┬──────────────┬──────────────┐
↓ ↓ ↓ ↓
handleAddItems handleDrag handleIndent handleEdit
↓ ↓ ↓ ↓
└──────────────┴──────────────┴──────────────┘
↓
hasUnsavedChanges = true
↓
"Save Changes" clicked
↓
┌──────────────────────────────────┐
↓ ↓
newItems (no id) existingItems (have id)
↓ ↓
POST /menu-items PATCH /menu-items/bulk-update
{ items: [...] } { items: [...] }
↓ ↓
└──────────────────────────────────┘
↓
invalidateQueries(["menu", menuId])
Cache Strategy¶
| System | Query Key | Invalidation | Notes |
|---|---|---|---|
| V2 menus | ["menu"] |
All menu mutations | Broad invalidation (matches ["menu", id] too) |
| V2 menu by id | ["menu", id] |
MenuItem mutations | Targeted by menuId |
| Old system | ["menu-builders"] |
Old menu mutations | Completely siloed from v2 |
| Old labels | ["label-groups"] |
Label mutations | Siloed |
| Backend cache | menu-items:${query} |
deleteCacheByPrefix("menu-items:") |
Server-side, 1-day TTL |
Dependency Graph¶
Backend¶
routes/v1/index.ts
├── MenuBuilder/Menu/menu.routes.ts
│ ├── middlewares/auth.ts
│ ├── middlewares/contextMiddleware.ts
│ ├── menu.validation.ts (Zod schemas)
│ └── menu.controller.ts
│ └── menu.service.ts
│ ├── entity/Menu.ts
│ ├── shared/getDbRepository.ts
│ ├── helper/checkPermissionAndThrow.ts
│ ├── helper/parsePaginationQuery.ts
│ ├── helper/parseBoolean.ts
│ ├── shared/getSortQuery.ts
│ ├── shared/generateUniqueSlug.ts
│ └── builder/getQueryBuilder.ts + QueryBuilder.ts
└── MenuBuilder/MenuItem/menuItem.routes.ts
├── middlewares/auth.ts
├── middlewares/contextMiddleware.ts
├── menuItem.validation.ts (Zod schemas)
└── menuItem.controller.ts
└── menuItem.service.ts
├── entity/MenuItem.ts
├── entity/Menu.ts
├── shared/getDbRepository.ts
├── helper/checkPermissionAndThrow.ts
├── helper/parsePaginationQuery.ts
├── helper/parseBoolean.ts
├── shared/getSortQuery.ts
├── shared/cache.ts (getCache, addCache, deleteCacheByPrefix)
└── shared/validatePostTypeAndId.ts
Frontend V2¶
app/(dashboard)/settings/menu-builder/page.js
└── components/pages/menu-builder/menu-builder-page-v2.jsx
├── api/menu/index.js (useMenu, useMenuById, useCreateMenu, useUpdateMenu, usePermanentDeleteMenu)
├── api/menu/menu-item.js (useBulkCreateMenuItems, useBulkUpdateMenuItems, useDeleteMenuItem)
├── api/pages (usePages)
├── api/blogs (useBlogs, useBlogCategories, useBlogTags)
├── lib/menuHelpers.js (buildMenuTree, flattenTree, canIndent, canOutdent)
├── MenuSelector.jsx → api/menu/index.js (useMenu, useCreateMenu)
├── MenuSettingsPanel.jsx → api/menu/index.js (useUpdateMenu, usePermanentDeleteMenu)
├── AddItemsSection.jsx (no API, pure UI)
├── WordPressStyleMenuTree.jsx
│ ├── SortableMenuItem.jsx (@dnd-kit/sortable)
│ └── MenuItemCard.jsx
└── EditItemDialog.jsx
Circular Dependencies¶
None detected.
Known Issues¶
Critical (3)¶
| # | Location | Description |
|---|---|---|
| 1 | menuItem.service.ts |
Permission string format mismatch: View/Read use menuItem.view/menuItem.read (camelCase dot), CRUD uses menu-item.create/menu-item.update/menu-item.delete/menu-item.restore (kebab-case dot). Seed data has 8 entries mixing both formats — but menuItem.read and menu.read are NOT seeded, so getMenuById and getMenuItemById will ALWAYS fail permission check |
| 2 | menu.service.ts:59-61 |
countRootLevelItems queries wrong entity: Queries Menu table with { id, parentId: null } but Menu has no parentId column — should query MenuItem with { menuId: id, parentId: IsNull() } |
| 3 | api/menu-builder/index.js |
9 of 11 frontend hooks call non-existent backend endpoints — the entire old system's API layer is broken (grouped-by-type, menu-labels, bulk-order-update, pages/reorder, items/reorder) |
High (7)¶
| # | Location | Description |
|---|---|---|
| 4 | menu.service.ts:93 |
Name uniqueness uses LIKE prefix match: ILike(\${name}%`)` — "Main" blocks "Main Menu", "Maintenance", etc. Should be exact match |
| 5 | menuItem.service.ts:510-515 |
Children guard is useless: deleteMenuItem checks children.length > 0 but the findOne call never loads the children relation — always undefined, guard never fires |
| 6 | api/menu/index.js |
useMenu() query param bug: { limits: 1000 } instead of { params: { limit: 1000 } } — no limit param actually sent, backend returns default page size |
| 7 | menu.service.ts:120-127 |
Menu restore drops locations: When restoring soft-deleted menu by slug match, menuData.locations is never applied — new locations silently lost |
| 8 | menu.service.ts:161-168 |
updateMenu ignores slug: slug accepted in validation but never written to entity |
| 9 | menu.validation.ts |
5 unseeded permissions used in code: menu.read, menu.restore, menuItem.view, menuItem.read, menu-item.restore — will fail checkPermissionAndThrow for non-super-admin users |
| 10 | api/menu-builder/menu-label.js |
Double-prefixed URL: api.get(\${NEXT_PUBLIC_API_URL}/menu-labels`)` — axios instance already has baseURL |
Medium (14)¶
| # | Location | Description |
|---|---|---|
| 11 | menu.service.ts:16 |
Dead cacheKey variable — caching planned but never implemented for menus |
| 12 | menu.service.ts:154-159 |
Dead code: deletedAt check unreachable (findOne without withDeleted excludes soft-deleted) |
| 13 | menu.service.ts:182-187 |
Same dead code pattern in deleteMenu |
| 14 | menuItem.service.ts:622-667 |
getMenuList(type) method exists but NOT exposed via any route — dead/incomplete feature |
| 15 | menuItem.service.ts:27 |
Suspicious JSON.parse(getFromCache as unknown as string) double-conversion on cache retrieval |
| 16 | menuItem.service.ts:356-366 |
Circular reference detection only 1-level deep — A→B→C→A not detected |
| 17 | menuItem.validation.ts:58-75 |
bulkUpdateMenuItemSchema missing permissions field (present in single-update schema) |
| 18 | Schema S1 | menuId nullable on MenuItem — orphan items possible |
| 19 | Schema S2 | Soft-delete on Menu does NOT cascade to items — visible orphans |
| 20 | api/menu-builder/index.js |
3 console.log debug statements in production code |
| 21 | api/menu/menu-item.js |
useBulkCreateMenuItems potential double-nesting: caller passes { items: { items: [...] } } |
| 22 | Old components | !depth > 0 logic bug — works only by coincidence for depths 0-1 |
| 23 | menu-form-modal.jsx |
PermissionWrapper wraps Cancel/Submit with menu-label.view — wrong permission |
| 24 | menu-builder-old/menu-label-page.jsx |
Raw API calls (api.post/patch/delete) without React Query — no loading states, no cache invalidation |
Low (18)¶
| # | Location | Description |
|---|---|---|
| 25 | menu.validation.ts:3-21 |
createMenuValidation and updateMenuValidation are exact duplicates |
| 26 | menu.validation.ts:37 |
bulkMenuRestore allows ID 0 and empty array |
| 27 | menuItem.validation.ts:103 |
Same min(0) issue for bulkMenuItemRestore |
| 28 | menu.service.ts:5 |
Not imported from typeorm but never used |
| 29 | menuItem.service.ts:6 |
IsNull imported from typeorm but never used |
| 30 | menu.service.ts:290 |
findByIds is deprecated in TypeORM |
| 31 | menuItem.service.ts:609 |
Same deprecated findByIds |
| 32 | menu.service.ts:246 |
restoreFromTrash returns stale entity (old deletedAt) |
| 33 | menuItem.service.ts:573 |
Same stale entity return |
| 34 | menu.controller.ts |
Inconsistent ID validation (some methods validate, some don't) |
| 35 | menuItem.controller.ts |
Same inconsistent ID validation |
| 36 | menu.controller.ts:93-105 |
Redundant Array.isArray check duplicates Zod validation |
| 37 | menu.validation.ts:13-21 |
updateMenuValidation requires name for PATCH (should be optional) |
| 38 | menuItem.service.ts:284 |
Comment says "menus" instead of "menu items" |
| 39 | Schema S3 | order is SQL reserved word |
| 40 | Schema S4 | No indexes on menuId, parentId |
| 41 | Dead imports | Edit2, useMemo, Skeleton in various legacy components |
| 42 | Legacy | handlePageReorder marked "currently not used" with console.log |
Total: 42 issues (3 Critical, 7 High, 14 Medium, 18 Low)
Testing Analysis¶
Test Files¶
None. No test files exist for the Menu Builder system — backend or frontend.
Testing Gaps¶
- No unit tests for
menu.service.tsormenuItem.service.ts - No integration tests for 19 API endpoints
- No component tests for any of the 20+ React components
- No E2E tests for the menu builder user flow
- The circular reference detection (1-level) has no test proving its limitation
Related Code & Reuse Opportunities¶
Similar Patterns in Codebase¶
| Pattern | Menu Builder | Also Used In |
|---|---|---|
checkPermissionAndThrow |
Every service method | All 31 backend modules |
getQueryBuilder + BaseQueryBuilder |
List queries | Blog, User, Product, etc. |
catchAsync wrapper |
All controllers | All controllers |
softDelete + restore + permanent |
Menu and MenuItem | Blog, User, Product, etc. |
simple-json column |
Menu.locations |
Setting, AppConfig entities |
@dnd-kit/sortable |
WordPressStyleMenuTree | Blog categories, email template builder |
PermissionWrapper |
v2 components | Dashboard pages across 15+ modules |
ConfirmationModal |
Delete confirmations | All dashboard CRUD pages |
Reusable Utilities Available¶
| Utility | Path | Could Help With |
|---|---|---|
generateUniqueSlug |
shared/generateUniqueSlug.ts |
Already used — slug generation |
validatePostTypeAndId |
shared/validatePostTypeAndId.ts |
Polymorphic FK validation |
buildMenuTree |
lib/menuHelpers.js |
Tree-building from flat data |
CustomizeManager |
components/pages/dashboard/customization/ |
Generic CRUD management (used by old label page) |
Modification Guidance¶
To Add a New Content Type to Menu Items¶
- Add enum value to
MenuItem.postTypecolumn inentity/MenuItem.ts - Add to Zod enum in
menuItem.validation.ts(menuItemSchema.postType) - Add validation case in
shared/validatePostTypeAndId.ts - Add accordion section in
AddItemsSection.jsxwith corresponding API hook - Run TypeORM sync or create migration for the new enum value
To Fix Permission String Inconsistency (#1)¶
- Decide on ONE format:
menu-item.verb(kebab-case, matches seed) ormenuItem.verb(camelCase) - Update
menuItem.service.tslines usingcheckPermissionAndThrowto use consistent format - Add missing permissions to seed:
menu.read,menu.restore,menu-item.restore - Remove or update
menuItem.viewandmenuItem.readtomenu-item.viewandmenu-item.read(matching seed) - Re-run seed
To Clean Up Dead Code¶
- Delete
services/menuService.js,services/menuItemService.js,hooks/useMenus.js,lib/dummyMenuData.js - Delete
api/menu-builder/directory (old system — all endpoints broken) - Delete
components/pages/menu-builder-old/directory (12 files, 2,821 LOC) - Delete route files:
app/(dashboard)/settings/menu-builder-old/andmenu-label/ - Remove unused imports in
entity/GenericPage.ts(MenuItem,OneToMany,ManyToMany) - Remove unused service method
menuItem.service.ts:getMenuList() - Remove
Notimport frommenu.service.ts,IsNullimport frommenuItem.service.ts
Testing Checklist for Changes¶
- Menu CRUD: create, list, get by id, update, soft-delete, restore, permanent delete
- MenuItem CRUD: bulk create, single update, bulk update, soft-delete, restore, permanent delete
- Permission checks: verify all 12 permission strings work with seeded data
- Hierarchy: create parent, create child, reorder siblings, indent, outdent
- Nesting: verify 3+ levels deep works
- Soft-delete cascade: delete menu, verify items status
- Slug uniqueness: create menus with same name, verify slug differentiation
- Drag-and-drop: reorder items, verify order persisted correctly
- Batch save: add items, reorder, edit, then save all at once
- Location assignment: assign menu to multiple locations, verify rendering
- Content linking: add blog, page, category, tag, custom link items
- Edit dialog: modify title, link, target, icon, permissions
Generated by document-project workflow (deep-dive mode)
Base Documentation: docs/index.md
Scan Date: 2026-02-25
Analysis Mode: Exhaustive