Rich menu custom
Overview
Rich menu custom is a rich menu that only runs during a set date window and is only seen by the people in one target group picked from Segment audience. It suits seasonal campaigns or temporary promotions that need a menu different from the usual one. The thing to understand up front: saving does not bind the menu to anyone right away — cms-api only reads that audience's LINE user list and stores it. The actual LINE calls that bind the menu to each user are a worker job that sweeps every 5 minutes, and it doesn't start until the start date you set has arrived.
Who uses it — marketing staff running audience- or time-limited campaigns (this module carries a real, enforced ModuleGate on the backend — see Access control below).
User journey
1. Review the custom menus
Open Rich menu > Rich menu custom. This page has a 3-field filter bar — Search (by title), Status (4 options: Active / Draft / Inactive / Expired), and Period (a date range, labeled "(BKK Time)"). Filter values are remembered in sessionStorage while you're on the page.
The table shows No. / Image / Title / Audiences / Period (start–end) / Status / Action. A menu flagged for automation carries a blue "Automation" tag after its title, and its audience cell reads -.
The edit and delete buttons disable themselves per status, with a tooltip stating the reason outright (e.g. "Cannot edit: Custom rich menu is expired").
2. Create a custom menu
- Click create, opening the form at
/rich-menu/form?type=custom. - Enter a title — must not duplicate another rich menu in the same OA.
- Choose who sees it — turn on the "For Automation / Trigger Rules" switch to let Workflow/Trigger rule swap the menu per user, or leave it off and pick a target group from Segment audience (required when the switch is off).
- Set the start and end date/time (always required — even with the automation switch on) — past days are disabled in the picker, and if you select today, hours/minutes already in the past are disabled too.
- Pick a layout / upload an image / configure per-cell actions — identical to Rich menu default in every respect (15 layouts across 3 tabs; JPG/PNG up to 1 MB at the allowed resolutions; the 5 action types; action label capped at 20 characters).
- Pick a status — this is the only one of the three types that actually exposes a status control. It defaults to Draft and can be set to Active. If you choose Active while the start or end date is already in the past, validation blocks the save (checked both ways: when you change the date and when you change the status).
- Save, confirm, and the system calls
POST /api/rich-menu.
Choosing Draft relaxes the action-setting fields to optional (link / message / phone / label / action type), so unfinished work can be parked — and a draft menu is never pushed to LINE.
3. Wait for the system to bind the menu
- Once the start date/time arrives, a background job (running every 5 minutes) picks up menus that are
activeand not yet bound to any user. - It reads the stored LINE user list, de-duplicates it, and calls LINE to link the menu in batches of 500 users.
- When done it writes the bound user count into the
totalcolumn and flips the status to "Completed" — that status means "everyone has been linked," not "the campaign is over." - Once the end date/time passes, the same job unlinks the menu from those users (handling guest and member users separately) and flips the status to "Expired."
4. Review stats and manage an existing menu
- Click the eye icon for read-only mode, with a menu preview and a per-cell click-stats table (Spot / Data / Action type / Total click / Unique click) plus a Re-sync button to request a recalculation.
- Editable only while the status is Active or Inactive — not once it's Completed or Expired, and not if the status is Active but the start or end date has already passed.
- Not deletable once Completed — every other status can be deleted (with confirmation). Deletion is a soft delete and takes any attached Switch rich menu submenus down with it.
Key details verified against the source
Confirmed from internal/modules/richmenu/{create_update,update,service}.go in line-management-cms-api-go and internal/richmenu/service_cron.go, internal/cronscheduler/rich_menu_lifecycle_scanner.go in line-management-worker-go:
- Binding the audience at save time means "read a CSV and store the list," not "call LINE" — cms-api looks up that audience's
lineUserIdsPathFilename(scoped to the caller'slineOaId+organizationId), loads the CSV from object storage into a DuckDB temp table named{lineOaHash}_{audienceId}_{epochMillis}, pages through it 10,000 rows at a time until exhausted, destroys the temp table, then stores the whole list as a JSON array in therich_menu_membertable. That's it — not a single LINE user-binding call happens at this point. - The real binding happens on the worker, 500 users at a time — a cron running every 5 minutes publishes onto the
line_change_richmenuqueue; the worker picks up menus wheretype = custom,status = active,complete_link_user = false,start_date <= now < end_dateand callslinkRichMenuIdToUsersin batches of 500 (LINE's own cap), then updatescomplete_link_user = true,total = user count,status = completedin one statement. The 5-minute cadence is deliberate: this job has no claim mechanism against overlapping runs, so a 1-minute cadence would start the next run before the previous one finished and fire duplicate LINE calls. - The "Completed" status comes from the worker, not from a person, and means "fully bound" — which is why the menu can no longer be edited or deleted, and why a menu whose target group turns out to be empty jumps straight to
completedwithout ever having been bound to anyone (the code checks: norich_menu_memberrow, or a row with an empty list → writecompletedand skip). - A menu with the "Automation" switch on is never bound by the cron at all — at save time the system sets
complete_link_user = trueandaudience_id = NULLimmediately, so the cron'scomplete_link_user = falsecondition never matches it again. Per-user binding is entirely Workflow/Trigger rule's job — but the menu still expires on its end date, because the second sweep picks up menus withcomplete_link_user = truepast their end date and flips them toexpired. - A stranded-menu sweep runs before anything that talks to LINE — the cron opens with a single
UPDATE ... RETURNING idthat expires custom menus past their end date which were never bound (complete_link_user = false). That ordering is intentional: every later phasereturns on its first LINE/OA error (a revoked channel token, say), which would otherwise starve the sweep on every tick — exactly when stranded menus most need clearing. - Setting the status to Expired or Delete cascades to every submenu — saving a custom menu with status
expiredordeletemakes the system find everyswitch-type menu whoseparent_idis this menu (and which isn't already deleted) and write the same status onto it, with the reason"Parent rich menu {id} status changed to {status}". Deletion behaves the same way with the reason"Parent rich menu {id} was deleted", then attempts a best-effort delete of both the parent and its children on LINE (failures are logged only, never failing the request). - The backend does not validate custom-menu status rules at all —
ValidateStatusTransition/ValidateStatusForAction/ValidateStatusForDeleteall open withif type == switch. Every rule you see in the UI (draft→active, active↔inactive, completed/expired being locked) is frontend-only enforcement; calling the API directly bypasses it. - The duplicate-title check uses different conditions on create than on edit — create looks only at rows with
status NOT IN ('delete'), while edit checkstitle+line_oa_id+id != selfwithout excluding deleted rows. So a title once used by a since-deleted menu can be reused for a new menu but cannot be applied to an existing one by editing (that returns 400"Title is already exist"). - The dates you type are treated as Bangkok time verbatim — the frontend sends a
YYYY-MM-DD HH:mmstring, and the backend appends:00+07:00before parsing (accepting both a space and aTseparator). If parsing fails it silently storesNULLrather than erroring. - The
containerOriginalused to scale custom-layout tap areas is stored for both layout 14 and 15 (14 =480×324, 15 =480×162), but the code that converts user-drawn areas into real image coordinates (imagemap) only runs for layout 14 — layout 15 therefore publishes zero tap areas to LINE (details on the Rich menu default page).
Related menus in this journey
- Segment audience — always required unless the automation switch is on, and the source of the LINE-user CSV used to bind the menu. If that audience has no list ready yet, the menu turns "Completed" without anyone ever having seen it.
- Switch rich menu — a custom menu can act as the "parent menu" for submenus; a submenu's status is cascaded when the parent expires or is deleted, and whether a submenu can be edited at all depends on this custom menu's status.
- Rich menu default — the same form and the same module; the default menu simply has no date window or audience, and is set as the OA's default instead.
- Workflow / Trigger rule — what actually applies a menu with the "Automation" switch on, through the
line_change_richmenuqueue as asetRichMenuByTriggerRulejob.
Things to watch out for
- A successful save doesn't mean users can see the menu — you have to wait for the start date/time, then up to 5 more minutes for the background job to bind it (a large group takes longer, since binding runs 500 users at a time).
- "Completed" doesn't mean the campaign is finished — it means "everyone has been bound," and once it's reached, editing and deleting are both closed off. Make content changes before the start date.
- An empty target group turns the menu "Completed" instantly without ever showing it to anyone — check the member count in Segment audience before setting a menu up.
- Deleting a parent menu deletes its submenus — check whether any Switch rich menu submenus are still in use first.
- The date picker pre-fills the end time as 11:59, not 23:59 — if you want the menu to run to the end of the day, change the time yourself.
- Duplicating a menu is supported in the form (via a
copyquery parameter), but the button on the list page is commented out, so there's no way to reach it from the UI. - The status filter on the list page has no "Completed" option even though the table can render that status tag — you can't isolate completed menus with a filter.
Access control (verified against the source)
- Frontend — this page is wrapped in a
PermissionGuardchecking theVIEWaction on therich-menu-customsubject, a separate subject fromrich-menu-defaultandrich-menu-switch. - 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 close just the custom type at the backend level. - Audience reads are always caller-scoped — the lookup that precedes the CSV read binds both the caller's
line_oa_idandorganization_id, so passing another organization'saudienceIdreturns 400APP_007rather than someone else's data. - Cross-tenant (IDOR) protection —
findById(which bothPUTandDELETEcall as their first step) always binds the caller'sline_oa_id,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 — the real enforcement is the module-level ModuleGate.
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/custom/page.tsx— list entry point (PermissionGuardsubjectrich-menu-custom)src/components/rich-menu/list/rich-menu-custom-filter.tsx— the custom-type-specific 3-field filtersrc/components/rich-menu/list/rich-menu.container.tsx— audience/period columns, the "Automation" tag, edit-and-delete disable rules and tooltipssrc/components/rich-menu/form/default/rich-menu-default-form.container.tsx—validateCustomRichMenuDatesAndStatus, FormData assembly (switching betweenisForAutomationandaudienceId)src/components/rich-menu/form/default/rich-menu-default-form.tsx— the automation switch, audience field, date-range pickersrc/components/rich-menu/form/default/components/RichMenuStatusRadioGroup.tsx— status options derived from the actually-stored statussrc/services/rich-menu.service.ts,src/services/audience.service.ts— the endpoints the frontend calls
Backend (line-management-cms-api-go)
internal/modules/richmenu/create_update.go—processRichMenuAudience,initDuckDbTempTableByAudienceID,getAudienceByIDScoped, the LINE call sequenceinternal/modules/richmenu/update.go— the submenu status cascade, the edit-time duplicate-title checkinternal/modules/richmenu/service.go—setFilter(overlapping date-range filter for the custom type),Deleteand the submenu cascadeinternal/modules/richmenu/controller.go— routes andModuleGate("rich-menu")
Worker (line-management-worker-go)
internal/richmenu/service_cron.go—cronSetRichMenuCustom(binding 500 at a time),cronDeleteRichMenuCustom(unlinking and settingexpired),expireCustomRichMenusWithoutCompletedLinkinternal/cronscheduler/rich_menu_lifecycle_scanner.go— the 5-minute cron cadence and why it isn't every minute
See the documentation team's technical write-up at Rich Menu (Default / Custom / Switch)