Skip to main content

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​

  1. Click create, opening the form at /rich-menu/form?type=custom.
  2. Enter a title — must not duplicate another rich menu in the same OA.
  3. 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).
  4. 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.
  5. 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).
  6. 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).
  7. 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​

  1. Once the start date/time arrives, a background job (running every 5 minutes) picks up menus that are active and not yet bound to any user.
  2. It reads the stored LINE user list, de-duplicates it, and calls LINE to link the menu in batches of 500 users.
  3. When done it writes the bound user count into the total column and flips the status to "Completed" — that status means "everyone has been linked," not "the campaign is over."
  4. 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's lineOaId + 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 the rich_menu_member table. 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_richmenu queue; the worker picks up menus where type = custom, status = active, complete_link_user = false, start_date <= now < end_date and calls linkRichMenuIdToUsers in batches of 500 (LINE's own cap), then updates complete_link_user = true, total = user count, status = completed in 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 completed without ever having been bound to anyone (the code checks: no rich_menu_member row, or a row with an empty list → write completed and 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 = true and audience_id = NULL immediately, so the cron's complete_link_user = false condition 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 with complete_link_user = true past their end date and flips them to expired.
  • A stranded-menu sweep runs before anything that talks to LINE — the cron opens with a single UPDATE ... RETURNING id that expires custom menus past their end date which were never bound (complete_link_user = false). That ordering is intentional: every later phase returns 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 expired or delete makes the system find every switch-type menu whose parent_id is 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 / ValidateStatusForDelete all open with if 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 checks title + line_oa_id + id != self without 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:mm string, and the backend appends :00+07:00 before parsing (accepting both a space and a T separator). If parsing fails it silently stores NULL rather than erroring.
  • The containerOriginal used 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).
  • 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_richmenu queue as a setRichMenuByTriggerRule job.

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 copy query 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 PermissionGuard checking the VIEW action on the rich-menu-custom subject, a separate subject from rich-menu-default and rich-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 the rich_menu table, distinguished only by a type column) — closing the rich-menu module 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_id and organization_id, so passing another organization's audienceId returns 400 APP_007 rather than someone else's data.
  • Cross-tenant (IDOR) protection — findById (which both PUT and DELETE call as their first step) always binds the caller's line_oa_id, recalculate-stat verifies ownership before queueing (a miss returns 404), and every soft-delete statement is scoped by line_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/:id is a soft delete; DELETE /api/rich-menu/:id/hard requires Super Admin plus a confirm=true query parameter.

Source file reference​

Frontend (cms-web-2026)

  • src/app/rich-menu/custom/page.tsx — list entry point (PermissionGuard subject rich-menu-custom)
  • src/components/rich-menu/list/rich-menu-custom-filter.tsx — the custom-type-specific 3-field filter
  • src/components/rich-menu/list/rich-menu.container.tsx — audience/period columns, the "Automation" tag, edit-and-delete disable rules and tooltips
  • src/components/rich-menu/form/default/rich-menu-default-form.container.tsx — validateCustomRichMenuDatesAndStatus, FormData assembly (switching between isForAutomation and audienceId)
  • src/components/rich-menu/form/default/rich-menu-default-form.tsx — the automation switch, audience field, date-range picker
  • src/components/rich-menu/form/default/components/RichMenuStatusRadioGroup.tsx — status options derived from the actually-stored status
  • src/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 sequence
  • internal/modules/richmenu/update.go — the submenu status cascade, the edit-time duplicate-title check
  • internal/modules/richmenu/service.go — setFilter (overlapping date-range filter for the custom type), Delete and the submenu cascade
  • internal/modules/richmenu/controller.go — routes and ModuleGate("rich-menu")

Worker (line-management-worker-go)

  • internal/richmenu/service_cron.go — cronSetRichMenuCustom (binding 500 at a time), cronDeleteRichMenuCustom (unlinking and setting expired), expireCustomRichMenusWithoutCompletedLink
  • internal/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)