AI Implementation feature(887): Admin Area General Settings and Categories 1.05 #27

Merged
m0rph3us1987 merged 2 commits from feature-887-1784728652854 into dev 2026-07-22 14:09:04 +00:00
10 changed files with 467 additions and 51 deletions
-36
View File
@@ -1,36 +0,0 @@
# Implementation Plan: Admin Area General Settings and Categories 1.04
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Node.js monorepo with a NestJS 10 REST backend and Angular 17 standalone frontend. Frontend components use `inject()`, Angular Signals, reactive forms, `ChangeDetectionStrategy.OnPush`, and async service methods backed by `HttpClient`/`firstValueFrom`. Global bootstrap data and CSS theme application are owned by the root-scoped `BootstrapService`; admin form persistence is owned by `AdminGeneralComponent` and `AdminService`.
- **Current flow and root cause:** Application startup calls `BootstrapService.load()`, stores the `/api/v1/bootstrap` payload, and writes its theme tokens to `document.documentElement.style`. Saving `/admin/general` successfully persists `themeKey` through `PUT /api/v1/admin/general/settings`, and the backend later resolves the chosen theme in bootstrap. However, `AdminGeneralComponent.onSubmit()` only patches the returned settings form; it never asks `BootstrapService` to refresh or apply the newly selected theme. The backend's `general` SSE event contains only `themeKey`, while the frontend event-status stream treats every hub payload as event-state data and has no theme propagation path. Thus the save response changes persistence but not the current document tokens.
- **Data Layer:** TypeORM with SQLite (`better-sqlite3`). The existing `setting` table stores `themeKey`; no schema or migration change is required. Persistent runtime data remains under `/data/hipctf` through existing setup conventions; the planned focused frontend tests require no persistent data.
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from `tests/frontend/**/*.spec.ts`, backend tests run in Node from `tests/backend/**/*.spec.ts`, and all tests are runnable with `npm test`. The fix should follow a minimal red-green-refactor path and test logic rather than visual appearance or browser automation.
- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, or `/data` assets are required. Existing Node/npm, Angular, Jest, jsdom, TypeScript, and `setup.sh` are sufficient; `setup.sh` and lockfiles should remain unchanged.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/core/services/bootstrap.service.ts` — expose a typed, reusable live theme application/refresh path that updates both bootstrap client state and document CSS custom properties without being blocked by the startup request cache.
- `frontend/src/app/features/admin/general.component.ts` — inject `BootstrapService` and invoke the live theme refresh only after `updateGeneralSettings()` succeeds, before reporting Save success.
- **To Create:**
- `tests/frontend/bootstrap-theme.spec.ts` — focused jsdom tests for applying all theme token fields and refreshing to a saved non-default theme.
## 3. Proposed Changes
1. **Database / Schema Migration:**
- No migration or backend persistence change. `AdminGeneralService.updateSettings()` already saves `SETTINGS_KEYS.THEME_KEY`, and `SystemService.bootstrap()` already reads that key and returns the corresponding complete theme object.
2. **Backend Logic & APIs:**
- Keep the existing `PUT /api/v1/admin/general/settings` and `GET /api/v1/bootstrap` contracts unchanged. The save response intentionally contains settings rather than theme tokens; reuse bootstrap as the canonical theme-token source instead of duplicating the backend theme model in the admin response.
- Do not add backend endpoints, dependencies, SSE handling, or theme-token duplication. The reported same-tab Save issue can be resolved deterministically from the successful save path, while existing persistence guarantees future reloads use the selected theme.
3. **Frontend UI Integration:**
- Replace the frontend `theme: any` shape with local typed `Theme`/`ThemeTokens` interfaces matching the bootstrap contract, including colors, font family, radii, and spacing scale. Type the theme application method accordingly and preserve the defensive no-token guard for malformed/absent payloads.
- Make theme application a reusable service operation rather than startup-only private behavior. It must continue setting all existing CSS variables (`--color-primary`, `--color-secondary`, `--color-accent`, `--color-surface`, `--color-text`, `--color-success`, `--color-warning`, `--color-danger`, `--font-family`, and all three radius properties), including valid zero-valued radius strings such as Monochrome's `0`.
- Add a force-refresh method in `BootstrapService` that performs a fresh `/api/v1/bootstrap` request instead of reusing `loadPromise`, validates HTTP success before consuming the payload, updates `payload` and `initialized`, and applies the returned theme to the live document. Keep startup `load()`/`ready()` request coalescing intact so guard/bootstrap race behavior does not regress.
- Inject `BootstrapService` into `AdminGeneralComponent`. After `AdminService.updateGeneralSettings()` resolves and the form is patched, await the force-refresh method before setting `saveOk`. This orders persistence before re-fetch, applies the canonical saved theme immediately, and also synchronizes other global bootstrap-backed settings in the current tab.
- Treat a failed refresh as a Save error rather than showing a false success: retain the persisted form response, surface the existing inline error path, and leave `submitting` cleanup in `finally`. Do not mutate theme tokens before the PUT succeeds, so failed saves leave the current live theme unchanged.
- No template or categories changes are needed; the Global Theme select already includes the server-provided canonical choices, and category behavior is unrelated to this regression.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/bootstrap-theme.spec.ts`, executed by the existing root `npm test` command (or selectively through the frontend Jest project during development). Do not add visual, browser, or persistence-volume tests.
- **Mocking Strategy:** Use jsdom's real `document.documentElement.style` and mock only global `fetch`. Reset CSS properties and the fetch mock between tests to avoid shared state. Provide small typed bootstrap payload fixtures rather than Angular TestBed or a backend/database instance.
- **Core success path:** Start with Classic CSS values, have the mocked refresh return a Monochrome bootstrap payload, call the refresh method, and assert the payload signal plus representative computed CSS variables change immediately to `--color-primary: #000000`, `--color-text: #0a0a0a`, and `--radius-md: 0`; also verify all token mappings in one compact table-driven assertion so every canonical theme token category is covered.
- **Key error condition:** Mock a non-OK bootstrap refresh response and assert the method rejects without replacing the existing payload or applying partial/new CSS tokens. This supports the component's existing save-error handling while keeping the suite minimal.
- **Implementation verification:** Run the focused frontend test first to prove the regression, then `npm test`, `npm run build`, and the repository's available TypeScript builds. No lint script is defined in the root or workspace package manifests, so no additional lint dependency or setup change should be introduced.
+173
View File
@@ -0,0 +1,173 @@
# 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`).
+2 -2
View File
@@ -7,8 +7,8 @@ export const GeneralSettingsSchema = z
logo: z.string().max(2048),
welcomeMarkdown: z.string().max(64_000),
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
eventStartUtc: z.string(),
eventEndUtc: z.string(),
eventStartUtc: z.union([z.literal(''), 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' })]),
defaultChallengeIp: z.string().min(1).max(255),
registrationsEnabled: z.boolean(),
})
+5 -3
View File
@@ -46,9 +46,11 @@ removes the row.
* `logo` up to 2048 chars (a public URL — uploaded separately)
* `welcomeMarkdown` up to 64 000 chars
* `themeKey` is one of `THEME_IDS`
* `eventStartUtc` / `eventEndUtc` parseable dates; `superRefine`
enforces `eventEndUtc > eventStartUtc` and reports the issue on
`eventEndUtc`
* `eventStartUtc` / `eventEndUtc` are validated as ISO-8601 datetimes
(zod `string().datetime(...)`) — empty strings are explicitly allowed
to keep the event window unconfigured. `superRefine` then enforces
`eventEndUtc > eventStartUtc` and reports the issue on `eventEndUtc`
when both fields are non-empty.
* `defaultChallengeIp` 1255 chars
* `registrationsEnabled` boolean
+3
View File
@@ -25,6 +25,7 @@ timestamp: 2026-07-22T13:40:00Z
| `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/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. |
# Frontend
@@ -45,6 +46,8 @@ timestamp: 2026-07-22T13:40:00Z
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. |
| `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/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`. |
| `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. |
+60 -5
View File
@@ -2,8 +2,8 @@
type: guide
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.
tags: [guide, admin, settings, general, tester]
timestamp: 2026-07-22T12:44:45Z
tags: [guide, admin, settings, general, tester, datetime, validation]
timestamp: 2026-07-22T14:07:58Z
---
# 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 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`. |
| Event start (UTC) | `general-eventStart` | `eventStartUtc` | `datetime-local` input converted to ISO UTC on save. |
| Event end (UTC) | `general-eventEnd` | `eventEndUtc` | Must be strictly after Event start. Invalid pair renders `general-endBeforeStart`. |
| 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 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`). |
| 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`. |
| Welcome description | `general-welcome` | `welcomeMarkdown` | Multi-line textarea; a live preview is rendered into `general-welcome-preview`. |
@@ -74,6 +74,15 @@ The page is a single reactive form with these controls (every
strictly after the start, the form becomes invalid and
`general-endBeforeStart` appears under the end input. Save remains
disabled.
* **Per-field datetime validation:** if the user types (or leaves) a value
that is not parseable as an ISO-8601 datetime in either the Event start
or Event end input, the affected input flips `aria-invalid="true"` and
its inline error element (`general-eventStart-error` or
`general-eventEnd-error`) renders the message
`"<Field> must be a valid ISO-8601 datetime."`. Empty strings are
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`
with all fields. The Page title input is validated client-side for
non-blank content; an empty or whitespace-only Page title disables the
@@ -117,6 +126,50 @@ Save, the control is reset to untouched/pristine and the
`pageTitleTouchedOrDirty` signal is cleared, so reloading valid
settings does not flash a stale error.
# Event start / end inline error messages
The inline error elements under the Event start and Event end inputs
(`general-eventStart-error` and `general-eventEnd-error`) are driven by
two pure helpers exported from
`frontend/src/app/features/admin/general.pure.ts`:
* `isoDatetimeValidator(ctrl)` — an Angular reactive-forms validator
that returns `null` for an empty value and `{ invalidDatetime: true }`
for any non-empty value that `Date.parse` cannot parse. Empty is
intentionally valid so the event window can be cleared.
* `datetimeMessage(fieldLabel, value, controlErrors)` — renders the
human-readable message `"<Field> must be a valid ISO-8601 datetime."`
either when `controlErrors.invalidDatetime` is set, or as a fallback
when the raw value itself is non-empty and unparseable.
The component wires both controls (`eventStartUtc`, `eventEndUtc`) with
this validator and keeps three local `signal`s per field — `Value`,
`Invalid`, `TouchedOrDirty` — exactly mirroring the Page-title pattern.
The `computed` signals `showEventStartError` / `showEventEndError` and
`eventStartMessage` / `eventEndMessage` are then bound to:
* `[attr.aria-invalid]` and `[attr.aria-describedby]` on each input so
assistive tech is informed the instant the field goes invalid.
* An `@if` block that renders the inline `<div class="field-error">`
with `data-testid="general-eventStart-error"` /
`data-testid="general-eventEnd-error"`.
| Condition | Message |
|------------------------------------------------------------|--------------------------------------------------------|
| Empty value (`''`) | `null` (no error; the event window may be unconfigured). |
| Non-empty value that `Date.parse` accepts | `null` (no error). |
| 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). |
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
a successful Save, both controls are reset to untouched/pristine via
`resetTouchedState()` and their `TouchedOrDirty` signals are cleared.
The cross-field `endBeforeStart` error (`general-endBeforeStart`) is
still emitted from the form-level `superRefine`/validator chain and is
unchanged — it now sits next to the new per-field messages when the
user supplies two non-empty values that are out of order.
# Visual elements
| Element | Selector |
@@ -127,6 +180,8 @@ settings does not flash a stale error.
| Form | `[data-testid="general-form"]` |
| 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 end inline error | `[data-testid="general-eventEnd-error"]` |
| Page-title inline error | `[data-testid="general-pageTitle-error"]` |
# Architecture map
@@ -138,7 +193,7 @@ settings does not flash a stale error.
| 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. |
| 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, and `eventEndUtc > eventStartUtc` via `superRefine`. |
| 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`. |
# Notes
@@ -7,8 +7,10 @@ import { BootstrapService } from '../../core/services/bootstrap.service';
import { MarkdownService } from '../../core/services/markdown.service';
import { AdminCategoriesComponent } from './categories/categories.component';
import {
datetimeMessage,
deriveEventState,
endAfterStartValidator,
isoDatetimeValidator,
normalizePageTitle,
pageTitleMessage,
toDatetimeLocal,
@@ -91,14 +93,34 @@ import {
<label for="eventStartUtc">Event start (UTC)</label>
<div>
<input id="eventStartUtc" type="datetime-local" formControlName="eventStartUtc" data-testid="general-eventStart" />
<input
id="eventStartUtc"
type="datetime-local"
formControlName="eventStartUtc"
data-testid="general-eventStart"
[attr.aria-invalid]="showEventStartError() ? 'true' : null"
[attr.aria-describedby]="showEventStartError() ? 'general-eventStart-error' : null"
/>
@if (showEventStartError()) {
<div class="field-error" id="general-eventStart-error" data-testid="general-eventStart-error">{{ eventStartMessage() }}</div>
}
</div>
<label for="eventEndUtc">Event end (UTC)</label>
<div>
<input id="eventEndUtc" type="datetime-local" formControlName="eventEndUtc" data-testid="general-eventEnd" />
<input
id="eventEndUtc"
type="datetime-local"
formControlName="eventEndUtc"
data-testid="general-eventEnd"
[attr.aria-invalid]="showEventEndError() || (form.controls.eventEndUtc.touched && !!form.error?.['endBeforeStart']) ? 'true' : null"
[attr.aria-describedby]="(showEventEndError() || (form.controls.eventEndUtc.touched && !!form.error?.['endBeforeStart'])) ? 'general-eventEnd-error general-endBeforeStart' : null"
/>
@if (showEventEndError()) {
<div class="field-error" id="general-eventEnd-error" data-testid="general-eventEnd-error">{{ eventEndMessage() }}</div>
}
@if (form.controls.eventEndUtc.touched && form.error?.['endBeforeStart']) {
<div class="field-error" data-testid="general-endBeforeStart">Event end must be after event start.</div>
<div class="field-error" id="general-endBeforeStart" data-testid="general-endBeforeStart">Event end must be after event start.</div>
}
</div>
@@ -179,14 +201,22 @@ export class AdminGeneralComponent implements OnInit {
private readonly pageTitleInvalid = signal<boolean>(false);
private readonly pageTitleTouchedOrDirty = signal<boolean>(false);
private readonly eventStartValue = signal<string>('');
private readonly eventStartInvalid = signal<boolean>(false);
private readonly eventStartTouchedOrDirty = signal<boolean>(false);
private readonly eventEndValue = signal<string>('');
private readonly eventEndInvalid = signal<boolean>(false);
private readonly eventEndTouchedOrDirty = signal<boolean>(false);
readonly form = this.fb.nonNullable.group(
{
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]),
logo: this.fb.nonNullable.control(''),
welcomeMarkdown: this.fb.nonNullable.control(''),
themeKey: this.fb.nonNullable.control('classic'),
eventStartUtc: this.fb.nonNullable.control(''),
eventEndUtc: this.fb.nonNullable.control(''),
eventStartUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
eventEndUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required]),
registrationsEnabled: this.fb.nonNullable.control(false),
},
@@ -210,6 +240,22 @@ export class AdminGeneralComponent implements OnInit {
pageTitleMessage(this.pageTitleValue(), this.form.controls.pageTitle.errors),
);
readonly showEventStartError = computed(
() => this.eventStartTouchedOrDirty() && this.eventStartInvalid(),
);
readonly eventStartMessage = computed(() =>
datetimeMessage('Event start', this.eventStartValue(), this.form.controls.eventStartUtc.errors),
);
readonly showEventEndError = computed(
() => this.eventEndTouchedOrDirty() && this.eventEndInvalid(),
);
readonly eventEndMessage = computed(() =>
datetimeMessage('Event end', this.eventEndValue(), this.form.controls.eventEndUtc.errors),
);
constructor() {
this.form.controls.welcomeMarkdown.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -231,6 +277,40 @@ export class AdminGeneralComponent implements OnInit {
this.pageTitleInvalid.set(pt.invalid);
this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty);
});
const es = this.form.controls.eventStartUtc;
this.eventStartValue.set(es.value);
this.eventStartInvalid.set(es.invalid);
this.eventStartTouchedOrDirty.set(es.touched || es.dirty);
es.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => {
this.eventStartValue.set(v);
this.eventStartTouchedOrDirty.set(es.touched || es.dirty);
});
es.statusChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.eventStartInvalid.set(es.invalid);
this.eventStartTouchedOrDirty.set(es.touched || es.dirty);
});
const ee = this.form.controls.eventEndUtc;
this.eventEndValue.set(ee.value);
this.eventEndInvalid.set(ee.invalid);
this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty);
ee.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => {
this.eventEndValue.set(v);
this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty);
});
ee.statusChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.eventEndInvalid.set(ee.invalid);
this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty);
});
}
async ngOnInit(): Promise<void> {
@@ -272,6 +352,14 @@ export class AdminGeneralComponent implements OnInit {
this.form.controls.pageTitle.markAsUntouched();
this.form.controls.pageTitle.markAsPristine();
this.pageTitleTouchedOrDirty.set(false);
this.form.controls.eventStartUtc.markAsUntouched();
this.form.controls.eventStartUtc.markAsPristine();
this.eventStartTouchedOrDirty.set(false);
this.form.controls.eventEndUtc.markAsUntouched();
this.form.controls.eventEndUtc.markAsPristine();
this.eventEndTouchedOrDirty.set(false);
}
async onSubmit(): Promise<void> {
@@ -64,3 +64,25 @@ export function toIsoUtc(local: string): string {
if (Number.isNaN(d.getTime())) return local;
return d.toISOString();
}
export function isoDatetimeValidator(ctrl: { value: string | null | undefined }): { invalidDatetime: true } | null {
const v = ctrl?.value ?? '';
if (v === '') return null;
const t = Date.parse(v);
return Number.isFinite(t) ? null : { invalidDatetime: true };
}
export function datetimeMessage(
fieldLabel: string,
value: string | null | undefined,
controlErrors: { [key: string]: unknown } | null | undefined,
): string | null {
if (controlErrors?.['invalidDatetime']) {
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
}
const v = value ?? '';
if (v !== '' && !Number.isFinite(Date.parse(v))) {
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
}
return null;
}
@@ -87,6 +87,73 @@ describe('GeneralSettingsSchema - validation rules', () => {
expect(r.data.pageTitle).toBe('OpenVelo');
}
});
it('rejects a non-datetime eventStartUtc ("not-a-date")', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: 'not-a-date',
eventEndUtc: '2026-02-01T00:00:00Z',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
});
expect(r.success).toBe(false);
if (!r.success) {
const paths = r.error.issues.map((i) => i.path.join('.'));
expect(paths).toContain('eventStartUtc');
expect(r.error.issues.some((i) => /datetime/i.test(i.message))).toBe(true);
}
});
it('rejects a non-datetime eventEndUtc and reports the issue on eventEndUtc', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: '2026-08-15T10:30:00.000Z',
eventEndUtc: 'also-not-a-date',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
});
expect(r.success).toBe(false);
if (!r.success) {
const paths = r.error.issues.map((i) => i.path.join('.'));
expect(paths).toContain('eventEndUtc');
expect(paths).not.toContain('eventStartUtc');
expect(r.error.issues.some((i) => /datetime/i.test(i.message))).toBe(true);
}
});
it('rejects both fields as non-datetime strings (the Job negative case)', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: 'not-a-date',
eventEndUtc: 'also-not-a-date',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
});
expect(r.success).toBe(false);
});
it('accepts empty strings (unconfigured event window) for both datetime fields', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: '',
eventEndUtc: '',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
});
expect(r.success).toBe(true);
});
});
describe('AdminGeneralService.updateSettings - happy path', () => {
+42
View File
@@ -1,6 +1,8 @@
import {
datetimeMessage,
deriveEventState,
endAfterStartValidator,
isoDatetimeValidator,
normalizePageTitle,
pageTitleError,
pageTitleMessage,
@@ -161,3 +163,43 @@ describe('pageTitleMessage', () => {
expect(pageTitleMessage('a'.repeat(121), { maxlength: { requiredLength: 120 } })).toBe(maxLengthMsg);
});
});
describe('isoDatetimeValidator', () => {
it('returns null for an empty value (unconfigured is allowed)', () => {
expect(isoDatetimeValidator({ value: '' })).toBeNull();
expect(isoDatetimeValidator({ value: null })).toBeNull();
expect(isoDatetimeValidator({ value: undefined })).toBeNull();
});
it('returns null for a valid ISO-8601 datetime', () => {
expect(isoDatetimeValidator({ value: '2026-08-15T10:30:00.000Z' })).toBeNull();
});
it('returns { invalidDatetime: true } for a non-datetime string', () => {
expect(isoDatetimeValidator({ value: 'not-a-date' })).toEqual({ invalidDatetime: true });
expect(isoDatetimeValidator({ value: 'also-not-a-date' })).toEqual({ invalidDatetime: true });
});
});
describe('datetimeMessage', () => {
it('returns the per-field error when controlErrors carries invalidDatetime', () => {
expect(datetimeMessage('Event start', 'not-a-date', { invalidDatetime: true }))
.toBe('Event start must be a valid ISO-8601 datetime.');
expect(datetimeMessage('Event end', 'garbage', { invalidDatetime: true }))
.toBe('Event end must be a valid ISO-8601 datetime.');
});
it('falls back to detecting an unparseable value when controlErrors are absent', () => {
expect(datetimeMessage('Event start', 'not-a-date', null))
.toBe('Event start must be a valid ISO-8601 datetime.');
});
it('returns null for an empty value', () => {
expect(datetimeMessage('Event start', '', null)).toBeNull();
expect(datetimeMessage('Event start', '', { invalidDatetime: false as any })).toBeNull();
});
it('returns null for a valid ISO-8601 datetime', () => {
expect(datetimeMessage('Event start', '2026-08-15T10:30:00.000Z', null)).toBeNull();
});
});