12 KiB
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:
- Backend (
backend/src/modules/admin/dto/general.dto.ts:10-11) — theeventStartUtc/eventEndUtcfields are typedz.union([z.literal(''), z.string().datetime({...})]). Empty strings bypass validation and overwrite a previously valid event schedule with''. The docs atdocs/api/admin.md:48-53anddocs/guides/admin-general-settings.mddescribe this empty-allowed behaviour, but the Job explicitly requires the empty case to be rejected so the prior schedule is preserved. - Frontend (
frontend/src/app/features/admin/general.component.ts:109-125) — the cross-fieldendBeforeStarterror and the per-fieldgeneral-eventEnd-errorare both gated onform.controls.eventEndUtc.touched. When the user simply types a value the field-level @if may not render visibly, and the End input has noaria-invalid,aria-describedby,title, orvalidity.validationMessagebinding, 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.tsconsumed viaZodValidationPipe(backend/src/common/pipes/zod-validation.pipe.ts). Errors are raised throughApiError.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 (seegeneral.component.ts:200-257). Templates use@ifblocks withdata-testidhooks tested intests/frontend/admin-general-pure.spec.ts. - Pure helpers:
frontend/src/app/features/admin/general.pure.tsexports all validation/message helpers and is the only place to mutate when changing field-level rules; the component imports them.
- Backend: NestJS 10, TypeScript strict, zod schemas under
-
Data Layer: SQLite (
better-sqlite3) with asettingkey/value table (docs/database/auth-settings.md).AdminGeneralService.updateSettings(backend/src/modules/admin/general.service.ts:61-74) writes eacheventStartUtc/eventEndUtcvalue throughSettingsService.set. No schema migration is needed for this Job. -
Test Framework & Structure: Jest (
ts-jest) with two projects (backend / frontend) configured intests/jest.config.js. Tests live undertests/backend/**andtests/frontend/**. The repo-rootnpm test(alias ofjest --config tests/jest.config.js) runs everything. Backend tests boot the full Nest app viaTest.createTestingModule({ imports: [AppModule] })and userequest.agent+ CSRF (tests/backend/csrf-client.ts); frontend tests are pure-function specs undertests/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 inpackage.jsonand bootstrapped bysetup.sh. The implementer does not need to install anything new.
2. Impacted Files
-
To Modify:
backend/src/modules/admin/dto/general.dto.ts— tighten theeventStartUtc/eventEndUtczod union so empty strings are rejected with the per-field message; leavesuperRefineend-after-start check in place.frontend/src/app/features/admin/general.component.ts— extend the Event end<input>bindings to surfacearia-invalid,aria-describedby,title, andvalidity.validationMessagewhenever the form is in theendBeforeStartinvalid state; ensure the inlinegeneral-endBeforeStartandgeneral-eventEnd-errorblocks render as soon as the cross-field validator fires (not only aftertouched); 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 forendBeforeStartandinvalidDatetimeavailable as exports if they are not already (they are inline strings today; centralise to keep the template clean). Optionally tightenisoDatetimeValidatorto 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: emptyeventEndUtcrejected 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 smallinvalidDatetimeregression asserting thatisoDatetimeValidator({ 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 astests/backend/admin-general-service.spec.tsbut focuses on PUT validation only.
3. Proposed Changes
Backend
-
Tighten the zod schema in
backend/src/modules/admin/dto/general.dto.ts:- Replace each
eventStartUtc/eventEndUtcfield fromz.union([z.literal(''), z.string().datetime({ message: '...' })])toz.string().datetime({ message: '... must be a valid ISO-8601 datetime' })directly. The empty-string alternative is what allowedeventEndUtc=''to overwrite a valid schedule (Job negative case 1). - Keep the
superRefinerule that emitseventEndUtc must be strictly after eventStartUtconpath: ['eventEndUtc']when both values are parseable andend <= start. - The error envelope remains
{ code: 'VALIDATION_FAILED', message: 'Request validation failed', details: [{path, message}] }(HTTP 400), emitted byZodValidationPipe(backend/src/common/pipes/zod-validation.pipe.ts:9-15) — no pipe changes needed.
- Replace each
-
Persist only on success: nothing to change in
AdminGeneralService.updateSettingsitself; becauseZodValidationPipethrows before the handler is called, the prior schedule is preserved automatically on rejected payloads (the SettingsService is never touched). -
Docs reconciliation (non-blocking): update
docs/api/admin.md:48-53anddocs/guides/admin-general-settings.mdsentences 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
-
Update
general.pure.tsto 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
isoDatetimeValidatorso''returns{ invalidDatetime: true }instead ofnull. If we keep empty-allowed client-side, thendatetimeMessagemust still show a helpful message on''so the field stays consistent with the disabled Save button. - Expose a
endBeforeStartMessagehelper that returns'Event end must be after event start.'so the template renders it from one source.
- If we choose to also reject empty strings client-side (recommended — keeps UI feedback consistent with backend), change
-
Update
general.component.tsfor 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 newshowEventEndCrossFieldErrorcomputed that is true whenever the form haserrors.endBeforeStart. Also bind[attr.title]to the message string and[attr.validity.validationMessage]is not directly supported by Angular — instead, surface adata-error-messageattribute or render a hidden<span class="sr-only">so screen readers can announce it. - Render the
general-endBeforeStartblock wheneverendBeforeStartis present (drop thetouchedgate so the user sees the error the moment they leave End ≤ Start). Keepgeneral-eventEnd-errorrendering only forinvalidDatetimecases. - Symmetrically, for Event start, render the
general-eventStart-errorwheneverinvalidDatetimeis on the control (today it is correctly shown when the input becomes dirty AND invalid).
- Bind
-
Save gating remains:
[disabled]="submitting() || form.invalid"keeps Save disabled while any of (a) the inline per-field validators, (b) the cross-fieldendBeforeStart, 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. Reusestests/backend/csrf-client.ts, follows the exact pattern fromtests/backend/admin-validation.spec.ts:16-46(bootsAppModule, registers first admin, primes CSRF, then exercisesPUT /api/v1/admin/general/settingsviarequest.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 plaindescribe/itwith assertions on function returns. - Mocking strategy: No new mocks. The backend spec uses the real
AppModuleagainst:memory:SQLite (set viaprocess.env.DATABASE_PATH = ':memory:'at the top of the file, mirroringadmin-validation.spec.ts:1-3). Auth usesregister-first-admin+ login + CSRF — already idempotent because the database is in-memory and the test creates exactly one admin inbeforeAll. - Cases to cover (minimal & focused):
- Backend negative — empty Event End ⇒
PUT /settingswitheventEndUtc: ''returns HTTP 400 and the per-field message, and a follow-upGET /settingsshowseventEndUtcunchanged. - Backend negative — empty Event Start ⇒ symmetric to (1).
- Backend negative — malformed datetime ⇒
PUT /settingswitheventEndUtc: 'not-a-date'returns HTTP 400 and'eventEndUtc must be a valid ISO-8601 datetime'. - Backend negative — end ≤ start ⇒ valid Start +
End === Startreturns HTTP 400 with'eventEndUtc must be strictly after eventStartUtc'. - Backend positive — valid window ⇒ well-formed
2026-08-15T10:30:00.000Z/2026-08-20T18:45:00.000Zpersists and is round-tripped unchanged. - Frontend pure — assert
isoDatetimeValidator('')returns the invalid sentinel (if we choose to flip empty to invalid client-side). - Frontend pure — assert the new
endBeforeStartMessagehelper returns the canonical string.
- Backend negative — empty Event End ⇒
- Single command:
npm testfrom/reporuns the full suite. Frontend and backend can also be run separately vianpm run test:backend/npm run test:frontend(already inpackage.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.