# Implementation Plan: REST Event-State Snapshot, SSE De-dup, Solve Publish-Once, REST Error Notification ## 0. Goal Four small refinements called out by the reviewer: 1. Add a typed REST endpoint that returns the authoritative `EventStatePayload` (a one-shot snapshot) and call it from `challenges.page.ts` before/alongside `wireSse` so the page has state synchronously even if the SSE stream hasn't delivered its first frame yet. 2. In `challenges.store.ts`, stop double-dispatching SSE frames: register only the named `'solve'` listener (plus a one-time `'message'` listener for backward compat with `EventStatusStore` which still uses `'message'`). Move the `applyStatusPayload` call out of the store and back into the page's status wiring, since the status SSE is the responsibility of `EventStatusStore`, not the challenges store. 3. In `backend/src/modules/challenges/challenges.service.ts`, remove the two `publishSolveEvent(...)` calls that fire on `already_solved` (existing-row branch and unique-constraint recovery branch). Only the fresh-insert branch (currently line 414) should publish — duplicate broadcasts are noise since the row is unchanged. 4. Add a small functional REST error interceptor (`HttpInterceptorFn`) that pipes 4xx/5xx responses into a `NotificationService.error(...)` call so the modal/grid can surface failure state without bespoke try/catch everywhere. Register it in `frontend/src/main.ts`. ## 1. Architectural Reconnaissance - **Backend controller surface for events**: `backend/src/modules/challenges/events.controller.ts` exposes `GET /api/v1/events` (SSE) and `backend/src/modules/system/system.controller.ts` exposes `GET /api/v1/events/status` (legacy SSE, plural-segmented) and a public `GET /api/v1/event/status` snapshot. There is no plain JSON REST snapshot for the event window under `/api/v1/challenges`. The new endpoint will live next to the challenges controller (`ChallengesController`). - **DTO**: `EventStatusService.getState()` returns `EventStatePayload` which matches the snake_case status shape (`serverNowUtc`, `eventStartUtc`, `eventEndUtc`, `secondsToStart`, `secondsToEnd`). We reuse it. - **Frontend SSE wiring today**: `ChallengesStore.wireSse` registers the same handler for `'message'`, `'solve'`, `'status'`. Because the backend SSE is well-formed (`event: solve` / `event: status`), the dispatcher in `AuthenticatedEventSourceService.parseAndDispatch` fires both `'message'` AND the named event — so the store's handler runs twice per frame. Fix: the challenges store only needs `'solve'` (to update cards + notify modal listeners); the `'status'` frame is consumed by `EventStatusStore` via its existing `'message'` listener (we'll keep that). - **`EventStatusStore` SSE wiring**: it still uses `addEventListener('message', ...)` and ignores named events. That's correct for backward compat — it doesn't double-process frames. - **REST error notification**: the app currently has no toast/notification service. We'll add a minimal `NotificationService` with an `error(message)` method that logs to console in non-browser contexts and a small DOM-mountable component is out of scope; the interceptor calls it and any future toast UI can subscribe to its signal. - **Interceptors run in registration order on the request and reverse order on the response** (`withInterceptors([a, b])` → request runs a→b, response runs b→a). For a pure error inspector that wants to act on every response regardless of what auth/csrf did, we register it AFTER `authInterceptor` so it sees the final response on the way out. ## 2. Impacted Files ### Backend — To Modify - `backend/src/modules/challenges/challenges.controller.ts` — add `@Get('status')` returning `EventStatePayload`. - `backend/src/modules/challenges/challenges.service.ts` — remove two `publishSolveEvent(...)` calls in the existing-row branch and the constraint-recovery branch; keep the one in the fresh-insert branch. ### Frontend — To Create - `frontend/src/app/core/services/notification.service.ts` — minimal signal-based notification store with `error(message)`, `info(message)`, and a `messages` signal. - `frontend/src/app/core/interceptors/error-notification.interceptor.ts` — `HttpInterceptorFn` that maps non-2xx responses to `NotificationService.error(...)`. ### Frontend — To Modify - `frontend/src/app/features/challenges/challenges.service.ts` — add `getEventState()` returning `Observable`. - `frontend/src/app/features/challenges/challenges.store.ts` — expose `applyStatus(payload)` (already exists), change `wireSse` to register ONLY `'solve'` (drop `'message'` and `'status'` listeners in this store). Move the local handler logic so it doesn't process status frames. - `frontend/src/app/features/challenges/challenges.page.ts` — call `store.getEventState()` once at boot (before `wireSse`) and pass the result to `eventStatus.applyServerStatus(payload)`; update `wireSse` listener if needed (no change required for status, since `EventStatusStore` handles it). - `frontend/src/main.ts` — register `errorNotificationInterceptor` AFTER `authInterceptor`. ### Tests — To Modify - `tests/backend/challenges-submit-flag.spec.ts` — change the SSE-solve-payload test to subscribe once after a fresh insert; assert that two consecutive correct submissions yield ONE `emitScoreboard` call, not two. - `tests/frontend/event-status.store.spec.ts` — add a small assertion that `currentServerTimeUtc` returns a valid ISO string and that `reloadAtCountdownZero` fires exactly once. - New test file `tests/frontend/notification-interceptor.spec.ts` — assert the interceptor calls `NotificationService.error(...)` on a 500 response and does NOT call it on a 2xx response. ## 3. Proposed Changes ### Backend 1. **`challenges.controller.ts`** — add (after the existing `board`/`detail`/`submit` handlers): ```ts @Get('status') @ApiOperation({ summary: 'Authenticated event-window snapshot (REST JSON)' }) async eventState(): Promise { return this.statusSvc.getState(); } ``` Inject `EventStatusService` into the controller (add to constructor). The existing `getState()` already returns `EventStatePayload`. This endpoint sits at `/api/v1/challenges/status`. We avoid `events/status` to not collide with the existing legacy SSE route. 2. **`challenges.service.ts`** — remove the `publishSolveEvent(...)` call at line 294 (existing-row branch) and the one at line 368 (unique-constraint recovery branch). Keep only the line 414 call (fresh-insert path). The `publishSolveEvent` helper itself stays (it's still called from the fresh-insert path). ### Frontend 1. **`notification.service.ts`** (new): ```ts import { Injectable, signal } from '@angular/core'; export interface Notification { id: number; kind: 'error' | 'info'; message: string; ts: number; } @Injectable({ providedIn: 'root' }) export class NotificationService { private next = 1; readonly messages = signal([]); error(message: string): void { this.push('error', message); // eslint-disable-next-line no-console console.error('[notification]', message); } info(message: string): void { this.push('info', message); // eslint-disable-next-line no-console console.info('[notification]', message); } dismiss(id: number): void { this.messages.update((cur) => cur.filter((m) => m.id !== id)); } private push(kind: Notification['kind'], message: string): void { const n: Notification = { id: this.next++, kind, message, ts: Date.now() }; this.messages.update((cur) => [...cur, n]); } } ``` 2. **`error-notification.interceptor.ts`** (new): ```ts import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; import { catchError, throwError } from 'rxjs'; import { NotificationService } from '../services/notification.service'; function friendlyMessage(status: number, body: any): string { const code = body?.code as string | undefined; const msg = (body?.message as string | undefined) ?? ''; if (code === 'EVENT_NOT_RUNNING') return 'Submissions are only accepted while the event is running.'; if (code === 'FLAG_INCORRECT') return 'Incorrect flag.'; if (status === 0) return 'Network error. Please try again.'; if (status === 401 || status === 403) return 'Your session is no longer valid.'; if (status >= 500) return 'Server error. Please try again later.'; return msg || `Request failed (${status}).`; } export const errorNotificationInterceptor: HttpInterceptorFn = (req, next) => { const notify = inject(NotificationService); return next(req).pipe( catchError((err) => { if (err instanceof HttpErrorResponse) { notify.error(friendlyMessage(err.status, err.error)); } else { notify.error('Unexpected error.'); } return throwError(() => err); }), ); }; ``` 3. **`challenges.service.ts`** — add: ```ts getEventState(): Observable { return this.http .get('/api/v1/challenges/status', { withCredentials: true }) .pipe(catchError((err) => throwError(() => toEnvelope(err)))); } ``` Add `EventStatePayload` to the import from `./challenges.pure`. 4. **`challenges.store.ts`** — update `wireSse` to register only `'solve'`: ```ts const handler = (ev: MessageEvent | Event) => this.handleSseFrame(ev); src.addEventListener('solve', handler); ``` Drop the `addEventListener('message', ...)` and `addEventListener('status', ...)` calls inside `wireSse`. `handleSseFrame` already short-circuits non-`'solve'` events by virtue of the `me.type === 'solve'` check at the top, so no further change needed inside that method. 5. **`challenges.page.ts`** — at `ngOnInit`, before wiring the SSE: ```ts // REST snapshot first so the page has synchronous state. this.store.getEventState().subscribe({ next: (payload) => this.eventStatus.applyServerStatus(payload), error: () => undefined, // SSE will deliver the next frame; don't notify. }); ``` No structural change to `wireSse` — the challenges store now only listens for `'solve'`; `EventStatusStore` continues to listen for `'message'` on the same source (which the transport dispatches in addition to `'solve'`). 6. **`main.ts`** — register the error interceptor last so it sees the final response: ```ts provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor])), ``` ## 4. Test Strategy - **Backend** (`tests/backend/challenges-submit-flag.spec.ts`): change the SSE-payload test to subscribe ONCE on `hub.scoreboard$()` and assert that two consecutive correct submissions (with an artificial constraint-recovery path forced via the existing concurrency test) emit only ONE payload. Add a new test that asserts `GET /api/v1/challenges/status` returns the running state with `server_now_utc` + `event_start_utc` + `event_end_utc` + `seconds_to_start` + `seconds_to_end` for an authenticated user. - **Frontend** (`tests/frontend/event-status.store.spec.ts`): add `applyServerStatus` once, advance the tick, assert `currentServerTimeUtc()` is a valid ISO string and that `reloadAtCountdownZero` fires exactly once (not twice on consecutive ticks where `secondsToStart` is still 0). - **Frontend** (`tests/frontend/notification-interceptor.spec.ts`, new): create an `HttpClient` via `provideHttpClient(withInterceptors([errorNotificationInterceptor]))` and a fake backend that returns 200 for one URL and 500 for another; assert `NotificationService.messages()` only contains the 500 message. - All existing 504 tests must continue to pass. ## 5. Notes / Trade-offs - We use a plain REST snapshot instead of "best-authenticated equivalent" of the public `/api/v1/event/status` because the public endpoint is unauthenticated and returns a slightly different shape (legacy `status` field, no `state` enum, only `countdownMs`). The new authenticated snapshot uses the canonical `EventStatePayload` shape and is reused by both the page bootstrap and (later) any admin tooling. - The error interceptor only fires for HTTP errors thrown by Angular's `HttpClient`. The `ChallengesApiService` wraps these in its own `toEnvelope()` and re-throws, so the interceptor sees `HttpErrorResponse` and the service's catchError re-emits the envelope. We do not double-notify. - Removing the two `publishSolveEvent` calls is safe because the fresh-insert path (line 414) is the only one that mutates state — the existing-row and constraint-recovery branches do not insert a new row, so emitting a "new solve" SSE frame for them is misleading to other connected clients (who would re-render an unchanged row).