AI Implementation feature(889): Admin Area General Settings and Categories 1.07 (#29)

This commit was merged in pull request #29.
This commit is contained in:
2026-07-22 14:53:01 +00:00
parent 3cf86b6233
commit 282fcbab94
11 changed files with 359 additions and 115 deletions
-83
View File
@@ -1,83 +0,0 @@
# Implementation Plan: Job 888 — Admin Area General Settings (event window validation)
## Status
NOT YET IMPLEMENTED. Two negative-case defects remain in the admin General settings form:
1. **Backend (`backend/src/modules/admin/dto/general.dto.ts:10-11`)** — the `eventStartUtc` / `eventEndUtc` fields are typed `z.union([z.literal(''), z.string().datetime({...})])`. Empty strings bypass validation and overwrite a previously valid event schedule with `''`. The docs at `docs/api/admin.md:48-53` and `docs/guides/admin-general-settings.md` describe this empty-allowed behaviour, but the Job explicitly requires the empty case to be rejected so the prior schedule is preserved.
2. **Frontend (`frontend/src/app/features/admin/general.component.ts:109-125`)** — the cross-field `endBeforeStart` error and the per-field `general-eventEnd-error` are both gated on `form.controls.eventEndUtc.touched`. When the user simply types a value the field-level @if may not render visibly, and the End input has no `aria-invalid`, `aria-describedby`, `title`, or `validity.validationMessage` binding, so a disabled Save button leaves the user with no visible cue.
The plan below fixes both defects and re-enables existing positive paths. No schema migration is required.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- **Backend:** NestJS 10, TypeScript strict, zod schemas under `backend/src/modules/<module>/dto/<module>.dto.ts` consumed via `ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts`). Errors are raised through `ApiError.validation(...)` returning `{ code: 'VALIDATION_FAILED', message, details: [{path, message}] }` with HTTP 400 (`backend/src/common/errors/api-error.ts`).
- **Frontend:** Angular 17+ standalone components with `ChangeDetectionStrategy.OnPush`, reactive forms (`ReactiveFormsModule`), and explicit signal wrappers (`Value`, `Invalid`, `TouchedOrDirty`) for inline error messaging (see `general.component.ts:200-257`). Templates use `@if` blocks with `data-testid` hooks tested in `tests/frontend/admin-general-pure.spec.ts`.
- **Pure helpers:** `frontend/src/app/features/admin/general.pure.ts` exports all validation/message helpers and is the only place to mutate when changing field-level rules; the component imports them.
- **Data Layer:** SQLite (`better-sqlite3`) with a `setting` key/value table (`docs/database/auth-settings.md`). `AdminGeneralService.updateSettings` (`backend/src/modules/admin/general.service.ts:61-74`) writes each `eventStartUtc` / `eventEndUtc` value through `SettingsService.set`. No schema migration is needed for this Job.
- **Test Framework & Structure:** Jest (`ts-jest`) with two projects (backend / frontend) configured in `tests/jest.config.js`. Tests live under `tests/backend/**` and `tests/frontend/**`. The repo-root `npm test` (alias of `jest --config tests/jest.config.js`) runs everything. Backend tests boot the full Nest app via `Test.createTestingModule({ imports: [AppModule] })` and use `request.agent` + CSRF (`tests/backend/csrf-client.ts`); frontend tests are pure-function specs under `tests/frontend/admin-general-pure.spec.ts` — no DOM, no UI. New tests must follow the same single-command layout.
- **Required Tools & Dependencies:** No new packages. The required tools (Node 20+, npm workspaces, `jest`, `ts-jest`, `better-sqlite3`) are already declared in `package.json` and bootstrapped by `setup.sh`. The implementer does not need to install anything new.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/admin/dto/general.dto.ts` — tighten the `eventStartUtc` / `eventEndUtc` zod union so empty strings are rejected with the per-field message; leave `superRefine` end-after-start check in place.
- `frontend/src/app/features/admin/general.component.ts` — extend the Event end `<input>` bindings to surface `aria-invalid`, `aria-describedby`, `title`, and `validity.validationMessage` whenever the form is in the `endBeforeStart` invalid state; ensure the inline `general-endBeforeStart` and `general-eventEnd-error` blocks render as soon as the cross-field validator fires (not only after `touched`); mirror the same logic symmetrically on the Event start side for completeness.
- `frontend/src/app/features/admin/general.pure.ts` — make the inline-error messages for `endBeforeStart` and `invalidDatetime` available as exports if they are not already (they are inline strings today; centralise to keep the template clean). Optionally tighten `isoDatetimeValidator` to keep treating empty as invalid client-side so the UI can also block Save when Start/End are blank after the fix — see "Open Question" below.
- `tests/backend/admin-validation.spec.ts` — add focused negative-case tests for the general-settings endpoint: empty `eventEndUtc` rejected with HTTP 400, malformed string rejected with HTTP 400, end ≤ start rejected with HTTP 400; plus a positive test that confirms valid ISO-8601 values persist unchanged.
- `tests/frontend/admin-general-pure.spec.ts` — add a small `invalidDatetime` regression asserting that `isoDatetimeValidator({ value: '' })` returns `{ invalidDatetime: true }` (if we choose to flip client validation to match backend) — see Open Question.
- **To Create:**
- `tests/backend/admin-general-event-window.spec.ts` — dedicated spec for the new negative cases; uses the same Nest-app fixture pattern as `tests/backend/admin-general-service.spec.ts` but focuses on PUT validation only.
## 3. Proposed Changes
### Backend
1. **Tighten the zod schema** in `backend/src/modules/admin/dto/general.dto.ts`:
- Replace each `eventStartUtc` / `eventEndUtc` field from `z.union([z.literal(''), z.string().datetime({ message: '...' })])` to `z.string().datetime({ message: '... must be a valid ISO-8601 datetime' })` directly. The empty-string alternative is what allowed `eventEndUtc=''` to overwrite a valid schedule (Job negative case 1).
- Keep the `superRefine` rule that emits `eventEndUtc must be strictly after eventStartUtc` on `path: ['eventEndUtc']` when both values are parseable and `end <= start`.
- The error envelope remains `{ code: 'VALIDATION_FAILED', message: 'Request validation failed', details: [{path, message}] }` (HTTP 400), emitted by `ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts:9-15`) — no pipe changes needed.
2. **Persist only on success:** nothing to change in `AdminGeneralService.updateSettings` itself; because `ZodValidationPipe` throws before the handler is called, the prior schedule is preserved automatically on rejected payloads (the SettingsService is never touched).
3. **Docs reconciliation (non-blocking):** update `docs/api/admin.md:48-53` and `docs/guides/admin-general-settings.md` sentences that say "empty strings are allowed" so the docs reflect that Start/End are now required (it is OK if the docs lag the change; the Job is authoritative).
### Frontend
1. **Update `general.pure.ts`** to keep the validator surface aligned with the new backend:
- If we choose to also reject empty strings client-side (recommended — keeps UI feedback consistent with backend), change `isoDatetimeValidator` so `''` returns `{ invalidDatetime: true }` instead of `null`. If we keep empty-allowed client-side, then `datetimeMessage` must still show a helpful message on `''` so the field stays consistent with the disabled Save button.
- Expose a `endBeforeStartMessage` helper that returns `'Event end must be after event start.'` so the template renders it from one source.
2. **Update `general.component.ts`** for the Event End input and surrounding @if blocks (lines 109-125):
- Bind `[attr.aria-invalid]` and `[attr.aria-describedby]` on the End `<input>` to a new `showEventEndCrossFieldError` computed that is true whenever the form has `errors.endBeforeStart`. Also bind `[attr.title]` to the message string and `[attr.validity.validationMessage]` is not directly supported by Angular — instead, surface a `data-error-message` attribute or render a hidden `<span class="sr-only">` so screen readers can announce it.
- Render the `general-endBeforeStart` block whenever `endBeforeStart` is present (drop the `touched` gate so the user sees the error the moment they leave End ≤ Start). Keep `general-eventEnd-error` rendering only for `invalidDatetime` cases.
- Symmetrically, for Event start, render the `general-eventStart-error` whenever `invalidDatetime` is on the control (today it is correctly shown when the input becomes dirty AND invalid).
3. **Save gating remains:** `[disabled]="submitting() || form.invalid"` keeps Save disabled while any of (a) the inline per-field validators, (b) the cross-field `endBeforeStart`, or (c) the page-title required/blank validator are in error. No change needed.
### Open Question (please confirm before implementation)
The current docs intentionally allow empty Start/End so the event window can be cleared (`general.pure.ts:69-72`, `docs/api/admin.md:48-53`). The Job description says empty strings should be rejected (negative case 1). I am interpreting the Job as the source of truth and proposing we **reject empty strings** in both backend and client. If instead the intent is to keep `''` valid but reject "malformed-but-not-empty" strings, the backend fix becomes a no-op (it already does that) and only the UI changes are needed. Please confirm before implementation begins.
## 4. Test Strategy
- **Target backend test file:** new `tests/backend/admin-general-event-window.spec.ts`. Reuses `tests/backend/csrf-client.ts`, follows the exact pattern from `tests/backend/admin-validation.spec.ts:16-46` (boots `AppModule`, registers first admin, primes CSRF, then exercises `PUT /api/v1/admin/general/settings` via `request.agent`).
- **Target frontend test file:** `tests/frontend/admin-general-pure.spec.ts`. Add only the helper-level assertions (e.g., `isoDatetimeValidator({ value: '' })` returns `{ invalidDatetime: true }`) — no DOM rendering tests. The existing pattern uses plain `describe` / `it` with assertions on function returns.
- **Mocking strategy:** No new mocks. The backend spec uses the real `AppModule` against `:memory:` SQLite (set via `process.env.DATABASE_PATH = ':memory:'` at the top of the file, mirroring `admin-validation.spec.ts:1-3`). Auth uses `register-first-admin` + login + CSRF — already idempotent because the database is in-memory and the test creates exactly one admin in `beforeAll`.
- **Cases to cover (minimal & focused):**
1. **Backend negative — empty Event End**`PUT /settings` with `eventEndUtc: ''` returns HTTP 400 and the per-field message, and a follow-up `GET /settings` shows `eventEndUtc` unchanged.
2. **Backend negative — empty Event Start** ⇒ symmetric to (1).
3. **Backend negative — malformed datetime**`PUT /settings` with `eventEndUtc: 'not-a-date'` returns HTTP 400 and `'eventEndUtc must be a valid ISO-8601 datetime'`.
4. **Backend negative — end ≤ start** ⇒ valid Start + `End === Start` returns HTTP 400 with `'eventEndUtc must be strictly after eventStartUtc'`.
5. **Backend positive — valid window** ⇒ well-formed `2026-08-15T10:30:00.000Z` / `2026-08-20T18:45:00.000Z` persists and is round-tripped unchanged.
6. **Frontend pure** — assert `isoDatetimeValidator('')` returns the invalid sentinel (if we choose to flip empty to invalid client-side).
7. **Frontend pure** — assert the new `endBeforeStartMessage` helper returns the canonical string.
- **Single command:** `npm test` from `/repo` runs the full suite. Frontend and backend can also be run separately via `npm run test:backend` / `npm run test:frontend` (already in `package.json`).
## 5. Persistent Project Data (/data)
Not applicable to this Job. No mock data, seed files, or shared assets are produced or consumed by the bug fix. Tests boot against `:memory:` SQLite, which is sufficient.
+258
View File
@@ -0,0 +1,258 @@
# Implementation Plan: Job 889 — Admin Area General Settings and Categories 1.07
## Status
NOT FULLY IMPLEMENTED. Two residual defects remain after Job 888:
1. **Categories SQL**`backend/src/modules/admin/categories.service.ts:30-37` uses
`createQueryBuilder('c').orderBy('LOWER(c.abbreviation)', 'ASC').addOrderBy('c.abbreviation', 'ASC').getMany()`.
`getMany()` auto-selects every column mapped on `CategoryEntity`, including
`created_at` and `updated_at` (added by migration
`1700000000200-AddCategoryTimestampsAndUniqueAbbrev`). The
`InitSchema1700000000000` migration (`backend/src/database/migrations/1700000000000-InitSchema.ts:28-37`)
creates the `category` table **without** those columns and relies on the
200-migration to add them at startup. On any database where the 200-migration
has not yet run (e.g. when an older pre-1.06 DB is hit before migrations
resolve, or when tests / fresh clones initialise the schema out of order),
SQLite raises `SqliteError: no such column: c.created_at` and the Categories
section on `/admin/general` (which embeds `AdminCategoriesComponent`) renders
the error to the admin. The fix is to make the `category` table include
`created_at` and `updated_at` in its initial `CREATE TABLE` and keep the 200
migration as a no-op `if (!names.has(...))` ALTER for pre-existing DBs.
2. **Event End equal-to-start field-level message**`frontend/src/app/features/admin/general.pure.ts:41-51`
already returns `{ endBeforeStart: true }` when `e <= s`, and
`frontend/src/app/features/admin/general.component.ts:123-128` renders the
`general-endBeforeStart` element whenever `eventEndCrossFieldError()` is true.
However the message lives in a **separate** `data-testid="general-endBeforeStart"`
div instead of inside the per-field error region
`data-testid="general-eventEnd-error"` that the Job (and `docs/guides/admin-general-settings.md`)
describe as the canonical "field-level validation message" for the Event End
input. The Job explicitly states: *"no visible message inside the field-level
error region (e.g., `[data-testid=general-eventEnd-error]`)"*. The fix is to
funnel the cross-field `endBeforeStart` message into `general-eventEnd-error`
(and surface it via `aria-invalid` / `aria-describedby` / `title` on the
input) so it is rendered as the Event End field's own validation message.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- **Backend:** NestJS 10, TypeScript strict, TypeORM 0.3.20 over
`better-sqlite3`. Migrations live under
`backend/src/database/migrations/` and are wired through
`DatabaseModule.MIGRATIONS` (`backend/src/database/database.module.ts:31-36`)
and run on bootstrap by `DatabaseInitService.init()` (`backend/src/database/database-init.service.ts:26-54`).
Entities are auto-selected from `DatabaseModule.ENTITIES` and must remain
in sync with the SQL schema produced by the migrations.
- **Frontend:** Angular 17+ standalone components with
`ChangeDetectionStrategy.OnPush`, reactive forms, and the `Value` /
`Invalid` / `TouchedOrDirty` signal trio for inline error messaging
(see `general.component.ts:204-321`). Pure validation / message helpers
live in `frontend/src/app/features/admin/general.pure.ts` and are
unit-tested under `tests/frontend/admin-general-pure.spec.ts`.
- **Error envelopes:** backend emits
`{ code: 'VALIDATION_FAILED', message, details: [{path, message}] }` via
`ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts:9-15`)
for `400`s; `ApiError` helpers live in `backend/src/common/errors/api-error.ts`.
- **Data Layer:** SQLite (`better-sqlite3`) database file at
`process.env.DATABASE_PATH` (default `/data/hipctf/db.sqlite`). The
`category` table is created by `InitSchema1700000000000` and seeded /
amended by `SeedSystemData1700000000100`,
`AddCategoryTimestampsAndUniqueAbbrev1700000000200`, and
`UpdateSystemCategoryKeys1700000000300`. The
`CategoryEntity` (`backend/src/database/entities/category.entity.ts`) is
the single source of truth for the column names that TypeORM will SELECT
via `getMany()`.
- **Test Framework & Structure:** Jest (`ts-jest`) with two projects
(`backend` and `frontend`) configured in `tests/jest.config.js`. Tests
live in `tests/backend/**/*.spec.ts` and `tests/frontend/**/*.spec.ts`
and run with a single command `npm test` from the repo root (already in
`package.json:scripts.test`). Backend tests typically boot the real
`AppModule` against `:memory:` SQLite via
`process.env.DATABASE_PATH = ':memory:'`; frontend tests are pure
function specs that never touch the DOM. New tests must follow the same
layout.
- **Required Tools & Dependencies:** No new packages. Node 20+, npm
workspaces, `jest`, `ts-jest`, `better-sqlite3`, `@nestjs/typeorm`,
and `@nestjs/common` are all already declared and bootstrapped by
`setup.sh` (`setup.sh:1-19`). No `setup.sh` change is required for
this Job.
## 2. Impacted Files
- **To Modify:**
- `backend/src/database/migrations/1700000000000-InitSchema.ts`
add `"created_at"` and `"updated_at"` columns to the initial
`CREATE TABLE "category"` so the columns exist from the very first
migration; preserve the `description` and `icon_path` defaults.
- `frontend/src/app/features/admin/general.component.ts`
extend the Event End `general-eventEnd-error` element so it also
renders the cross-field `endBeforeStart` message when no
per-field `invalidDatetime` error is present; keep
`general-endBeforeStart` as a redundant/legacy visual element only
if it is already asserted in tests (see Test Strategy); bind
`[attr.aria-invalid]` / `[attr.aria-describedby]` / `[attr.title]`
on the Event End `<input>` to the merged state.
- `frontend/src/app/features/admin/general.pure.ts` — add a tiny
helper (e.g. `eventEndFieldError(fieldErrors, crossField)`) that
returns the right human-readable string for the merged Event End
field error, so the template can read it from a single source.
- `tests/frontend/admin-general-pure.spec.ts` — add focused unit
assertions for the new helper (equal-to-start and end-before-start
cases).
- `tests/backend/migrations.spec.ts` — add a focused assertion that
running `InitSchema1700000000000` against an empty database
produces a `category` table that contains a `created_at` column
(and `updated_at`). This locks the SQL schema in place so the
Categories endpoint can never regress to the
`no such column: c.created_at` state.
- `docs/guides/admin-general-settings.md` — clarify that the
Event End inline error region (`general-eventEnd-error`) is the
canonical place where the end-before-start message surfaces.
- **To Create:**
- None required. Existing test files are sufficient.
## 3. Proposed Changes
### 1. Database / Schema Migration
1. **Update `InitSchema1700000000000`** at
`backend/src/database/migrations/1700000000000-InitSchema.ts:28-37` so
the `category` `CREATE TABLE` includes `created_at` and `updated_at`
columns from the very first migration:
```sql
CREATE TABLE IF NOT EXISTS "category" (
"id" TEXT PRIMARY KEY,
"system_key" TEXT,
"name" TEXT NOT NULL,
"abbreviation" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"icon_path" TEXT NOT NULL DEFAULT '',
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
"updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
```
Mirror the `strftime('%Y-%m-%dT%H:%M:%fZ','now')` default already used
for `user.created_at` (line 17) and `challenge.created_at` (line 54) so
newly-inserted rows get a sane timestamp without a follow-up
UPDATE-pass.
2. **Keep `AddCategoryTimestampsAndUniqueAbbrev1700000000200`
unchanged.** Its `if (!names.has('created_at'))` /
`if (!names.has('updated_at'))` guards (`backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev.ts:8-20`)
make the ALTER a no-op for fresh databases and still patch
older pre-1.06 databases that lack the columns. No changes needed
to `down()` either — the down() drops the columns, which is the
correct mirror of `up()`.
3. **Keep `UpdateSystemCategoryKeys1700000000300` unchanged.** Its
`INSERT` already lists `created_at` and `updated_at`
(`backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys.ts:51`).
4. No need to touch `CategoryEntity`
(`backend/src/database/entities/category.entity.ts`) — its current
`@Column('text', { name: 'created_at', default: '' })` and
`@Column('text', { name: 'updated_at', default: '' })` annotations
match the columns we are guaranteeing.
### 2. Backend Logic & APIs
No controller / service logic changes are required. With the schema
fix, `AdminCategoriesService.list()` at
`backend/src/modules/admin/categories.service.ts:30-37` will resolve
`c.created_at` and `c.updated_at` and return rows that satisfy the
existing `CategoryView` shape used by the front-end.
If desired, the controller surface can stay untouched
(`backend/src/modules/admin/admin-categories.controller.ts:30-34`) —
no new endpoint is needed.
### 3. Frontend UI Integration
1. **Update `frontend/src/app/features/admin/general.pure.ts`** to
expose a small `eventEndFieldMessage(controlErrors, crossField)` pure
helper that returns the canonical Event End field error string:
| `controlErrors?.['invalidDatetime']` | `form.errors?.['endBeforeStart']` | Returned message |
|---------------------------------------|-----------------------------------|----------------------------------------------------------|
| `true` | any | `"Event end must be a valid ISO-8601 datetime."` |
| falsy | `true` | `"Event end must be after event start."` |
| falsy | falsy | `null` |
This mirrors the existing `datetimeMessage` pattern and keeps the
template a thin consumer.
2. **Update `frontend/src/app/features/admin/general.component.ts`**:
- Replace the current `@if (showEventEndError())` /
`@if (eventEndCrossFieldError())` pair (lines 123-128) with a single
`eventEndFieldMessage` consumer that always renders inside the
`data-testid="general-eventEnd-error"` element. When both
validators are off, the element is absent.
- Bind `[attr.aria-invalid]` to `true` when
`eventEndFieldMessage(...)` is non-null, otherwise null.
- Bind `[attr.aria-describedby]` to `"general-eventEnd-error"` and
bind `[attr.title]` to the resolved message string in that same
case.
- Drop the separate `general-endBeforeStart` block **only after**
confirming no existing test asserts on it (a quick search of
`tests/frontend` shows it is only mentioned in
`tests/frontend/admin-general-pure.spec.ts` as a value assertion on
the constant `endBeforeStartMessage`, not as a DOM query). If a test
asserts on the element, keep it as a redundant duplicate or update
the test.
3. **Do not change** the form-level validator at
`general.pure.ts:41-51` — it already returns `{ endBeforeStart: true }`
for both `e < s` and `e === s`. The Job requirement is purely a
rendering fix.
## 4. Test Strategy
- **Target backend test file:** `tests/backend/migrations.spec.ts` (new
spec). Add a focused `describe('InitSchema creates category with
created_at + updated_at columns')` that:
- Boots `AppModule` against `:memory:` SQLite.
- Calls `dataSource.runMigrations({ transaction: 'each' })`.
- Queries `PRAGMA table_info("category")` and asserts the returned
names contain both `created_at` and `updated_at`.
- Sanity-checks that `AdminCategoriesService.list()` returns an array
(length ≥ 6 seeded system rows) without throwing — this is the
regression test that prevents the user-visible
`no such column: c.created_at` error from coming back.
- **Target backend test file:** `tests/backend/admin-categories-service.spec.ts`
(extend). Add one `it('list() succeeds against the migrated schema and
returns seeded rows')` that runs after the existing CRUD specs. The
existing tests already use `synchronize: true` which would have masked
the bug; the new test should rely on the **migrations** path so it
actually exercises the schema the production app uses.
- **Target frontend test file:** `tests/frontend/admin-general-pure.spec.ts`
(extend). Add a `describe('eventEndFieldMessage')` block with three
focused cases:
1. Returns the ISO message when `invalidDatetime` is set, even if
`endBeforeStart` is also set.
2. Returns the end-before-start message when `invalidDatetime` is
absent and `endBeforeStart` is set.
3. Returns `null` when both are absent.
- **Mocking Strategy:** No new mocks. Backend spec uses the real
`AppModule` against `:memory:` SQLite (already the pattern in
`tests/backend/admin-validation.spec.ts` and
`tests/backend/admin-general-service.spec.ts`). Frontend spec
continues to assert on the pure helper return values only.
- **Single command:** `npm test` from `/repo` runs the full suite
(already in `package.json:14`). Frontend and backend can also be run
separately via `npm run test:backend` / `npm run test:frontend`.
## 5. Persistent Project Data (/data)
Not applicable to this Job. The DB schema change is a migration; tests
use `:memory:` SQLite and never write to `/data`. The Categories
endpoint reads from the existing `category` table without touching
uploaded files or any other shared asset.
@@ -32,7 +32,9 @@ export class InitSchema1700000000000 implements MigrationInterface {
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"abbreviation" TEXT NOT NULL, "abbreviation" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '', "description" TEXT NOT NULL DEFAULT '',
"icon_path" TEXT NOT NULL DEFAULT '' "icon_path" TEXT NOT NULL DEFAULT '',
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
"updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
); );
`); `);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`); await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`);
+3 -3
View File
@@ -3,7 +3,7 @@ type: database
title: Challenge Tables title: Challenge Tables
description: category, challenge, challenge_file, and solve tables — how CTF challenges and scoring are stored. description: category, challenge, challenge_file, and solve tables — how CTF challenges and scoring are stored.
tags: [database, challenge, category, solve] tags: [database, challenge, category, solve]
timestamp: 2026-07-21T14:18:00Z timestamp: 2026-07-22T14:50:25Z
--- ---
# Tables # Tables
@@ -18,8 +18,8 @@ timestamp: 2026-07-21T14:18:00Z
| `abbreviation` | TEXT | Short label (e.g. `CRY`), 26 chars, server-uppercased. Globally unique — enforced by `uq_category_abbreviation`. | | `abbreviation` | TEXT | Short label (e.g. `CRY`), 26 chars, server-uppercased. Globally unique — enforced by `uq_category_abbreviation`. |
| `description` | TEXT | Optional description. | | `description` | TEXT | Optional description. |
| `icon_path` | TEXT | Default icon URL (e.g. `/uploads/icons/CRY.png`). | | `icon_path` | TEXT | Default icon URL (e.g. `/uploads/icons/CRY.png`). |
| `created_at` | TEXT | ISO 8601 timestamp the row was created (added by `AddCategoryTimestampsAndUniqueAbbrev1700000000200`). | | `created_at` | TEXT | ISO 8601 timestamp the row was created. Declared in the initial `InitSchema1700000000000` migration (defaulting to `strftime('%Y-%m-%dT%H:%M:%fZ','now')`); the `AddCategoryTimestampsAndUniqueAbbrev1700000000200` migration adds it as a no-op for older pre-existing databases. |
| `updated_at` | TEXT | ISO 8601 timestamp the row was last updated (added by the same migration; bumped on `PUT /api/v1/admin/categories/:id`). | | `updated_at` | TEXT | ISO 8601 timestamp the row was last updated (bumped on `PUT /api/v1/admin/categories/:id`). Same provenance as `created_at`. |
System rows are seeded by the System rows are seeded by the
`UpdateSystemCategoryKeys1700000000300` migration so the canonical `UpdateSystemCategoryKeys1700000000300` migration so the canonical
+7 -1
View File
@@ -3,7 +3,7 @@ type: database
title: Database Schema Overview title: Database Schema Overview
description: SQLite (better-sqlite3) schema for HIPCTF: tables, relationships, indexes, and WAL journal mode. description: SQLite (better-sqlite3) schema for HIPCTF: tables, relationships, indexes, and WAL journal mode.
tags: [database, sqlite, typeorm, schema] tags: [database, sqlite, typeorm, schema]
timestamp: 2026-07-21T14:18:00Z timestamp: 2026-07-22T14:50:25Z
--- ---
# Overview # Overview
@@ -17,6 +17,12 @@ The schema is created by a single migration
(`backend/src/database/migrations/1700000000000-InitSchema.ts`) and seeded (`backend/src/database/migrations/1700000000000-InitSchema.ts`) and seeded
by a second migration by a second migration
(`backend/src/database/migrations/1700000000100-SeedSystemData.ts`). (`backend/src/database/migrations/1700000000100-SeedSystemData.ts`).
The initial migration declares every column used by the entities
(including `category.created_at` and `category.updated_at`), so the
later `AddCategoryTimestampsAndUniqueAbbrev1700000000200` and
`UpdateSystemCategoryKeys1700000000300` migrations only patch legacy
databases that pre-date those columns and reseed the canonical system
rows.
# Tables # Tables
+25 -13
View File
@@ -3,7 +3,7 @@ type: guide
title: Admin — General Settings title: Admin — General Settings
description: How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page. description: How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page.
tags: [guide, admin, settings, general, tester, datetime, validation] tags: [guide, admin, settings, general, tester, datetime, validation]
timestamp: 2026-07-22T14:24:08Z timestamp: 2026-07-22T14:50:25Z
--- ---
# When this view is available # When this view is available
@@ -17,7 +17,7 @@ by navigating directly to the URL.
|------------------|----------------------------------------------------------------------------|------------------------------------------------| |------------------|----------------------------------------------------------------------------|------------------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `/admin/general` child of `adminGuard`. | | Client route | `frontend/src/app/app.routes.ts` | `/admin/general` child of `adminGuard`. |
| Client component | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent.ngOnInit` fetches settings + themes. | | Client component | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent.ngOnInit` fetches settings + themes. |
| Client predicate | `frontend/src/app/features/admin/general.pure.ts` (`deriveEventState`, `pageTitleError`, `pageTitleMessage`) | Pure helpers for event-state derivation and Page-title error/message mapping. | | Client predicate | `frontend/src/app/features/admin/general.pure.ts` (`deriveEventState`, `pageTitleError`, `pageTitleMessage`, `eventEndFieldMessage`) | Pure helpers for event-state derivation, Page-title error/message mapping, and the merged Event End field message (per-field `invalidDatetime` + cross-field `endBeforeStart`). |
| Server route | `backend/src/modules/admin/admin-general.controller.ts` | `GET/PUT /api/v1/admin/general/settings` + `GET /api/v1/admin/general/themes`. | | Server route | `backend/src/modules/admin/admin-general.controller.ts` | `GET/PUT /api/v1/admin/general/settings` + `GET /api/v1/admin/general/themes`. |
# How to access (tester steps) # How to access (tester steps)
@@ -43,7 +43,7 @@ The page is a single reactive form with these controls (every
| Logo upload status | `general-logo-uploading` / `general-logo-error` / `general-logo-current` | — | Inline status text under the file picker. | | Logo upload status | `general-logo-uploading` / `general-logo-error` / `general-logo-current` | — | Inline status text under the file picker. |
| Global theme | `general-themeKey` | `themeKey` | `<select>` populated from `/themes`. Value is one of `THEME_IDS`. | | Global theme | `general-themeKey` | `themeKey` | `<select>` populated from `/themes`. Value is one of `THEME_IDS`. |
| Event start (UTC) | `general-eventStart` | `eventStartUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventStart-error` and disables Save. | | Event start (UTC) | `general-eventStart` | `eventStartUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventStart-error` and disables Save. |
| Event end (UTC) | `general-eventEnd` | `eventEndUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventEnd-error`; it must also be strictly after Event start (`general-endBeforeStart`). | | Event end (UTC) | `general-eventEnd` | `eventEndUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventEnd-error`; it must also be strictly after Event start — when it is not, the same `general-eventEnd-error` region displays "Event end must be after event start." |
| Default challenge IP | `general-defaultIp` | `defaultChallengeIp` | Required, max 255 chars. | | Default challenge IP | `general-defaultIp` | `defaultChallengeIp` | Required, max 255 chars. |
| Enable registrations | `general-registrations` | `registrationsEnabled` | Boolean checkbox. When `false`, the public register endpoint returns `REGISTRATIONS_DISABLED`. | | Enable registrations | `general-registrations` | `registrationsEnabled` | Boolean checkbox. When `false`, the public register endpoint returns `REGISTRATIONS_DISABLED`. |
| Welcome description | `general-welcome` | `welcomeMarkdown` | Multi-line textarea; a live preview is rendered into `general-welcome-preview`. | | Welcome description | `general-welcome` | `welcomeMarkdown` | Multi-line textarea; a live preview is rendered into `general-welcome-preview`. |
@@ -71,9 +71,9 @@ The page is a single reactive form with these controls (every
* `start <= now < end``RUNNING` * `start <= now < end``RUNNING`
* `now >= end``STOPPED` * `now >= end``STOPPED`
* **End-before-start validation:** if the user picks an end that is not * **End-before-start validation:** if the user picks an end that is not
strictly after the start, the form becomes invalid and strictly after the start, the form becomes invalid and the
`general-endBeforeStart` appears under the end input. Save remains `general-eventEnd-error` region under the end input renders
disabled. "Event end must be after event start." Save remains disabled.
* **Per-field datetime validation:** clearing a field or supplying a value * **Per-field datetime validation:** clearing a field or supplying a value
that cannot be parsed as an ISO-8601 datetime makes that control invalid. that cannot be parsed as an ISO-8601 datetime makes that control invalid.
The affected input receives `aria-invalid="true"`, references its inline The affected input receives `aria-invalid="true"`, references its inline
@@ -136,6 +136,16 @@ two pure helpers exported from
* `datetimeMessage(fieldLabel, value, controlErrors)` — renders the * `datetimeMessage(fieldLabel, value, controlErrors)` — renders the
human-readable message `"<Field> must be a valid ISO-8601 datetime."` human-readable message `"<Field> must be a valid ISO-8601 datetime."`
when `invalidDatetime` is set or when the raw value is unparseable. when `invalidDatetime` is set or when the raw value is unparseable.
* `eventEndFieldMessage(controlErrors, crossFieldErrors)` — returns the
canonical message for the Event End field's own error region. It
prefers the per-field ISO message
(`"Event end must be a valid ISO-8601 datetime."`) when
`controlErrors?.['invalidDatetime']` is set, otherwise it surfaces the
cross-field `endBeforeStart` message
(`"Event end must be after event start."`), otherwise `null`. The
component reads this helper through the `eventEndFieldMessageText`
computed signal so both error kinds render inside the single
`general-eventEnd-error` element.
The component wires both controls (`eventStartUtc`, `eventEndUtc`) with The component wires both controls (`eventStartUtc`, `eventEndUtc`) with
this validator and keeps three local `signal`s per field — `Value`, this validator and keeps three local `signal`s per field — `Value`,
@@ -160,11 +170,14 @@ The error is shown only when the control is both `touched || dirty`
AND `invalid`, so the inputs do not flash errors on initial load. After AND `invalid`, so the inputs do not flash errors on initial load. After
a successful Save, both controls are reset to untouched/pristine via a successful Save, both controls are reset to untouched/pristine via
`resetTouchedState()` and their `TouchedOrDirty` signals are cleared. `resetTouchedState()` and their `TouchedOrDirty` signals are cleared.
The cross-field `endBeforeStart` error (`general-endBeforeStart`) is The cross-field `endBeforeStart` error is emitted by the form-level
emitted by the form-level validator whenever both valid timestamps are validator whenever both valid timestamps are present and end is not
present and end is not strictly after start. The Event end input also strictly after start. The Event end input receives `aria-invalid="true"`,
receives `aria-invalid="true"`, references both possible error elements, references `general-eventEnd-error` via `aria-describedby`, and exposes
and exposes `Event end must be after event start.` as its title. `Event end must be after event start.` as its title — the message itself
is rendered inside the per-field error region
(`data-testid="general-eventEnd-error"`) by the
`eventEndFieldMessage` helper.
# Visual elements # Visual elements
@@ -175,9 +188,8 @@ and exposes `Event end must be after event start.` as its title.
| Load error | `[data-testid="general-error"]` | | Load error | `[data-testid="general-error"]` |
| Form | `[data-testid="general-form"]` | | Form | `[data-testid="general-form"]` |
| Welcome preview | `[data-testid="general-welcome-preview"]` | | Welcome preview | `[data-testid="general-welcome-preview"]` |
| End-before-start message | `[data-testid="general-endBeforeStart"]` |
| Event start inline error | `[data-testid="general-eventStart-error"]` | | Event start inline error | `[data-testid="general-eventStart-error"]` |
| Event end inline error | `[data-testid="general-eventEnd-error"]` | | Event end inline error | `[data-testid="general-eventEnd-error"]` (renders both the per-field `invalidDatetime` message and the cross-field `endBeforeStart` "Event end must be after event start." message) |
| Page-title inline error | `[data-testid="general-pageTitle-error"]` | | Page-title inline error | `[data-testid="general-pageTitle-error"]` |
# Architecture map # Architecture map
+1 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-22T14:24:08Z. they need. Last regenerated 2026-07-22T14:50:25Z.
# Architecture # Architecture
@@ -11,6 +11,7 @@ import {
deriveEventState, deriveEventState,
endAfterStartValidator, endAfterStartValidator,
endBeforeStartMessage, endBeforeStartMessage,
eventEndFieldMessage,
isoDatetimeValidator, isoDatetimeValidator,
normalizePageTitle, normalizePageTitle,
pageTitleMessage, pageTitleMessage,
@@ -114,17 +115,12 @@ import {
type="datetime-local" type="datetime-local"
formControlName="eventEndUtc" formControlName="eventEndUtc"
data-testid="general-eventEnd" data-testid="general-eventEnd"
[attr.aria-invalid]="showEventEndError() || eventEndCrossFieldError() ? 'true' : null" [attr.aria-invalid]="eventEndFieldMessageText() ? 'true' : null"
[attr.aria-describedby]="(showEventEndError() || eventEndCrossFieldError()) [attr.aria-describedby]="eventEndFieldMessageText() ? 'general-eventEnd-error' : null"
? 'general-eventEnd-error general-endBeforeStart' [attr.title]="eventEndFieldMessageText()"
: null"
[attr.title]="(showEventEndError() ? eventEndMessage() : eventEndCrossFieldError() ? endBeforeStartMessage : null)"
/> />
@if (showEventEndError()) { @if (eventEndFieldMessageText(); as msg) {
<div class="field-error" id="general-eventEnd-error" data-testid="general-eventEnd-error">{{ eventEndMessage() }}</div> <div class="field-error" id="general-eventEnd-error" data-testid="general-eventEnd-error">{{ msg }}</div>
}
@if (eventEndCrossFieldError()) {
<div class="field-error" id="general-endBeforeStart" data-testid="general-endBeforeStart">{{ endBeforeStartMessage }}</div>
} }
</div> </div>
@@ -260,9 +256,11 @@ export class AdminGeneralComponent implements OnInit {
datetimeMessage('Event end', this.eventEndValue(), this.form.controls.eventEndUtc.errors), datetimeMessage('Event end', this.eventEndValue(), this.form.controls.eventEndUtc.errors),
); );
readonly eventEndCrossFieldError = computed( readonly eventEndFieldMessageText = computed(() => {
() => !!this.form.errors?.['endBeforeStart'], const fieldErrs = this.eventEndTouchedOrDirty() ? this.form.controls.eventEndUtc.errors : null;
); const crossErrs = this.form.errors;
return eventEndFieldMessage(fieldErrs, crossErrs);
});
constructor() { constructor() {
this.form.controls.welcomeMarkdown.valueChanges this.form.controls.welcomeMarkdown.valueChanges
@@ -87,3 +87,16 @@ export function datetimeMessage(
} }
export const endBeforeStartMessage = 'Event end must be after event start.'; export const endBeforeStartMessage = 'Event end must be after event start.';
export function eventEndFieldMessage(
controlErrors: { [key: string]: unknown } | null | undefined,
crossFieldErrors: { [key: string]: unknown } | null | undefined,
): string | null {
if (controlErrors?.['invalidDatetime']) {
return 'Event end must be a valid ISO-8601 datetime.';
}
if (crossFieldErrors?.['endBeforeStart']) {
return endBeforeStartMessage;
}
return null;
}
+18
View File
@@ -73,4 +73,22 @@ describe('Migrations', () => {
const restrict = fks.filter((f: any) => f.on_delete === 'RESTRICT'); const restrict = fks.filter((f: any) => f.on_delete === 'RESTRICT');
expect(restrict.length).toBeGreaterThanOrEqual(2); expect(restrict.length).toBeGreaterThanOrEqual(2);
}); });
it('creates the category table with created_at and updated_at columns (Job 889)', async () => {
const cols: any[] = await dataSource.query(`PRAGMA table_info("category")`);
const names = new Set(cols.map((c: any) => c.name));
expect(names.has('created_at')).toBe(true);
expect(names.has('updated_at')).toBe(true);
});
it('lists categories without a "no such column: c.created_at" error (Job 889)', async () => {
const rows = await dataSource
.getRepository(CategoryEntity)
.createQueryBuilder('c')
.orderBy('LOWER(c.abbreviation)', 'ASC')
.addOrderBy('c.abbreviation', 'ASC')
.getMany();
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBeGreaterThanOrEqual(6);
});
}); });
+20
View File
@@ -2,6 +2,7 @@ import {
datetimeMessage, datetimeMessage,
deriveEventState, deriveEventState,
endAfterStartValidator, endAfterStartValidator,
eventEndFieldMessage,
isoDatetimeValidator, isoDatetimeValidator,
normalizePageTitle, normalizePageTitle,
pageTitleError, pageTitleError,
@@ -203,3 +204,22 @@ describe('datetimeMessage', () => {
expect(datetimeMessage('Event start', '2026-08-15T10:30:00.000Z', null)).toBeNull(); expect(datetimeMessage('Event start', '2026-08-15T10:30:00.000Z', null)).toBeNull();
}); });
}); });
describe('eventEndFieldMessage (Job 889)', () => {
const endIsoMsg = 'Event end must be a valid ISO-8601 datetime.';
const endBeforeMsg = 'Event end must be after event start.';
it('returns the ISO message when invalidDatetime is set, even if endBeforeStart is also set', () => {
expect(eventEndFieldMessage({ invalidDatetime: true }, { endBeforeStart: true })).toBe(endIsoMsg);
});
it('returns the end-before-start message when invalidDatetime is absent and endBeforeStart is set', () => {
expect(eventEndFieldMessage(null, { endBeforeStart: true })).toBe(endBeforeMsg);
expect(eventEndFieldMessage({}, { endBeforeStart: true })).toBe(endBeforeMsg);
});
it('returns null when both error sources are absent', () => {
expect(eventEndFieldMessage(null, null)).toBeNull();
expect(eventEndFieldMessage({}, {})).toBeNull();
});
});