AI Implementation feature(863): Challenges Page and Challenge Solve Modal #45

Merged
m0rph3us1987 merged 2 commits from feature-863-1784759774352 into dev 2026-07-23 00:13:59 +00:00
8 changed files with 576 additions and 11 deletions
Showing only changes of commit 77d31e0ee1 - Show all commits
+220
View File
@@ -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": "<category-uuid>",
"abbreviation": "CRY",
"name": "Cryptography",
"iconPath": "/uploads/icons/CRY.png",
"cards": [
{
"id": "<challenge-uuid>",
"name": "RSA Rollers",
"descriptionMd": "...",
"categoryId": "<category-uuid>",
"categoryAbbreviation": "CRY",
"categoryIconPath": "/uploads/icons/CRY.png",
"difficulty": "MEDIUM",
"livePoints": 480,
"solveCount": 4,
"solvedByMe": false,
"solvers": [ /* SolverRow[] when ?include=solvers */ ]
}
]
}
],
"solvedChallengeIds": ["<uuid>", "..."],
"perChallengeLivePoints": { "<uuid>": 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)
+15 -3
View File
@@ -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)
- [Uploads Endpoints](/api/uploads.md)
- [Challenges Endpoints](/api/challenges.md)
+5 -1
View File
@@ -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
+14 -3
View File
@@ -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)
+29 -3
View File
@@ -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)
+198
View File
@@ -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 `<h2>Challenges</h2>` 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-<uuid>"]` | 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-<uuid>"]` | `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-<uuid>"]`). The modal has a dark
backdrop; clicking the backdrop or pressing `Esc` closes it.
| Section | Selector / element | Expected |
|------------------------|----------------------------------------------------------|------------------------------------------------------------------------------------------|
| Header | `<h3>` + 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 | `<em>No solvers yet.</em>` | 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)
+85
View File
@@ -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 (<status>).` |
`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)
+10 -1
View File
@@ -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.