11 KiB
11 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|
| guide | Admin — Categories | How an admin lists, creates, edits, and deletes challenge categories from the /admin/categories page, including system-row protection and challenge-attached protection. |
|
2026-07-22T18:37: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
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)
- Sign in as an admin user.
- Open Admin area → Categories in the side-nav, or visit
/admin/categoriesdirectly. (The page also renders below the General settings form at/admin/general.) - 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"] |
<ul> of category rows. |
| Row | [data-testid="cat-row-{ABBR}"] |
One <li> 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 inAdminCategoriesComponent.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
- Click
cat-add→cat-form-modalopens in create mode. - Fill name, abbreviation, description. Optionally pick an icon file (preview appears immediately).
- Click
cf-ok. The component callsAdminService.createCategory({...}), then — if an icon file was selected —uploadCategoryIcon(id, file)and finallyupdateCategory(id, { iconPath: publicUrl })to persist the icon URL. - On success the modal closes and the list refreshes.
Edit
- Click
cat-edit-{ABBR}→ modal opens in edit mode, pre-filled with the row's values. - For system rows, the abbreviation field is
readonly. - On save the component issues
updateCategory(id, {...}). If an icon file is selected, the new icon is uploaded first and the returnedpublicUrlreplacesiconPathin the same update.
Edit prefill mechanics
When the parent sets open=true + mode='edit' + a category, a
constructor effect() runs syncCategoryForm(form, mode, category)
which:
- writes
name,abbreviation,descriptionvia per-controlsetValue(..., { emitEvent: false }), - marks every control pristine + untouched so validators do not flash errors on a freshly opened modal,
- returns
{ abbreviationReadonly: category.isSystem === true, iconPreview: category.iconPath || null }which the effect pushes intoabbreviationReadonlyandiconPreviewsignals and clearsiconFile, - and triggers
ChangeDetectorRef.markForCheck()so the OnPush view repaints the freshly-patched<input>/<textarea>values.
Switching back to mode='create' with category=null re-invokes the
helper, which resets every control to '' and unlocks the abbreviation
input. syncCategoryForm is exported from the modal file so it can be
exercised in isolation by
tests/frontend/admin-categories-form-modal.spec.ts.
Delete behavior
- System rows: the delete modal renders
"This is a system category and cannot be deleted." and the OK
button is hidden.
canConfirm()returnsfalse. If somehow the request is dispatched, the backend returns403 SYSTEM_PROTECTEDand the UI showsSystem categories cannot be deleted.. - Rows with attached challenges: the backend returns
409 CATEGORY_HAS_CHALLENGESwith{ count }indetails. The UI maps this toCannot delete: category has N challenge(s) attached.and renders it incat-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_abbreviationindex — see Challenge Tables). - System rows are seeded by the
UpdateSystemCategoryKeys1700000000300migration and identified by a non-nullsystem_keycolumn. - The icon upload pipeline normalizes the image to a fixed size; the
returned
publicUrlis stored incategory.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 — side-nav layout and the General settings page that embeds categories.
- Admin — General Settings
- Admin Endpoints —
GET/POST/PUT/DELETE /api/v1/admin/categories. - Challenge Tables —
categoryschema and migrations. - Uploads Endpoints —
POST /api/v1/uploads/category-icon.