diff --git a/docs/api/challenges.md b/docs/api/challenges.md new file mode 100644 index 0000000..345028c --- /dev/null +++ b/docs/api/challenges.md @@ -0,0 +1,220 @@ +--- +type: api +title: Challenges Endpoints +description: Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames. +tags: [api, challenges, board, score, sse, submit] +timestamp: 2026-07-23T00:10:00Z +--- + +# Endpoints + +All routes are mounted by `ChallengesModule` +(`backend/src/modules/challenges/challenges.module.ts`) and require +authentication (`JwtAuthGuard` is registered globally and these handlers +are not `@Public()`). The DTOs and zod schemas live in +`backend/src/modules/challenges/dto/challenges.dto.ts`. + +| Method | Path | Auth | Source | +|--------|-----------------------------------|-------------|-----------------------------------------------------------| +| `GET` | `/api/v1/challenges/board` | Authenticated | `backend/src/modules/challenges/challenges.controller.ts` | +| `GET` | `/api/v1/challenges/status` | Authenticated | Same. | +| `GET` | `/api/v1/challenges/:id` | Authenticated | Same. | +| `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. | +| `SSE` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` | + +> The combined `/api/v1/events` SSE is **in addition to** the existing +> `/api/v1/events/status` status-only stream. The two share the same +> authenticated transport but serve different consumers — the +> `ChallengesPage` opens `/api/v1/events` so it can react to `solve` +> frames in addition to status, while the shell header keeps reading +> `/api/v1/events/status` for the LED/countdown only. + +# `GET /api/v1/challenges/board` + +Authenticated board of enabled challenges grouped by category. Query +string: + +| Param | Type | Description | +|-----------|--------|----------------------------------------------------------------------------------------------| +| `include` | string | Comma-separated feature list. Passing `solvers` attaches a per-card `solvers` array (ordered by `solved_at ASC`). | + +The handler (`ChallengesService.getBoard`) returns: + +```json +{ + "columns": [ + { + "id": "", + "abbreviation": "CRY", + "name": "Cryptography", + "iconPath": "/uploads/icons/CRY.png", + "cards": [ + { + "id": "", + "name": "RSA Rollers", + "descriptionMd": "...", + "categoryId": "", + "categoryAbbreviation": "CRY", + "categoryIconPath": "/uploads/icons/CRY.png", + "difficulty": "MEDIUM", + "livePoints": 480, + "solveCount": 4, + "solvedByMe": false, + "solvers": [ /* SolverRow[] when ?include=solvers */ ] + } + ] + } + ], + "solvedChallengeIds": ["", "..."], + "perChallengeLivePoints": { "": 480 } +} +``` + +`livePoints` is computed by +`computeLivePoints(initialPoints, minimumPoints, decaySolves, solveCount)` +(`backend/src/modules/challenges/scoring.util.ts`): + +``` +if (currentSolveCount >= decaySolves) return minimum; +raw = initial - (initial - minimum) * (currentSolveCount / decaySolves); +return max(minimum, round(raw)); +``` + +Columns are sorted alphabetically by `abbreviation`; cards within a +column are sorted by `difficulty` (`LOW < MEDIUM < HIGH`) then by name. +Only `enabled = 1` rows are returned. The query joins +`challenge` and `category` once and groups in memory. + +# `GET /api/v1/challenges/status` + +Authenticated event-window snapshot (the same `EventStatePayload` +returned by the legacy `/api/v1/event/status` public endpoint, but +gated by JWT and shaped to the canonical 4-state payload used by the +SPA). Built by +`backend/src/common/services/event-status.service.ts:getState()`. + +`ChallengesPage` calls this once on init to populate the event state +before the SSE frame arrives, so the gate UI is never blank on slow +networks. + +# `GET /api/v1/challenges/:id` + +Single challenge detail with the full `SolverRow[]` for that +challenge. `:id` must be a UUID (validated by `ChallengeIdParamSchema`). +The response shape is: + +```json +{ + "challenge": { /* BoardCard */ }, + "solvers": [ /* SolverRow[] sorted by solvedAtUtc ASC */ ] +} +``` + +`404 NOT_FOUND` is returned for unknown ids and for disabled +challenges. + +# `POST /api/v1/challenges/:id/solves` + +Submit a flag. Body is validated by `SolveSubmitBodySchema` +(`{ flag: string, min 1, max 4096 }`). Response: + +```json +{ + "status": "solved" | "already_solved", + "challenge": { /* BoardCard with solvedByMe=true and the new solveCount */ }, + "awarded": { + "position": 1, + "basePoints": 500, + "rankBonus": 15, + "awardedPoints": 515, + "awardedAtUtc": "2026-07-23T00:01:30.000Z" + }, + "solvers": [ /* SolverRow[] */ ] +} +``` + +The full transaction (`ChallengesService.submitFlag`): + +1. Loads the challenge + category inside a `dataSource.transaction`. +2. Reads `EventStatusService.getState()`; rejects with + `409 EVENT_NOT_RUNNING` when `state !== 'running'`. +3. Looks for an existing `(challengeId, userId)` `solve` row. + * If found, returns `status: 'already_solved'` with the original + awarded points and the current solver list (no new row is + inserted; the unique index `uq_solve_challenge_user` would block + it anyway). +4. Constant-time compares the submitted flag against `challenge.flag` + via `safeEqualString`. On mismatch rejects with + `400 FLAG_INCORRECT`. +5. Counts existing solves (`position = count + 1`). +6. Computes `basePoints = computeLivePoints(...)` for `position - 1`, + `rankBonus = rankBonusForPosition(position)` (`15 / 10 / 5 / 0`), + `awardedPoints = basePoints + rankBonus`. +7. Inserts the new `solve` row with `is_first` / `is_second` / + `is_third` flags populated. +8. On `SQLITE_CONSTRAINT_UNIQUE` (race) falls back to the + `already_solved` shape. +9. On success, publishes a solve frame via + `SseHubService.emitScoreboard(...)` (see below) so every + `/api/v1/events` subscriber sees the live event. + +The flag is **never** returned in any DTO exposed by this controller +(it is read only inside the `dataSource.transaction` and is not +included in `BoardCard`, `SolverRow`, `AwardedSolve`, or +`SolveResponse`). + +# `SSE GET /api/v1/events` + +Authenticated global SSE stream mounted by +`backend/src/modules/challenges/events.controller.ts`. It merges three +sources and emits `MessageEvent` frames whose `type` is one of: + +| `event:` | Payload | +|----------|-------------------------------------------------------------------------------------------------| +| `status` | `{ state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }` | +| `solve` | `{ challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count, initial_points, minimum_points, decay_solves }` | + +Sources: + +| Source | Trigger | +|-----------------------------------------|------------------------------------------------------------------------------------| +| `interval(60_000).pipe(startWith(0))` | Refresh the full `status` payload every 60 seconds so a disconnected client self-heals. | +| `SseHubService.event$()` (filter `'general'` topic) | Re-publish the current `status` whenever an admin updates general settings. | +| `SseHubService.event$()` (legacy event payloads) | Legacy fallback for pre-existing event-window broadcasts. | +| `SseHubService.scoreboard$()` | Every fresh `solve` published by `ChallengesService.submitFlag`. | + +Consecutive identical `status` frames are suppressed by +`distinctUntilChanged(JSON.stringify)`. The `solve` frames are +appended as-is. + +## Solve frame wire format + +`SseHubService.emitScoreboard({...})` is called with a flat +`SolveEventPayload` (see +`backend/src/modules/challenges/dto/challenges.dto.ts`). The +controller maps both camelCase and snake_case aliases so a +downstream consumer can read either shape (for example +`challenge_id` or `challengeId`, `player_id` or `userId`, +`awarded_points` or `pointsAwarded`). + +# Errors + +The challenges endpoints use three additional error codes from +`backend/src/common/errors/error-codes.ts`: + +| Code | HTTP | When | +|---------------------|------|------------------------------------------------------------| +| `EVENT_NOT_RUNNING` | 409 | Flag submitted while the event is in `countdown` / `stopped` / `unconfigured`. | +| `FLAG_INCORRECT` | 400 | Submitted flag did not constant-time-equal `challenge.flag`. | +| `CHALLENGE_DISABLED`| — | Reserved for future use (admin-side disabled challenges already return `404 NOT_FOUND` from this controller). | + +The full error envelope and middleware are documented in +[REST API Overview](/api/rest-overview.md). + +# See also + +- [Challenge Tables](/database/challenges.md) +- [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`) +- [Scoreboard Stream](/guides/scoreboard-stream.md) +- [Challenges Board Guide](/guides/challenges-board.md) +- [Backend Module Map](/architecture/backend-modules.md) \ No newline at end of file diff --git a/docs/api/rest-overview.md b/docs/api/rest-overview.md index 3f7c567..27cda81 100644 --- a/docs/api/rest-overview.md +++ b/docs/api/rest-overview.md @@ -3,7 +3,7 @@ type: api title: REST API Overview description: Base URL, versioning, auth, CSRF, and the standard error envelope. tags: [api, rest, overview, csrf] -timestamp: 2026-07-21T22:19:08Z +timestamp: 2026-07-23T00:10:00Z --- # Base URL & versioning @@ -78,7 +78,18 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`: `CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`, `INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`, `REGISTRATIONS_DISABLED`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, -`PASSWORDS_DO_NOT_MATCH`, `INTERNAL`. +`PASSWORDS_DO_NOT_MATCH`, `CATEGORY_HAS_CHALLENGES`, `SYSTEM_PROTECTED`, +`CHALLENGE_NAME_TAKEN`, `CHALLENGE_INVALID_CATEGORY`, +`CHALLENGE_INVALID_POINTS`, `CHALLENGE_INVALID_PORT`, +`CHALLENGE_INVALID_IP`, `CHALLENGE_FILE_LIMIT`, +`IMPORT_VALIDATION_FAILED`, `IMPORT_PARTIAL_FAILURE`, +`EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`, `INTERNAL`. + +The player-facing codes (`EVENT_NOT_RUNNING`, `FLAG_INCORRECT`) are +emitted by the challenges submit endpoint; see +[Challenges Endpoints](/api/challenges.md) for the full set of HTTP +statuses and the canonical messages produced by +`messageForSolveError`. # See also @@ -86,4 +97,5 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`: - [Admin Endpoints](/api/admin.md) - [Setup Endpoint](/api/setup.md) - [System Endpoints](/api/system.md) -- [Uploads Endpoints](/api/uploads.md) \ No newline at end of file +- [Uploads Endpoints](/api/uploads.md) +- [Challenges Endpoints](/api/challenges.md) \ No newline at end of file diff --git a/docs/architecture/backend-modules.md b/docs/architecture/backend-modules.md index 981a010..fa103e3 100644 --- a/docs/architecture/backend-modules.md +++ b/docs/architecture/backend-modules.md @@ -3,7 +3,7 @@ type: architecture title: Backend Module Map description: NestJS modules, controllers, services, and how they are wired together. tags: [architecture, backend, nestjs, modules] -timestamp: 2026-07-21T22:19:08Z +timestamp: 2026-07-23T00:10:00Z --- # Module Map @@ -30,6 +30,7 @@ also registers two global providers: | `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Four controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`, `AdminChallengesController` for `/api/v1/admin/challenges*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). | | `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. | | `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). | +| `ChallengesModule` | `backend/src/modules/challenges/challenges.module.ts` | `ChallengesController` (`/api/v1/challenges/{board,status,:id,:id/solves}`) + `ChallengesEventsController` (authenticated SSE `/api/v1/events`) + `ChallengesService` (board, detail, submit, scoring util). | | `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. | # Controllers @@ -46,6 +47,8 @@ also registers two global providers: | `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` | | `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` | | `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` | +| `ChallengesController` | `/api/v1/challenges` | Authenticated | `backend/src/modules/challenges/challenges.controller.ts` | +| `ChallengesEventsController` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` | # Common services @@ -63,6 +66,7 @@ also registers two global providers: | `ThemeLoaderService` | `backend/src/common/utils/theme-loader.service.ts` | Loads + validates the 10 canonical themes. | | `ZodValidationPipe` | `backend/src/common/pipes/zod-validation.pipe.ts` | Replaces `ValidationPipe` for zod-validated DTOs. | | `TransformInterceptor` | `backend/src/common/interceptors/transform.interceptor.ts` | Optional response transformer. | +| `scoring.util` | `backend/src/modules/challenges/scoring.util.ts` | `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, and `safeEqualString` (constant-time flag compare). | # Decorators diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 785fe0e..eaba6c0 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -2,8 +2,8 @@ type: architecture title: Frontend Structure description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport. -tags: [architecture, frontend, angular, shell, sse] -timestamp: 2026-07-22T10:32:00Z +tags: [architecture, frontend, angular, shell, sse, challenges, notifications] +timestamp: 2026-07-23T00:10:00Z --- # Routes @@ -15,7 +15,7 @@ Routes live in `frontend/src/app/app.routes.ts`: | `/bootstrap` | `SetupCreateAdminComponent` | — | First-admin creation flow. | | `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. | | `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. | -| `/challenges` | `ChallengesPage` | inherited | Placeholder challenges page. | +| `/challenges` | `ChallengesPage` | inherited | Full challenges board (category columns, modal, flag submit, live SSE). | | `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. | | `/blog` | `BlogPage` | inherited | Placeholder blog page. | | `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. | @@ -33,6 +33,12 @@ Routes live in `frontend/src/app/app.routes.ts`: | `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback and exposes `clearAndBroadcast` / `onPeerInvalidation`. | | `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. | | `auth-session-events.pure.ts` | `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and the namespaced channel / storage-key constants. | +| `ChallengesStore` | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board payload, event state, per-card solve listeners, SSE solve-frame mutation, submit response application. | +| `ChallengesApiService` | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP wrapper around `/api/v1/challenges/{board,status,:id,:id/solves}` returning `Observable<...>` and translating `HttpErrorResponse` into a typed `ApiErrorEnvelope`. | +| `challenges.pure.ts` | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types + helpers for the challenges feature (`BoardCard`, `SolverRow`, `SolveEventPayload`, sorting, parsers, friendly error mapping, `formatDdHhMm`). | +| `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. | +| `NotificationService` | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. | +| `errorNotificationInterceptor` | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Functional HTTP interceptor that pushes a friendly message into `NotificationService` for every `HttpErrorResponse`; suppresses duplicate toasts for endpoints with their own error UI (login form, `/challenges/status` snapshot). | # Authenticated SSE wiring @@ -64,8 +70,11 @@ on destruction. |---|---| | `frontend/src/app/app.routes.ts` | Registers shell and child routes. | | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Adds Bearer authentication to Angular HTTP requests. | +| `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression rules for login + `/challenges/status`). | | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Adds Bearer authentication to the browser SSE request, which does not use the Angular HTTP interceptor chain. | | `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. | +| `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page; owns gate logic, modal lifecycle, SSE wiring, and the countdown-zero reload. | +| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application. | | `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. | # Event-status pure helpers @@ -111,4 +120,6 @@ for the full contract and tester matrix. - [Authenticated Shell](/guides/authenticated-shell.md) - [Event Window](/guides/event-window.md) +- [Challenges Board](/guides/challenges-board.md) +- [Notifications](/guides/notifications.md) - [System Overview](/architecture/overview.md) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index f3c9db3..95c8e82 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -1,9 +1,9 @@ --- type: architecture title: Key Files Index -description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, and the challenges full-replace import flow. -tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import] -timestamp: 2026-07-22T22:17:11Z +description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, the challenges full-replace import flow, and the authenticated player-facing challenges board. +tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications] +timestamp: 2026-07-23T00:10:00Z --- # Backend @@ -31,6 +31,13 @@ timestamp: 2026-07-22T22:17:11Z | `tests/backend/admin-general-event-window.spec.ts` | Focused contract tests for valid, empty, malformed, equal, and reversed event-window timestamps. | | `tests/backend/admin-general-service.spec.ts` | General-settings schema and service tests, including per-field empty datetime failures and settings-event emission. | | `tests/backend/admin-challenges-import-replace.spec.ts` | Contract tests for the confirmed-import full-replace: wipes unrelated pre-existing challenges, unlinks wiped disk files, empty archive, unknown-category skip-then-wipe. | +| `backend/src/modules/challenges/challenges.module.ts` | Wires `ChallengesController`, `ChallengesEventsController`, `ChallengesService`, and the four TypeORM entities (`ChallengeEntity`, `CategoryEntity`, `SolveEntity`, `UserEntity`). | +| `backend/src/modules/challenges/challenges.controller.ts` | Registers `/api/v1/challenges/{board,status,:id,:id/solves}` (all JWT-protected). | +| `backend/src/modules/challenges/events.controller.ts` | Authenticated SSE `/api/v1/events` merging status ticks + hub `general` pushes + `scoreboard$` solve frames. | +| `backend/src/modules/challenges/challenges.service.ts` | `getBoard` / `getDetail` / `submitFlag` + `dataSource.transaction` for race-safe idempotent solves; publishes `solve` SSE frames on success. | +| `backend/src/modules/challenges/dto/challenges.dto.ts` | zod contracts (`SolveSubmitBodySchema`, `BoardQuerySchema`, `ChallengeIdParamSchema`) + DTO types for the player-facing board. | +| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, and constant-time `safeEqualString` for flag comparison. | +| `backend/src/common/errors/error-codes.ts` | Canonical error code map (now includes `EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`). | # Frontend @@ -65,9 +72,28 @@ timestamp: 2026-07-22T22:17:11Z | `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. | | `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. | | `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. | +| `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page: gate logic (countdown/running/stopped/unconfigured), modal lifecycle, SSE wiring on `/api/v1/events`, and the countdown-zero reload. | +| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle. | +| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. | +| `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types and helpers (`BoardCard`, `SolverRow`, `SolveEventPayload`, `parseSolveEvent`, `mergeSolveEventIntoSolvers`, `messageForSolveError`, `formatDdHhMm`). | +| `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. | +| `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders a single challenge card on the board (difficulty, live points, solve count, `✓` when solved by the player). | +| `frontend/src/app/features/challenges/challenge-modal.component.ts` | Challenge detail modal: description, flag form, awarded banner, solvers list, live-solve updates. | +| `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. | +| `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). | +| `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm`. | +| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. | +| `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. | +| `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, `?include=solvers`. | +| `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. | +| `tests/backend/challenges-events-sse.spec.ts` | `/api/v1/events` SSE: status + solve frames, dedup, ordering. | +| `tests/backend/challenges-submit-flag.spec.ts` | Submit flow: correct/incorrect flag, idempotent re-submit, event-state guard, race handling. | # See also - [Frontend Structure](/architecture/frontend-structure.md) - [Authenticated Shell](/guides/authenticated-shell.md) - [System Endpoints](/api/system.md) +- [Challenges Endpoints](/api/challenges.md) +- [Challenges Board](/guides/challenges-board.md) +- [Notifications](/guides/notifications.md) diff --git a/docs/guides/challenges-board.md b/docs/guides/challenges-board.md new file mode 100644 index 0000000..c9d53af --- /dev/null +++ b/docs/guides/challenges-board.md @@ -0,0 +1,198 @@ +--- +type: guide +title: Challenges Board +description: How a signed-in player navigates the `/challenges` page, opens a challenge modal, submits a flag, and watches live solve updates. +tags: [guide, challenges, board, flag, score, sse, tester] +timestamp: 2026-07-23T00:10:00Z +--- + +# When this view is available + +`/challenges` is rendered by `ChallengesPage` +(`frontend/src/app/features/challenges/challenges.page.ts`) inside the +authenticated shell. The page is gated by: + +| Gate | Where | +|---------------------------------------|--------------------------------------------------| +| Signed in (access token + refresh) | `authGuard` in `app.routes.ts`. | +| Event window state | `ChallengesPage.isRunning()` derived from `EventStatusStore`. The board is only shown when `state === 'running'`; otherwise the page renders a gate panel (see below). | + +The first time the page mounts it triggers `ChallengesStore.load()` +which calls `GET /api/v1/challenges/board`. It also calls +`GET /api/v1/challenges/status` once to populate the event state +synchronously, then opens the authenticated SSE stream on +`/api/v1/events` for live `solve` frames. + +# How to access (tester steps) + +1. Initialize the instance and sign in (`/login`). +2. Confirm the SPA routes to `/` and redirects to `/challenges`. +3. Configure an event window (`/admin/general`) whose + `eventStartUtc` is in the past and `eventEndUtc` is in the future + so the event state is `running`. + +# Expected behavior + +## Board grid (state = `running`) + +The page renders an `

