Menu Builder
Overview
Menu Builder is where you design a multi-level, tree-structured web/LIFF menu and publish it behind a public token link. It suits building a shortcut hub of links and content pages for customers to open from a rich menu button or from a message you send out.
The one thing to understand up front: the entire menu structure is stored as a single JSON blob in the config column, not as per-item rows. That means there is no nesting depth limit at all (unlike Subcategories, capped at 4 levels), and a menu item pointing at a content page or content link stores the token value as it was at selection time rather than a referential id.
Who uses it — content/marketing staff assembling a shortcut hub for customers (this module has no ModuleGate on the backend — see Access control below).
User journey
1. Review the menu list
Open Menu Builder — a LINE OA must already be selected, otherwise the system warns "Please select a LINE OA first" and fires no requests at all. The table has 7 columns: No., Menu Name (with the template name as a second line, or "Custom Layout"), Items (counted recursively across all levels, shown as a blue tag), Status, Public Token (monospace and copyable, with a regenerate button beside it), Created (DD/MM/YYYY) and Actions.
The filter bar is staged — type a search term or pick a status, then press the Search button (or Enter in the search box) to actually fire the request, with a Clear (N) button appearing while filters are active. The right side shows the total menu count.
2. Create a menu
Click Create Menu to open the 3-column builder, with a sticky header bar (the menu-name field plus copy-link / preview / save buttons):
- Left column — Menu Structure — Expand All / Collapse All / Reset All Styles buttons plus Add Root Menu Item. Below the tree sits a Select Template card offering List Layout or Grid Layout (Grid lets you pick 1–4 columns, labeled "4 Columns (Max)").
- Center column — Live Preview — a phone frame you can actually navigate into submenus with.
- Right column — Item Style / Global Theme tabs — the first tab styles the selected item (including a Targeting sub-tab that sets which audiences see that item); the second tunes the whole menu's theme (primary color, text color, border radius, four padding steps, font size) plus 5 preset themes — Default Blue, Nature Green, Purple Modern, Vibrant Red, Minimal Black.
3. Add menu items
Click Add Root Menu Item, or the add-child button on any branch, to open a 520px-wide dialog with Menu Name (required) and Action Type as four selectable cards:
| Type | What else you fill in |
|---|---|
Folder (node) | Nothing — it holds child items |
External link (linkout) | External URL (required, URL-format-validated in the form) |
Content page (content_page) | Chosen from options that pull published pages from the Content Pages menu |
Content link (content_link) | Chosen from options that pull active links from the Content Links menu |
Choosing Content link adds a Behavior option with two modes — Navigate to listing page (opens the full content listing page with search and filters) or Show inline submenu (expands the content items right inside the menu). Picking inline then adds a Display Style choice — Card View (image, title, excerpt, date) or Button View (compact buttons).
4. Reorder and customize
Drag branches to reorder, but only within their existing parent — dragging across parents, or dropping onto a branch to make it a child, is refused with "Menu items can only be reordered within the same parent" (checked twice: allowDrop blocks it during the drag, and onDrop re-checks). The Reset All Styles button clears customStyle, image, showText and textPosition from every item at every level back to the default theme (with a confirmation).
5. Save, preview and copy the link
The first Save Menu creates the menu, issues a public token immediately, and switches the URL into edit mode (?id=…). Before that first save the Preview button is disabled and the copy-link button doesn't render at all (the warning reads "Please save the menu first to generate a preview link").
Two link shapes come out, depending on whether any item in the menu, at any level, has audiences set:
- Audiences set, and the OA has a LIFF ID →
https://liff.line.me/{liffId}/{lineOaHash}/menu/{token}("LIFF link copied! (Opens in LINE app for audience filtering)") - Everything else →
{NEXT_PUBLIC_BASE_APP_URL or the current origin}/{lineOaHash}/menu/{token}
6. Clone, regenerate, delete
From the actions column on the list — the clone button and the regenerate token button (the refresh icon in the token column) both fire immediately with no confirmation dialog. Only delete asks first, via a Popconfirm.
Key details verified against the source
Confirmed from internal/modules/menubuilder/{controller,service,dto}.go and internal/modules/publicmod/controller.go in line-management-cms-api-go, plus internal/menubuilder/{service,repository}.go in line-management-client-api-go:
- Menu Builder's token is meaningfully weaker than every other module's in this system —
generateRandomToken()draws 12 characters from A–Z a–z 0–9 usingMath.random(), and a source comment says outright: "non-cryptographic — parity, not security." Compare Content Links and Content Pages, which usecrypto.randomBytes(32)for a 64-character hex token. The system does loop until it finds a value not already taken by a non-deleted row (generateUniqueToken). - A menu's creator/editor is always recorded as user id 1, no matter who did it — the controller writes
req.user?.id || 1, but the payload the JWT strategy returns is only{ userId, lineOaId, organizationId }— there is noidfield — so the value always falls through to1. The controller's file header comment spells this out (controllerUserID()simply returns1). Themenu_buildertable'screated_by/updated_bycolumns therefore cannot be used to trace who touched a menu. - Images uploaded in the builder all pile into one shared
defaultfolder across every organization — for the same reason,req.user?.lineOaHash || 'default'always falls through to'default'because that field isn't in the payload (unlike the Content Pages image upload, which really does read the LINE OA hash from context and separates folders per OA). The file constraints are the same: JPEG/PNG/GIF up to 10MB, namedmenu-image-{timestamp}. - There are three public endpoints reading the same menu, and they disagree about status —
GET /api/menu-builder/public/:token(cms-api) → a non-active status returns 400 "Menu is not active"GET /api/public/menu/:token(cms-api, thepublicmodmodule) → forcesstatus = activein the query, returning 404 "Menu not found" otherwise, and carries a specialtest-menu-tokenvalue that returns a hard-coded sample menu without touching the database at allGET /menu-builder/:token(client-api — the endpoint the real customer-facing web app calls) → the query filters onlydeleted_at IS NULL, and the source comment reads "deliberately NO status filter", so it keeps serving a menu you set to inactive
- Audience filtering happens on the server, not by hiding things with CSS — client-api's
filterItemsByAudience()walks the tree recursively: children filtered first; an item with noaudienceIdsis visible to everyone; an item with them is visible when any one overlaps the user's set (OR); and a folder item whose children were all filtered out is dropped along with the whole branch. Items that fail the filter never appear in the response at all. - A failed LIFF verification leaves the user seeing public items only, not an error — client-api makes a best-effort attempt to read audiences from
x-liff-token; on failure it swallows the error and continues with an empty audience set, so every audience-restricted item is hidden. That's a stricter degradation, unlike Content Links, where swallowing the error leaves the link's own filter in charge. The customer-side frontend is also smart enough to skip the LIFF step entirely when no item has audiences set. configis mandatory on create, and the error message is a JavaScript TypeError string — the service dereferencesdto.config.versiondirectly, so omittingconfigyields 400 "Failed to create menu: Cannot read properties of undefined (reading 'version')". The Go port reproduces that text verbatim on purpose. Whenversionisn't supplied the system fills in"1.0".- Updating
configis a shallow merge —{ ...stored, ...incoming }across top-level keys only, so sendingconfig.itemsreplaces the whole item set rather than merging item by item.versiontakes the new value if sent, otherwise keeps the stored one. - The
sortByparameter has no allowed-value whitelist — the DTO checks only@IsOptional() @IsString(), and the resulting value goes into a column-name mapper that returns unknown values verbatim to be appended to theORDER BYclause, which a source comment explains reproduces the original TypeORM behavior of letting Postgres reject it as a 500. (The frontend always sends the constantcreatedAt.) - The ownership check only runs when there's a
lineOaIdto compare against — every method (FindOne/Update/Delete/Clone/RegenerateToken) testsif lineOaID != nil && m.LineOaID != *lineOaID, so a JWT without a lineOaId skips the check entirely. On failure it returns a 403 with an action-specific message (e.g. "You do not have permission to clone this menu"). - Delete is a single-column soft delete —
UPDATE menu_builder SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?(runUnscoped()to reproduce TypeORM not re-appendingdeleted_at IS NULLon a soft delete). It does not flip the status to delete and does not write adeleted_by. - Cloning copies the
configblob verbatim — naming it"{original} (Copy)", issuing a new token, and forcingactivestatus. The old config blob is copied exactly as-is, including theid,createdAtandpublicToken(an empty string) that the frontend stashes inside it — so those become duplicated values that no longer reflect reality. - This module doesn't use the system's standard response envelope — every method returns a hand-built
{ code, message, data }object (e.g.{ code: 200, message: "Menu cloned successfully", data: {...} }), unlike all four content modules — and the cms-api public endpoint trims the payload down to{ id, name, config }.
Related menus in this journey
- Content Pages — the source for "content page" menu items; the options pull published pages only and skip any page that has no
publicTokenyet. What gets stored on the menu item is that page'spublicToken. - Content Links — the source for "content link" menu items; the options pull active links only, and what gets stored is the link's
token. The center-column preview calls that menu'sGET /content-links/:id/preview-contentsto render real content. - Categories / Subcategories — relevant indirectly, since the category criteria set on a content link determine what an inline menu item actually shows.
- Segment audience — the source of the per-item audiences set from the Item Style > Targeting tab, and what decides whether the copied link comes out as LIFF or plain web.
- Rich menu / Rich message / Campaign — the destinations where the menu link gets embedded as a button.
- LINE OA management — the source of the
lineOaHashandlineLiffIdused to assemble links (fetched in a separate request after the page loads).
Things to watch out for
- The list page's "regenerate token" button fires instantly with no confirmation — unlike Content Links, which asks first with a warning. One misclick kills the old link immediately with no grace period, and if that link is already embedded in a rich menu or a message you've sent, you have to fix every destination yourself. The clone button likewise has no confirmation — one click and you have a new menu with a new token.
- Setting the status to inactive does not take the menu offline for real users — the endpoint the customer-facing web app calls (
GET /menu-builder/:tokenon client-api) deliberately doesn't filter status at all. To actually take it down, regenerate the token (breaking the old link) or delete the menu. - The
draftstatus in the filter can never be produced from this screen — the form always sendsstatus: 'active'on both create and update, and cloning forces active too. That filter option returns nothing in practice (and nothing in the UI can set inactive either). - Menu items store a token as a fixed value, not a reference — hit "Regenerate Token" over in Content Links and every menu item pointing at that link breaks silently, still holding the old token. Deleting a content page or content link likewise leaves the menu item as a dead end, with no warning anywhere on this screen. You have to come back and fix the item yourself.
- There is no nesting depth cap on either the frontend or the backend — the DTO validates the nested structure (
items→children) but never counts levels, unlike Subcategories with its 4-level limit. Nesting too deep means end users tapping through many layers, and a center-column preview that's hard to read. - Every row's
created_by/updated_byis1— you cannot audit who created or edited a menu (see the details section above). - Images uploaded in the builder sit in a
defaultfolder shared with other OAs — they're public URLs, so don't upload anything that shouldn't be visible to others. - If no LINE OA is selected, the list page warns and then loads nothing at all — the menu-count badge just sits at 0 with no error.
- The copy-link button is disabled while the system doesn't know the LINE OA hash — that value comes from a separate LINE OA fetch, and a failure only logs to the console.
- An update sends the whole
configblob over the stored one — if two people edit the same menu at once, whoever saves second overwrites the first person's work entirely (there is no version check of any kind). - Any other user on the same LINE OA can edit, delete, clone or regenerate every menu — the permission check only compares
line_oa_id.
Access control (verified against the source)
- Frontend — the list and builder pages are not wrapped in
PermissionGuard. The only permission check is the sidebar, testingability.can(VIEW, 'menu-builder')before rendering this item (a separate permission from the Content Management group'scontent-management). Without it the menu disappears, but typing/menu-builderdirectly still opens the page. The menu also appears in the Quick access bar under the same subject. - Backend — all 8 authenticated routes declare policies on the
system_modulesubject (notline-oa, as all four content modules use), but run throughauth.CheckPolicies, a no-op that lets every request through. Real enforcement is therefore JWT plus the per-methodline_oa_idcheck inside the service. - There is no
ModuleGatein this module — inspectingmenubuilder/controller.goturns up nomodulegate.ModuleGate(d, "...")call (unlike thecampaignmodule, which has one), and the module appears in neither thegatedModulesnor theexemptModuleslist inmodulegate/coverage_test.go. Per that test's own explanation, a platform admin cannot close the Menu Builder module for an organization, because there's nowhere the gate is applied. GET /api/menu-builder/public/:tokenis@Public()by design — no JWT required; protection comes from the 12-character token alone (plus this endpoint's active-status requirement), and the response is trimmed to{ id, name, config }.GET /api/menu-builder's data scope is bounded by LINE OA because the controller overwrites anylineOaIdsent in the query with the JWT's value every time (as doesPOST, overwritinglineOaIdin the body) — so sending another OA'slineOaIdhas no effect.- Customer-side audience filtering is genuinely enforced on the server (failing items are never sent), but it depends on LIFF identification — open the link as a plain web page with no
x-liff-tokenand you only see the unrestricted items.
Source file reference
Frontend (cms-web-2026)
src/app/menu-builder/page.tsx— the 7-column table, staged filters, LIFF-vs-web URL assembly, the unconfirmed regenerate/clone buttonssrc/app/menu-builder/form/page.tsx— the 3-column builder, theme/template defaults, the save logic that always sendsstatus: 'active'src/app/menu-builder/preview/page.tsx— the preview page opened with?token=src/components/menu-builder/MenuTree.tsx— the structure tree, the four action-type cards, the drag rules, the reset-styles buttonsrc/components/menu-builder/ButtonCustomizer.tsx— the item-style tab and its Targeting sub-tab (the only place per-itemaudienceIdscan be set)src/components/menu-builder/ThemeCustomizer.tsx,TemplateSelector.tsx— the 5 preset themes, the List/Grid templates and 1–4 columnssrc/components/menu-builder/ContentPagePicker.tsx,ContentLinkPicker.tsx— the status filtering and the token-as-value storagesrc/components/menu-builder/MenuPreviewEnhanced.tsx— the live preview and itspreview-contentscall for content linkssrc/services/menu-builder.service.ts— the endpoints the frontend callssrc/components/layout/sidemenu/sidemenu.container.tsx,src/components/layout/quick-access/quick-access.tsx— the menu-level permission check on themenu-buildersubject
Backend (line-management-cms-api-go)
internal/modules/menubuilder/controller.go— all 9 routes,controllerUserID()always returning 1, the upload'slineOaHash = "default", thelineOaIdoverwrites, the absent ModuleGateinternal/modules/menubuilder/service.go—generateRandomToken(12 non-crypto characters), the shallow config merge,columnForSort, soft delete, clone, regenerate tokeninternal/modules/menubuilder/dto.go— the four menu item types,contentLinkBehavior, the unwhitelistedsortByvalidationinternal/modules/publicmod/controller.go—GET /public/menu/:tokenand the hard-codedtest-menu-tokensampleinternal/modules/modulegate/coverage_test.go— the list of genuinely gated modules and the exempt ones
Backend (line-management-client-api-go)
internal/menubuilder/service.go—filterItemsByAudience(the recursive filtering and empty-folder pruning), the swallowed LIFF errorinternal/menubuilder/repository.go— the query that deliberately doesn't filter status
See the documentation team's earlier technical write-up at Menu Builder