Skip to main content

Content Management — Categories

Overview​

Categories are the top level of the content taxonomy: they keep content pages organized, and they're the starting point for the Subcategories menu, which nests several levels deeper. Lay the category structure out before you start building real content pages, because the categories you create here become the options offered in Content Pages and Content Links.

The one thing to understand up front: deleting a category really does delete its whole subcategory subtree (a cascading soft delete on the server), but it doesn't touch content pages at all — those pages keep a categoryId pointing at a category that no longer exists.

Who uses it — admins or content staff who own the taxonomy structure (this module has no ModuleGate on the backend — see Access control below).

User journey​

1. Review the category list​

Open Content Management > Categories. The page heading reads Content Categories and the table has 7 columns — Name (clickable, opens the edit form), Slug (rendered as code), Subcategories (a link button with a count badge, blue when above 0, grey when 0), Sort Order, Status (uppercase tag, green/orange/red), Updated (formatted MMM DD, YYYY), and a three-dot menu on the right.

Two filters sit above it — a press-Enter/click search box (Search categories...) and Filter by status, which offers only active and inactive (there is no delete option).

2. Create or edit a category​

Click Create Category to open a single-column form (max 600px wide) with 5 fields:

  1. Category Name — required, max 255 characters with a live counter. Every keystroke regenerates the Slug (the hint below reads "URL-friendly identifier. Auto-generated from name.") — and the condition for regenerating is "we're in create mode or there's no existing slug," so it never overwrites the slug of an already-saved category.
  2. Slug — required, max 255 characters, validated in the form against ^[a-z0-9-]+$ with the message "Slug must contain only lowercase letters, numbers, and hyphens."
  3. Description — optional, max 1,000 characters with a counter.
  4. Sort Order — a number ≥ 0, defaulting to 0.
  5. Status — required, defaults to active, offering active / inactive.

Click Create Category / Update Category and the system bounces you straight back to the list on success.

3. Jump across to the subcategories​

Click the subcategory count in the table, or pick View Subcategories from the three-dot menu. The system routes to the Subcategories menu passing both categoryId and categoryName (URL-encoded) as query parameters, so that page can pre-filter and show the parent category's name without a second API call.

4. Delete a category​

From the three-dot menu, choose Delete. A confirmation dialog titled Delete Category names the category and adds a red warning: "This will also delete all subcategories under this category." The confirm button is styled danger.

Key details verified against the source​

Confirmed from internal/modules/contentcategory/{controller,service,dto}.go and internal/entities/content_category.go in line-management-cms-api-go:

  • The "will also delete all subcategories" warning is literally true, not just UI copy — Remove() loads the category with every subcategory preloaded, then sets deleted_date + status = delete + updated_by on the parent and on every child inside a single transaction (the original used TypeORM's cascade save on the subcategories relation). The log line even records how many subcategories went with it (subcategoriesDeleted). But the content_page rows pointing at that category are never touched.
  • Editing nothing but a category's name bumps the "Updated" timestamp of every one of its subcategories — Update() saves the category row and then re-saves every preloaded subcategory even though none of their values changed, purely so the BeforeUpdate hook fires and pushes content_subcategory.updated_date, matching what TypeORM's cascade did in the original. A source comment states plainly that this side effect is reproduced on purpose.
  • The slug is lowercased and trimmed on the backend before the regex ever runs — the DTO carries a value?.toLowerCase().trim() transform that executes ahead of @Matches(/^[a-z0-9-]+$/), so posting " My-Slug " straight to the API becomes my-slug and passes — unlike the Content Pages menu, which has no such transform and rejects uppercase outright. Same regex in both modules, different outcomes.
  • Slug uniqueness is per LINE OA, and on update it's only checked when the slug actually changes — the condition is line_oa_id + slug + deleted_date IS NULL, returning 409 "A category with this slug already exists" on collision. Worth noting: a deleted category's slug can be reused, because that condition filters deleted_date IS NULL out.
  • The subcategory count in the table includes already-deleted subcategories — content_subcategory.deleted_date is declared as a plain column (*httpx.JSTime), not gorm.DeletedAt, so Preload("Subcategories") in FindAll() pulls everything with no soft-delete filter. The count badge on the list page (computed frontend-side from record.subcategories.length) therefore still counts subcategories you already threw away. Conversely, GET /content-categories/dropdown-group does filter in the Go code (status == active && deletedDate == nil) — so the two numbers can disagree.
  • GET /content-categories/stats has the same problem, worse — the SQL uses LEFT JOIN content_subcategory and LEFT JOIN content_page with no deleted_date IS NULL condition on either joined table (only on content_category itself). Both subcategoriesCount and contentPagesCount therefore count deleted rows, and the values come back as strings, not numbers, because Postgres COUNT() is a bigint.
  • The form's sort-order explanation doesn't match the list page — the hint under "Sort Order" claims "Categories with the same sort order are sorted by name," but FindAll() orders by sort_order ASC, created_date DESC (newest first). Sorting by name is the behavior of the dropdown endpoints only (sort_order ASC, name ASC).
  • GET /content-categories/:id distinguishes "doesn't exist" from "isn't yours" — the query looks the row up by id + deleted_date IS NULL without filtering line_oa_id, then calls validateOwnership afterwards. So an id that exists but belongs to another OA answers 403 "You do not have permission to access this category" while an id that doesn't exist answers 404 "Category not found" — enough to probe which ids are real in the system.
  • A non-numeric :id yields a 500, not a 404 — parseParamID reproduces JavaScript's +id exactly: "abc" becomes NaN, which Postgres rejects, so the API returns 500 with invalid input syntax for type integer: "NaN". A source comment says this specifically reproduces the original API's behavior.
  • Status filtering has a quirk — if no status is sent, or delete is sent, the service switches to the condition status != 'delete' instead. In other words there is no way to list deleted categories through this API at all, even by asking for status=delete explicitly.
  • The description length cap is frontend-only — the form limits it to 1,000 characters, but the backend DTO only checks that it's a string, with no MaxLength at all (unlike name at 255 and thumbnail at 500, which are real).
  • Subcategories — the next level of this same taxonomy, and what gets deleted alongside a category.
  • Content Pages — pulls the category list from the dropdown endpoint (active status only) to offer as options when creating or editing a page.
  • Content Links — uses these categories as its content-selection criteria, calling the same dropdown endpoint.
  • Menu Builder — when a menu item references a content link, the category criteria set here also shape what the end user sees in that menu.

Things to watch out for​

  • The search box needs Enter pressed twice — the search handler sets the query-text state and then immediately calls the fetch on the next line, which still reads the old query text out of its closure; and the fetching effect doesn't list searchText among its dependencies. So the first search sends the previous term, and you have to press again (or change page/status) before the real results appear.
  • A Thai-only category name produces an empty slug — this menu's slug generator strips every character outside [a-z0-9\s-] with no Thai-to-Latin transliteration at all (unlike Content Pages, which ships its own transliteration table). An all-Thai name therefore leaves the slug blank, and the form then complains "Please enter slug" — you have to type the slug yourself in that case.
  • Delete a category and the content pages under it stay bound to a category that no longer exists. Move those pages elsewhere first.
  • An inactive category still appears in the list (and via the filter), but disappears from the dropdowns in Content Pages and Content Links, because the dropdown endpoint only returns active rows — a handy way to hide a category from end users without deleting it.
  • The Sort Order value affects ordering across every endpoint, but the list page has no drag-and-drop reordering — you edit the number per category in the form (unlike Subcategories, which does support dragging).
  • Any other user on the same LINE OA can edit and delete every category — this module's validateOwnership only compares line_oa_id (it doesn't check the creator or the organization the way Content Pages does).

