AI Implementation feature(917): Category Icons (#65)

This commit was merged in pull request #65.
This commit is contained in:
2026-07-23 16:14:08 +00:00
parent e81c568fca
commit 143ed3c538
10 changed files with 553 additions and 75 deletions
+7 -1
View File
@@ -3,7 +3,7 @@ type: architecture
title: Backend Module Map
description: NestJS modules, controllers, services, and how they are wired together.
tags: [architecture, backend, nestjs, modules]
timestamp: 2026-07-23T15:46:55Z
timestamp: 2026-07-23T16:10:00Z
---
# Module Map
@@ -86,6 +86,11 @@ also registers two global providers:
`DatabaseInitService.init()``FilesystemTransactionService.recoverTransactions()`
(resolves interrupted restore manifests before TypeORM opens the live SQLite file;
ambiguous generations abort startup) → stale unowned swap-artifact sweep → migrations + WAL
`verifySeed()` (best-effort)
`ensureSystemCategoryIcons()` (best-effort — writes
`CRY/HW/MSC/PWN/REV/WEB.png` into `<UPLOAD_DIR>/icons/` if missing so
freshly bootstrapped instances never serve 404 icons; see
[System Category Icon Seed](/architecture/system-category-icons.md))
`app.use(...)` middlewares (helmet, parsers, CSRF, static)
`SwaggerModule.setup(...)` (OpenAPI 3.1)
`SpaFallbackMiddleware``app.listen()`.
@@ -94,6 +99,7 @@ ambiguous generations abort startup) → stale unowned swap-artifact sweep → m
- [System Overview](/architecture/overview.md)
- [Key Files Index](/architecture/key-files.md)
- [System Category Icon Seed](/architecture/system-category-icons.md)
- [REST API Overview](/api/rest-overview.md)
- [Blog API](/api/blog.md)
- [Admin System API](/api/admin-system.md)
+5 -2
View File
@@ -3,7 +3,7 @@ type: architecture
title: Key Files Index
description: One-line responsibility for important source and contract-test files, including event-window, challenge, scoreboard, session, notification, and blog flows.
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications, per-user, session, blog]
timestamp: 2026-07-23T15:46:55Z
timestamp: 2026-07-23T16:10:00Z
---
# Backend
@@ -14,7 +14,8 @@ timestamp: 2026-07-23T15:46:55Z
| `backend/src/app.module.ts` | Wires feature modules and global providers. |
| `backend/src/config/env.schema.ts` | Zod-validated runtime config; canonical defaults for `DATABASE_PATH` (`./data/db.sqlite`) and `UPLOAD_DIR` (`./data/uploads`). |
| `backend/src/database/database.module.ts` | Configures TypeORM with SQLite and imports the shared `FilesystemTransactionModule` used by pre-TypeORM startup recovery. |
| `backend/src/database/database-init.service.ts` | Before opening SQLite, resolves durable swap manifests to a coherent old or verified-new database/uploads generation, fails startup closed on ambiguity, best-effort sweeps stale unowned swap artifacts, then runs migrations, enforces WAL, and verifies seeds. |
| `backend/src/database/database-init.service.ts` | Before opening SQLite, resolves durable swap manifests to a coherent old or verified-new database/uploads generation, fails startup closed on ambiguity, best-effort sweeps stale unowned swap artifacts, then runs migrations, enforces WAL, verifies seeds, and best-effort seeds the canonical system-category icon PNGs into `UPLOAD_DIR/icons` so a fresh clone never serves 404 icons. |
| `backend/src/database/system-category-icons.ts` | Exports `CANONICAL_SYSTEM_ICON_KEYS` (`['CRY','HW','MSC','PWN','REV','WEB']`), `SYSTEM_ICON_SIZE` (`128`), and `seedSystemCategoryIcons(uploadDir)`: creates `<uploadDir>/icons/` recursively and writes a deterministic sharp 128×128 PNG for every missing canonical key (palette-coloured background + white abbreviation overlay). Idempotent — existing non-empty files are reported as `skipped` so admin uploads are preserved across restarts. |
| `backend/src/modules/admin/system/filesystem-transaction.module.ts` | Exports one shared `FilesystemTransactionService` provider to `DatabaseModule` and `AdminSystemModule`, avoiding a circular dependency. |
| `backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories.ts` | Forward-only repair migration: adds `created_at`/`updated_at` to legacy six-column `category` tables, deletes obsolete `FOR`/`OSI` system rows, rewrites canonical row metadata, inserts any missing canonical key, and re-asserts unique indexes. Exports `CANONICAL_SYSTEM_CATEGORIES`. |
| `backend/src/common/guards/jwt-auth.guard.ts` | Global JWT authorization guard. |
@@ -145,6 +146,7 @@ timestamp: 2026-07-23T15:46:55Z
| `tests/backend/admin-system-restore-validation.spec.ts` | Archive schema validation, base64 size mismatch, sha256 mismatch, safe-path rejection, and duplicate-path rejection. |
| `tests/backend/admin-system-restore-commit.spec.ts` | Controller forwarding, real-file restore, trigger preservation, admin-less archive rejection, and kill-during-swap regression proving restart recovers one coherent database/uploads generation. |
| `tests/backend/database-init-recovery.spec.ts` | Startup recovery contracts: restores old generations from interrupted manifests, fails closed without mutation on hybrid state, and removes stale unowned artifacts. |
| `tests/backend/system-category-icons.spec.ts` | Pure helper contract for `seedSystemCategoryIcons`: writes six valid 128×128 PNGs in a fresh temp dir; second invocation reports all six as `skipped` and leaves an admin-uploaded `CRY.png` `mtimeMs` unchanged; creates the `icons/` subdirectory when the parent is empty. Real `sharp` + `os.tmpdir()`, no mocks. |
| `tests/backend/filesystem-transaction.spec.ts` | Durable manifest phase, commit/rollback, old/new generation recovery, ambiguous fail-closed, and stale artifact sweep contracts. |
| `frontend/src/app/features/admin/system/system.component.ts` | `/admin/system` smart page: two panels (Database + Danger Zone), backup download, restore pick + validate, confirm modal trigger, and per-operation success/error surfacing + cross-store invalidation. |
| `frontend/src/app/features/admin/system/system-confirm-modal.component.ts` | Re-authentication + confirmation-phrase modal with operation-specific copy from `system.pure.ts`. |
@@ -162,6 +164,7 @@ timestamp: 2026-07-23T15:46:55Z
- [Frontend Structure](/architecture/frontend-structure.md)
- [Authenticated Shell](/guides/authenticated-shell.md)
- [System Category Icon Seed](/architecture/system-category-icons.md)
- [System Endpoints](/api/system.md)
- [Challenges Endpoints](/api/challenges.md)
- [Scoreboard Page Guide](/guides/scoreboard-page.md)
+135
View File
@@ -0,0 +1,135 @@
---
type: architecture
title: System Category Icon Seed
description: Idempotent startup hook that writes deterministic 128×128 PNG icons for the canonical six system categories (CRY/HW/MSC/PWN/REV/WEB) into UPLOAD_DIR/icons, so freshly bootstrapped instances never serve 404 icons.
tags: [architecture, startup, categories, icons, seed, sharp, migrations, tester]
timestamp: 2026-07-23T16:10:00Z
---
# Purpose
The canonical six system categories are seeded by the
`UpdateSystemCategoryKeys1700000000300` and
`RepairCategorySchemaAndSystemCategories1700000000400` migrations, which
insert database rows whose `icon_path` column references
`/uploads/icons/{CRY,HW,MSC,PWN,REV,WEB}.png`. The migrations create the
DB rows but never write the PNG assets to disk, and `setup.sh` only
`mkdir -p`s the upload directory — it does not populate it.
As a result, `GET /uploads/icons/CRY.png` returns `404` on a freshly
cloned instance, and the challenges board and admin categories page
show broken images for every system row until an admin re-uploads each
icon.
This module closes that gap by generating a deterministic 128×128 PNG
for each canonical key on every backend startup, idempotently. Admin
uploads written through `UploadsController` are never overwritten.
# Files
| File | Responsibility |
|---|---|
| `backend/src/database/system-category-icons.ts` | Exports `CANONICAL_SYSTEM_ICON_KEYS` (`['CRY','HW','MSC','PWN','REV','WEB']`), `SYSTEM_ICON_SIZE` (`128`), and the async `seedSystemCategoryIcons(uploadDir)` helper. Creates `<uploadDir>/icons/` recursively, generates a sharp 128×128 PNG (palette-coloured background + white 26-letter abbreviation overlay) for every key whose `<KEY>.png` does not yet exist or is zero-bytes, and returns `{ written, skipped }`. |
| `backend/src/database/database-init.service.ts` | Calls `seedSystemCategoryIcons(path.resolve(config.get('UPLOAD_DIR', './data/uploads')))` from `init()` after migrations and `verifySeed()`. Skips when `UPLOAD_DIR` is unset or contains `:memory:`. Failures log a `WARN` and never abort startup. |
| `tests/backend/system-category-icons.spec.ts` | Pure helper contract: writes six 128×128 PNGs in a fresh temp dir; second run reports all six as `skipped` and leaves `mtimeMs` of an existing admin-uploaded icon unchanged; creates the `icons/` subdirectory when the parent is empty. |
# How the helper works
`seedSystemCategoryIcons(uploadDir)`:
1. Resolves `iconsDir = path.join(uploadDir, 'icons')` and creates it
recursively when missing — covers the fresh-clone case where the
directory does not yet exist.
2. For each `key` in `CANONICAL_SYSTEM_ICON_KEYS`:
* `target = path.join(iconsDir, `${key}.png`)`.
* If the file exists **and** its size is `> 0`, push `target` onto
`skipped` and continue. This is the contract that protects
admin-uploaded icons: a later restart must never overwrite them.
* Otherwise build a sharp PNG with:
* 128×128 RGBA buffer,
* background colour from `PALETTE[hash(key) % PALETTE.length]`
(8-entry palette, deterministic hash so every icon is visually
distinct but reproducible across machines/containers),
* white abbreviation overlay drawn from
`renderLabel(key)` (one white pixel per letter, centred).
* Write the buffer with `fs.writeFileSync(target, buf)` and push
`target` onto `written`.
3. Return `{ written, skipped }`.
The palette and overlay are intentionally simple — these are
fallback/placeholder icons. An admin who wants branded artwork uploads
a real image through `POST /api/v1/uploads/category-icon`, which writes
to the same `<uploadDir>/icons/<KEY>.png` path and is then preserved on
subsequent restarts.
# Startup wiring
Inside `DatabaseInitService.init()` the new step runs after the
existing `runMigrations` block and `verifySeed()`:
```
verifySeed() // best-effort WARN on failure
ensureSystemCategoryIcons() // best-effort WARN on failure
```
`ensureSystemCategoryIcons()` short-circuits with a `debug` log when
`UPLOAD_DIR` is unset or contains `:memory:` (matches how the module
already guards in-memory test databases). Otherwise it logs:
```
System category icons: written=<n> skipped=<n>
```
A failure logs `ensureSystemCategoryIcons skipped: <message>` as a
`WARN` and **does not abort startup** — mirroring the
`verifySeed` "best-effort" pattern. Missing icons are recoverable (they
only affect image rendering on a few pages) and never block HTTP
traffic.
# Source-of-truth coupling
`CANONICAL_SYSTEM_ICON_KEYS` is duplicated from
`CANONICAL_SYSTEM_CATEGORIES` in
`backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories.ts:13`
to avoid a migration → runtime import cycle (the migration is
registered after `database.module.ts` is built, and re-importing from
the migration would be circular). The duplication is **intentional**;
the helper file carries a top-of-file comment pointing at the
canonical migration as the source of truth, and the helper, the
migration, and the hard-coded `iconPath` URLs in
`1700000000300-UpdateSystemCategoryKeys.ts` and
`1700000000400-RepairCategorySchemaAndSystemCategories.ts` must be kept
in sync whenever a system category is added or renamed.
# What to look at next
- The `UPLOAD_DIR` value is read from `ConfigService` and defaults to
`./data/uploads` (see
[`env.schema.ts`](/architecture/key-files.md) for the canonical
defaults).
- The icons directory is served by `app.use('/uploads', express.static(uploadDir))`
in `backend/src/main.ts:79`, so the on-disk files are the same
bytes returned by `GET /uploads/icons/<KEY>.png` to the SPA.
- Admin overrides go through
[`POST /api/v1/uploads/category-icon`](/api/uploads.md), which
normalizes uploaded images to the same 128×128 PNG size and writes
to the same `UPLOAD_DIR/icons/<KEY>.png` path.
- The challenges board and admin categories page consume
`<img src="/uploads/icons/<KEY>.png">` from each `category.icon_path`
column — see [Challenges Board](/guides/challenges-board.md) and
[Admin — Categories](/guides/admin-categories.md).
# Tests
| File | What it asserts |
|---|---|
| `tests/backend/system-category-icons.spec.ts` | Real `sharp` + `os.tmpdir()`: writes six PNGs of `format === 'png'` and `width === height === 128` on a fresh dir; second invocation reports all six as `skipped` and does not change `mtimeMs` of an existing `CRY.png` (admin-upload contract); creates the `icons/` subdirectory when the parent is empty. No mocks. |
# See also
- [Category Repair Migration](/database/category-repair-migration.md) — canonical six system categories, DB-side.
- [Uploads Endpoints](/api/uploads.md) — admin override path for the same on-disk files.
- [Challenges Board](/guides/challenges-board.md) — tester-visible consumption of the icons.
- [Admin — Categories](/guides/admin-categories.md) — admin override UI.
- [Backend Module Map](/architecture/backend-modules.md) — startup chain.
- [Key Files Index](/architecture/key-files.md) — file-by-file responsibilities.