Skip to main content

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​

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).

Click Create Link to open a 600px-wide modal — no page navigation. The form holds:

  1. Link Name — required, placeholder "e.g., News Articles, Product Updates".
  2. Description — optional, described as being for internal reference.
  3. 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)"
  4. 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.

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.

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 + 1 lives in line-management-client-api-go (the publiccontent module); 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 requires status = 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 published content pages are ever selected — both the CMS preview endpoint and the public API always filter content_page.status = 'published' and deleted_date IS NULL, ordering by published_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 (filtering line_oa_id + deleted_date IS NULL) and puts the whole set into a subcategory_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 compares published_at >= from and published_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.
  • audienceIds is always normalized to [] and can never be null — both create and update coerce a missing or null value into jsonb [], as the source comment records (dto.audienceIds ?? []), which is why "does it have audiences?" checks can rely on array length alone.
  • PATCH genuinely distinguishes "field omitted" from "field sent as null" — the DTO carries ...Set flags separate from the values, so sending categoryId: null clears the column to NULL while omitting the key entirely leaves the stored value alone. The frontend relies on this deliberately (it sends values.categoryId ?? null with the comment "Explicitly set null when undefined to ensure update works").
  • Sending a non-string name/description yields a 500 — reproducing the original TypeScript's value?.trim() TypeError with the message value.trim is not a function (the same as the Subcategories module).
  • GET /content-links/:id separates 403 from 404 — the query looks the row up by id + deleted_date IS NULL without filtering line_oa_id, then calls validateOwnership. 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.
  • Categories — the primary selection criterion, and also a filter on the list page (calling the same dropdown endpoint).
  • 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 audienceIds set 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-contents returning both the content list and linkInfo, but the Content Links screen never calls it; only Menu Builder does. (The list page file still carries unused imports of the EyeOutlined and FilterOutlined icons, 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 searchText among its dependencies (the same behavior as the Categories page).
  • If NEXT_PUBLIC_BASE_APP_URL isn't configured, copied links point at https://client.example.com/... — the code uses that as a hard-coded fallback with no warning (unlike Content Pages, whose fallback is window.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 subcategoryId value is applied via setTimeout(..., 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.
  • name is capped at 255 characters on the backend, but the form neither limits nor counts it — while description has no length cap on either side.
  • Any other user on the same LINE OA can edit, delete or regenerate every link — validateOwnership only compares line_oa_id.

Access control (verified against the source)​

  • Frontend — the list page is not wrapped in PermissionGuard. The only permission check is sidebar rendering, testing ability.can(VIEW, 'content-management') across the whole Content Management group — typing /content-links directly still opens the page without that permission.
  • Backend — all 7 routes declare policies (READALL/READ/CREATE/UPDATE/DELETE on the line-oa subject) but run through auth.CheckPolicies, a no-op that lets every request through. Real enforcement is JWT plus validateOwnership's line_oa_id comparison (Super Admin bypasses everything). Note also that regenerating a token is annotated with the CREATE policy, not DELETE, even though its effect is to break the existing link.
  • There is no ModuleGate in this module — inspecting contentlink/controller.go turns up no modulegate.ModuleGate(d, "...") call (unlike the campaign module, which has one), and the module appears in neither the gatedModules nor the exemptModules list in modulegate/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 in line-management-client-api-go (the publiccontent module), 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's line_oa_id is always the first condition) — while findOne, update, remove, regenerate-token and preview-contents look 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 takes lineOaHash from 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 confirmations
  • src/app/content-links/components/ContentLinkFormModal.tsx — the modal form, the category → subcategory chaining, the deliberate null sends on update
  • src/services/content-link.service.ts — the endpoints the frontend calls, including the getPreviewContents only Menu Builder uses
  • src/components/menu-builder/MenuPreviewEnhanced.tsx — the CMS's only caller of preview-contents
  • messages/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, jsNumberID
  • internal/modules/contentlink/service.go — create/update/remove/regenerate-token, LINE OA sanitization, the audienceIds → [] normalization, parseJSDate
  • internal/modules/contentlink/preview.go — getAllDescendantSubcategoryIds, the jsonb audience SQL, the published_at DESC ordering
  • internal/modules/contentlink/dto.go — validation rules, optionalChainTrim and the 500 behavior
  • internal/entities/content_link.go — the BeforeCreate hook generating the 32-byte token
  • internal/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, the skip > 500 cap
  • internal/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