Content Subcategory (Hierarchical)
Overview
Content Subcategory is the classification layer that sits beneath Content Category. Subcategories can nest inside one another through a self-reference (parentId combined with level), and the UI caps the depth at four levels.
Unlike most management screens in the CMS, this one is not a table — it renders a tree, so parent-child relationships are visible at a glance. It supports drag-and-drop reordering and a delete flow that always surfaces the impact before anything is removed.
The feature is aimed at content teams with a large library who need finer grouping than top-level categories provide, for example "Promotions > New Customers > January".
Business Flow
- Opening the subcategory list loads the category dropdown used by the filter. Users may also arrive from the category screen with a pre-selected category passed in the URL.
- With a category selected, the screen renders the tree for that category alone and expands every branch automatically.
- With no category selected, the screen loads the tree of every category and presents them as collapsible panels grouped by category, auto-opening the first panel that actually contains subcategories.
- Each branch exposes three actions: edit, add a child beneath it (shown only while the branch is still below level 4), and delete.
- Drag-and-drop reordering is restricted to siblings — nodes sharing the same parent. Once a valid drop occurs, the new ordering for the whole sibling group is computed and saved in a single request.
- Deletion happens in two passes. The screen first attempts a plain delete; if the server reports dependent children or attached content, it returns the affected counts, which appear in a confirmation dialog. Only after the user confirms does the screen issue a forced delete.
- Create and edit use a dedicated form. The category is required and locked in edit mode; the parent subcategory is optional and likewise locked when editing. Selecting a parent fetches its level so the form can hint at the resulting depth and warn when the parent has already reached the ceiling.
- The remaining fields are name (which auto-generates a slug the same way categories do), slug, description, sort order, and status. On save the user returns to the list, still filtered by the original category.
Key Screens & Components
List screen (src/app/content-subcategory/page.tsx) — the heart of the feature. It combines the category filter, the two tree modes, drag-and-drop ordering, and impact-aware deletion. Tree data comes from GET /content-subcategories/by-category/{categoryId}/tree, and ordering is persisted through PATCH /content-subcategories/reorder/sort-order.
Form screen (src/app/content-subcategory/form/page.tsx) — handles both create and edit. An id parameter switches it into edit mode, while categoryId and parentId seed the defaults when creating. Parent options come from a hierarchical dropdown endpoint that renders the nesting level inline.
Shared service (src/services/content-subcategory.service.ts) — collects every operation for the module: tree and dropdown reads, path lookup for a branch, full create/update/delete, and reordering. The delete call accepts an additional force flag.
Core data shape — a subcategory record carries parentId, level, its owning category, its parent, and its children. The delete response reports whether the record was removed along with the number of affected children and content items.
Dependencies
- Permissions — the subject
content-subcategorykeys the submenu entry and is unlocked by the backendline-oamodule. Visibility of the parent menu depends on the Content Management permission. - Content Category — the direct parent level, which links across to this screen and passes the category name along as context.
- Content Management — consumes this module's subcategory options when authoring or editing content.
- Content Links — uses subcategories as one of its content selection criteria.
CategoryCascadercomponent — converts the same tree into cascader options, treating level-4 branches as leaves.- Shared constants — the standard status enum, the permission subject registry, and the four-level depth ceiling, which is declared in both the list and the form.
Backend Details (CMS API)
The module lives in internal/modules/contentsubcategory/, and every endpoint is registered under the /api/content-subcategories group.
Permissions and data scoping
- Every request passes through the global
JwtAuthfirst — this module exposes no public endpoint. - Each handler already declares
line-oapolicy metadata (readAll/read/create/update/delete), but it is not actually enforced in the current version, and noModuleGateguards the module. In practice any authenticated user can reach these endpoints even without the specific sub-permission, so menu hiding on the frontend is the effective control. - Scoping comes from the request context (
lineOaId,organizationId) rather than from the payload, so a user only ever sees the subcategories of the OA they are currently working in.
Tree depth ceiling
- The backend keeps the ceiling as a
maxSubcategoryLevelconstant and checks it onPOST /api/content-subcategories. Exceeding the ceiling is rejected server-side, not merely warned about in the UI. - Level counting treats the Content Category as level 1, with four further nesting levels for subcategories — that is where the "4 levels" shown in the UI comes from.
- Because the check is server-side, passing a
parentIdthat already sits at the deepest level (via a direct API call or a stale deep link) is blocked as well.
Endpoints unique to this module
Being a tree, this module offers more read shapes than a flat category list, each with a distinct purpose.
GET /api/content-subcategories/by-category/:categoryId/tree— the full tree for one category, backing the tree view.GET /api/content-subcategories/by-category/:categoryId/hierarchical-dropdown— an indented dropdown used as the "parent subcategory" picker in the form.GET /api/content-subcategories/dropdownand.../by-category/:categoryId/dropdown— flat dropdowns, either system-wide or per category.GET /api/content-subcategories/:id/path— returns the route from the root category down to the node, used for breadcrumbs and for reading the parent's level.
Reordering
PATCH /api/content-subcategories/reorder/sort-order accepts a batch of id / sortOrder pairs and updates the whole group in one call, which matches the UI recomputing the entire sibling group before saving. Worth noting: this route must be registered before PATCH /api/content-subcategories/:id, otherwise the literal reorder would be captured as an :id.
Deletion and side effects
DELETE /api/content-subcategories/:idis rejected when the node still has child subcategories or attached content, and returns the affected counts — this is the source of the two-pass confirmation dialog in the UI.- Pass the
forcequery parameter to delete anyway. - Deletion is a soft delete written manually to a
deleted_datecolumn rather than through the ORM's soft-delete mechanism. Consequences worth knowing:- Every query must add
deleted_date IS NULLby hand; miss it in one place and deleted records reappear. - The delete is an ordinary UPDATE, so the row stays in the database — recoverable, but it also still occupies its old slug.
- Every query must add
Tables involved
content_subcategory is the primary table, read alongside content_category, content_page (to count attached content), and line_oa.