Content Management — Content Pages
Overview
Content Pages is where you build and edit the LINE OA's standalone content pages — articles, promotion pages, service information — published for customers to open via a link or QR code, with no extra web development. Content is stored as HTML edited through a Rich Text Editor (Tiptap), and one record holds both Thai and English versions.
The one thing to understand up front: a content page has exactly one slug, even though the form shows a separate "URL Slug" field on both the Thai and English tabs. On save the frontend picks a single value (content.th.slug || content.en.slug) — the English slug is only used when the Thai side has none, and when you reopen a page for editing that one slug is loaded back into both tabs.
Who uses it — marketing or content staff who need to publish self-serve information pages (this module has no ModuleGate on the backend — see Access control below).
User journey
1. Review the content page list
Open Content Management > Content Pages. The table shows No., Content, Category, Visibility, Status, Views, Updated, plus an actions column pinned to the right.
- The "Content" column shows the title with badges for the languages that actually exist (TH in blue / EN in green), a lime FT badge when Friends Track is linked, and the slug in monospace underneath (truncated at 30 characters).
- The "Category" column renders as a badge pair,
Category › Subcategory. - The "Visibility" column shows a Public badge when no audience restriction is set, or a purple "N audiences" badge whose tooltip lists every restricted audience by name.
- The filter bar has 2 controls — search content, and Status (All / Published / Draft / Archived). Both reset to page 1 on change. The right side of the toolbar shows the total page count.
2. Create a new content page
Click Create Content to open the two-column form — the left is a 4-tab editor, the right is a live preview with three device frames (desktop = full width, tablet = 768px, mobile = 375px) plus a Search result preview card below that mocks up how the page would look in Google.
- Content tab — pick a Category (optional) sourced from the Categories menu first. The Subcategory field stays disabled until a category is chosen (its placeholder reads "Select a category first"); once chosen, the system fetches the hierarchical dropdown from the Subcategories menu and shows the hint "Subcategories support up to 4 levels of hierarchy" — and changing the category clears any selected subcategory every time.
- Below that are the language tabs ภาษาไทย (TH) / English (EN), each with Title, URL Slug and Content. Typing a title auto-generates the slug (overwriting anything you'd edited by hand on every keystroke). The Auto-translate button in the tab bar is still a placeholder — clicking it pops an alert reading "Translation feature coming soon!"
- The collapsible SEO Settings panel per language holds Meta Title, Meta Description (counted, max 160), Excerpt (max 200), OG Title (max 160), OG Description (max 200) and OG Image URL.
- Access Control tab — select Audience Restriction across multiple groups from the Segment audience menu, and/or flip on Password Protection. Those two can be combined, but the audience field is disabled the moment a Friends Track campaign is linked, and vice versa.
- Friends Track tab — pick an active friend-add campaign from the Friend track menu. Once selected, a green card shows the campaign's name and token, along with a CTA Button Text field (max 50 characters, defaults to "Add Friend") and a preview of the LINE-green (
#06C755) button that will sit sticky at the bottom of the live content page. - Campaign Settings tab — UTM parameters (utmSource/utmMedium are tag-mode selects with preset options; utmCampaign/utmTerm/utmContent are text fields) and analytics (Google Analytics Events as JSON, Facebook Pixel Events, LINE Tag Events, custom head scripts).
- Click Save as Draft or Publish — both buttons stay disabled until at least one language has a title or content, and before firing the API the form re-checks twice more: "Please enter content in at least one language" and "Please provide a URL slug for at least one language."
3. Edit and flip status
Click the pencil icon in the table to open the edit page (/content-management/edit?id=…). It mirrors the create form but adds a status badge next to the heading, and the button bar changes to match the current status:
- Draft → Save Changes and Publish
- Published → Save Changes and Unpublish (which sends
status = draftback) - The password field's placeholder changes to "Leave empty to keep current password."
4. Distribute to other channels
From the list, click the link icon to copy the link (disabled if the page has no publicToken yet, or if the system doesn't yet know the LINE OA hash), or the QR icon to open a 256px QR dialog offering Download PNG, Copy Link and close. The link shape depends on the settings:
- Audience-restricted + the OA has a LIFF ID →
https://liff.line.me/{liffId}/{lineOaHash}/content/…(confirmation reads "LIFF link copied! (opens in the LINE app)") - No restriction →
{BASE_APP_URL}/{lineOaHash}/content/…
Take that link into the Menu Builder menu, a rich menu in Rich menu, or a broadcast in Campaign.
Key details verified against the source
Confirmed from internal/modules/contentpage/{controller,service,dto,helpers}.go and internal/modules/publicmod/controller.go in line-management-cms-api-go:
- The slug format is strictly enforced on the backend and not validated at all on the frontend — the backend applies the regex
^[a-z0-9-]+$and replies "Slug must contain only lowercase letters, numbers, and hyphens" (max 255 characters), while the form lets you type anything into the URL Slug field. Hand-edit a slug with an uppercase letter, underscore or Thai character and you get rejected on save, not while typing. The frontend's auto-slug generator carries its own Thai-to-Latin transliteration table (ก→k, ข/ฃ/ค/ฅ/ฆ→kh, เ→e, ไ/ใ→ai and so on) and then strips whatever is left over — Thai tone marks and the thanthakhat aren't in the table, so they silently vanish. - Slug uniqueness is per LINE OA, not global — checked against
line_oa_id + slug + deleted_date IS NULL, returning 409 "A content page with this slug already exists" on collision. On update the check only runs when the slug actually differs from the stored one, so re-saving without touching the slug can never be wrongly rejected. - Audience restriction and Friends Track are mutually exclusive, and the backend really enforces it — both create and update reject a payload carrying at least one
audienceIdsentry together with afriendTrackCampaignId, with a 400 spelling out the reason: "Audience Restriction requires authentication which blocks the publicly accessible Friend Track CTA." Crucially, on update the check doesn't look only at what you sent — it merges the stored row with the incoming payload first, then checks. So sending a singleaudienceIdsvalue while editing a page that already has Friends Track linked is still rejected. - The public token is issued once and never rotates — on the first save with status
publishedthe backend generatescrypto.randomBytes(32)as a 64-character hexpublic_tokenand stampspublishedAt/publishedBy. But on update the condition for issuing a new token is simply "there isn't one yet" — so no matter how many times you unpublish and republish, the link stays identical. And this menu has no re-issue-token button at all (unlike Content Links and Menu Builder, which both do). - View count increments on every load, and the "unique" counter never moves —
GET /api/public/content/:tokenbumpsview_countby 1 on every successful read (including plain refreshes and previews opened from the list).unique_view_countis only ever raised by the session-recordingtrackViewfunction — but the tracking endpoint's handler merely confirms the content exists and returns "View tracked successfully" without writing anything to the database (a source comment traces this to the original's note that "we'll skip the actual tracking implementation"). The consequence is that the entire analytics set (viewsByDate, topReferrers, deviceTypes) has no data feeding it. - Two public routes read the same page, and they answer differently on a bad password —
GET /api/public/content-pages/:tokenreplies 403 "Password required" / "Invalid password", whereasGET /api/public/content/:token(the one the customer-facing web app actually calls) replies 200 with a body of{ requirePassword: true, message: "…" }so the page can render a password form. The latter route also checks audience restriction via thex-liff-tokenheader (no header → 403 "Authentication required for this content"), while the former doesn't check audience restriction at all. - Delete is a column-scoped soft delete —
DELETE /api/content-pages/:idreturns an empty 204 and writes exactly 3 columns (deleted_date,deleted_by,updated_date), deliberately leavingupdated_byuntouched; a source comment explains this reproduces the original TypeORM partial-select behavior. - Editor image uploads are only type-restricted on the backend — the Rich Text Editor accepts
accept="image/*"and uploads from the button, drag-and-drop and clipboard paste alike, but the backend only allowsimage/jpeg,image/jpg,image/png,image/gif(10MB max). WebP, AVIF and SVG therefore sail past the frontend and get rejected with "Invalid file type. Only JPEG, PNG, and GIF are allowed." Successful uploads are namedcontent-image-{timestamp}in a folder keyed by LINE OA hash.
Related menus in this journey
- Categories — the source of the "Category" options on the Content tab (fetched from a dedicated dropdown endpoint).
- Subcategories — loaded on demand for the chosen category, using a hierarchical dropdown indented to show the tree shape.
- Content Links — the sibling menu that filters against this same category/subcategory tree and bundles every matching content page behind one link.
- Segment audience — the source of audience groups on the Access Control tab, and what decides whether the copied link comes out as a LIFF or a plain web URL.
- Friend track — the source of friend-add campaigns on the Friends Track tab (active status only); the content page then becomes that campaign's landing page.
- Menu Builder — menu items of the "content page" type reference published pages from this menu directly.
- Rich menu / Campaign — the destinations where the copied link or QR code gets embedded.
Things to watch out for
- The copied link uses the slug, not the token — the list page builds the path from
record.slug || record.publicToken(slug always wins, and slug is always required), but the public reader page andGET /api/public/content/:tokenlook the page up strictly bypublic_token. If a copied link opens to "Content not found," check the page'spublicTokenvalue first. - The copy-link and QR buttons are disabled while the system doesn't know the LINE OA hash — that value comes from a separate LINE OA fetch after the page loads, and if that fetch fails it only logs to the console while the buttons stay quietly disabled.
- The Archived status exists in the filter and the backend has a
POST /content-pages/:id/archiveendpoint for it, but nothing in the UI ever sets it — so that filter option returns nothing in practice. - The "Generated URLs" preview on the Campaign Settings tab shows
{origin}/th/{slug}and{origin}/en/{slug}, which is not the real link shape the system copies for you ({base}/{lineOaHash}/content/…). Read it as a UTM illustration only. - The slug field is overwritten by the auto-generated value on every title edit — if you want a custom slug, finish the title first and then fix the slug.
- Other users in the same organization can edit or delete your content page — the backend's ownership check passes on same user OR same organization, as long as it's the same LINE OA.
Access control (verified against the source)
- Frontend — this menu's list and form pages are not wrapped in
PermissionGuard(unlike, say, Campaign, which is). The only permission check is sidebar rendering, which testsability.can(VIEW, 'content-management')before drawing the Content Management group and all four of its children — so a user without that permission loses the menu, but typing/content-managementdirectly still opens the page. - Backend — every route declares an action-level policy (
READALL/READ/CREATE/UPDATE/DELETEon theline-oasubject), butauth.CheckPoliciesis middleware that lets every request through, as its own comment states outright: "Enforcement disabled — allow all authenticated requests (TS parity)." Real enforcement therefore comes down to JWT plus the ownership check (validateOwnership: Super Admin always passes; everyone else must be on the same LINE OA and must either own the page or share its organization). - There is no
ModuleGatein this module — inspecting the route registration incontentpage/controller.goturns up nomodulegate.ModuleGate(d, "...")call anywhere (unlike thecampaignmodule, which has one), and the list of genuinely gated modules inmodulegate/coverage_test.godoesn't include content-page either. Notably, this module also isn't in theexemptModuleslist that records modules deliberately left ungated — and per that test's own explanation, an ungated module means a platform admin cannot close this module for an organization at all, because there's nowhere the gate is applied. - The two public routes (
/api/public/content-pages/:tokenand/api/public/content/:token) require no JWT by design — protection comes from the unguessable token plus the password (bcrypt cost 10) plus the audience check viax-liff-token. - Data scope is always bounded by LINE OA —
GET /api/content-pagesfilters on the caller'sline_oa_idas its first condition every time.
Source file reference
Frontend (cms-web-2026)
src/app/content-management/page.tsx— list page, filters, copy-link/QR buttons, LIFF-vs-web URL assemblysrc/app/content-management/create-multilang/page.tsx,src/app/content-management/edit/page.tsx— the 4-tab form, live preview, save/publish/unpublish button logicsrc/components/content-management/MultiLanguageForm.tsx— language tabs, auto-slug generation, collapsible SEO panelsrc/components/content-management/TiptapEditorSimple.tsx— the Rich Text Editor and its image uploads (button / drag-drop / paste)src/utils/slugify.ts— the Thai-to-Latin transliteration tablesrc/services/content-page.service.ts— every endpoint the frontend callssrc/components/layout/sidemenu/sidemenu.container.tsx— the menu-levelcontent-managementpermission check
Backend (line-management-cms-api-go)
internal/modules/contentpage/controller.go— all 12 routes (including the 2 public ones), image upload, and the absent ModuleGateinternal/modules/contentpage/service.go— create/update/delete/duplicate logic, public-token issuance,validateOwnership, view countinginternal/modules/contentpage/dto.go— the slug regex and the full inbound validation rule setinternal/modules/publicmod/controller.go—GET /public/content/:token,liff-config, and the customer-side audience and password checksinternal/modules/auth/policy.go— the no-opCheckPoliciesinternal/modules/modulegate/coverage_test.go— the list of genuinely gated modules and the exempt ones
See the documentation team's earlier technical write-up at Content Page Management