Skip to main content

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 categoryId and categoryName in 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 categoryId and parentId pre-filled.

The form has 7 fields:

  1. Parent Category — required, locked in edit mode.
  2. 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.
  3. Subcategory Name — required, max 255 characters with a counter, auto-generates the slug.
  4. Slug — required, max 255 characters, validated in the form against ^[a-z0-9-]+$.
  5. Description — optional, max 1,000 characters.
  6. Sort Order — a number ≥ 0, hinted as "Lower numbers appear first within the same category."
  7. 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, otherwise parent.Level + 1, and rejects parent.Level >= 4 with a 400 "Maximum nesting depth reached (4 levels)." The DTO does declare a level field validated with @Min(2) @Max(4), but the service never reads it — send level: 4 alongside a parentId sitting 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.CategoryID you 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 no line_oa_id filter 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), and name/description are trimmed too.
  • Sending a non-string name/slug/description gives a 500, not a 400 — the original TypeScript calls value?.trim() directly, which throws a TypeError when the value is a number or an object, and ValidationPipe doesn't catch it. The Go port reproduces this specifically, with the message value.trim is not a function (the trimCrash helper in the source exists for nothing else).
  • An inactive subcategory disappears from the tree entirely — the endpoints this screen uses (.../tree and .../hierarchical-dropdown) always filter status = 'active', whereas GET /content-subcategories (the paginated list endpoint, which this screen never calls) only filters status != 'delete'. Setting a branch inactive therefore means "hide it from this screen" — and all of its children pop up as new roots, because buildTree() 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 every parentId into a set (using -1 as 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) sets subcategory_id = NULL on every content_page bound to those branches, (2) soft-deletes all descendants, (3) soft-deletes itself. Steps (1) and (2) run with SkipHooks deliberately, 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 ConflictException carrying an object with affectedContentCount and hint, 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/:id and .../path separate 403 from 404 — the query looks the row up by id + deleted_date IS NULL without filtering line_oa_id, then calls validateOwnership. 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." .../path additionally walks up parent by parent to the root and returns the chain root-first.
  • A non-numeric :id yields a 500 with invalid input syntax — reproducing JavaScript's +id behavior, the same as the Categories module.
  • 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-dropdown as 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-dropdown endpoint.
  • 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 affectedContentCount and affectedChildrenCount off the 409 error body, but the backend strips both fields (see the details section above). Both read undefined → 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 call DELETE /api/content-subcategories/:id?force=true yourself if you need it.
  • The "view all" mode fires one API call per category, sequentially — the code for-loops with an await per 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 /:id does accept parentId and writes it straight through without recomputing level — 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 name at 255 and thumbnail at 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, testing ability.can(VIEW, 'content-management') across the whole Content Management group — typing /content-subcategory directly still opens the page without that permission.
  • Backend — all 12 routes declare policies (READALL/READ/CREATE/UPDATE/DELETE on the line-oa subject) but run through auth.CheckPolicies, a no-op that lets every request through. Real enforcement is JWT plus validateOwnership's line_oa_id comparison (Super Admin bypasses everything), with validateCategory checking the parent category's line_oa_id as an extra layer before creation.
  • There is no ModuleGate in this module — inspecting contentsubcategory/controller.go turns up no modulegate.ModuleGate(d, "...") call (unlike the campaign module, which has one), and the module appears in neither the gatedModules nor the exemptModules list in modulegate/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, dropdown and the by-category/* family all filter the caller's line_oa_id) — except findOne, :id/path and the slug-uniqueness check, which don't filter line_oa_id in 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_LEVEL and the level badge colors
  • src/app/content-subcategory/form/page.tsx — the 7-field form, the edit-mode field locks, the level hint, the auto-slug generator
  • src/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, jsUnaryPlusInt64 and the NaN → 500 behavior
  • internal/modules/contentsubcategory/service.go — maxSubcategoryLevel, validateParentAndGetLevel, buildTree, GetHierarchicalDropdown (the ├─/└─ connectors), UpdateSortOrder, and forced Remove with SkipHooks
  • internal/modules/contentsubcategory/dto.go — the lowercase/trim transforms, trimCrash (the 500 behavior), and the validated-but-unused level field
  • 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 Hierarchical Content Subcategories