AI Implementation feature(908): Challenges Page and Challenge Solve Modal 1.04 (#50)
This commit was merged in pull request #50.
This commit is contained in:
@@ -28,7 +28,14 @@ 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.
|
||||
`/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)
|
||||
|
||||
@@ -209,4 +216,5 @@ destruction of the page the SSE source is closed via
|
||||
- [System Endpoints](/api/system.md) (legacy `/events/status`)
|
||||
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||
- [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)
|
||||
@@ -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)
|
||||
@@ -30,9 +30,11 @@ This change adds a same-browser **cross-tab session-invalidation channel**:
|
||||
`sessionStorage` state **without** rebroadcasting, then notifies
|
||||
subscribers.
|
||||
4. `HomeComponent` subscribes to the new peer-invalidation event, resets
|
||||
`UserStore`, closes any open change-password / user-menu overlays, and
|
||||
navigates to `/login`. A re-entrancy flag prevents duplicate navigations
|
||||
when the originating tab already navigated.
|
||||
both `UserStore` and `ChallengesStore` (so the per-user board cache is
|
||||
never leaked to the next signed-in user), closes any open
|
||||
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
|
||||
`'unauthorized'` event when the SSE fetch returns `401` or `403`. Generic
|
||||
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
|
||||
`onPeerInvalidation`.
|
||||
* `handleSessionInvalidated(reason)`:
|
||||
* Calls `userStore.reset()` and closes `changePasswordOpen` /
|
||||
`userMenuOpen` modal signals.
|
||||
* Calls `userStore.reset()` and `challengesStore.reset()` and closes
|
||||
`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
|
||||
router is already on `/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)
|
||||
- [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)
|
||||
- [REST API Overview](/api/rest-overview.md)
|
||||
- [Auth Endpoints](/api/auth.md)
|
||||
|
||||
Reference in New Issue
Block a user