AI Implementation feature(888): Admin Area General Settings and Categories 1.06 #28

Merged
m0rph3us1987 merged 2 commits from feature-888-1784729697928 into dev 2026-07-22 14:27:44 +00:00
12 changed files with 228 additions and 233 deletions
-173
View File
@@ -1,173 +0,0 @@
# Implementation Plan: Admin General Settings — Event Start/End Validation (Job 887)
## Status
NOT already implemented. Two distinct gaps are described in the Job:
1. **Backend (`PUT /api/v1/admin/general/settings`)** accepts any string for
`eventStartUtc` / `eventEndUtc` and persists it. Only the cross-field
`end > start` rule is enforced. Garbage payloads like
`eventStartUtc='not-a-date'` return HTTP 200 and overwrite the stored
schedule.
2. **Frontend (`/admin/general`)** has no per-field validation feedback for the
two `datetime-local` inputs. The cross-field rule fires correctly
(`form.invalid`, Save disabled), but the inputs render no `title`,
`aria-invalid`, `aria-describedby`, `validationMessage`, or `[data-testid=...-error]`
element when either field is empty/invalid individually.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Backend: NestJS controllers, Zod schemas in `*.dto.ts`, validated by the
shared `ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts`)
which throws `ApiError.validation('Request validation failed', details)`
mapped to HTTP 400 `VALIDATION_FAILED`.
- Frontend: Angular 17+ standalone components, signal-based state, `OnPush`,
`ReactiveFormsModule`, pure helper module at
`frontend/src/app/features/admin/general.pure.ts`. Inline error display
pattern is already used for the `pageTitle` field
(`general-pageTitle-error` + `pageTitleMessage(...)` computed signal +
`showPageTitleError()` computed) — replicate this exact pattern for the
two date inputs.
- **Data Layer:** SQLite via `better-sqlite3`. `SettingsService` stores every
general-settings key as a single string row in the `setting` table. No
schema migration is required for this job — we are tightening input
validation, not changing storage.
- **Test Framework & Structure:**
- Jest 29 with `ts-jest`, root config at `tests/jest.config.js`. Two
projects: `backend` (`tests/backend/`) and `frontend`
(`tests/frontend/`). Tests live ONLY in `/repo/tests/`.
- Single command `npm test` runs everything.
- **Required Tools & Dependencies:** None. We use the already-installed
`zod` (datetime check is built into zod ≥ 3.20) and the existing
Angular reactive-forms stack. No `setup.sh` changes are required.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/admin/dto/general.dto.ts` — tighten
`eventStartUtc` / `eventEndUtc` to require real ISO-8601 datetimes.
- `frontend/src/app/features/admin/general.component.ts` — add per-field
datetime error rendering and inline error elements under both inputs.
- `frontend/src/app/features/admin/general.pure.ts` — add pure helpers
(`isoDatetimeValidator`, `datetimeMessage`) so the same logic is
unit-testable in isolation.
- `tests/backend/admin-general-service.spec.ts` — add negative cases for
non-datetime payloads.
- `tests/frontend/admin-general-pure.spec.ts` — add tests for the new
validators + message helpers.
- **To Create:** None. All changes fit inside existing files.
## 3. Proposed Changes
### 3.1 Backend — strict ISO-8601 datetime validation
In `backend/src/modules/admin/dto/general.dto.ts`:
- Replace the two loose `z.string()` declarations with zod's built-in
`.datetime({ offset: false })` check (or
`z.string().datetime({ message: '...' })` with explicit messages), so that
`GeneralSettingsSchema` rejects non-ISO-8601 strings with a clear
`INVALID_DATETIME` message before the request reaches
`SettingsService.set(...)`. This guarantees:
- The previously valid schedule is unchanged when validation fails (the
service is never called).
- The error is surfaced via the standard
`ApiError.validation('Request validation failed', details)` envelope
produced by `ZodValidationPipe` (HTTP 400 `VALIDATION_FAILED`).
- Keep the existing `superRefine` end > start rule; it now runs only on
values that already passed the per-field datetime check.
- Empty string handling: the current UI sends `''` when the user clears an
input, and the current code accepts `''`. To avoid regressing the
"unconfigured event" state, allow `z.union([z.string().datetime(...), z.literal('')])`
(an empty string is a valid "no event scheduled" value). Anything that
is neither empty nor a valid ISO datetime is rejected.
### 3.2 Frontend — per-field datetime error rendering
In `frontend/src/app/features/admin/general.component.ts`:
- Apply `isoDatetimeValidator` (new, see 3.3) as a per-control validator
on both `eventStartUtc` and `eventEndUtc` (alongside the existing
group-level `endAfterStartValidator`).
- For each input, add the `aria-describedby` / `aria-invalid` attributes
and an inline `<div class="field-error" data-testid="general-eventStart-error">…</div>`
(and the matching `general-eventEnd-error`) that renders when the
control is touched/dirty AND invalid, driven by a `computed` signal —
exactly mirroring the existing `showPageTitleError` /
`general-pageTitle-error` pattern in this same file.
- The message text comes from the new `datetimeMessage(value, errors)`
pure helper (see 3.3). Messages:
- non-empty value that fails `Date.parse`
`"Event start must be a valid ISO-8601 datetime."` (or "Event end").
- empty value → no message (empty is the intentional "unconfigured"
state; the `endBeforeStart` group validator already stays quiet when
one side is empty).
- The existing `general-endBeforeStart` element stays put; it now
coexists with the two new per-field error nodes.
### 3.3 Frontend pure helpers
In `frontend/src/app/features/admin/general.pure.ts`, add:
```ts
export function isoDatetimeValidator(ctrl: { value: string | null }) {
const v = ctrl?.value ?? '';
if (v === '') return null; // unconfigured is allowed
const t = Date.parse(v);
return Number.isFinite(t) ? null : { invalidDatetime: true };
}
export function datetimeMessage(
fieldLabel: string,
value: string | null | undefined,
controlErrors: { [k: string]: unknown } | null | undefined,
): string | null {
if (controlErrors?.['invalidDatetime']) {
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
}
// also catch the case where the user typed something unparseable but
// the validator did not run yet (defensive)
const v = value ?? '';
if (v !== '' && !Number.isFinite(Date.parse(v))) {
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
}
return null;
}
```
These are exported from the existing pure file so the component can call
them directly and the spec file can exercise them in isolation.
### 3.4 No persistence side-effects on rejection
This is already enforced by the existing `ZodValidationPipe` (the
service is never invoked when validation fails), so no further change is
needed there.
## 4. Test Strategy
- **Target Unit Test Files:**
- `tests/backend/admin-general-service.spec.ts` — extend the existing
`GeneralSettingsSchema - validation rules` describe block with two
cases:
- `eventStartUtc = 'not-a-date'` → schema returns `success: false`.
- `eventStartUtc = '2026-08-15T10:30:00.000Z'` and
`eventEndUtc = 'also-not-a-date'` → schema returns
`success: false`, and the issue message includes `'eventEndUtc'`
/ contains the word `datetime` (to assert the new error is the
source of the rejection, not the cross-field rule).
- `tests/frontend/admin-general-pure.spec.ts` — extend with:
- `isoDatetimeValidator` returns `null` for empty input.
- `isoDatetimeValidator` returns `null` for a valid ISO string.
- `isoDatetimeValidator` returns `{ invalidDatetime: true }` for
`'not-a-date'`.
- `datetimeMessage` returns the correct per-field message for
`'Event start'` / `'Event end'` and returns `null` for empty /
valid input.
- **Mocking Strategy:** Pure-function tests need no mocks. The existing
backend spec already constructs the schema in-process; we just feed
it bad payloads and inspect `safeParse` results. No DB, no HTTP
server, no Angular `TestBed` are required.
- **Command:** `npm test` (or the narrower
`npm run test:backend` / `npm run test:frontend`).
+83
View File
@@ -0,0 +1,83 @@
# 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.
+2 -2
View File
@@ -7,8 +7,8 @@ export const GeneralSettingsSchema = z
logo: z.string().max(2048), logo: z.string().max(2048),
welcomeMarkdown: z.string().max(64_000), welcomeMarkdown: z.string().max(64_000),
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]), themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
eventStartUtc: z.union([z.literal(''), z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' })]), eventStartUtc: z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' }),
eventEndUtc: z.union([z.literal(''), z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' })]), eventEndUtc: z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' }),
defaultChallengeIp: z.string().min(1).max(255), defaultChallengeIp: z.string().min(1).max(255),
registrationsEnabled: z.boolean(), registrationsEnabled: z.boolean(),
}) })
+9 -7
View File
@@ -1,9 +1,9 @@
--- ---
type: api type: api
title: Admin Endpoints title: Admin Endpoints
description: Admin-only endpoints for user management, general settings, categories, and supporting uploads. description: Admin-only endpoints for user management, required general settings, categories, and supporting uploads.
tags: [api, admin, users, general, categories] tags: [api, admin, users, general, categories]
timestamp: 2026-07-22T12:00:00Z timestamp: 2026-07-22T14:24:08Z
--- ---
# Endpoints # Endpoints
@@ -46,11 +46,13 @@ removes the row.
* `logo` up to 2048 chars (a public URL — uploaded separately) * `logo` up to 2048 chars (a public URL — uploaded separately)
* `welcomeMarkdown` up to 64 000 chars * `welcomeMarkdown` up to 64 000 chars
* `themeKey` is one of `THEME_IDS` * `themeKey` is one of `THEME_IDS`
* `eventStartUtc` / `eventEndUtc` are validated as ISO-8601 datetimes * `eventStartUtc` / `eventEndUtc` must each be non-empty ISO-8601
(zod `string().datetime(...)`) — empty strings are explicitly allowed datetimes. Empty and malformed strings produce a field-specific
to keep the event window unconfigured. `superRefine` then enforces `400 VALIDATION_FAILED` issue (`eventStartUtc must be a valid ISO-8601
`eventEndUtc > eventStartUtc` and reports the issue on `eventEndUtc` datetime` or `eventEndUtc must be a valid ISO-8601 datetime`).
when both fields are non-empty. `superRefine` additionally requires `eventEndUtc > eventStartUtc` and
reports `eventEndUtc must be strictly after eventStartUtc` on the end
field when both values parse but are out of order.
* `defaultChallengeIp` 1255 chars * `defaultChallengeIp` 1255 chars
* `registrationsEnabled` boolean * `registrationsEnabled` boolean
+8 -5
View File
@@ -1,9 +1,9 @@
--- ---
type: architecture type: architecture
title: Key Files Index title: Key Files Index
description: One-line responsibility for important source files, including bootstrap payloads, runtime theme application, authenticated event streaming, and validated site-logo uploads. description: One-line responsibility for important source and contract-test files, including strict event-window validation.
tags: [architecture, index, key-files] tags: [architecture, key-files, event-window, validation]
timestamp: 2026-07-22T13:40:00Z timestamp: 2026-07-22T14:24:08Z
--- ---
# Backend # Backend
@@ -25,7 +25,9 @@ timestamp: 2026-07-22T13:40:00Z
| `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. | | `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. |
| `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. | | `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. |
| `backend/src/modules/uploads/uploads.controller.ts` | Registers admin-only multipart uploads, including Sharp-backed site-logo format validation. | | `backend/src/modules/uploads/uploads.controller.ts` | Registers admin-only multipart uploads, including Sharp-backed site-logo format validation. |
| `backend/src/modules/admin/dto/general.dto.ts` | Zod schema for `PUT /api/v1/admin/general/settings` — string-length rules, theme-key enum, ISO-8601 datetime check on the event-window fields (empty string allowed), and the `endAfterStart` super-refine. | | `backend/src/modules/admin/dto/general.dto.ts` | Zod contract for `PUT /api/v1/admin/general/settings`; both event timestamps are required ISO-8601 values and end must be strictly after start. |
| `tests/backend/admin-general-event-window.spec.ts` | Focused contract tests for valid, empty, malformed, equal, and reversed event-window timestamps. |
| `tests/backend/admin-general-service.spec.ts` | General-settings schema and service tests, including per-field empty datetime failures and settings-event emission. |
# Frontend # Frontend
@@ -47,7 +49,8 @@ timestamp: 2026-07-22T13:40:00Z
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. | | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. | | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. |
| `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent` reactive form for `/admin/general` — per-field inline error rendering (page-title + event-start + event-end), logo upload wiring, welcome Markdown preview, event-state derivation, and SSE `general` event handling. | | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent` reactive form for `/admin/general` — per-field inline error rendering (page-title + event-start + event-end), logo upload wiring, welcome Markdown preview, event-state derivation, and SSE `general` event handling. |
| `frontend/src/app/features/admin/general.pure.ts` | Pure helpers used by `AdminGeneralComponent``deriveEventState`, `endAfterStartValidator`, `normalizePageTitle`, `toIsoUtc`, `toDatetimeLocal`, `pageTitleError` / `pageTitleMessage`, `isoDatetimeValidator`, and `datetimeMessage`. | | `frontend/src/app/features/admin/general.pure.ts` | Pure General Settings helpers, including required datetime validation, field messages, UTC conversion, and end-after-start validation. |
| `tests/frontend/admin-general-pure.spec.ts` | Pure client-contract tests for required event timestamps, datetime messaging, UTC conversion, and event-window ordering. |
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. | | `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. |
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. | | `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
+26 -27
View File
@@ -1,9 +1,9 @@
--- ---
type: guide type: guide
title: Admin — General Settings title: Admin — General Settings
description: How an admin edits global platform settings (page title, logo, theme, event window, default challenge IP, registrations, welcome Markdown) 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:07:58Z timestamp: 2026-07-22T14:24:08Z
--- ---
# When this view is available # When this view is available
@@ -42,8 +42,8 @@ The page is a single reactive form with these controls (every
| Logo (file picker) | `general-logo-file` | `logo` (public URL) | Uploads via `POST /api/v1/uploads/logo`; the returned `publicUrl` is bound to a hidden input `general-logo`. | | Logo (file picker) | `general-logo-file` | `logo` (public URL) | Uploads via `POST /api/v1/uploads/logo`; the returned `publicUrl` is bound to a hidden input `general-logo`. |
| 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` | `datetime-local` input. Empty string is allowed; any non-empty value must be a valid ISO-8601 datetime — invalid input renders `general-eventStart-error`. | | 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` | `datetime-local` input. Empty string is allowed; any non-empty value must be a valid ISO-8601 datetime — invalid input renders `general-eventEnd-error`. 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 (`general-endBeforeStart`). |
| 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`. |
@@ -74,15 +74,12 @@ The page is a single reactive form with these controls (every
strictly after the start, the form becomes invalid and strictly after the start, the form becomes invalid and
`general-endBeforeStart` appears under the end input. Save remains `general-endBeforeStart` appears under the end input. Save remains
disabled. disabled.
* **Per-field datetime validation:** if the user types (or leaves) a value * **Per-field datetime validation:** clearing a field or supplying a value
that is not parseable as an ISO-8601 datetime in either the Event start that cannot be parsed as an ISO-8601 datetime makes that control invalid.
or Event end input, the affected input flips `aria-invalid="true"` and The affected input receives `aria-invalid="true"`, references its inline
its inline error element (`general-eventStart-error` or error through `aria-describedby`, and renders
`general-eventEnd-error`) renders the message `"<Field> must be a valid ISO-8601 datetime."`. Save remains disabled
`"<Field> must be a valid ISO-8601 datetime."`. Empty strings are until both timestamps are valid and ordered correctly.
considered valid (the event window may be unconfigured), so clearing
both inputs removes the error. Save remains disabled while either input
is invalid.
* **Save:** clicking Save sends `PUT /api/v1/admin/general/settings` * **Save:** clicking Save sends `PUT /api/v1/admin/general/settings`
with all fields. The Page title input is validated client-side for with all fields. The Page title input is validated client-side for
non-blank content; an empty or whitespace-only Page title disables the non-blank content; an empty or whitespace-only Page title disables the
@@ -134,13 +131,11 @@ two pure helpers exported from
`frontend/src/app/features/admin/general.pure.ts`: `frontend/src/app/features/admin/general.pure.ts`:
* `isoDatetimeValidator(ctrl)` — an Angular reactive-forms validator * `isoDatetimeValidator(ctrl)` — an Angular reactive-forms validator
that returns `null` for an empty value and `{ invalidDatetime: true }` that returns `null` only when `Date.parse` accepts the value. Empty and
for any non-empty value that `Date.parse` cannot parse. Empty is malformed values return `{ invalidDatetime: true }`.
intentionally valid so the event window can be cleared.
* `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."`
either when `controlErrors.invalidDatetime` is set, or as a fallback when `invalidDatetime` is set or when the raw value is unparseable.
when the raw value itself is non-empty and unparseable.
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`,
@@ -156,19 +151,20 @@ The `computed` signals `showEventStartError` / `showEventEndError` and
| Condition | Message | | Condition | Message |
|------------------------------------------------------------|--------------------------------------------------------| |------------------------------------------------------------|--------------------------------------------------------|
| Empty value (`''`) | `null` (no error; the event window may be unconfigured). | | Empty value (`''`) | `"<Field> must be a valid ISO-8601 datetime."` |
| Non-empty value that `Date.parse` accepts | `null` (no error). | | Value that `Date.parse` accepts | `null` (no error). |
| Non-empty value that `Date.parse` rejects | `"<Field> must be a valid ISO-8601 datetime."` | | Non-empty value that `Date.parse` rejects | `"<Field> must be a valid ISO-8601 datetime."` |
| Control carries `invalidDatetime` error | Same message (covered by the validator path). | | Control carries `invalidDatetime` error | Same message (covered by the validator path). |
The error is shown only when the control is both `touched || dirty` 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 (`general-endBeforeStart`) is
still emitted from the form-level `superRefine`/validator chain and is emitted by the form-level validator whenever both valid timestamps are
unchanged — it now sits next to the new per-field messages when the present and end is not strictly after start. The Event end input also
user supplies two non-empty values that are out of order. receives `aria-invalid="true"`, references both possible error elements,
and exposes `Event end must be after event start.` as its title.
# Visual elements # Visual elements
@@ -193,7 +189,7 @@ user supplies two non-empty values that are out of order.
| 3 | `frontend/src/app/core/services/admin.service.ts` | `getGeneralSettings()``GET /api/v1/admin/general/settings`; `listAdminThemes()``GET /api/v1/admin/general/themes`; `updateGeneralSettings()``PUT .../settings`; `uploadLogo()``POST /api/v1/uploads/logo`. | | 3 | `frontend/src/app/core/services/admin.service.ts` | `getGeneralSettings()``GET /api/v1/admin/general/settings`; `listAdminThemes()``GET /api/v1/admin/general/themes`; `updateGeneralSettings()``PUT .../settings`; `uploadLogo()``POST /api/v1/uploads/logo`. |
| 4 | `backend/src/modules/admin/admin-general.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. | | 4 | `backend/src/modules/admin/admin-general.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. |
| 5 | `backend/src/modules/admin/general.service.ts` | `getSettings` reads 8 keys via `SettingsService`; `updateSettings` writes all 8, emits `{ topic: 'general', themeKey }` via `SseHubService`. | | 5 | `backend/src/modules/admin/general.service.ts` | `getSettings` reads 8 keys via `SettingsService`; `updateSettings` writes all 8, emits `{ topic: 'general', themeKey }` via `SseHubService`. |
| 6 | `backend/src/modules/admin/dto/general.dto.ts` | `GeneralSettingsSchema` enforces string lengths, `themeKey` enum, per-field ISO-8601 datetime check on `eventStartUtc`/`eventEndUtc` (empty string allowed), and `eventEndUtc > eventStartUtc` via `superRefine`. | | 6 | `backend/src/modules/admin/dto/general.dto.ts` | `GeneralSettingsSchema` requires ISO-8601 values for both event-window fields and enforces `eventEndUtc > eventStartUtc` via `superRefine`. |
# Notes # Notes
@@ -202,8 +198,11 @@ user supplies two non-empty values that are out of order.
without polling. Other tabs do not auto-refresh settings values. without polling. Other tabs do not auto-refresh settings values.
* The "Event controls" toggle is intentionally a derived display, not * The "Event controls" toggle is intentionally a derived display, not
an editable control — adjust the UTC timestamps to change state. an editable control — adjust the UTC timestamps to change state.
* All timestamps are stored as ISO-8601 UTC strings in the `setting` * All valid timestamps are stored as ISO-8601 UTC strings in the `setting`
table; the UI converts to/from `datetime-local` for display. table; the UI converts to/from `datetime-local` for display.
* Both timestamps are required by the General Settings form and API.
The `UNCONFIGURED` derived display remains a defensive state for missing
or invalid data, but clearing either input makes the form unsavable.
* Saving the form requires the page title and default challenge IP to * Saving the form requires the page title and default challenge IP to
be non-empty; the form is `invalid` and Save stays disabled until be non-empty; the form is `invalid` and Save stays disabled until
they are. they are.
+3 -4
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-22T13:40:00Z. they need. Last regenerated 2026-07-22T14:24:08Z.
# Architecture # Architecture
@@ -62,9 +62,8 @@ they need. Last regenerated 2026-07-22T13:40:00Z.
navigate the post-login admin area side-nav (General, Challenges, navigate the post-login admin area side-nav (General, Challenges,
Players, Blog, System) and reach the enabled child pages. Players, Blog, System) and reach the enabled child pages.
* [Admin — General Settings](/guides/admin-general-settings.md) - How an * [Admin — General Settings](/guides/admin-general-settings.md) - How an
admin edits platform-wide settings (page title, logo, theme, event admin edits platform-wide settings, including the required and strictly
window, default challenge IP, registrations, welcome Markdown) at ordered event window, at `/admin/general`.
`/admin/general`.
* [Admin — Categories](/guides/admin-categories.md) - How an admin lists, * [Admin — Categories](/guides/admin-categories.md) - How an admin lists,
creates, edits, and deletes challenge categories at `/admin/categories`, creates, edits, and deletes challenge categories at `/admin/categories`,
including system-row and challenge-attached protection. including system-row and challenge-attached protection.
@@ -10,6 +10,7 @@ import {
datetimeMessage, datetimeMessage,
deriveEventState, deriveEventState,
endAfterStartValidator, endAfterStartValidator,
endBeforeStartMessage,
isoDatetimeValidator, isoDatetimeValidator,
normalizePageTitle, normalizePageTitle,
pageTitleMessage, pageTitleMessage,
@@ -113,14 +114,17 @@ import {
type="datetime-local" type="datetime-local"
formControlName="eventEndUtc" formControlName="eventEndUtc"
data-testid="general-eventEnd" data-testid="general-eventEnd"
[attr.aria-invalid]="showEventEndError() || (form.controls.eventEndUtc.touched && !!form.error?.['endBeforeStart']) ? 'true' : null" [attr.aria-invalid]="showEventEndError() || eventEndCrossFieldError() ? 'true' : null"
[attr.aria-describedby]="(showEventEndError() || (form.controls.eventEndUtc.touched && !!form.error?.['endBeforeStart'])) ? 'general-eventEnd-error general-endBeforeStart' : null" [attr.aria-describedby]="(showEventEndError() || eventEndCrossFieldError())
? 'general-eventEnd-error general-endBeforeStart'
: null"
[attr.title]="(showEventEndError() ? eventEndMessage() : eventEndCrossFieldError() ? endBeforeStartMessage : null)"
/> />
@if (showEventEndError()) { @if (showEventEndError()) {
<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">{{ eventEndMessage() }}</div>
} }
@if (form.controls.eventEndUtc.touched && form.error?.['endBeforeStart']) { @if (eventEndCrossFieldError()) {
<div class="field-error" id="general-endBeforeStart" data-testid="general-endBeforeStart">Event end must be after event start.</div> <div class="field-error" id="general-endBeforeStart" data-testid="general-endBeforeStart">{{ endBeforeStartMessage }}</div>
} }
</div> </div>
@@ -256,6 +260,10 @@ 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(
() => !!this.form.errors?.['endBeforeStart'],
);
constructor() { constructor() {
this.form.controls.welcomeMarkdown.valueChanges this.form.controls.welcomeMarkdown.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -67,7 +67,6 @@ export function toIsoUtc(local: string): string {
export function isoDatetimeValidator(ctrl: { value: string | null | undefined }): { invalidDatetime: true } | null { export function isoDatetimeValidator(ctrl: { value: string | null | undefined }): { invalidDatetime: true } | null {
const v = ctrl?.value ?? ''; const v = ctrl?.value ?? '';
if (v === '') return null;
const t = Date.parse(v); const t = Date.parse(v);
return Number.isFinite(t) ? null : { invalidDatetime: true }; return Number.isFinite(t) ? null : { invalidDatetime: true };
} }
@@ -81,8 +80,10 @@ export function datetimeMessage(
return `${fieldLabel} must be a valid ISO-8601 datetime.`; return `${fieldLabel} must be a valid ISO-8601 datetime.`;
} }
const v = value ?? ''; const v = value ?? '';
if (v !== '' && !Number.isFinite(Date.parse(v))) { if (!Number.isFinite(Date.parse(v))) {
return `${fieldLabel} must be a valid ISO-8601 datetime.`; return `${fieldLabel} must be a valid ISO-8601 datetime.`;
} }
return null; return null;
} }
export const endBeforeStartMessage = 'Event end must be after event start.';
@@ -0,0 +1,68 @@
process.env.NODE_ENV = 'test';
import { GeneralSettingsSchema } from '../../backend/src/modules/admin/dto/general.dto';
const baseValid = {
pageTitle: 'OpenVelo',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
};
function withEvent(start: string, end: string) {
return { ...baseValid, eventStartUtc: start, eventEndUtc: end };
}
describe('Admin general settings - event window validation (Job 888)', () => {
it('accepts a valid event window', () => {
const r = GeneralSettingsSchema.safeParse(
withEvent('2026-08-15T10:30:00.000Z', '2026-08-20T18:45:00.000Z'),
);
expect(r.success).toBe(true);
});
it('rejects an empty eventEndUtc with a per-field message', () => {
const r = GeneralSettingsSchema.safeParse(withEvent('2026-08-15T10:30:00.000Z', ''));
expect(r.success).toBe(false);
if (r.success) return;
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
expect(endIssue?.message).toBe('eventEndUtc must be a valid ISO-8601 datetime');
});
it('rejects an empty eventStartUtc with a per-field message', () => {
const r = GeneralSettingsSchema.safeParse(withEvent('', '2026-08-20T18:45:00.000Z'));
expect(r.success).toBe(false);
if (r.success) return;
const startIssue = r.error.issues.find((i) => i.path.join('.') === 'eventStartUtc');
expect(startIssue?.message).toBe('eventStartUtc must be a valid ISO-8601 datetime');
});
it('rejects a non-empty malformed eventEndUtc', () => {
const r = GeneralSettingsSchema.safeParse(withEvent('2026-08-15T10:30:00.000Z', 'not-a-date'));
expect(r.success).toBe(false);
if (r.success) return;
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
expect(endIssue?.message).toBe('eventEndUtc must be a valid ISO-8601 datetime');
});
it('rejects end equal to start', () => {
const ts = '2026-08-15T10:30:00.000Z';
const r = GeneralSettingsSchema.safeParse(withEvent(ts, ts));
expect(r.success).toBe(false);
if (r.success) return;
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
expect(endIssue?.message).toBe('eventEndUtc must be strictly after eventStartUtc');
});
it('rejects end before start', () => {
const r = GeneralSettingsSchema.safeParse(
withEvent('2026-08-20T18:45:00.000Z', '2026-08-15T10:30:00.000Z'),
);
expect(r.success).toBe(false);
if (r.success) return;
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
expect(endIssue?.message).toBe('eventEndUtc must be strictly after eventStartUtc');
});
});
+7 -2
View File
@@ -141,7 +141,7 @@ describe('GeneralSettingsSchema - validation rules', () => {
expect(r.success).toBe(false); expect(r.success).toBe(false);
}); });
it('accepts empty strings (unconfigured event window) for both datetime fields', () => { it('rejects empty strings for event datetime fields with per-field messages (Job 888)', () => {
const r = GeneralSettingsSchema.safeParse({ const r = GeneralSettingsSchema.safeParse({
pageTitle: 'T', pageTitle: 'T',
logo: '', logo: '',
@@ -152,7 +152,12 @@ describe('GeneralSettingsSchema - validation rules', () => {
defaultChallengeIp: '127.0.0.1', defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false, registrationsEnabled: false,
}); });
expect(r.success).toBe(true); expect(r.success).toBe(false);
if (r.success) return;
const startIssue = r.error.issues.find((i) => i.path.join('.') === 'eventStartUtc');
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
expect(startIssue?.message).toBe('eventStartUtc must be a valid ISO-8601 datetime');
expect(endIssue?.message).toBe('eventEndUtc must be a valid ISO-8601 datetime');
}); });
}); });
+7 -7
View File
@@ -165,10 +165,10 @@ describe('pageTitleMessage', () => {
}); });
describe('isoDatetimeValidator', () => { describe('isoDatetimeValidator', () => {
it('returns null for an empty value (unconfigured is allowed)', () => { it('returns { invalidDatetime: true } for an empty value (empty string no longer accepted)', () => {
expect(isoDatetimeValidator({ value: '' })).toBeNull(); expect(isoDatetimeValidator({ value: '' })).toEqual({ invalidDatetime: true });
expect(isoDatetimeValidator({ value: null })).toBeNull(); expect(isoDatetimeValidator({ value: null })).toEqual({ invalidDatetime: true });
expect(isoDatetimeValidator({ value: undefined })).toBeNull(); expect(isoDatetimeValidator({ value: undefined })).toEqual({ invalidDatetime: true });
}); });
it('returns null for a valid ISO-8601 datetime', () => { it('returns null for a valid ISO-8601 datetime', () => {
@@ -194,9 +194,9 @@ describe('datetimeMessage', () => {
.toBe('Event start must be a valid ISO-8601 datetime.'); .toBe('Event start must be a valid ISO-8601 datetime.');
}); });
it('returns null for an empty value', () => { it('returns the per-field error for an empty value (empty is now invalid)', () => {
expect(datetimeMessage('Event start', '', null)).toBeNull(); expect(datetimeMessage('Event start', '', null)).toBe('Event start must be a valid ISO-8601 datetime.');
expect(datetimeMessage('Event start', '', { invalidDatetime: false as any })).toBeNull(); expect(datetimeMessage('Event end', '', { invalidDatetime: true })).toBe('Event end must be a valid ISO-8601 datetime.');
}); });
it('returns null for a valid ISO-8601 datetime', () => { it('returns null for a valid ISO-8601 datetime', () => {