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:
- 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.
- 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." - Description — optional, max 1,000 characters with a counter.
- Sort Order — a number ≥ 0, defaulting to 0.
- 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 setsdeleted_date+status = delete+updated_byon the parent and on every child inside a single transaction (the original used TypeORM's cascade save on thesubcategoriesrelation). The log line even records how many subcategories went with it (subcategoriesDeleted). But thecontent_pagerows 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 theBeforeUpdatehook fires and pushescontent_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 becomesmy-slugand 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 filtersdeleted_date IS NULLout. - The subcategory count in the table includes already-deleted subcategories —
content_subcategory.deleted_dateis declared as a plain column (*httpx.JSTime), notgorm.DeletedAt, soPreload("Subcategories")inFindAll()pulls everything with no soft-delete filter. The count badge on the list page (computed frontend-side fromrecord.subcategories.length) therefore still counts subcategories you already threw away. Conversely,GET /content-categories/dropdown-groupdoes filter in the Go code (status == active && deletedDate == nil) — so the two numbers can disagree. GET /content-categories/statshas the same problem, worse — the SQL usesLEFT JOIN content_subcategoryandLEFT JOIN content_pagewith nodeleted_date IS NULLcondition on either joined table (only oncontent_categoryitself). BothsubcategoriesCountandcontentPagesCounttherefore count deleted rows, and the values come back as strings, not numbers, because PostgresCOUNT()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 bysort_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/:iddistinguishes "doesn't exist" from "isn't yours" — the query looks the row up byid + deleted_date IS NULLwithout filteringline_oa_id, then callsvalidateOwnershipafterwards. 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
:idyields a 500, not a 404 —parseParamIDreproduces JavaScript's+idexactly:"abc"becomes NaN, which Postgres rejects, so the API returns 500 withinvalid 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
statusis sent, ordeleteis sent, the service switches to the conditionstatus != 'delete'instead. In other words there is no way to list deleted categories through this API at all, even by asking forstatus=deleteexplicitly. - 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
MaxLengthat all (unlikenameat 255 andthumbnailat 500, which are real).
Related menus in this journey
- Subcategories — the next level of this same taxonomy, and what gets deleted alongside a category.
- Content Pages — pulls the category list from the
dropdownendpoint (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
dropdownendpoint. - 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
searchTextamong 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
validateOwnershiponly comparesline_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 testsability.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-categorydirectly still opens the page. - Backend — all 8 routes in this module declare policies (
READALL/READ/CREATE/UPDATE/DELETEon theline-oasubject) but run throughauth.CheckPolicies, which is a no-op that lets every request through. Real enforcement therefore comes down to JWT plusvalidateOwnership'sline_oa_idcomparison (Super Admin bypasses everything). - There is no
ModuleGatein this module — inspectingcontentcategory/controller.goturns up nomodulegate.ModuleGate(d, "...")call anywhere (unlike thecampaignmodule, which has one), and the module appears in neither thegatedModulesnor theexemptModuleslist inmodulegate/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-groupandstatsall filter the caller'sline_oa_id) — exceptfindOne, which filters after the fact viavalidateOwnershipinstead. - There are no public (
@Public) routes in this module at all — the category data end users see comes from the separatepublicmodmodule (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 confirmationsrc/app/content-category/form/page.tsx— the 5-field form, frontend validation rules, auto-slug generatorsrc/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 copysrc/components/layout/sidemenu/sidemenu.container.tsx— the menu-levelcontent-managementpermission check
Backend (line-management-cms-api-go)
internal/modules/contentcategory/controller.go— all 8 routes, the absent ModuleGate,parseParamIDand the NaN → 500 behaviorinternal/modules/contentcategory/service.go— cascading soft delete, the subcategory re-save on update, ordering,GetCategoryStatsinternal/modules/contentcategory/dto.go— the slug lowercase/trim transform, regex, length rulesinternal/entities/content_category.go,internal/entities/content_subcategory.go—deleted_dateas a plain column rather thangorm.DeletedAtinternal/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 Category