8.1 KiB
8.1 KiB
Implementation Plan: Admin General Settings — Event Start/End Validation (Job 887)
Status
NOT already implemented. Two distinct gaps are described in the Job:
- Backend (
PUT /api/v1/admin/general/settings) accepts any string foreventStartUtc/eventEndUtcand persists it. Only the cross-fieldend > startrule is enforced. Garbage payloads likeeventStartUtc='not-a-date'return HTTP 200 and overwrite the stored schedule. - Frontend (
/admin/general) has no per-field validation feedback for the twodatetime-localinputs. The cross-field rule fires correctly (form.invalid, Save disabled), but the inputs render notitle,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 sharedZodValidationPipe(backend/src/common/pipes/zod-validation.pipe.ts) which throwsApiError.validation('Request validation failed', details)mapped to HTTP 400VALIDATION_FAILED. - Frontend: Angular 17+ standalone components, signal-based state,
OnPush,ReactiveFormsModule, pure helper module atfrontend/src/app/features/admin/general.pure.ts. Inline error display pattern is already used for thepageTitlefield (general-pageTitle-error+pageTitleMessage(...)computed signal +showPageTitleError()computed) — replicate this exact pattern for the two date inputs.
- Backend: NestJS controllers, Zod schemas in
- Data Layer: SQLite via
better-sqlite3.SettingsServicestores every general-settings key as a single string row in thesettingtable. 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 attests/jest.config.js. Two projects:backend(tests/backend/) andfrontend(tests/frontend/). Tests live ONLY in/repo/tests/. - Single command
npm testruns everything.
- Jest 29 with
- 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. Nosetup.shchanges are required.
2. Impacted Files
- To Modify:
backend/src/modules/admin/dto/general.dto.ts— tighteneventStartUtc/eventEndUtcto 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 (orz.string().datetime({ message: '...' })with explicit messages), so thatGeneralSettingsSchemarejects non-ISO-8601 strings with a clearINVALID_DATETIMEmessage before the request reachesSettingsService.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 byZodValidationPipe(HTTP 400VALIDATION_FAILED).
- Keep the existing
superRefineend > 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, allowz.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 botheventStartUtcandeventEndUtc(alongside the existing group-levelendAfterStartValidator). - For each input, add the
aria-describedby/aria-invalidattributes and an inline<div class="field-error" data-testid="general-eventStart-error">…</div>(and the matchinggeneral-eventEnd-error) that renders when the control is touched/dirty AND invalid, driven by acomputedsignal — exactly mirroring the existingshowPageTitleError/general-pageTitle-errorpattern 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
endBeforeStartgroup validator already stays quiet when one side is empty).
- non-empty value that fails
- The existing
general-endBeforeStartelement 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:
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 existingGeneralSettingsSchema - validation rulesdescribe block with two cases:eventStartUtc = 'not-a-date'→ schema returnssuccess: false.eventStartUtc = '2026-08-15T10:30:00.000Z'andeventEndUtc = 'also-not-a-date'→ schema returnssuccess: false, and the issue message includes'eventEndUtc'/ contains the worddatetime(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:isoDatetimeValidatorreturnsnullfor empty input.isoDatetimeValidatorreturnsnullfor a valid ISO string.isoDatetimeValidatorreturns{ invalidDatetime: true }for'not-a-date'.datetimeMessagereturns the correct per-field message for'Event start'/'Event end'and returnsnullfor 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
safeParseresults. No DB, no HTTP server, no AngularTestBedare required. - Command:
npm test(or the narrowernpm run test:backend/npm run test:frontend).