Files
HIPCTF2/.kilo/plans/887.md
T

174 lines
8.1 KiB
Markdown

# 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`).