Challenges

` followed by a horizontal +flex container (`[data-testid="challenges-grid"]`) of one column per +category. + +| Element | Selector | Expected | +|----------------------------------------|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| Category column | `[data-testid="category-column-CR"]` (or any abbreviation) | Header shows the category `icon`, uppercase `abbreviation`, and full `name`; below it is a vertical list of cards. | +| Category column with no challenges | Same | The header renders but the body shows `No challenges yet.` | +| Challenge card | `[data-testid="challenge-card-"]` | Click anywhere on the card to open the modal. | +| Difficulty pill | `.diff.diff-LOW` / `.diff-MEDIUM` / `.diff-HIGH` | Green / yellow / red pill matching the challenge difficulty. | +| Live points | `[data-testid="points-"]` | `livePoints` from the board payload; updates live as `solve` frames arrive. | +| Solve count | text `N solve` / `N solves` | Number of distinct players who have solved this challenge. | +| Solved-by-me check | `.check` (the `✓` glyph) | Appears on the card once the player has solved it; the card border also flips to the success color. | + +Columns are sorted alphabetically by `abbreviation`; cards within a +column are sorted `LOW → MEDIUM → HIGH` then by name (lowercased). + +## Gate panel (state != `running`) + +When the event is in any other state, the grid is hidden and the page +renders `[data-testid="challenges-gate"]`: + +| Event state | Headline | Countdown text (`[data-testid="challenges-countdown"]`) | Helper label | +|---------------|---------------------------|--------------------------------------------------------|-----------------------------------------------------| +| `countdown` | `Event starts in` | `DD:HH:mm` until `eventStartUtc` | `The board will be available once the event is running.` | +| `running` | _(grid shown instead)_ | — | — | +| `stopped` | `Event ended` | `Event ended` | `The board is closed.` | +| `unconfigured`| `Awaiting configuration` | empty | _(none)_ | + +When `secondsToStart` or `secondsToEnd` reach zero, the +`ChallengesPage` triggers a full `window.location.reload()` via +`EventStatusStore.reloadAtCountdownZero(...)` so the SPA re-evaluates +the board visibility from the server snapshot. + +## Challenge modal + +Clicking a card opens `ChallengeModalComponent` +(`[data-testid="challenge-modal-"]`). The modal has a dark +backdrop; clicking the backdrop or pressing `Esc` closes it. + +| Section | Selector / element | Expected | +|------------------------|----------------------------------------------------------|------------------------------------------------------------------------------------------| +| Header | `

