Content Management — Subcategories
Overview
Subcategories are the taxonomy layer beneath the Categories menu, nestable several levels deep and rendered as a tree so the parent-child relationships are visible at a glance. It suits content teams with a lot of material that needs finer grouping than the top-level categories provide — something like "Promotions > New Customers > January."
The one thing to understand up front: the system counts the parent category as level 1, and the enforced ceiling is level 4 — so subcategories get 3 tiers: L2, L3, L4, matching the colored badges on screen (L2 blue / L3 green / L4 purple). The form's hint about "up to 4 levels" already counts the category itself.
Who uses it — content teams laying out the fine-grained taxonomy on top of the Categories menu (this module has no ModuleGate on the backend — see Access control below).
User journey
1. Reach the tree view
There are two ways into Content Management > Subcategories:
- Straight in — the page shows All Subcategories as one collapsible panel per parent category. The system fetches the category list from the dropdown endpoint and then requests each category's tree one call at a time, auto-expanding the first category that actually has subcategories.
- Arriving from the Categories menu — that menu passes
categoryIdandcategoryNamein the URL, so this page pre-filters to the named category, titles itself Subcategories of "category name", and offers a View all categories button to get back to the combined view.
In the single-category view the system auto-expands every branch and shows a level legend (L2 - Subcategory / L3 - Sub-subcategory / L4 - Sub-sub-subcategory) plus a hint under the tree: "Drag items to reorder within the same level."
Each branch in the tree renders a drag handle, a level-colored icon, the name, an L2/L3/L4 badge, the slug in monospace (tooltip /slug), and a three-dot menu.
2. Add a subcategory
- Click Create Subcategory in the header to make an L2 branch.
- Or open any branch's three-dot menu and pick Add Child — that item doesn't appear at all when the branch is already at level 4. The system routes to the form with
categoryIdandparentIdpre-filled.
The form has 7 fields:
- Parent Category — required, locked in edit mode.
- Parent Subcategory (Optional) — the options come from a hierarchical dropdown drawing
├─/└─connectors in monospace to show the tree shape. Disabled until a parent category is chosen, and locked in edit mode. The hint underneath reads "Leave empty to create a direct child of the category. Current level: N," turning into red text — "Maximum nesting depth reached (4 levels). Cannot add children." — once the chosen parent is at the ceiling. - Subcategory Name — required, max 255 characters with a counter, auto-generates the slug.
- Slug — required, max 255 characters, validated in the form against
^[a-z0-9-]+$. - Description — optional, max 1,000 characters.
- Sort Order — a number ≥ 0, hinted as "Lower numbers appear first within the same category."
- Status — active / inactive.
On save the system returns you to the tree view already filtered to that category.
3. Reorder by drag and drop
Drag a branch into the gap between branches only — dropping onto a branch (to make it a child) is refused with "Drop between items to reorder. Use 'Add Child' from menu to create nested items," and dragging across parents is refused with "Can only reorder items within the same level." (Both are checked twice: allowDrop blocks it during the drag, and onDrop re-checks.)
On a successful drop the frontend recalculates the order of the whole sibling group and sends one PATCH /content-subcategories/reorder/sort-order with sortOrder numbered from 0, then refetches the tree.
4. Delete a branch
From the three-dot menu, choose Delete. The frontend first fires a non-forced delete so the server can report whether anything is attached. With nothing attached it succeeds immediately with "Subcategory deleted successfully" — with dependencies the server answers 409 (see the watch-out below about the confirmation dialog meant to show the affected counts).
Key details verified against the source
Confirmed from internal/modules/contentsubcategory/{controller,service,dto}.go in line-management-cms-api-go:
- The level is computed from the parent server-side; the submitted value is never trusted —
validateParentAndGetLevel()returns 2 when there's no parent, otherwiseparent.Level + 1, and rejectsparent.Level >= 4with a 400 "Maximum nesting depth reached (4 levels)." The DTO does declare alevelfield validated with@Min(2) @Max(4), but the service never reads it — sendlevel: 4alongside aparentIdsitting at level 2 and you still get level 3 from the calculation. - The parent must live in the same top-level category — if
parent.CategoryID != dto.CategoryIDyou get a 400 "Parent subcategory must belong to the same category," and a missing (or already-deleted) parent gives a 404 "Parent subcategory not found." - Slug uniqueness is scoped per parent category, not per LINE OA — the condition is
category_id + slug + deleted_date IS NULL(with noline_oa_idfilter at all), colliding with 409 "A subcategory with this slug already exists in this category." So the same slug can be reused across different categories, but is forbidden within one category even across different parent branches. - The slug is lowercased and trimmed on the backend before the regex runs, exactly as in Categories (the
value?.toLowerCase().trim()transform executes ahead of@Matches), andname/descriptionare trimmed too. - Sending a non-string
name/slug/descriptiongives a 500, not a 400 — the original TypeScript callsvalue?.trim()directly, which throws aTypeErrorwhen the value is a number or an object, and ValidationPipe doesn't catch it. The Go port reproduces this specifically, with the messagevalue.trim is not a function(thetrimCrashhelper in the source exists for nothing else). - An inactive subcategory disappears from the tree entirely — the endpoints this screen uses (
.../treeand.../hierarchical-dropdown) always filterstatus = 'active', whereasGET /content-subcategories(the paginated list endpoint, which this screen never calls) only filtersstatus != 'delete'. Setting a branch inactive therefore means "hide it from this screen" — and all of its children pop up as new roots, becausebuildTree()places any node whose parent isn't in the dataset at the root level. - The "same parent" reorder rule is genuinely enforced server-side —
UpdateSortOrder()loads every submitted id; if the number of rows found doesn't match the number of items sent it returns 400 "Some subcategories not found," then collects everyparentIdinto a set (using-1as the sentinel for null) and returns 400 "Can only reorder subcategories within the same parent" if there's more than one. Ownership is then checked row by row, and the updates run one SQL statement per row with no wrapping transaction — a failure partway through leaves the ordering half-applied. - A forced delete unlinks content pages but never touches
updated_date—Remove(force=true)walks every descendant id recursively first, then (1) setssubcategory_id = NULLon everycontent_pagebound to those branches, (2) soft-deletes all descendants, (3) soft-deletes itself. Steps (1) and (2) run withSkipHooksdeliberately, reproducing the original QueryBuilder's behavior of not touching@UpdateDateColumn— so the unlinked content pages show no trace of "just modified" in their Updated column. - The non-forced delete's 409 loses its affected counts on the wire — the original TypeScript throws a
ConflictExceptioncarrying an object withaffectedContentCountandhint, but the exception filter only reads.message, a string. The body that actually goes out therefore contains only "Cannot delete subcategory with dependencies." A comment in the Go source records this field-dropping explicitly as reproduced original behavior. On a successful delete (force=true, or nothing attached) you do get the full{ deleted, affectedContentCount, affectedChildrenCount }. GET /content-subcategories/:idand.../pathseparate 403 from 404 — the query looks the row up byid + deleted_date IS NULLwithout filteringline_oa_id, then callsvalidateOwnership. So another OA's id answers 403 "You do not have permission to access this subcategory" while a nonexistent id answers 404 "Subcategory not found.".../pathadditionally walks up parent by parent to the root and returns the chain root-first.- A non-numeric
:idyields a 500 withinvalid input syntax— reproducing JavaScript's+idbehavior, the same as the Categories module.
Related menus in this journey
- Categories — the direct parent of this tree and the other way in (via the subcategory count or the "View Subcategories" item). Deleting a top-level category takes this whole tree with it.
- Content Pages — uses this menu's
hierarchical-dropdownas its subcategory options when creating or editing a page, and it's what gets "unlinked" when a branch is force-deleted. - Content Links — uses subcategories as one of its content-selection criteria, calling the same
hierarchical-dropdownendpoint. - Menu Builder — when a menu item references a content link, the subcategory criteria set here also shape what the end user sees in that menu.
Things to watch out for
- The "Delete Subcategory with Dependencies" dialog meant to show the affected counts can never appear — the frontend reads
affectedContentCountandaffectedChildrenCountoff the 409 error body, but the backend strips both fields (see the details section above). Both readundefined→ 0, so the condition that would open the dialog is never true and the user just sees the error message "Failed to delete subcategory." The consequence: the "Delete Anyway" force-delete button is unreachable from the UI — you'd have to callDELETE /api/content-subcategories/:id?force=trueyourself if you need it. - The "view all" mode fires one API call per category, sequentially — the code
for-loops with anawaitper category rather than issuing them in parallel, so the more top-level categories exist, the slower the page loads (on top of the initial dropdown call). - Marking a subcategory inactive reshapes the tree — the disabled branch vanishes entirely and its children shift up to render as roots of that category, while their level badges (L3/L4) stay as they were — so you'll see an L3 branch sitting at root position.
- A Thai-only subcategory name produces an empty slug — the slug generator strips every character outside
[a-z0-9\s-]with no Thai transliteration (the same code as the Categories menu; unlike Content Pages, which does have a transliteration table). You'll have to type the slug yourself. - You can't move a branch under a different parent from the UI — both the parent-category and parent-subcategory fields are locked in edit mode, and drag-and-drop is confined to one sibling group. (At the API level,
PATCH /:iddoes acceptparentIdand writes it straight through without recomputinglevel— so calling the API directly can leave the level data inconsistent.) - The 1,000-character description cap is frontend-only — the backend DTO only checks that it's a string (unlike
nameat 255 andthumbnailat 500, which are real). - Reordering isn't wrapped in a transaction — if an update fails partway, that sibling group's ordering is left incomplete; drag it again to fix.
Access control (verified against the source)
- Frontend — the tree and form pages are not wrapped in
PermissionGuard. The only permission check is sidebar rendering, testingability.can(VIEW, 'content-management')across the whole Content Management group — typing/content-subcategorydirectly still opens the page without that permission. - Backend — all 12 routes declare policies (
READALL/READ/CREATE/UPDATE/DELETEon theline-oasubject) but run throughauth.CheckPolicies, a no-op that lets every request through. Real enforcement is JWT plusvalidateOwnership'sline_oa_idcomparison (Super Admin bypasses everything), withvalidateCategorychecking the parent category'sline_oa_idas an extra layer before creation. - There is no
ModuleGatein this module — inspectingcontentsubcategory/controller.goturns up nomodulegate.ModuleGate(d, "...")call (unlike thecampaignmodule, which has one), and the module appears in neither thegatedModulesnor theexemptModuleslist inmodulegate/coverage_test.go— so a platform admin cannot close this module for an organization, because there's nowhere the gate is applied. - Data scope is bounded by LINE OA on every multi-row endpoint (
findAll,dropdownand theby-category/*family all filter the caller'sline_oa_id) — exceptfindOne,:id/pathand the slug-uniqueness check, which don't filterline_oa_idin the query itself. - There are no public (
@Public) routes in this module at all.
Source file reference
Frontend (cms-web-2026)
src/app/content-subcategory/page.tsx— both tree views, drag-to-reorder (allowDrop/onDrop), the two-step delete logic,MAX_LEVELand the level badge colorssrc/app/content-subcategory/form/page.tsx— the 7-field form, the edit-mode field locks, the level hint, the auto-slug generatorsrc/services/content-subcategory.service.ts— the endpoints the frontend calls (getTreeByCategoryId,getHierarchicalDropdownByCategoryId,updateSortOrder,deleteById(id, force))src/services/axios-instance.ts—handleAxiosError, which rethrows the error body directly (relevant to the delete-dialog watch-out)messages/th/content-subcategory.json,messages/en/content-subcategory.json— the full on-screen copy
Backend (line-management-cms-api-go)
internal/modules/contentsubcategory/controller.go— all 12 routes, the absent ModuleGate,jsUnaryPlusInt64and the NaN → 500 behaviorinternal/modules/contentsubcategory/service.go—maxSubcategoryLevel,validateParentAndGetLevel,buildTree,GetHierarchicalDropdown(the├─/└─connectors),UpdateSortOrder, and forcedRemovewithSkipHooksinternal/modules/contentsubcategory/dto.go— the lowercase/trim transforms,trimCrash(the 500 behavior), and the validated-but-unusedlevelfieldinternal/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 Hierarchical Content Subcategories