Switch rich menu
Overview
Switch rich menu is a submenu attached to a parent menu (Rich menu default or Rich menu custom), serving as the destination when a user taps a "switch rich menu" cell on the parent. It suits multi-level or tab-style menus. The thing to understand up front: the link between two menus is a deterministic hash, not a database lookup. So if the destination menu has never actually been published to LINE, tapping the button does nothing at all, with no warning anywhere.
Who uses it — admins building a multi-level menu on top of an existing Rich menu default/custom (this module carries a real, enforced ModuleGate on the backend — see Access control below).
User journey
1. Review the submenus
Open Rich menu > Switch rich menu. This page has a 3-field filter — Search (by title), Parent rich menu (a dropdown grouped into "Rich menu default" and "Rich menu custom", each option showing a small thumbnail next to the name), and Updated on (a date range labeled "(BKK Time)").
The table shows No. / Image / Title / Parent rich menu / Updated on / Action — note that this page has no Status column (unlike the other two), because a submenu has no user-settable status.
2. Create a submenu
- A parent menu must already exist, since the form requires you to declare which menu this is a submenu of.
- Click create, opening the form at
/rich-menu/form?type=switch— or take the shortcut straight from the parent menu's form: in a "switch rich menu" action cell there's a "Create new" button that opens this form in a new tab withparentIdalready passed through (that button is disabled unless the parent is in Draft or Inactive status). - Pick the parent rich menu — the dropdown is grouped into default and custom menus and is searchable by name. Changing the parent clears every tap area and action you've configured, and on the edit page this field is permanently disabled — a submenu cannot be moved under a different parent.
- Pick a layout / upload an image / configure per-cell actions — identical to the other two types (15 layouts across 3 tabs; JPG/PNG up to 1 MB at the allowed resolutions; the 5 action types).
- This form has no status field at all, on create or on edit — it's hidden and set to Active automatically. As a result the submenu is pushed to LINE the moment it's saved, and because the status is Active from the outset, every field in the action-setting section is required (there is no draft mode for parking unfinished work).
- One of the action options is "Switch rich menu", pointing back at the parent or across to a sibling submenu. That dropdown has two groups — "Parent rich menu" and "Sub-rich menu" — shows a thumbnail preview of the selected destination, and carries the note "You can only select rich menus of the same Parent rich menu."
- Save, confirm, and the system calls
POST /api/rich-menu.
3. Review stats and manage an existing submenu
- Click the eye icon for read-only mode, with a menu preview, the per-cell click-stats table, and a Re-sync button to request a recalculation.
- Editable only while the parent is in Draft or Inactive status (for custom parents), and never once the submenu itself is Completed, Expired, or Deleted — the button's tooltip states the reason outright (e.g. "Cannot edit: Parent rich menu must be Draft or Inactive status").
- Not deletable while either the submenu itself or its parent is "Completed" — every other status can be deleted (with confirmation).
Key details verified against the source
Confirmed from internal/modules/richmenu/{service,update,create_update}.go, internal/modules/linemessageapi/richmenu.go, and internal/core/httpx/codes.go in line-management-cms-api-go:
switchis the only type whose status rules the backend actually enforces —ValidateStatusTransition/ValidateStatusForAction/ValidateStatusForDeleteall open withif type == switchand pass straight through for anything else. The rules that really bite: Draft can only become Active; Active can only become Active/Inactive/Delete; Inactive can only become Delete; and reverting from Inactive or Delete is forbidden. On top of that, a submenu in Inactive / Delete / Completed / Expired status cannot be modified at all (400"Cannot modify rich menu in current status"), and a Completed submenu cannot be deleted.- The two rules above combine into a real consequence: a deactivated submenu can never be edited through the UI again — because the form hides the status field and always sends
status = active, while the stored row isinactive(usually cascaded down from the parent). The save is rejected by the "cannot revert from Inactive" rule, and rejected a second time by the "cannot modify in current status" rule. The only way forward is to create a new submenu. - The switch linkage is a fixed hash with no database lookup — when building a switch-type tap area, the system computes
richMenuAliasIdasrichmenu-alias-+ the first 16 chars ofmd5("{organizationId}_{lineOaId}_{destinationRichMenuId}")and setsdatatorichmenu-changed-to-+ that same hash, while each menu registers its own alias on LINE using exactly the same formula. The two sides meet without any relationship being stored anywhere. The upside:line_rich_menu_idcan change on every save without touching a single switch button. The downside: if the destination has never been published to LINE, that alias doesn't exist there, and the tap does nothing. - Nothing checks whether the destination is actually live before saving — the error code
RMN_003("Rich menu alias is not active") is declared in the error-code table, and the frontend has handling for bothRMN_003andRMN_004with explanatory copy, but the Go rich-menu module never emits either (RMN_004isn't even declared in the code table). So pointing a switch button at a menu that isn't ready "saves successfully" and then fails silently when a user taps it in the LINE app. - The destination dropdown in the Switch form filters by status differently than the other forms — inside a parent menu's form (default/custom) the submenu options are filtered to
status = active, but inside the Switch form itself no status condition is sent at all, so you see sibling submenus in every status, including ones that were never activated. That is the main path by which a switch button ends up pointing at an unready menu. Both cases filter on the sameparentIdand always exclude the menu being edited from its own options. - Every save creates a new submenu on LINE and deletes the old one — same as the other two types — and the alias is updated first, falling back to create if that fails, so the parent's switch button needs no adjustment.
- Deleting a submenu does nothing to its parent — the delete path only gathers a "submenus to cascade" list when the menu being deleted is of type custom. Deleting a submenu is therefore just a soft delete of itself (
status = deleteplus adeleted_datestamp) followed by a best-effort delete on LINE — the parent's switch tap area still points at a dead alias until you open the parent and save it again. - A submenu's stats don't use the default menu's "compare the two most recent archives" mechanism — that live-recompute only applies to guest/member types (and
rich_menu_archiverows are only written for those two). A submenu just reads whatever the worker wrote intotemplate_tracking. - A switch button's clicks are recorded through the webhook, not a redirect link — a switch tap area becomes a LINE
richmenuswitchaction with metadata attached in itsdatafield (contentType: rich_menu,type: richmenu_switch,organizationId,lineOaId,richMenuId,richMenuActionIndex,richMenuArchiveId). And because that event never passes through the redirect service, it never sets theRICH_MENU_STAT_DIRTYflag — so the hourly cron never picks that menu up for recalculation and you have to use the Re-sync button (details on the Rich menu default page).
Related menus in this journey
- Rich menu default — can be a parent (guest or member type) and is where the user taps to reach this submenu. Inside a default menu's form, the submenu options offered are filtered to
activeonly. - Rich menu custom — can also be a parent, and it's the type that affects submenus most: a submenu's status is cascaded when the custom menu expires or is deleted (with the reason written into a
reasoncolumn naming the parent), and whether the submenu can be edited or deleted at all depends on that custom menu's status. - Segment audience — not involved in submenus directly, but it determines who sees a custom parent menu (and therefore who can tap through to this submenu).
- Workflow / Trigger rule — can switch a specific user's rich menu directly through the
line_change_richmenuqueue as asetRichMenuByTriggerRulejob, without waiting for the user to tap anything.
Things to watch out for
- Make sure the destination menu has actually been published to LINE before pointing a switch button at it — nothing validates this, there is no warning, and the end user gets a button that does nothing.
- A deactivated submenu can no longer be edited — you have to create a new one (see above).
- Picking the wrong parent means starting over — the "Parent rich menu" field is permanently disabled on the edit page; a submenu can't be moved under a different parent.
- Changing the parent on the create page clears every tap area and action you configured — settle on the parent before you start configuring actions.
- After deleting a submenu, go edit the parent too — otherwise the parent's switch button points at a destination that no longer exists.
- This form has no draft mode — because the status is forced to Active, every field is required and unfinished work can't be parked.
- Avoid layout 15, same as the other two types — the tap areas you draw won't work in the real LINE app.
Access control (verified against the source)
- Frontend — this page is wrapped in a
PermissionGuardchecking theVIEWaction on therich-menu-switchsubject, a separate subject fromrich-menu-defaultandrich-menu-custom. - Backend — every route is wrapped in a real, enforced
ModuleGate("rich-menu"), and it is a single gate covering all three types at once (Default / Custom / Switch share therich_menutable, distinguished only by atypecolumn) — closing therich-menumodule for an organization 403s all three pages together; there's no way to enable or disable just submenus at the backend level. - This is the only type with real server-side "which action is allowed in which status" enforcement — unlike Default/Custom, whose status rules live entirely on the frontend, calling the API directly to edit an Inactive/Completed/Expired submenu really is rejected with a 400.
- Cross-tenant (IDOR) protection —
findById(which bothPUTandDELETEcall as their first step) always binds the caller'sline_oa_id(a miss returns 400APP_007),recalculate-statverifies ownership before queueing (a miss returns 404), and every soft-delete statement is scoped byline_oa_id. - Action-level policy annotations are all declared but run through no-op middleware — module-level enforcement is the ModuleGate; status-level enforcement lives in the service logic.
DELETE /api/rich-menu/:idis a soft delete;DELETE /api/rich-menu/:id/hardrequires Super Admin plus aconfirm=truequery parameter.
Source file reference
Frontend (cms-web-2026)
src/app/rich-menu/switch/page.tsx— list entry point (PermissionGuardsubjectrich-menu-switch)src/components/rich-menu/list/rich-menu-switch-filter.tsx— the switch-type-specific 3-field filter (search / parent rich menu / updated on)src/components/rich-menu/list/rich-menu.container.tsx— the parent and updated-on columns, the hidden status column, edit-and-delete disable rules driven by parent statussrc/components/rich-menu/form/action-setting/action-setting-render-type.tsx—RenderTypeAlias: the grouped dropdown, the "Create new" button that passesparentId, the destination thumbnail previewsrc/components/rich-menu/form/default/rich-menu-default-form.container.tsx— destination filtering byparentId(and thestatuscondition that differs by form type), readingparentIdoff the URLsrc/components/rich-menu/form/default/components/RichMenuStatusRadioGroup.tsx— hiding the status field entirely for the switch typesrc/services/rich-menu.service.ts— every endpoint the frontend calls
Backend (line-management-cms-api-go)
internal/modules/richmenu/service.go—ValidateStatusTransition/ValidateStatusForAction/ValidateStatusForDelete/GetAvailableStatuses(all switch-only) and the delete pathinternal/modules/richmenu/update.go— status-rule checks before an edit, the cascade from a parent menu, the update-then-fall-back-to-create alias handlinginternal/modules/richmenu/create_update.go—generateRichMenuAliasIdand the LINE call sequence on createinternal/modules/richmenu/util.go— the alias hash formula and its validation rules (non-empty, ≤255 chars, onlya-z A-Z 0-9 - _)internal/modules/linemessageapi/richmenu.go— converting a switch tap area into a LINErichmenuswitchaction and the metadata attached to itinternal/core/httpx/codes.go— theRMN_003code that is declared but never emitted by any module
See the documentation team's technical write-up at Rich Menu (Default / Custom / Switch)