---
type: guide
title: Admin — Categories
description: How an admin lists, creates, edits, and deletes challenge categories from the /admin/categories page, including system-row protection and challenge-attached protection.
tags: [guide, admin, categories, tester]
timestamp: 2026-07-22T12:00:00Z
---
# When this view is available
The Categories admin page renders at `/admin/categories` for users with
`role === 'admin'`. It is reached from the [Admin Shell](/guides/admin-shell.md)
side-nav (categories are also embedded inside the General settings page).
| Layer | File | Check |
|------------------|----------------------------------------------------------------------------|------------------------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `/admin/categories` child of `adminGuard`. |
| Client component | `frontend/src/app/features/admin/categories/categories.component.ts` | `AdminCategoriesComponent.ngOnInit` fetches the list. |
| Client modals | `frontend/src/app/features/admin/categories/category-form-modal.component.ts` | Create / edit modal. |
| | `frontend/src/app/features/admin/categories/category-delete-modal.component.ts` | Delete confirmation modal. |
| Server route | `backend/src/modules/admin/admin-categories.controller.ts` | `GET/POST /api/v1/admin/categories`, `PUT/DELETE /:id`. |
# How to access (tester steps)
1. Sign in as an admin user.
2. Open **Admin area** → **Categories** in the side-nav, or visit
`/admin/categories` directly. (The page also renders below the
General settings form at `/admin/general`.)
3. The page shows `Loading categories...` (`data-testid="cat-loading"`)
while the list request is in flight, then renders one row per
category sorted alphabetically by (lowercased) abbreviation.
# Visual elements
| Element | Selector | Purpose |
|------------------------|--------------------------------------------|---------|
| Page section | `[data-testid="admin-categories"]` | Root container. |
| Add button (`+`) | `[data-testid="cat-add"]` | Opens the create modal. |
| Loading | `[data-testid="cat-loading"]` | "Loading categories..." placeholder. |
| Load error | `[data-testid="cat-error"]` | Red error text. |
| List | `[data-testid="cat-list"]` | `
` of category rows. |
| Row | `[data-testid="cat-row-{ABBR}"]` | One `- ` per category. |
| Edit button | `[data-testid="cat-edit-{ABBR}"]` | Opens the edit modal. Disabled for system rows (the abbreviation input becomes `readonly`). |
| Delete button | `[data-testid="cat-delete-{ABBR}"]` | Opens the delete confirmation modal. |
| Form modal | `[data-testid="cat-form-modal"]` | Create/edit dialog (`cat-form-backdrop` is the click-outside dismiss layer). |
| Delete modal | `[data-testid="cat-delete-modal"]` | Confirmation dialog (`cat-delete-backdrop` is the dismiss layer). |
| Delete error | `[data-testid="cat-delete-error"]` | Inline error message after a failed delete. |
# Form modal fields
| Label | `data-testid` | Notes |
|-------------------------------|----------------|-------|
| Name | `cf-name` | Required, max 120 chars. |
| Abbreviation (uppercase) | `cf-abbr` | Required, 2–6 chars; server upper-cases on save. `readonly` when editing a system row. |
| Description | `cf-desc` | Optional, max 2000 chars. |
| Icon (file picker) | `cf-icon` | Optional image; uploaded to `POST /api/v1/uploads/category-icon` and resized/normalized server-side. |
| Cancel / OK | `cf-cancel`, `cf-ok` | OK disabled while form invalid or already submitting. |
# Expected behavior
## List
* The list is sorted by `LOWER(abbreviation)` ascending. Server returns
rows already sorted; the client re-sorts defensively in
`AdminCategoriesComponent.load()`.
* System rows (`isSystem === true`) render with the edit/delete actions
still visible, but the abbreviation field is locked when editing, and
the delete confirmation modal hides the "OK" button (see "Delete
behavior" below).
## Create
1. Click `cat-add` → `cat-form-modal` opens in create mode.
2. Fill name, abbreviation, description. Optionally pick an icon file
(preview appears immediately).
3. Click `cf-ok`. The component calls
`AdminService.createCategory({...})`, then — if an icon file was
selected — `uploadCategoryIcon(id, file)` and finally
`updateCategory(id, { iconPath: publicUrl })` to persist the icon
URL.
4. On success the modal closes and the list refreshes.
## Edit
1. Click `cat-edit-{ABBR}` → modal opens in edit mode, pre-filled with
the row's values.
2. For system rows, the abbreviation field is `readonly`.
3. On save the component issues `updateCategory(id, {...})`. If an icon
file is selected, the new icon is uploaded first and the returned
`publicUrl` replaces `iconPath` in the same update.
## Delete behavior
* **System rows:** the delete modal renders
*"This is a system category and cannot be deleted."* and the OK
button is hidden. `canConfirm()` returns `false`. If somehow the
request is dispatched, the backend returns `403 SYSTEM_PROTECTED`
and the UI shows `System categories cannot be deleted.`.
* **Rows with attached challenges:** the backend returns
`409 CATEGORY_HAS_CHALLENGES` with `{ count }` in `details`. The UI
maps this to `Cannot delete: category has N challenge(s) attached.`
and renders it in `cat-delete-error`.
* **Normal row:** the modal confirms the name + abbreviation, OK
dispatches `DELETE /api/v1/admin/categories/:id`, and the list
refreshes.
## Validation and error codes (server)
| Code | HTTP | Triggered by |
|----------------------------|------|---------------------------------------------------------------|
| `VALIDATION_FAILED` | 400 | `CreateCategorySchema` / `UpdateCategorySchema` fails (length, required). |
| `NOT_FOUND` | 404 | `PUT`/`DELETE` on a non-existent id. |
| `CONFLICT` | 409 | Duplicate abbreviation on create or on update (user row). |
| `SYSTEM_PROTECTED` | 409 | Update attempts to change a system row's abbreviation. |
| `SYSTEM_PROTECTED` | 403 | Delete on a system row. |
| `CATEGORY_HAS_CHALLENGES` | 409 | Delete on a category with at least one attached challenge (carries `{ count }` in `details`). |
# Architecture map
| Step | Where | What happens |
|------|-------------------------------------------------------------|-----------------------------------------------------------------------------|
| 1 | `frontend/src/app/app.routes.ts` | `/admin/categories` lazy-loads `AdminCategoriesComponent`. |
| 2 | `frontend/src/app/features/admin/categories/categories.component.ts` | `ngOnInit` calls `AdminService.listCategories()`. |
| 3 | `frontend/src/app/core/services/admin.service.ts` | `listCategories`, `createCategory`, `updateCategory`, `deleteCategory`, `uploadCategoryIcon`. |
| 4 | `frontend/src/app/features/admin/categories/category-form-modal.component.ts` | Holds the form state, emits `CategoryFormSubmit`. |
| 5 | `frontend/src/app/features/admin/categories/category-delete-modal.component.ts` | Maps error codes to user-friendly messages. |
| 6 | `backend/src/modules/admin/admin-categories.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. |
| 7 | `backend/src/modules/admin/categories.service.ts` | `list` (sorted by `LOWER(abbreviation)`), `create` (uppercase + dup-check), `update` (system-abbr-immutable), `remove` (system-protected, challenge-count check). |
| 8 | `backend/src/modules/admin/dto/categories.dto.ts` | zod schemas with length constraints; param validator for `:id`. |
| 9 | `backend/src/modules/uploads/uploads.controller.ts` | `POST /api/v1/uploads/category-icon` (multipart) — also admin-only. |
# Notes
* Abbreviations are uppercased server-side before persistence and
uniqueness check (DB enforces uniqueness via the
`uq_category_abbreviation` index — see
[Challenge Tables](/database/challenges.md)).
* System rows are seeded by the
`UpdateSystemCategoryKeys1700000000300` migration and identified by
a non-null `system_key` column.
* The icon upload pipeline normalizes the image to a fixed size; the
returned `publicUrl` is stored in `category.icon_path`.
* The Categories component is embedded inside the General settings
page, so changes to a category are visible on either route without
an extra refresh.
# See also
- [Admin Shell](/guides/admin-shell.md) — side-nav layout and the General settings page that embeds categories.
- [Admin — General Settings](/guides/admin-general-settings.md)
- [Admin Endpoints](/api/admin.md) — `GET/POST/PUT/DELETE /api/v1/admin/categories`.
- [Challenge Tables](/database/challenges.md) — `category` schema and migrations.
- [Uploads Endpoints](/api/uploads.md) — `POST /api/v1/uploads/category-icon`.