` + pills | Challenge name, category abbreviation pill, difficulty pill (`LOW`/`MEDIUM`/`HIGH`), live points pill, and `Close` button. | +| Solved banner | `[data-testid="modal-solved-banner"]` | Renders `You solved this challenge.` when `card.solvedByMe` is true. | +| Awarded banner | `[data-testid="modal-awarded-banner"]` | `Awarded X pts (base Y + rank bonus Z)` after a successful submit. | +| Notice banner | `.notice` | When the event is not running, shows `Submissions are only accepted while the event is running.` and disables the input. | +| Description | `[innerHTML]` from `MarkdownService.render(...)` | Markdown body, links, code, etc. rendered from `card.descriptionMd`. | +| Flag input | `[data-testid="flag-input"]` | Free text input; disabled while submitting or while the event is not running. | +| Solve button | `[data-testid="solve-button"]` | Disabled while submitting. Submits via `ChallengesStore.submit(...)`. | +| Inline error | `[data-testid="modal-error"]` | Renders the friendly message from `messageForSolveError(parseSolveError(...))`. | +| Solvers list | `[data-testid="modal-solvers"]` | Header `Solvers (N)` then a row per solver (`#position`, name, local-time, awarded points). Top three ranks are coloured gold / silver / bronze; subsequent ranks use the success color. | +| Empty solvers state | `No solvers yet.` | Shown when the challenge has no solves yet. | + +## Submitting a flag + +1. Type the flag into `[data-testid="flag-input"]`. +2. Click `[data-testid="solve-button"]` (or press Enter inside the + form). +3. The modal calls `ChallengesStore.submit(challengeId, flag)` which + POSTs `{ flag }` to `/api/v1/challenges/:id/solves`. +4. On `200 solved`: + * The flag input is cleared. + * The `Awarded X pts (base Y + rank bonus Z)` banner appears. + * The solvers list re-renders with the player now at position `N`. + * The corresponding card on the board gets the success border and + `✓`. +5. On `200 already_solved` (idempotent re-submit): + * Same banner + solvers list, but the inline error area shows + `You already solved this challenge.` +6. On `400 FLAG_INCORRECT`: + * Inline error: `Incorrect flag. Try again.` + * Flag input is re-enabled so the player can retry. +7. On `409 EVENT_NOT_RUNNING`: + * Inline error: `Submissions are only accepted while the event is running.` + * The notice banner also reflects the event state. +8. On any other error (network, 5xx): + * The `errorNotificationInterceptor` also pushes a top-level + notification in the SPA's notification store (see + [Notifications](/guides/notifications.md)). + +## Live solve updates + +While the modal is open, the page subscribes to live `solve` frames +for the selected challenge via +`ChallengesStore.registerSolveListener(id, payload => modal.applyLiveSolve(payload))`. +Each `solve` SSE frame: + +* Updates `card.livePoints` and `card.solveCount` on the modal. +* Merges the new solver into the existing `solvers` array (sorted by + `solvedAtUtc` ASC) via `mergeSolveEventIntoSolvers`. +* Updates the matching board card (point value + solve count + the + `✓` if the player is the solver). + +When the user closes the modal, the listener is unregistered; on +destruction of the page the SSE source is closed via +`ChallengesStore.stop()`. + +# Tester flows + +## Running event board + +1. Configure the event window as running; confirm the board renders + the columns/cards described above. +2. Open and close a card — the modal should mount and unmount cleanly + with no console errors; the SSE source should remain connected. +3. Solve a flag and confirm the awarded banner, the new solver row, + the updated board card (`✓` and new live points), and the new + `livePoints` on the same card across all open clients. + +## Countdown / stopped gate + +1. Set `eventStartUtc` in the future; confirm the gate panel shows + `Event starts in` and the `DD:HH:mm` countdown. +2. Wait for the countdown to hit zero; the SPA reloads and now shows + the board. +3. Set `eventEndUtc` in the past; confirm the gate shows + `Event ended`. +4. Delete one of the event-window settings; confirm `Awaiting + configuration` and an empty countdown. + +## Submission errors + +1. Submit an empty string — the modal's local guard skips the call. +2. Submit a wrong flag — expect `Incorrect flag. Try again.`. +3. Submit a correct flag twice — second call returns `already_solved` + and shows both the awarded banner and the + `You already solved this challenge.` inline error. +4. Try to submit while the event is `stopped` — expect + `Submissions are only accepted while the event is running.` and a + matching notice banner. + +# Architecture map + +| File | Responsibility | +|---------------------------------------------------------|------------------------------------------------------------------------------------------------| +| `frontend/src/app/features/challenges/challenges.page.ts` | Smart page; owns the modal lifecycle, gate logic, SSE wiring, and the countdown-zero reload. | +| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, solve listeners, per-card mutation on submit + SSE solve. | +| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service: `getBoard`, `getDetail`, `getEventState`, `submit` (returns `ApiErrorEnvelope`). | +| `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types (`BoardCard`, `SolverRow`, `SolveEventPayload`, etc.) + helpers (`sortColumnsByAbbrev`, `formatDdHhMm`, `parseSolveEvent`, `messageForSolveError`). | +| `frontend/src/app/features/challenges/category-column.component.ts` | Renders one category column (header + cards). | +| `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders one challenge card on the board. | +| `frontend/src/app/features/challenges/challenge-modal.component.ts` | Renders the challenge detail modal, flag form, solvers list, and live-solve updates. | +| `frontend/src/app/core/services/event-status.store.ts` | Anchor-based clock + 4-state event store; reused here for the gate logic. | +| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based SSE transport used to open `/api/v1/events`. | +| `frontend/src/app/core/services/notification.service.ts` | Signal-backed notification store used by `errorNotificationInterceptor`. | +| `tests/frontend/challenges.pure.spec.ts` | Pure helpers (sorting, parsers, friendly error mapping). | +| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, stop(). | +| `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. | +| `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, and `?include=solvers` flag. | +| `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. | +| `tests/backend/challenges-events-sse.spec.ts` | `/api/v1/events` SSE: status + solve frames, dedup, ordering. | +| `tests/backend/challenges-submit-flag.spec.ts` | Submit flow: correct/incorrect flag, idempotent re-submit, event-state guard, race handling. | + +# See also + +- [Challenges Endpoints](/api/challenges.md) +- [Challenge Tables](/database/challenges.md) +- [System Endpoints](/api/system.md) (legacy `/events/status`) +- [Scoreboard Stream](/guides/scoreboard-stream.md) +- [Authenticated Shell](/guides/authenticated-shell.md) (SSE transport) +- [Notifications](/guides/notifications.md) (error interceptor + service) \ No newline at end of file diff --git a/docs/guides/notifications.md b/docs/guides/notifications.md new file mode 100644 index 0000000..f5e5cfb --- /dev/null +++ b/docs/guides/notifications.md @@ -0,0 +1,85 @@ +--- +type: guide +title: Notifications +description: SPA-wide notification store + the global HTTP error interceptor that translates backend error codes and statuses into friendly user-facing messages. +tags: [guide, notifications, error, interceptor, toast, ux] +timestamp: 2026-07-23T00:10:00Z +--- + +# Components + +| Symbol | Path | Responsibility | +|-------------------------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------| +| `NotificationService` | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. | +| `errorNotificationInterceptor` | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Functional HTTP interceptor that catches `HttpErrorResponse`, looks up the error code, and pushes a friendly message into `NotificationService`. | +| `NotificationService.messages` | signal (consumed by future toast UI) | Currently the messages signal is appended in-memory only; the list itself doubles as the integration point for a future toast/banner component. Each push also mirrors the message to the browser console (`console.error` for `error`, `console.info` for `info`). | + +# Wiring + +`frontend/src/main.ts` registers the interceptor **after** `authInterceptor` so +the auth header is already attached when the error interceptor sees +the request: + +```ts +provideHttpClient( + withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor]), +) +``` + +The interceptor: + +1. Catches every `HttpErrorResponse` thrown from any HTTP call. +2. Maps the response to a friendly message using `friendlyMessage(status, err.error)`. +3. Suppresses notifications for endpoints that have their own + user-facing error presentation (see *Suppression rules* below). +4. Pushes the message via `NotificationService.error(...)`. +5. Re-throws the original error so the caller still receives the + `ApiErrorEnvelope` (e.g. `ChallengesApiService.submit` reads + `envelope.code` to render the modal's inline error). + +# Friendly message mapping + +`friendlyMessage(status, body)` resolves in this order: + +| Source | Message | +|-----------------------------------------------------|------------------------------------------------------------------------------| +| `status === 0` (network / CORS / aborted) | `Network error. Please try again.` | +| `body.code` matches `FRIENDLY_MESSAGES` | The mapped friendly text (see table below). | +| `status === 401` or `403` | `Your session is no longer valid.` (unless suppressed). | +| `status === 404` | `Not found.` | +| `status >= 500` | `Server error. Please try again later.` | +| `body.message` (non-empty) | Falls back to that message. | +| otherwise | `Request failed ().` | + +`FRIENDLY_MESSAGES` lookup table: + +| Error code | Friendly text | +|-----------------------|------------------------------------------------------------| +| `EVENT_NOT_RUNNING` | `Submissions are only accepted while the event is running.` | +| `FLAG_INCORRECT` | `Incorrect flag.` | +| `UNAUTHORIZED` | `Your session is no longer valid.` | +| `FORBIDDEN` | `You are not allowed to perform this action.` | +| `NOT_FOUND` | `Not found.` | +| `VALIDATION_FAILED` | `Invalid input.` | + +# Suppression rules + +Some errors are expected and have their own UI. To avoid double-toasting: + +| URL pattern | Status | Why | +|---------------------------------------|--------|---------------------------------------------------------------------------------------------------| +| `/api/v1/auth/login` `/csrf` `/register` | `401` | A failed login or first-fetch of the CSRF cookie produces a `401`; the form already renders the inline error. | +| `/api/v1/challenges/status` | `401` | The challenges page uses the snapshot as a bootstrap hint only — the SSE stream (`/api/v1/events`) is the source of truth and will deliver the next frame. | + +In both cases the interceptor still re-throws the error so callers can +react locally; only the notification push is skipped. + +# See also + +- [Challenges Board](/guides/challenges-board.md) (uses + `errorNotificationInterceptor` for non-flag submission failures) +- [Authenticated Shell](/guides/authenticated-shell.md) (cross-tab + invalidation propagates from a 401 SSE response via + `NotificationService` is **not** triggered — the shell uses its own + peer-invalidation channel) +- [REST API Overview](/api/rest-overview.md) (error envelope) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index e97932b..56f24fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin controls. The docs below are organized by purpose so agents can pull just the slice -they need. Last regenerated 2026-07-22T22:17:11Z. +they need. Last regenerated 2026-07-23T00:10:00Z. # Architecture @@ -50,6 +50,9 @@ they need. Last regenerated 2026-07-22T22:17:11Z. * [Admin Endpoints](/api/admin.md) - Admin-only endpoints covering user management, general settings (`/api/v1/admin/general/*`), and categories (`/api/v1/admin/categories/*`). +* [Challenges Endpoints](/api/challenges.md) - Authenticated challenge board, + detail, flag submission, event-state snapshot, and the combined + `/api/v1/events` SSE stream. * [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE streams, public event-window settings, and the authenticated `/events/status` SSE stream. @@ -80,6 +83,9 @@ they need. Last regenerated 2026-07-22T22:17:11Z. * [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED + countdown, quick-jump tabs, user menu, and SSE lifecycle that frame every signed-in page. +* [Challenges Board](/guides/challenges-board.md) - How a signed-in + player navigates `/challenges`, opens the modal, submits a flag, and + watches live solve updates. * [Change Password](/guides/change-password.md) - How a signed-in user changes their own password, including every error branch and the refresh-token revocation behavior. @@ -103,3 +109,6 @@ they need. Last regenerated 2026-07-22T22:17:11Z. truth for the shell status dot's color in each `EventState`. * [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are broadcast to clients. +* [Notifications](/guides/notifications.md) - The root notification + store + global HTTP error interceptor that translate backend error + codes and statuses into friendly user-facing messages.