# Implementation Plan: Job 901 — Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.01 ## 0. Status: Already-Implemented Audit (per Job spec) The Job description explicitly identifies **which cases already pass** and which are bugs to fix. Per the README/architecture docs (`docs/guides/admin-challenges.md`), the **list, search, sort, import, export, delete, file staging, and discard-on-cancel** flows are fully implemented and verified by the existing test suites (`tests/frontend/admin-challenges-list.spec.ts`, `tests/frontend/admin-challenges-form.spec.ts`, `tests/backend/admin-challenges-*.spec.ts`). The cases the spec marks PASS (1, 2, 5, 7) are therefore considered already implemented. **Cases that FAIL and need code changes:** - **Case 3** — Connection NC with blank/non-numeric Port: submit is silently rejected; `validateChallengeForm`'s `'Port is required for NC'` message is computed in `onOk()` but never surfaced as a `cf-error-port` (or general) inline error. Also `` strips non-digits before they reach the validator. - **Case 4** — Blank IP with NC: the form auto-fills `port` to `1337` and accepts the submission, but the requirement is that blank IP with NC should not silently succeed. (Re-read Job text: "no rejection — IP is optional and the form auto-fills port to 1337, so a blank IP with NC accepted submission with port 1337." — i.e. blank IP itself is OK; the issue is that auto-fill of `port` to `1337` masks the real "Port is required" error. The Job marks this as a separate failing case to be discussed.) - **Case 6** — Drag-and-drop oversized upload: the modal only wires ``. No `drop`/`dragover` handler exists. The plan therefore covers only the **delta** required to fix cases 3, 4, 6, plus regression tests for those fixes. ## 1. Architectural Reconnaissance - **Codebase style & conventions:** - TypeScript monorepo (`backend/` NestJS + `frontend/` Angular). `strict` TS across both. - Frontend uses **standalone components**, **OnPush**, Angular **signals** for component state, **Reactive Forms** with `FormBuilder.nonNullable.group(...)`. - Errors are surfaced via per-field signals `fieldErrors` (Record) and a top-level `formError` signal; inline elements use `data-testid="cf-error-"`. - Backend: NestJS modules; `admin-challenges.controller.ts` + `challenges.service.ts` + `challenge-files.service.ts`. Zod-validated DTOs. Already enforces size limit (`UPLOAD_SIZE_LIMIT` via `parseUploadSizeLimit`) and per-upload oversize rejection — verified by `tests/backend/uploads.spec.ts`. - Styling is component-scoped via `styles: [...]` literals (no global CSS edits required). - **Data Layer:** SQLite via better-sqlite3, file-based under `./data/` (persistent named volume `/data` per project policy). - **Test Framework & Structure:** - Jest with two projects (`tests/jest.config.js`): `backend` (node) and `frontend` (jsdom). - Single command from root: `npm test`. - Tests live in `tests/backend/*.spec.ts` and `tests/frontend/*.spec.ts` — **never** alongside source. - **Required Tools & Dependencies:** No new packages required. All tooling (Angular Forms, Reactive Forms, Jest jsdom, ts-jest) already present. ## 2. Impacted Files - **To Modify:** - `frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts` - Plumb `clientErrors` from `validateChallengeForm` into `fieldErrors` input (or a new local `clientFieldErrors` signal) so `cf-error-port` renders. - Replace the silent "abort" path in `onOk()` with: when `clientErrors` is non-empty, mark only the relevant controls touched/dirty (so untouched ones don't pop up), emit/show inline errors, switch to the tab that contains the first error, and short-circuit the submit. Clear these client errors when the user edits a field. - Change `` to `type="text" inputmode="numeric"` so non-numeric characters are not silently stripped; add `Validators.pattern(/^\d+$/)` and an integer validator (`Validators.min(1)`, `Validators.max(65535)`) on the `port` control so `` quirks don't hide the value. Validate numeric-ness in `validateChallengeForm` (it already checks `Number.isInteger`). - Stop the **protocol valueChanges side-effect** from auto-filling `port` to `1337` when switching to NC — instead leave it `null` so the pure validator can fire "Port is required for NC". (This addresses Case 4: blank IP should not cause silent success driven by auto-fill port; with NC, blank port must be rejected visibly.) - Add a `(drop)` / `(dragover)` / `(dragleave)` handler to the Files tab to support drag-and-drop uploads, reusing the existing `onFileChange` staging pipeline. Reject oversize files using the same `globalFileLimit` check. - Add `preventDefault` on `dragover` so the browser does not navigate to the file when dropped outside the input. - `frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts` (template portion only): - Add a drag-and-drop zone (`
`) wrapping the file input. Show visual state on dragover. - `frontend/src/app/features/admin/challenges/challenge-form.pure.ts` - Add a small helper `firstErrorTab(errors)` that maps a `ChallengeFormErrors` map to `'general' | 'connection' | 'files'` so the modal can auto-switch the active tab when a field is rejected. - Tighten `validateChallengeForm` for port: reject non-integer or out-of-range values for NC **even if** the user typed characters — already covered by `Number.isInteger`/`min`/`max`. (No logic change needed; just rely on it.) - `tests/frontend/admin-challenges-form.spec.ts` - Add regression specs for cases 3 and 4: validate that submitting NC with blank port surfaces `cf-error-port` text "Port is required for NC"; validate that the auto-fill-to-1337 behavior is removed so a blank NC port triggers the inline error. - Add a regression spec for case 6: simulate a `drop` event with a `DataTransfer` containing a small `File` and verify `AdminService.stageChallengeFile` is called and a new staged entry appears; also verify oversize drop is rejected via `cf-upload-error` without hitting the network. - **To Create:** - **None.** All edits go into existing files. (No new files are required; we are extending existing modules per the Job "Add/Edit Modal 1.01" patch.) ## 3. Proposed Changes ### 3.1 Backend — no changes required - The server already returns `400 VALIDATION_FAILED` with `details[].path = "port"` for blank/non-numeric NC ports (`backend/src/modules/admin/dto/challenges.dto.ts` Zod schema enforces `port: z.number().int().min(1).max(65535).optional()` with conditional refinement for `NC`). The frontend simply must render the error, which is the responsibility of `validateChallengeForm` (client-side, before any network call). - The file-size limit is already enforced by `challenge-files.service.ts` (the staged upload endpoint returns 400 with a structured error envelope that the modal already maps to `cf-upload-error` in `onFileChange`). ### 3.2 Frontend — `challenge-form-modal.component.ts` 1. **Stop auto-filling `port` on protocol change.** - In the `protocol.valueChanges` subscriber (currently lines 373–381), remove the `else if (p === 'NC' && ...)` branch that sets `port` to `1337`. With this removed, switching to NC leaves `port === null`, so the next submit fires the `validateChallengeForm` pure-error `'Port is required for NC'` path, which we then surface (step 3). 2. **Surface `validateChallengeForm` errors inline.** - Add a private `clientFieldErrors = signal(null)`. - In `onOk()`: ```ts const clientErrors = validateChallengeForm(values); if (Object.keys(clientErrors).length > 0) { this.clientFieldErrors.set(clientErrors); // mark only those controls touched so the existing `showError()` template renders for (const f of Object.keys(clientErrors) as (keyof ChallengeFormErrors)[]) { const ctrl = this.form.controls[f as keyof ChallengeFormControls]; if (ctrl) { ctrl.markAsTouched(); ctrl.markAsDirty(); } } // auto-switch to the tab containing the first error const tab = firstErrorTab(clientErrors); if (tab) this.tab.set(tab); return; } this.clientFieldErrors.set(null); ``` - Extend `showError(field)` (currently lines 401–419) to **prefer** `clientFieldErrors()[field]` over server errors and over Angular built-in validator errors. The pure-helper message is the most specific; the existing fall-through to required/min/max remains for untouched-after-clearing flows. - Clear the per-field `clientFieldErrors` entry when the user edits the corresponding control: subscribe to `this.form.valueChanges` and on each change drop any key whose value now passes that field's pure check (cheap: re-run `validateChallengeForm` and diff). This keeps the error visible until the user actually fixes the input. 3. **Replace `` for port with ``.** - Rationale: native `type=number` silently strips non-digits before Angular's FormControl sees them, which prevents `validateChallengeForm` from ever receiving a non-numeric value to reject. Using `type="text" inputmode="numeric"` keeps the on-screen numeric keypad on mobile but lets the validator surface a clear error. - Add to the `port` control: `Validators.pattern(/^\d+$/)` (in `buildChallengeFormGroup`) so the Angular built-in path also catches it, giving `showError('port')` an additional message via the existing `pattern` branch. (Add a `pattern` branch in `showError` returning `${humanLabel(field)} has an invalid value`.) 4. **Add drag-and-drop support to the Files tab.** - Template change (inside the `tab() === 'files'` block): ```html

Drop files here or use the picker below

``` - Component: ```ts readonly dragOver = signal(false); onDragOver(ev: DragEvent): void { ev.preventDefault(); this.dragOver.set(true); } onDragLeave(ev: DragEvent): void { this.dragOver.set(false); } onDrop(ev: DragEvent): void { ev.preventDefault(); this.dragOver.set(false); const files = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : []; if (files.length === 0) return; // Reuse the same staging pipeline as the picker: void this.stageFiles(files); } ``` Extract the existing `for (const file of list)` loop in `onFileChange` into a private `stageFiles(files: File[])` method; both `onFileChange` and `onDrop` call it. Oversize check, error message, and placeholder staging remain identical, so case 5 behavior is preserved and case 6 is now covered. ### 3.3 Frontend — `challenge-form.pure.ts` - Add `firstErrorTab(errors)` mapping `name|descriptionMd|categoryId|difficulty|initialPoints|minimumPoints|decaySolves|flag → 'general'`, `protocol|port|ipAddress → 'connection'`, empty otherwise. ### 3.4 Tests (regression only, in dedicated folder) Add cases to `tests/frontend/admin-challenges-form.spec.ts`: 1. **Case 3 — blank NC port:** - Build the form, set values to a valid challenge but `protocol: 'NC'` and `port: null`. - Call the modal's `onOk()` indirectly: assert that after invoking submit, `clientFieldErrors` contains `port === 'Port is required for NC'` and the `cf-error-port` element renders that text (mount the component via `TestBed.createComponent`). - Assert that the port `` no longer has `type="number"` (regression for non-numeric stripping). 2. **Case 3 — non-numeric NC port:** - Simulate user typing `"12a3"` into the port field. Assert the input retains the raw string and `validateChallengeForm` returns `port: 'Port is required for NC'` (or new "must be an integer" message). 3. **Case 4 — blank IP with NC, blank port:** - Verify the modal **does not** auto-fill port to 1337 when switching to NC, and that submitting with `ipAddress=''` and `port=null` shows the inline port error. 4. **Case 6 — drag-and-drop:** - Stub `AdminService.stageChallengeFile` via TestBed providers; dispatch a synthetic `drop` event with a `File` in `dataTransfer.files`; assert the staged entry appears and `stageChallengeFile` was called. - Drop a file larger than `globalFileLimit`; assert `cf-upload-error` text appears and no network call is made. (For new tests the existing `tests/frontend/admin-challenges-form.spec.ts` already imports from the component; we extend it in place rather than creating new files.) ## 4. Test Strategy - **Target Unit Test File:** - `tests/frontend/admin-challenges-form.spec.ts` (existing; extend with the four cases above). - **Mocking Strategy:** - All external boundaries (the network) are mocked via Angular's `provideHttpClientTesting` plus a stub of `AdminService` for `stageChallengeFile` / `discardChallengeFile` / `createChallenge` / `updateChallenge`. - The drag-and-drop test stubs `AdminService.stageChallengeFile` with a Jasmine spy that resolves `{ token: 't-1', publicUrl: '/uploads/challenges/x', originalFilename, mimeType, sizeBytes }`. - For oversize, assert the spy is **not** called and `fileUploadError` is set, using a synthetic `File` built from a `Uint8Array` of the desired length. - Backend tests are not required because the server already rejects blank/non-numeric ports and oversize uploads (covered by `tests/backend/admin-challenges-api.spec.ts` and `tests/backend/uploads.spec.ts`). ## 5. Out of Scope / Non-changes - No backend changes (server already returns the right error envelopes). - No new dependencies, no `setup.sh` edits, no migration scripts, no DB schema changes. - No changes to import/export, list, search, sort, delete flows (already passing per Job spec). - No drag-and-drop file picker behavior for import — that uses a `` chooser inside `AdminChallengesComponent.pickImportFile`, which the Job does not flag.