Skip to main content

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.

  1. 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.
  2. 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!"
  3. 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.
  4. 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.
  5. 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.
  6. 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).
  7. 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 = draft back)
  • 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 audienceIds entry together with a friendTrackCampaignId, 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 single audienceIds value 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 published the backend generates crypto.randomBytes(32) as a 64-character hex public_token and stamps publishedAt/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/:token bumps view_count by 1 on every successful read (including plain refreshes and previews opened from the list). unique_view_count is only ever raised by the session-recording trackView function — 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/:token replies 403 "Password required" / "Invalid password", whereas GET /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 the x-liff-token header (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/:id returns an empty 204 and writes exactly 3 columns (deleted_date, deleted_by, updated_date), deliberately leaving updated_by untouched; 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 allows image/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 named content-image-{timestamp} in a folder keyed by LINE OA hash.
  • 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 and GET /api/public/content/:token look the page up strictly by public_token. If a copied link opens to "Content not found," check the page's publicToken value 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/archive endpoint 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 tests ability.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-management directly still opens the page.
  • Backend — every route declares an action-level policy (READALL/READ/CREATE/UPDATE/DELETE on the line-oa subject), but auth.CheckPolicies is 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 ModuleGate in this module — inspecting the route registration in contentpage/controller.go turns up no modulegate.ModuleGate(d, "...") call anywhere (unlike the campaign module, which has one), and the list of genuinely gated modules in modulegate/coverage_test.go doesn't include content-page either. Notably, this module also isn't in the exemptModules list 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/:token and /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 via x-liff-token.
  • Data scope is always bounded by LINE OA — GET /api/content-pages filters on the caller's line_oa_id as 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 assembly
  • src/app/content-management/create-multilang/page.tsx, src/app/content-management/edit/page.tsx — the 4-tab form, live preview, save/publish/unpublish button logic
  • src/components/content-management/MultiLanguageForm.tsx — language tabs, auto-slug generation, collapsible SEO panel
  • src/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 table
  • src/services/content-page.service.ts — every endpoint the frontend calls
  • src/components/layout/sidemenu/sidemenu.container.tsx — the menu-level content-management permission 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 ModuleGate
  • internal/modules/contentpage/service.go — create/update/delete/duplicate logic, public-token issuance, validateOwnership, view counting
  • internal/modules/contentpage/dto.go — the slug regex and the full inbound validation rule set
  • internal/modules/publicmod/controller.go — GET /public/content/:token, liff-config, and the customer-side audience and password checks
  • internal/modules/auth/policy.go — the no-op CheckPolicies
  • internal/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