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
+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.