Content Management — Content Links
Overview
A Content Link is a token-bearing link that doesn't point at one specific article. Instead it stores content-selection criteria (category, subcategory, published date range, audiences). When a recipient opens the link, the system fetches whatever content pages match those criteria at that moment and renders them as a list. Marketing therefore builds the link once, embeds it in a rich menu or a campaign, and the content the user sees updates itself as new matching articles appear — no editing the link again.
The one thing to understand up front: the "Audience Filter" field filters content; it does not restrict who can open the link — exactly as the form's own tooltip says, "Content will be shown if it has ANY overlap with selected audiences." Anyone holding the token can open the link; only the list of content they see is filtered.
Who uses it — marketing staff who want an article-roundup link they don't have to update every time new content ships (this module has no ModuleGate on the backend — see Access control below).
User journey
1. Review the link list
Open Content Management > Content Links. The page heading reads Content Links and the table has 6 columns:
- Name — click to open the edit dialog; the description shows as a second line (truncated at 50 characters).
- Filters — renders the criteria as colored tags: category (blue), subcategory (cyan), date range (a calendar tag formatted
MM/DD), audience count (purple) — with no criteria at all it shows "No filters (all content)". - Status — a green/orange tag (active/inactive).
- Clicks — a blue count badge, shown even at zero.
- Created — formatted
MMM DD, YYYY. - Actions — a quick copy-link button plus a three-dot menu.
Three filters sit above — search links, Filter by status (active/inactive) and Filter by category (fed by the Categories menu's dropdown endpoint).
2. Create or edit a link
Click Create Link to open a 600px-wide modal — no page navigation. The form holds:
- Link Name — required, placeholder "e.g., News Articles, Product Updates".
- Description — optional, described as being for internal reference.
- A grey box headed Filter Criteria (leave empty to show all content) containing 4 fields:
- Category — placeholder "All categories"
- Subcategory — disabled until a category is chosen (placeholder becomes "Select a category first"); once chosen it loads the hierarchical dropdown with
├─/└─connectors from the Subcategories menu, and changing the category clears this field every time - Published Date Range — a RangePicker
- Audience Filter — multi-select from the Segment audience menu, placeholder "All audiences (no filter)"
- Status — an active/inactive switch, defaulting to active.
Click Create / Update and the dialog closes as the table reloads. The token is generated server-side on the first save; there's no field for it.
3. Copy the link for use
Click the copy button (or the Copy Link menu item). Two link shapes come out:
- Audiences set, and the OA has a LIFF ID →
https://liff.line.me/{liffId}/{lineOaHash}/pages/{token}with the message "LIFF link copied! (Opens in LINE app)" - Everything else →
{NEXT_PUBLIC_BASE_APP_URL}/{lineOaHash}/pages/{token}with "Link copied to clipboard"
Embed the link in a rich menu (Rich menu), a campaign (Campaign), or a menu item in Menu Builder.
4. Regenerate the token, or delete the link
From the three-dot menu:
- Regenerate Token — a confirmation titled "Regenerate Token" names the link and adds a red warning: "The old link will stop working immediately." The confirm button reads Regenerate and is styled danger.
- Delete — a confirmation titled "Delete Content Link" names the link (a soft delete).
Key details verified against the source
Confirmed from internal/modules/contentlink/{controller,service,preview,dto}.go in line-management-cms-api-go and internal/publiccontent/service.go in line-management-client-api-go:
- Audience filtering on the customer side works as a three-rung ladder — when a user opens the link through the public API (
line-management-client-api-go), the logic is: (1) if the link has audiences set, filter by the link's audiences; (2) otherwise, if the viewer carries audiences from LIFF, filter by the user's audiences; (3) if neither exists, show public content only. Note that rung (1) always beats rung (2) — so once you set audiences on the link, the viewer's own audiences are never consulted at all. - A failed LIFF token verification is silently swallowed — the public API makes a best-effort attempt to read the viewer's audiences from
x-liff-token; if verification fails, it returns no error and carries on with no user-audience data. That confirms again that audiences on this link are not an access gate, unlike a single content page (the Content Pages menu), which answers 403 "Authentication required for this content" when the LIFF token is missing. - The click counter is only incremented by the public API, and it's incremented before the content is fetched —
UPDATE content_link SET click_count = click_count + 1lives inline-management-client-api-go(thepubliccontentmodule); nowhere in cms-api writes that column. So previewing from the CMS never moves the number — and because the increment happens before the content query, a request that ends up with an empty list or an error has still been counted. - An inactive link answers 404, not 403 — the public API looks it up via
FindActiveByToken, which requiresstatus = active, so flipping the switch gives "Content link not found or inactive." Deactivating and regenerating the token therefore look identical to the user; the difference is that deactivating is reversible. - Only
publishedcontent pages are ever selected — both the CMS preview endpoint and the public API always filtercontent_page.status = 'published'anddeleted_date IS NULL, ordering bypublished_at DESC. Drafts and archived pages never surface, even if they match every category criterion. - A subcategory criterion automatically pulls in every descendant level —
getAllDescendantSubcategoryIds()walks the children recursively (filteringline_oa_id+deleted_date IS NULL) and puts the whole set into asubcategory_id IN (...)condition. Pick an L2 branch and you get its L3 and L4 content too, without selecting them. - The preview's audience filter is written as raw jsonb SQL and deliberately lets public content through — the condition is
audience_ids = '[]'::jsonb OR audience_ids IS NULL OR EXISTS (... elem::int = ANY(...)), which means a content page with no audience restriction always appears in an audience-filtered link rather than being filtered out. - The published-date range uses midnight of the chosen dates and compares inclusively against a timestamp — the form sends dates as
YYYY-MM-DD(no time), the backend parses them as midnight UTC, then comparespublished_at >= fromandpublished_at <= to. The consequence: content published on the same day as the end date but after midnight doesn't match. To include the final day, set the end date one day later. audienceIdsis always normalized to[]and can never benull— both create and update coerce a missing ornullvalue into jsonb[], as the source comment records (dto.audienceIds ?? []), which is why "does it have audiences?" checks can rely on array length alone.PATCHgenuinely distinguishes "field omitted" from "field sent as null" — the DTO carries...Setflags separate from the values, so sendingcategoryId: nullclears the column to NULL while omitting the key entirely leaves the stored value alone. The frontend relies on this deliberately (it sendsvalues.categoryId ?? nullwith the comment "Explicitly set null when undefined to ensure update works").- Sending a non-string
name/descriptionyields a 500 — reproducing the original TypeScript'svalue?.trim()TypeErrorwith the messagevalue.trim is not a function(the same as the Subcategories module). GET /content-links/:idseparates 403 from 404 — the query looks the row up byid + deleted_date IS NULLwithout filteringline_oa_id, then callsvalidateOwnership. Another OA's id answers 403 "You do not have permission to access this content link"; a nonexistent id answers 404 "Content link not found."- The public API caps pagination depth —
skip > 500(page 51 at limit 10, for instance) returns a 400 "Page number exceeds maximum allowed," preventing someone from walking the entire dataset through a public link.
Related menus in this journey
- Categories — the primary selection criterion, and also a filter on the list page (calling the same
dropdownendpoint). - Subcategories — the second criteria layer, where picking one branch automatically covers all its descendants.
- Content Pages — the destination content this link selects (published pages only), and the
audienceIdsset on each individual page also affect the filtering. - Segment audience — the source of the groups in the audience filter, and what decides whether the copied link comes out as LIFF or plain web.
- Menu Builder — menu items of the content-link type reference these links directly, and it's the only place in the CMS that calls
GET /content-links/:id/preview-contents, using it to render the content preview inside the menu preview. - Rich menu / Campaign — the destinations where the link gets embedded.
Things to watch out for
- This list page has no button to preview the content the link would select — the backend has a ready-to-use
GET /content-links/:id/preview-contentsreturning both the content list andlinkInfo, but the Content Links screen never calls it; only Menu Builder does. (The list page file still carries unused imports of theEyeOutlinedandFilterOutlinedicons, suggesting such a button was once planned.) - The search box needs Enter pressed twice — the search handler sets state and immediately calls the fetch on the next line, which still reads the old query text from its closure, and the fetching effect doesn't list
searchTextamong its dependencies (the same behavior as the Categories page). - If
NEXT_PUBLIC_BASE_APP_URLisn't configured, copied links point athttps://client.example.com/...— the code uses that as a hard-coded fallback with no warning (unlike Content Pages, whose fallback iswindow.location.origin). - The token is the only thing protecting the link — audiences merely filter the content list, and a failed LIFF verification is swallowed. If a token leaks, use "Regenerate Token" or flip the status to inactive.
- Regenerating kills the old link instantly, with no grace period — if the old link is already embedded in a rich menu, in a campaign that's been sent, or in a Menu Builder item, you have to go fix every one of those destinations yourself.
- Leaving every criterion empty means "show every published content page on this OA" — the form says so outright ("leave empty to show all content") and the table displays "No filters (all content)". Watch for links created before the criteria were filled in.
- In the edit dialog the
subcategoryIdvalue is applied viasetTimeout(..., 300)to wait for the hierarchical dropdown to load — on a slower connection the subcategory field can show as empty even though a real value is stored. Close and reopen the dialog if that happens. nameis capped at 255 characters on the backend, but the form neither limits nor counts it — whiledescriptionhas no length cap on either side.- Any other user on the same LINE OA can edit, delete or regenerate every link —
validateOwnershiponly comparesline_oa_id.
Access control (verified against the source)
- Frontend — the list page is not wrapped in
PermissionGuard. The only permission check is sidebar rendering, testingability.can(VIEW, 'content-management')across the whole Content Management group — typing/content-linksdirectly still opens the page without that permission. - Backend — all 7 routes declare policies (
READALL/READ/CREATE/UPDATE/DELETEon theline-oasubject) but run throughauth.CheckPolicies, a no-op that lets every request through. Real enforcement is JWT plusvalidateOwnership'sline_oa_idcomparison (Super Admin bypasses everything). Note also that regenerating a token is annotated with theCREATEpolicy, notDELETE, even though its effect is to break the existing link. - There is no
ModuleGatein this module — inspectingcontentlink/controller.goturns up nomodulegate.ModuleGate(d, "...")call (unlike thecampaignmodule, which has one), and the module appears in neither thegatedModulesnor theexemptModuleslist inmodulegate/coverage_test.go— so a platform admin cannot close this module for an organization. - There are no public (
@Public) routes in this module at all — the customer-side read happens inline-management-client-api-go(thepubliccontentmodule), which looks the link up by token plus active status and needs no JWT by design. - Data scope is bounded by LINE OA on
GET /content-links(the caller'sline_oa_idis always the first condition) — whilefindOne,update,remove,regenerate-tokenandpreview-contentslook the row up by id first and check permission afterwards. - The LINE OA data attached to responses is reduced to 3 fields (
id,lineOaName,lineOaHash) by a sanitize function — the frontend takeslineOaHashfrom there to assemble links.
Source file reference
Frontend (cms-web-2026)
src/app/content-links/page.tsx— the 6-column table, criteria tags, LIFF-vs-web URL assembly, the regenerate and delete confirmationssrc/app/content-links/components/ContentLinkFormModal.tsx— the modal form, the category → subcategory chaining, the deliberatenullsends on updatesrc/services/content-link.service.ts— the endpoints the frontend calls, including thegetPreviewContentsonly Menu Builder usessrc/components/menu-builder/MenuPreviewEnhanced.tsx— the CMS's only caller ofpreview-contentsmessages/th/content-links.json,messages/en/content-links.json— the full on-screen copy, including the tooltip stating audiences are a filter
Backend (line-management-cms-api-go)
internal/modules/contentlink/controller.go— all 7 routes, the absent ModuleGate,jsNumberIDinternal/modules/contentlink/service.go— create/update/remove/regenerate-token, LINE OA sanitization, theaudienceIds→[]normalization,parseJSDateinternal/modules/contentlink/preview.go—getAllDescendantSubcategoryIds, the jsonb audience SQL, thepublished_at DESCorderinginternal/modules/contentlink/dto.go— validation rules,optionalChainTrimand the 500 behaviorinternal/entities/content_link.go— theBeforeCreatehook generating the 32-byte tokeninternal/modules/modulegate/coverage_test.go— the list of genuinely gated modules and the exempt ones
Backend (line-management-client-api-go)
internal/publiccontent/service.go—GetContentsByLinkToken, the three-rung audience ladder, the swallowed LIFF error, theskip > 500capinternal/contentlink/repository.go—IncrementClickCount(the only place in the system that raises the click counter)
See the documentation team's earlier technical write-up at Content Links