Access control (verified against the source)​

  • Frontend — the list and form pages are not wrapped in PermissionGuard. The only permission check is sidebar rendering, which tests ability.can(VIEW, 'content-management') across the whole Content Management group (Content Pages, Categories, Subcategories and Content Links share one permission — they cannot be granted separately). Without it, the menu disappears, but typing /content-category directly still opens the page.
  • Backend — all 8 routes in this module declare policies (READALL/READ/CREATE/UPDATE/DELETE on the line-oa subject) but run through auth.CheckPolicies, which is a no-op that lets every request through. Real enforcement therefore comes down to JWT plus validateOwnership's line_oa_id comparison (Super Admin bypasses everything).
  • There is no ModuleGate in this module — inspecting contentcategory/controller.go turns up no modulegate.ModuleGate(d, "...") call anywhere (unlike the campaign module, which has one), and the module appears in neither the gatedModules nor the exemptModules list in modulegate/coverage_test.go. Per that test's own explanation, this means a platform admin cannot close the content-category module for an organization, because there's nowhere the gate is applied.
  • Data scope is bounded by LINE OA on every list-returning endpoint (findAll, dropdown, dropdown-group and stats all filter the caller's line_oa_id) — except findOne, which filters after the fact via validateOwnership instead.
  • There are no public (@Public) routes in this module at all — the category data end users see comes from the separate publicmod module (GET /api/public/contents/categories).

Source file reference​

Frontend (cms-web-2026)

  • src/app/content-category/page.tsx — the 7-column table, filters, subcategory count badge, delete confirmation
  • src/app/content-category/form/page.tsx — the 5-field form, frontend validation rules, auto-slug generator
  • src/services/content-category.service.ts — the endpoints the frontend calls (getList, getById, getDropdown, create, update, deleteById)
  • messages/th/content-category.json, messages/en/content-category.json — the full on-screen copy
  • src/components/layout/sidemenu/sidemenu.container.tsx — the menu-level content-management permission check

Backend (line-management-cms-api-go)

  • internal/modules/contentcategory/controller.go — all 8 routes, the absent ModuleGate, parseParamID and the NaN → 500 behavior
  • internal/modules/contentcategory/service.go — cascading soft delete, the subcategory re-save on update, ordering, GetCategoryStats
  • internal/modules/contentcategory/dto.go — the slug lowercase/trim transform, regex, length rules
  • internal/entities/content_category.go, internal/entities/content_subcategory.go — deleted_date as a plain column rather than gorm.DeletedAt
  • 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 Category