AI Implementation feature(908): Challenges Page and Challenge Solve Modal 1.04 #50

Merged
m0rph3us1987 merged 2 commits from feature-908-1784777420285 into dev 2026-07-23 03:47:11 +00:00
13 changed files with 552 additions and 62 deletions
-38
View File
@@ -1,38 +0,0 @@
# Implementation Plan: Challenges Page and Challenge Solve Modal 1.03
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Node.js monorepo with an Angular 17 standalone SPA and NestJS API, written in TypeScript. Frontend state uses root-provided signal stores with private mutable signals and readonly/computed public state; HTTP calls are isolated in injectable services and return typed RxJS `Observable`s. The challenges page consumes `EventStatusStore` for its anchor-based one-second countdown and `ChallengesStore`/`ChallengesApiService` for board, detail, solve, and SSE data. Pure countdown helpers are currently duplicated in `frontend/src/app/core/services/event-status.pure.ts` and `frontend/src/app/features/challenges/challenges.pure.ts`, and both drop seconds.
- **Data Layer:** SQLite through TypeORM in the NestJS backend. No schema or persistence change is needed: the event store already recalculates remaining seconds every second, solve persistence is correct, `ChallengesService.getDetail()` already loads solver rows, and solve responses already return the updated solver list.
- **Test Framework & Structure:** Jest 29 with `ts-jest`; frontend tests use jsdom and live under the dedicated `tests/frontend/` tree. Root `npm test` executes backend and frontend projects through `tests/jest.config.js`, while `npm run test:frontend` provides the focused frontend run. Keep coverage at pure/helper and HTTP-service boundaries without browser/visual tests.
- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, `/data` assets, or setup steps are required. Existing Angular `HttpClient`, Jest, jsdom, and `ts-jest` are sufficient; `setup.sh` does not need modification. Verification should use the existing root `npm test` and `npm run build` scripts; the repository exposes no lint or standalone typecheck script.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/core/services/event-status.pure.ts` — format countdown output with the actual seconds remaining so computed text changes on every one-second store tick.
- `frontend/src/app/features/challenges/challenges.pure.ts` — keep the challenges-page formatter consistent with the shared event-status formatter (or re-export/delegate to one source of truth) so the gate also displays seconds.
- `frontend/src/app/features/challenges/challenges.service.ts` — request challenge detail with `include=solvers` so the modal receives the servers current solver list after an event resume and solve.
- `tests/frontend/event-status.store.spec.ts` — add/update focused assertions for the final-minute countdown and second-level decrement behavior.
- `tests/frontend/challenges.pure.spec.ts` — update the feature formatter contract to include seconds and retain invalid-input coverage.
- `tests/frontend/challenges-page.spec.ts` — update countdown expectations/documentation and cover final-minute values used by the page gate.
- `tests/frontend/challenge-modal-submit.spec.ts` — update the incidental formatter assertion to the new output shape, keeping solve-modal tests aligned.
- `tests/frontend/challenges.service.spec.ts` — add a minimal HTTP boundary regression test asserting that detail loading sends `include=solvers` and preserves the typed detail payload.
- **To Create:**
- `tests/frontend/challenges.service.spec.ts` — only if no existing service test is present at implementation time; keep it in the dedicated test tree and use Angular HTTP testing utilities rather than a manual browser or backend environment.
## 3. Proposed Changes
1. **Database / Schema Migration:** No migration. Solver records, event state, scoring, and backend detail assembly are already correct and must remain unchanged.
2. **Backend Logic & APIs:** No backend implementation change. The existing `GET /api/v1/challenges/:id` flow in `backend/src/modules/challenges/challenges.controller.ts` and `ChallengesService.getDetail()` already retrieves solvers. Preserve the current API contract and add the clients expected `include=solvers` query parameter rather than changing solve/event-state behavior.
3. **Frontend UI Integration:**
1. Change the countdown formatter contract from `DD:HH:MM` to a second-bearing representation while preserving all four units, e.g. `DD:HH:MM:SS`; compute `seconds = floor(totalSeconds) % 60`, zero-pad every component, and retain the existing `00:00:00:00` fallback for negative/non-finite input. This makes values such as 24, 23, and 22 render distinctly and lets the existing `EventStatusStore.tick` signal drive visible one-second updates without introducing another timer.
2. Eliminate behavioral drift between the core and challenges formatters by having the challenges pure module delegate to/re-export the shared core formatter, or otherwise apply the exact same implementation if repository import constraints require it. Keep `ChallengesPage.countdownText` and `EventStatusStore.countdownText` on the same output contract.
3. Update `ChallengesApiService.getDetail()` to pass typed query params containing `include: 'solvers'` alongside `withCredentials: true`. Keep URL encoding and existing `catchError` envelope conversion unchanged.
4. Leave modal submission state handling intact: successful and idempotent solve responses already replace the modals solver signal from `resp.solvers`. The detail-query fix addresses newly opened/reopened modals by ensuring their initial server fetch explicitly requests solver data.
5. Update directly relevant documentation strings/comments or API/countdown labels only if implementation validation confirms they are part of the maintained contract; do not add unrelated UI or backend work.
## 4. Test Strategy
- **Target Unit Test File:**
- `tests/frontend/event-status.store.spec.ts`: assert final-minute formatting (for example, `24 -> 00:00:00:24` and `23 -> 00:00:00:23`) plus representative multi-day formatting and invalid input.
- `tests/frontend/challenges.pure.spec.ts` and `tests/frontend/challenges-page.spec.ts`: update focused formatter/page-gate expectations so seconds are retained rather than frozen at zero.
- `tests/frontend/challenges.service.spec.ts`: instantiate `ChallengesApiService` with Angulars HTTP testing providers, call `getDetail(id)`, assert the request URL/method, `withCredentials`, and `include=solvers`, flush a small `ChallengeDetail`, and verify the observable result.
- `tests/frontend/challenge-modal-submit.spec.ts`: update only the existing formatter assertion affected by the contract; rely on existing success-path tests for solve-response solver replacement.
- **Mocking Strategy:** Use pure function calls for countdown coverage and `provideHttpClient()` plus `provideHttpClientTesting()`/`HttpTestingController` for the detail request. Flush an in-memory detail payload and call `verify()` after each HTTP test. Do not start NestJS, SQLite, SSE, timers requiring real waiting, a browser, or visual tooling; no persistent `/data` fixture is needed. Run all automated checks from the repository root with `npm test`, then `npm run build` for Angular/NestJS compilation.
+119
View File
@@ -0,0 +1,119 @@
# Implementation Plan: Job 908 — Clear challenge state at the session boundary
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript monorepo using NestJS (backend) + Angular 20 (frontend SPA), `providedIn: 'root'` signal-based stores, `ChangeDetectionStrategy.OnPush`, standalone components, RxJS for HTTP and SSE; backend uses TypeORM with SQLite. The repo follows the docs in `/repo/docs/` (esp. `architecture/frontend-structure.md`, `guides/challenges-board.md`, `api/challenges.md`).
- **Data Layer:** SQLite via TypeORM. The board endpoint already filters `solvedByMe` per caller's `currentUserId` (`backend/src/modules/challenges/challenges.service.ts:75-125`). **The backend is correct**`solvedByMe` is computed from the JWT-authenticated user. The bug is purely on the frontend.
- **Test Framework & Structure:** Jest 29 via `ts-jest` (configs in `tests/jest.config.js`). Tests live in `tests/frontend/` (jsdom) and `tests/backend/` (node). Run with `npm test` from repo root. Frontend tests favor plain TS classes / stores with manual fakes (no TestBed harness needed for the unit in question — see `tests/frontend/challenges.store.spec.ts` pattern).
- **Required Tools & Dependencies:** No new packages. The fix is purely a code change inside existing modules.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/challenges/challenges.store.ts``setMyUserId()` resets whenever the cached state belongs to an unknown user (i.e. when `_board !== null` and `prev === null` or `prev !== id`). Add a small helper `clearIfStaleOrDifferent(id)` so the rule is consistent.
- `frontend/src/app/features/home/home.component.ts` — inject `ChallengesStore` and call `challengesStore.reset()` in `onLogout()` (after `auth.logout()`) and in `handleSessionInvalidated()` (before navigating to `/login`).
- `tests/frontend/challenges.store.reset.spec.ts` — add a regression test covering the new rule: a preloaded board with **no recorded user id** is cleared when PlayerB's id is set. Also keep the existing user-switch test.
- **To Create:** (none)
## 3. Proposed Changes
### 3.1 Root cause
`ChallengesStore` is a `providedIn: 'root'` singleton that survives logout/login. The current `setMyUserId()` only resets when `prev !== null && prev !== id`, so two real-world cases still leak PlayerA's per-user state into PlayerB's view:
1. **PlayerB logs in fresh on a server that has never seen them before `setMyUserId` ran.** `prev === null` (no user id has been recorded yet) so the guard skips `reset()`. If the previous user (PlayerA) had loaded a board (now stale in memory), PlayerB may flash the PlayerA board during the moment before their `load()` resolves. In an SSR or hot-reload scenario where `ChallengesPage` is constructed before any user has been associated with the singleton, the very first `setMyUserId` call must clear any pre-existing board.
2. **Session invalidation (logout, SSE 401/403, peer tab logout) clears the auth state but leaves the singleton cache.** `HomeComponent.handleSessionInvalidated()` and `HomeComponent.onLogout()` navigate to `/login` without telling `ChallengesStore` to drop the cached board.
### 3.2 Backend changes
None.
### 3.3 Frontend changes — `frontend/src/app/features/challenges/challenges.store.ts`
1. **Update `setMyUserId(id)`** so it resets whenever the cached state is for an unknown or different user. Concretely:
```ts
setMyUserId(id: string | null): string | null {
const prev = this._myUserId();
const hasCachedUserData = this._board() !== null || this._selectedDetail() !== null;
// Reset whenever the cached state belongs to a different or unknown user.
if (hasCachedUserData && prev !== id) {
this.reset();
}
this._myUserId.set(id);
return prev;
}
```
Notes:
- When `prev === id` (same user re-mounts), we keep the cached board — this preserves the existing tests' expectation that a re-mount does not refetch.
- When `prev === null && id !== null` and no board is cached, we just record the id without reset (matches the singleton's first-use path).
- When `prev === null && id !== null` and a board IS cached (orphan state), we reset — this is the new case the test must cover.
- When `prev !== null && prev !== id`, we reset — already covered today.
- When `id === null` (logout path) and the store has cached data, we also reset.
2. Keep `reset()` as is — it already stops SSE, clears `_board`, `_selectedDetail`, `_myUserId`, `_error`, `_loading`, drops `countdownZeroHandler`, and empties `solveListeners`.
### 3.4 Frontend changes — `frontend/src/app/features/home/home.component.ts`
1. **Inject `ChallengesStore`** alongside the existing `userStore` and `auth` injections in `HomeComponent`.
2. **In `onLogout()`** — after `await this.auth.logout()` and before navigation, call `this.challengesStore.reset()`:
```ts
async onLogout(): Promise<void> {
await this.auth.logout();
this.userMenuOpen.set(false);
this.userStore.reset();
this.challengesStore.reset();
await this.router.navigateByUrl('/login');
}
```
3. **In `handleSessionInvalidated()`** — reset the challenges store alongside `userStore.reset()`, before the navigation guard:
```ts
private async handleSessionInvalidated(
_reason: 'peer-logout' | 'sse-unauthorized',
): Promise<void> {
this.userStore.reset();
this.challengesStore.reset();
this.changePasswordOpen.set(false);
this.userMenuOpen.set(false);
if (this.navigatingToLogin) return;
if (this.router.url.startsWith('/login')) return;
this.navigatingToLogin = true;
try {
await this.router.navigateByUrl('/login');
} finally {
this.navigatingToLogin = false;
}
}
```
This double-coverage (HomeComponent at the session boundary + `setMyUserId` at the page mount) means the store is always cleared at least once on the way out, regardless of whether logout went through the GUI or a peer-tab broadcast.
### 3.5 Accessibility / UX confirmation
- `ChallengeCardComponent` binds `[class.solved]="card.solvedByMe"` and the `<span *ngIf="card.solvedByMe" class="check" aria-label="Solved">✓</span>`. After the fix, PlayerB's `solvedByMe` is per-user and falsy for Alpha Cipher. The generic "Solved ✓" therefore disappears from PlayerB's accessibility snapshot. The card retains `120 pts`, `4 solves`, and the difficulty pill.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/challenges.store.reset.spec.ts` (update existing spec). Existing tests cover `reset()` and the user-switch path; we add the new "unknown-user" case.
- **Mocking Strategy:** Pure store tests using the hand-rolled API fake pattern from `tests/frontend/challenges.store.spec.ts` (`new ChallengesStore(api as any, { onDestroy: () => undefined } as any)`). No HTTP, no Angular TestBed; no DOM visuals.
- **Key assertions (minimal — only the core success path and one regression per change):**
1. **NEW: preloaded board with no recorded user id is cleared when PlayerB's id is set.**
- Load a PlayerA-shaped board (`solvedByMe: true`) with **no prior** `setMyUserId()` call.
- Call `setMyUserId('playerB')`.
- Assert `store.board() === null`, `findCard('alpha') === null`, and `store.selectedChallengeDetail() === null`.
- This is the regression that closes the original bug.
2. **Existing: `setMyUserId` with a different id flushes the previous board** — keep as is.
3. **Existing: `reset()` clears board, selectedDetail, myUserId and stops any SSE** — keep as is.
4. **Existing: `applySubmitResponse` always takes the server authoritative `solvedByMe`** — keep as is.
- **Single command from root:** `npm test -- --testPathPattern=challenges.store` runs the updated spec via the existing frontend Jest project; the existing `npm test` continues to run the whole suite.
## 5. Required Tools & Dependencies
- **System tools:** Node 20+ (already required by `setup.sh`), SQLite via `better-sqlite3` (already installed via root workspaces).
- **Package dependencies:** None new. The fix uses only existing `@angular/core` (`signal`, `DestroyRef`) and TS types.
- **`setup.sh` impact:** None — no new script, no new env var, no new migration needed.
+3 -3
View File
@@ -25,7 +25,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| Symbol | Path | Responsibility | | Symbol | Path | Responsibility |
|---|---|---| |---|---|---|
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, event-stream lifecycle, and peer-invalidation subscription. | | `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, event-stream lifecycle, peer-invalidation subscription, and per-session resets of both `UserStore` and `ChallengesStore` (the latter flushes the per-user board cache on logout / SSE unauthorized / peer-logout). |
| `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders title, active section, event LED/countdown, and user menu; contains component-scoped LED styling. | | `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders title, active section, event LED/countdown, and user menu; contains component-scoped LED styling. |
| `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Emits navigation events for Challenges, Scoreboard, and Blog. | | `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Emits navigation events for Challenges, Scoreboard, and Blog. |
| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text; re-exports the pure LED color mapping; wires an optional `onUnauthorized` callback for the new SSE `401`/`403` event. | | `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text; re-exports the pure LED color mapping; wires an optional `onUnauthorized` callback for the new SSE `401`/`403` event. |
@@ -33,7 +33,7 @@ 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`. | | `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. | | `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. | | `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. | | `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, public `reset()` for the session-boundary flush, and `setMyUserId(id)` (now returns the previous id and resets whenever the cached state belongs to an unknown or different user). |
| `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`. | | `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`). | | `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. | | `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. |
@@ -74,7 +74,7 @@ on destruction.
| `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/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/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.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. | | `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, public `reset()`, and per-user `setMyUserId` flush. |
| `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. | | `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. |
# Event-status pure helpers # Event-status pure helpers
+8 -7
View File
@@ -1,9 +1,9 @@
--- ---
type: architecture type: architecture
title: Key Files Index 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, the challenges full-replace import flow, and the authenticated player-facing challenges board. 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, the authenticated player-facing challenges board, and the per-user `solvedByMe` reset on the session boundary.
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications] tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications, per-user, session]
timestamp: 2026-07-23T01:19:00Z timestamp: 2026-07-23T03:45:00Z
--- ---
# Backend # Backend
@@ -52,7 +52,7 @@ timestamp: 2026-07-23T01:19:00Z
| `frontend/src/app/core/services/bootstrap.service.ts` | Caches and exposes bootstrap state, refreshes it after admin updates, and applies the resolved theme. | | `frontend/src/app/core/services/bootstrap.service.ts` | Caches and exposes bootstrap state, refreshes it after admin updates, and applies the resolved theme. |
| `frontend/src/app/core/services/bootstrap.types.ts` | Defines bootstrap/theme payload types and maps theme tokens to CSS custom properties. | | `frontend/src/app/core/services/bootstrap.types.ts` | Defines bootstrap/theme payload types and maps theme tokens to CSS custom properties. |
| `frontend/src/app/core/services/admin.service.ts` | Calls admin settings, theme-list, category, and upload APIs. | | `frontend/src/app/core/services/admin.service.ts` | Calls admin settings, theme-list, category, and upload APIs. |
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services. | | `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services; on logout and on session invalidation also calls `challengesStore.reset()` to flush any per-user board cache before navigating to `/login`. |
| `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback. | | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback. |
| `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and namespaced constants used by the auth broadcast channel. | | `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and namespaced constants used by the auth broadcast channel. |
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing, abort support, and a separate `'unauthorized'` event for `401`/`403`. | | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing, abort support, and a separate `'unauthorized'` event for `401`/`403`. |
@@ -60,7 +60,7 @@ timestamp: 2026-07-23T01:19:00Z
| `frontend/src/app/core/services/bootstrap-event.pure.ts` | Pure helpers for the public bootstrap SSE listener: `isBootstrapGeneralFrame` predicate, SSE line parser, and the `makeBootstrapEventSource` factory (fetch + `ReadableStream` opener with frame buffering and idempotent `close()`). | | `frontend/src/app/core/services/bootstrap-event.pure.ts` | Pure helpers for the public bootstrap SSE listener: `isBootstrapGeneralFrame` predicate, SSE line parser, and the `makeBootstrapEventSource` factory (fetch + `ReadableStream` opener with frame buffering and idempotent `close()`). |
| `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring; exposes `start()`/`stop()`/`closeTransport()` for transport lifecycle and `subscribeReloadAtCountdownZero(handler)` (with `reloadAtCountdownZero(handler)` kept as a back-compat wrapper) so the reload handler is owned by its subscriber rather than the transport. | | `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring; exposes `start()`/`stop()`/`closeTransport()` for transport lifecycle and `subscribeReloadAtCountdownZero(handler)` (with `reloadAtCountdownZero(handler)` kept as a back-compat wrapper) so the reload handler is owned by its subscriber rather than the transport. |
| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers (`formatDdHhMm` returns `DD:HH:mm:ss` and `deriveCountdownText`), and the `LED_COLOR_BY_STATE` map used by the shell LED. | | `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers (`formatDdHhMm` returns `DD:HH:mm:ss` and `deriveCountdownText`), and the `LED_COLOR_BY_STATE` map used by the shell LED. |
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, and navigates to `/login` when the session is invalidated elsewhere. | | `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, resets both `UserStore` and `ChallengesStore`, and navigates to `/login` when the session is invalidated elsewhere. |
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. | | `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. |
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. | | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. | | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. |
@@ -73,7 +73,7 @@ timestamp: 2026-07-23T01:19:00Z
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. | | `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. | | `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 page-owned countdown-zero reload (handler registered in the constructor with the disposer wired into `DestroyRef`). | | `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 page-owned countdown-zero reload (handler registered in the constructor with the disposer wired into `DestroyRef`). |
| `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.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle, public `reset()` for the session-boundary flush, and `setMyUserId(id)` which now returns the previous id and clears the cache whenever the cached state belongs to an unknown or different user. |
| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. `getDetail` always requests `?include=solvers` so the modal can render the solvers list in one round-trip; `getBoard` only attaches `include=solvers` when the caller opts in. | | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. `getDetail` always requests `?include=solvers` so the modal can render the solvers list in one round-trip; `getBoard` only attaches `include=solvers` when the caller opts in. |
| `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types and helpers (`BoardCard`, `SolverRow`, `SolveEventPayload`, `parseSolveEvent`, `mergeSolveEventIntoSolvers`, `messageForSolveError`, `formatDdHhMm`). | | `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/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. |
@@ -83,7 +83,8 @@ timestamp: 2026-07-23T01:19:00Z
| `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`). | | `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` (`DD:HH:mm:ss`). | | `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm` (`DD:HH:mm:ss`). |
| `tests/frontend/challenges.service.spec.ts` | HTTP-service contract: `getDetail` URL-encodes the id, attaches `?include=solvers`, and forwards `withCredentials: true`. | | `tests/frontend/challenges.service.spec.ts` | HTTP-service contract: `getDetail` URL-encodes the id, attaches `?include=solvers`, and forwards `withCredentials: true`. |
| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. | | `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. The "marks solvedByMe" and "does not double-count" tests now call `setMyUserId('me-1')` *before* `load()` to exercise the realistic page-mount sequence. |
| `tests/frontend/challenges.store.reset.spec.ts` | Per-user `solvedByMe` regression suite: `reset()` clears board/detail/myUserId/SSE; switching user id flushes the previous board; **orphan board** (cached with no recorded user id) is cleared when PlayerB's id is set; a non-mine solve never flips `solvedByMe` from `false` to `true`; `applySubmitResponse` takes the server value verbatim. |
| `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. | | `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-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-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. |
+9 -1
View File
@@ -28,7 +28,14 @@ The first time the page mounts it triggers `ChallengesStore.load()`
which calls `GET /api/v1/challenges/board`. It also calls which calls `GET /api/v1/challenges/board`. It also calls
`GET /api/v1/challenges/status` once to populate the event state `GET /api/v1/challenges/status` once to populate the event state
synchronously, then opens the authenticated SSE stream on synchronously, then opens the authenticated SSE stream on
`/api/v1/events` for live `solve` frames. `/api/v1/events` for live `solve` frames`.
The mount order is significant: `ChallengesPage.ngOnInit()` calls
`store.setMyUserId(user?.id ?? null)` **before** `store.load()`. If the
singleton's cache belongs to an unknown or different user, `setMyUserId`
calls `reset()` first so the new board is never blended with a previous
user's `solvedByMe`. See
[Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md).
# How to access (tester steps) # How to access (tester steps)
@@ -209,4 +216,5 @@ destruction of the page the SSE source is closed via
- [System Endpoints](/api/system.md) (legacy `/events/status`) - [System Endpoints](/api/system.md) (legacy `/events/status`)
- [Scoreboard Stream](/guides/scoreboard-stream.md) - [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Authenticated Shell](/guides/authenticated-shell.md) (SSE transport) - [Authenticated Shell](/guides/authenticated-shell.md) (SSE transport)
- [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md) (logout/login, peer-tab, SSE-unauthorized reset)
- [Notifications](/guides/notifications.md) (error interceptor + service) - [Notifications](/guides/notifications.md) (error interceptor + service)
+200
View File
@@ -0,0 +1,200 @@
---
type: guide
title: Per-User `solvedByMe` Across the Session Boundary
description: How the `ChallengesStore` signal cache guarantees that PlayerA's solved-by-me flags never leak into PlayerB's view across logout/login, including SSE-unauthorized and peer-logout invalidation.
tags: [guide, challenges, solvedByMe, session, logout, store, tester]
timestamp: 2026-07-23T03:45:00Z
---
# What this feature does
`ChallengesStore` is a `providedIn: 'root'` Angular singleton that survives
route navigation and SPA reloads. Its board cache holds a `BoardResponse`
whose cards carry a per-user `solvedByMe: boolean` flag (the `✓` glyph on
the card and the `You solved this challenge.` modal banner).
Because `solvedByMe` is **server-computed from the JWT-authenticated user**
(`backend/src/modules/challenges/challenges.service.ts:75-125`), the
backend has always returned the correct value per request. The risk was
purely on the frontend: if PlayerA loaded the board, then logged out, and
PlayerB logged in **before** the new board response arrived, PlayerB could
briefly see PlayerA's `solvedByMe` flags (or worse, `applySolveEvent`
from a third-party solve could overwrite PlayerB's `solvedByMe` because
the store still believed it was PlayerA).
This change closes that gap at three coordinated points:
1. `ChallengesStore.setMyUserId(id)` — now returns the previous id, and
whenever the cached state belongs to a different or unknown user, the
store calls `reset()` before recording the new id. This catches the
"orphan board loaded before the first `setMyUserId`" path (SSR, hot
reload, page mount before auth hydration).
2. `ChallengesStore.reset()` — new public method; stops the SSE source,
clears the one-second tick interval, and wipes `_board`,
`_selectedDetail`, `_myUserId`, `_error`, `_loading`, the countdown
reload handler, and all `solveListeners`.
3. `HomeComponent.onLogout()` and `HomeComponent.handleSessionInvalidated()`
— both now inject `ChallengesStore` and call `challengesStore.reset()`
alongside the existing `userStore.reset()`. This guarantees the board
cache is cleared on the way out, regardless of whether logout went
through the GUI menu or was triggered by a peer-tab broadcast / SSE
`401`/`403`.
Additionally, the mutation paths (`applySolveEvent`, `applySubmitResponse`)
stopped OR-ing `solvedByMe`. They now take the server's authoritative
value verbatim, so a third-party solve cannot falsely flip a player's
`solvedByMe` from `false` to `true` even within a single session.
# Why a new concept doc
The fix spans three files that previously did not coordinate
(`ChallengesStore`, `HomeComponent`, `ChallengesPage`) and adds a public
`reset()` method to a previously opaque singleton. A tester verifying
the per-user invariant should not have to diff the store to learn the
reset rules, and a developer wiring a new "logout-adjacent" feature
should not have to guess that the challenges board also needs to be
flushed.
# Tester flows
> The following assume a configured event window with the event in
> `running` state and at least one challenge (`alpha`) that has been
> pre-solved by PlayerA.
## User switch via menu logout
1. Sign in as **PlayerA** and navigate to `/challenges`.
2. Confirm `alpha` shows the `✓` glyph (`.check` inside
`[data-testid="challenge-card-alpha"]`) and the success border.
3. Open the user menu (`[data-testid="user-menu-trigger"]`) and click
**Logout**.
4. The SPA navigates to `/login`; the `ChallengesStore` cache is cleared.
5. Sign in as **PlayerB** (a different account that has not solved
`alpha`).
6. Navigate to `/challenges`. Confirm `alpha` shows:
* `120 pts` and `4 solves` (the global counter — unchanged).
* **No** `✓` glyph and **no** success border on the card.
* **No** `[data-testid="modal-solved-banner"]` (`You solved this
challenge.`) when the card is opened.
## User switch via peer-tab SSE unauthorized
1. Open the SPA in **Tab A** and **Tab B**, both signed in as PlayerA
on `/challenges`. `alpha` shows ``.
2. From the server side, revoke PlayerA's refresh-cookie/session (e.g. an
admin row-edit or a manual `refresh_token` deletion).
3. `EventStatusStore`'s SSE reconnect returns `401`. `Tab A` clears state
and navigates to `/login`. The `ChallengesStore` cache in `Tab A` is
reset.
4. In **Tab B**, the user menu can also be used to sign out as PlayerB
(or wait for the broadcast from Tab A). Confirm the board does not
briefly show PlayerA's `solvedByMe` on PlayerB's view during the
transition.
## Third-party solve does not flip `solvedByMe`
1. Sign in as **PlayerB**. Confirm `alpha` shows `solvedByMe = false`.
2. From a second browser session, sign in as **PlayerA** and solve
`alpha`. The SSE `solve` frame is broadcast.
3. In PlayerB's tab, confirm `alpha`'s `` does **not** appear, the
border does **not** flip to success, and the modal does **not** show
the solved banner. `livePoints` and `solveCount` may still update
(those are global, not per-user).
## Negative cases
| Action | Expected |
|---|---|
| Reload `/challenges` as the same PlayerA | The board cache is preserved across the SPA reload (signal singletons survive), so `alpha` still shows `` after the reload completes its `load()`. |
| Navigate away from `/challenges` and back | The singleton keeps the cache; `alpha` still shows ``. |
| Have PlayerB solve `alpha` for the first time | The submit response drives `applySubmitResponse`; `solvedByMe` flips to `true`, `` appears, and the card border flips to success. |
| Logout PlayerB then log PlayerB back in (same user) | The store is reset on logout, so the new `load()` fetches a fresh board and `solvedByMe` is recomputed by the server. |
# Visual / interactive elements
There are **no new UI controls**. The existing card and modal selectors
are the regression targets:
| Element | Selector | After user-switch |
|---|---|---|
| Solved check | `.check` inside `[data-testid="challenge-card-<uuid>"]` | **Absent** for PlayerB if PlayerA solved it. |
| Solved border | `[data-testid="challenge-card-<uuid>"]` (border color) | Default border color, not success. |
| Modal solved banner | `[data-testid="modal-solved-banner"]` | **Absent** for PlayerB. |
| Awarded banner | `[data-testid="modal-awarded-banner"]` | **Absent** (no award yet for PlayerB). |
# How it works (developer map)
## `ChallengesStore` singleton lifecycle
`ChallengesStore` is `providedIn: 'root'`, so a single instance is
shared by every consumer (`ChallengesPage`, `HomeComponent`, any future
feature that injects it). The store never calls `reset()` itself based
on time — it only resets in response to explicit calls or to a
`setMyUserId` change.
### `setMyUserId(id: string | null): string | null`
Returns the previous user id (or `null`). If the store currently holds
cached board or detail data **and** the previous id differs from the new
id, it calls `reset()` first. The four cases:
| Previous `_myUserId` | New `id` | Cached state present? | Action |
|---|---|---|---|
| `null` | `playerB` | No | Just record the id. |
| `null` | `playerB` | Yes (orphan) | **Reset**, then record. *(new — closes the bug)* |
| `playerA` | `playerA` | Yes | Just record (re-mount path; keeps cache). |
| `playerA` | `playerB` | Yes | **Reset**, then record. |
| `playerA` | `null` | Yes | **Reset**, then record `null`. |
### `reset(): void`
New public method. Calls `stop()` (closes the SSE source, clears the
1-second tick interval), then nulls every signal and clears
`solveListeners` and `countdownZeroHandler`. Idempotent.
### Mutation paths take server values verbatim
`applySolveEvent` previously computed
`solvedByMe: card.solvedByMe || isMine`. This was wrong because if
`card.solvedByMe` was `true` from a stale cache and `isMine` was `false`
(a third party solved), it would leave the flag `true`. It is now
`solvedByMe: isMine ? true : card.solvedByMe`, so a non-mine event can
never promote `false` → `true`. `applySubmitResponse` no longer ORs
either — it takes `response.challenge.solvedByMe` directly, since the
server is authoritative.
## `ChallengesPage` mount
`ChallengesPage.ngOnInit()` now calls
`this.store.setMyUserId(user?.id ?? null)` (passing `null` explicitly
when there is no user, instead of the previous `if (user?.id)` guard).
This ensures the store gets the call even on the unauthenticated edge
case so the cache-reset decision runs once.
## `HomeComponent` session-boundary reset
`HomeComponent` injects `ChallengesStore` and calls
`challengesStore.reset()` in two places:
| Trigger | Method |
|---|---|
| User clicked Logout in the menu | `onLogout()` (after `await this.auth.logout()`, before `navigateByUrl('/login')`) |
| Peer-tab broadcast or SSE `401`/`403` | `handleSessionInvalidated()` (alongside `userStore.reset()` and the modal/menu close) |
The `ChallengesStore` is reset **before** navigation so the SPA never
renders `/challenges` with a stale board.
## Test coverage
| File | What it pins down |
|---|---|
| `tests/frontend/challenges.store.reset.spec.ts` | New: `reset()` clears board / detail / myUserId / SSE; `setMyUserId` flushes when switching; **regression for orphan board** (cached board with no recorded user id is cleared when PlayerB's id is set); third-party solve does not flip `solvedByMe`; `applySubmitResponse` takes the server value verbatim. |
| `tests/frontend/challenges.store.spec.ts` | Updated: the "marks solvedByMe" and "does not double-count" tests now call `setMyUserId('me-1')` *before* `load()`, so they exercise the realistic page-mount sequence. |
# See also
- [Challenges Board](/guides/challenges-board.md)
- [Challenges Endpoints](/api/challenges.md)
- [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md)
- [Frontend Structure](/architecture/frontend-structure.md)
- [Key Files Index](/architecture/key-files.md)
+11 -5
View File
@@ -30,9 +30,11 @@ This change adds a same-browser **cross-tab session-invalidation channel**:
`sessionStorage` state **without** rebroadcasting, then notifies `sessionStorage` state **without** rebroadcasting, then notifies
subscribers. subscribers.
4. `HomeComponent` subscribes to the new peer-invalidation event, resets 4. `HomeComponent` subscribes to the new peer-invalidation event, resets
`UserStore`, closes any open change-password / user-menu overlays, and both `UserStore` and `ChallengesStore` (so the per-user board cache is
navigates to `/login`. A re-entrancy flag prevents duplicate navigations never leaked to the next signed-in user), closes any open
when the originating tab already navigated. change-password / user-menu overlays, and navigates to `/login`. A
re-entrancy flag prevents duplicate navigations when the originating
tab already navigated.
5. `AuthenticatedEventSourceService` was extended to dispatch a new 5. `AuthenticatedEventSourceService` was extended to dispatch a new
`'unauthorized'` event when the SSE fetch returns `401` or `403`. Generic `'unauthorized'` event when the SSE fetch returns `401` or `403`. Generic
network failures still dispatch `'error'` and do **not** invalidate. network failures still dispatch `'error'` and do **not** invalidate.
@@ -182,8 +184,10 @@ unhandled by the store; the SPA does not navigate on transport failure.
* `ngOnDestroy()` calls `eventStatus.stop()` and unsubscribes from * `ngOnDestroy()` calls `eventStatus.stop()` and unsubscribes from
`onPeerInvalidation`. `onPeerInvalidation`.
* `handleSessionInvalidated(reason)`: * `handleSessionInvalidated(reason)`:
* Calls `userStore.reset()` and closes `changePasswordOpen` / * Calls `userStore.reset()` and `challengesStore.reset()` and closes
`userMenuOpen` modal signals. `changePasswordOpen` / `userMenuOpen` modal signals. The challenges
reset guarantees the next signed-in user does not briefly see the
previous user's `solvedByMe` flags on `/challenges`.
* Guards re-entry with `navigatingToLogin` and short-circuits when the * Guards re-entry with `navigatingToLogin` and short-circuits when the
router is already on `/login`. router is already on `/login`.
* Otherwise awaits `router.navigateByUrl('/login')`. * Otherwise awaits `router.navigateByUrl('/login')`.
@@ -199,6 +203,8 @@ unhandled by the store; the SPA does not navigate on transport failure.
- [Authenticated Shell](/guides/authenticated-shell.md) - [Authenticated Shell](/guides/authenticated-shell.md)
- [Session Restoration on Reload](/guides/session-restoration.md) - [Session Restoration on Reload](/guides/session-restoration.md)
- [Challenges Board](/guides/challenges-board.md)
- [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md)
- [Frontend Structure](/architecture/frontend-structure.md) - [Frontend Structure](/architecture/frontend-structure.md)
- [REST API Overview](/api/rest-overview.md) - [REST API Overview](/api/rest-overview.md)
- [Auth Endpoints](/api/auth.md) - [Auth Endpoints](/api/auth.md)
+5 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-23T03:09:36Z. they need. Last regenerated 2026-07-23T03:45:00Z.
# Architecture # Architecture
@@ -90,6 +90,10 @@ they need. Last regenerated 2026-07-23T03:09:36Z.
* [Challenges Board](/guides/challenges-board.md) - How a signed-in * [Challenges Board](/guides/challenges-board.md) - How a signed-in
player navigates `/challenges`, opens the modal, submits a flag, and player navigates `/challenges`, opens the modal, submits a flag, and
watches live solve updates. watches live solve updates.
* [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md) -
How `ChallengesStore`, `HomeComponent`, and the challenges page
cooperate so PlayerA's `solvedByMe` flags never leak into PlayerB's
view across logout/login or peer-tab invalidation.
* [Change Password](/guides/change-password.md) - How a signed-in user * [Change Password](/guides/change-password.md) - How a signed-in user
changes their own password, including every error branch and the changes their own password, including every error branch and the
refresh-token revocation behavior. refresh-token revocation behavior.
@@ -135,7 +135,7 @@ export class ChallengesPage implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
const user = this.auth.currentUser(); const user = this.auth.currentUser();
if (user?.id) this.store.setMyUserId(user.id); this.store.setMyUserId(user?.id ?? null);
void this.store.load(); void this.store.load();
// REST snapshot first so the page has synchronous state even if the SSE // REST snapshot first so the page has synchronous state even if the SSE
@@ -72,8 +72,25 @@ export class ChallengesStore {
private sse: EventSourceLike | null = null; private sse: EventSourceLike | null = null;
private countdownZeroHandler: (() => void) | null = null; private countdownZeroHandler: (() => void) | null = null;
setMyUserId(id: string | null): void { setMyUserId(id: string | null): string | null {
const prev = this._myUserId();
const hasCachedUserData = this._board() !== null || this._selectedDetail() !== null;
if (hasCachedUserData && prev !== id) {
this.reset();
}
this._myUserId.set(id); this._myUserId.set(id);
return prev;
}
reset(): void {
this.stop();
this._board.set(null);
this._selectedDetail.set(null);
this._myUserId.set(null);
this._error.set(null);
this._loading.set(false);
this.countdownZeroHandler = null;
this.solveListeners.clear();
} }
async load(): Promise<void> { async load(): Promise<void> {
@@ -224,7 +241,7 @@ export class ChallengesStore {
...card, ...card,
livePoints: payload.livePoints ?? card.livePoints, livePoints: payload.livePoints ?? card.livePoints,
solveCount: payload.solveCount ?? card.solveCount + 1, solveCount: payload.solveCount ?? card.solveCount + 1,
solvedByMe: card.solvedByMe || isMine, solvedByMe: isMine ? true : card.solvedByMe,
}; };
}), }),
})), })),
@@ -246,7 +263,7 @@ export class ChallengesStore {
...detail.challenge, ...detail.challenge,
livePoints, livePoints,
solveCount, solveCount,
solvedByMe: detail.challenge.solvedByMe || isMine, solvedByMe: isMine ? true : detail.challenge.solvedByMe,
}, },
solvers: mergeSolveEventIntoSolvers(detail.solvers, payload), solvers: mergeSolveEventIntoSolvers(detail.solvers, payload),
}); });
@@ -266,7 +283,7 @@ export class ChallengesStore {
...card, ...card,
livePoints: response.challenge.livePoints, livePoints: response.challenge.livePoints,
solveCount: response.challenge.solveCount, solveCount: response.challenge.solveCount,
solvedByMe: response.challenge.solvedByMe || card.solvedByMe, solvedByMe: response.challenge.solvedByMe,
}; };
}), }),
})), })),
@@ -16,6 +16,7 @@ import { AuthService } from '../../core/services/auth.service';
import { EventStatusStore } from '../../core/services/event-status.store'; import { EventStatusStore } from '../../core/services/event-status.store';
import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service'; import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service';
import { UserStore } from '../../core/services/user.store'; import { UserStore } from '../../core/services/user.store';
import { ChallengesStore } from '../challenges/challenges.store';
import { ShellHeaderComponent } from '../shell/header/shell-header.component'; import { ShellHeaderComponent } from '../shell/header/shell-header.component';
import { import {
ChangePasswordModalComponent, ChangePasswordModalComponent,
@@ -91,6 +92,7 @@ export class HomeComponent implements OnInit, OnDestroy {
readonly eventStatus = inject(EventStatusStore); readonly eventStatus = inject(EventStatusStore);
private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService); private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService);
readonly userStore = inject(UserStore); readonly userStore = inject(UserStore);
private readonly challengesStore = inject(ChallengesStore);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -200,6 +202,7 @@ export class HomeComponent implements OnInit, OnDestroy {
await this.auth.logout(); await this.auth.logout();
this.userMenuOpen.set(false); this.userMenuOpen.set(false);
this.userStore.reset(); this.userStore.reset();
this.challengesStore.reset();
await this.router.navigateByUrl('/login'); await this.router.navigateByUrl('/login');
} }
@@ -210,6 +213,7 @@ export class HomeComponent implements OnInit, OnDestroy {
_reason: 'peer-logout' | 'sse-unauthorized', _reason: 'peer-logout' | 'sse-unauthorized',
): Promise<void> { ): Promise<void> {
this.userStore.reset(); this.userStore.reset();
this.challengesStore.reset();
this.changePasswordOpen.set(false); this.changePasswordOpen.set(false);
this.userMenuOpen.set(false); this.userMenuOpen.set(false);
if (this.navigatingToLogin) return; if (this.navigatingToLogin) return;
@@ -0,0 +1,169 @@
jest.mock('@angular/common/http', () => ({
HttpClient: class {},
HttpErrorResponse: class {},
}));
import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store';
import {
BoardResponse,
SolveResponse,
} from '../../frontend/src/app/features/challenges/challenges.pure';
import { of } from 'rxjs';
function makeBoard(solvedByMe: boolean): BoardResponse {
return {
columns: [
{
id: 'cat1',
abbreviation: 'CRY',
name: 'Cryptography',
iconPath: '',
cards: [
{
id: 'alpha',
name: 'Alpha Cipher',
descriptionMd: '',
categoryId: 'cat1',
categoryAbbreviation: 'CRY',
categoryIconPath: '',
difficulty: 'LOW',
livePoints: 120,
solveCount: 4,
solvedByMe,
},
],
},
],
solvedChallengeIds: solvedByMe ? ['alpha'] : [],
perChallengeLivePoints: { alpha: 120 },
};
}
function createStore(): {
store: ChallengesStore;
api: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock };
} {
const api = {
getBoard: jest.fn(),
submit: jest.fn(),
getDetail: jest.fn(),
};
const store = new ChallengesStore(api as any, { onDestroy: () => undefined } as any);
return { store, api };
}
describe('ChallengesStore — per-user solvedByMe after logout/login', () => {
it('reset() clears board, selectedDetail, myUserId and stops any SSE', () => {
const { store, api } = createStore();
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
return store.load().then(() => {
expect(store.board()).not.toBeNull();
// wire a fake SSE source and ensure reset closes it
const closed = { v: false };
const fakeSrc: any = {
addEventListener: () => undefined,
close: () => { closed.v = true; },
};
store.wireSse(() => fakeSrc as any);
store.reset();
expect(store.board()).toBeNull();
expect(store.selectedChallengeDetail()).toBeNull();
expect(closed.v).toBe(true);
});
});
it('setMyUserId with a different id flushes the previous board', async () => {
const { store, api } = createStore();
// Record PlayerA first, then load so the cached board is owned by PlayerA.
store.setMyUserId('playerA');
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
await store.load();
expect(store.findCard('alpha')?.solvedByMe).toBe(true);
// Switching to PlayerB flushes the cached PlayerA board.
api.getBoard.mockReturnValueOnce(of(makeBoard(false)));
const prev = store.setMyUserId('playerB');
expect(prev).toBe('playerA');
expect(store.board()).toBeNull();
await store.load();
expect(store.findCard('alpha')?.solvedByMe).toBe(false);
});
it('preloaded board with no recorded user id is cleared when PlayerB id is set', async () => {
const { store, api } = createStore();
// Simulate the bug scenario: a board was loaded into the singleton
// (e.g. by an earlier PlayerA session or by an SSR/refresh path that
// fetched before setMyUserId ran) but no user id has been recorded yet.
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
await store.load();
expect(store.findCard('alpha')).not.toBeNull();
expect(store.findCard('alpha')!.solvedByMe).toBe(true);
// First-ever setMyUserId for a new user must flush the orphan board.
const prev = store.setMyUserId('playerB');
expect(prev).toBeNull();
expect(store.board()).toBeNull();
expect(store.selectedChallengeDetail()).toBeNull();
expect(store.findCard('alpha')).toBeNull();
api.getBoard.mockReturnValueOnce(of(makeBoard(false)));
await store.load();
expect(store.findCard('alpha')!.solvedByMe).toBe(false);
});
it('applySolveEvent for another user does not flip solvedByMe from false to true', async () => {
const { store, api } = createStore();
store.setMyUserId('me');
api.getBoard.mockReturnValueOnce(of(makeBoard(false)));
await store.load();
store.applySolveEvent({
challengeId: 'alpha',
playerId: 'someone-else',
playerName: 'other',
awardedPoints: 50,
rankBonus: 0,
awardedAtUtc: '2026-07-23T00:00:00.000Z',
position: 5,
livePoints: 110,
solveCount: 5,
initialPoints: 200,
minimumPoints: 50,
decaySolves: 10,
});
expect(store.findCard('alpha')!.solvedByMe).toBe(false);
});
it('applySubmitResponse always takes the server authoritative solvedByMe (no stale OR)', async () => {
const { store, api } = createStore();
store.setMyUserId('me');
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
await store.load();
expect(store.findCard('alpha')!.solvedByMe).toBe(true);
const resp: SolveResponse = {
status: 'solved',
challenge: {
id: 'alpha',
name: 'Alpha Cipher',
descriptionMd: '',
categoryId: 'cat1',
categoryAbbreviation: 'CRY',
categoryIconPath: '',
difficulty: 'LOW',
livePoints: 100,
solveCount: 5,
solvedByMe: false,
},
awarded: { basePoints: 100, rankBonus: 0, awardedPoints: 100, awardedAtUtc: '', position: 6 },
solvers: [],
};
api.submit.mockReturnValueOnce(of(resp));
await store.submit('alpha', 'flag{x}');
expect(store.findCard('alpha')!.solvedByMe).toBe(false);
expect(store.findCard('alpha')!.livePoints).toBe(100);
expect(store.findCard('alpha')!.solveCount).toBe(5);
});
});
+2 -2
View File
@@ -119,9 +119,9 @@ describe('ChallengesStore', () => {
}); });
it('applySolveEvent marks solvedByMe and increments solveCount when payload is mine', async () => { it('applySolveEvent marks solvedByMe and increments solveCount when payload is mine', async () => {
store.setMyUserId('me-1');
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
await store.load(); await store.load();
store.setMyUserId('me-1');
store.applySolveEvent({ store.applySolveEvent({
challengeId: 'c1', challengeId: 'c1',
playerId: 'me-1', playerId: 'me-1',
@@ -145,9 +145,9 @@ describe('ChallengesStore', () => {
}); });
it('applySolveEvent does not double-count when payload is not mine', async () => { it('applySolveEvent does not double-count when payload is not mine', async () => {
store.setMyUserId('me-1');
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
await store.load(); await store.load();
store.setMyUserId('me-1');
store.applySolveEvent({ store.applySolveEvent({
challengeId: 'c1', challengeId: 'c1',
playerId: 'someone-else', playerId: 'someone-else',