13 KiB
Implementation Plan: Admin Area General Settings and Categories 1.10 — Public Landing Modal Reactivity to registrationsEnabled
1. Architectural Reconnaissance
- Codebase style & conventions: TypeScript everywhere. Monorepo with
backend/(NestJS + TypeORM + SQLite) andfrontend/(Angular standalone components, signals, OnPush change detection). Login is a singleLandingComponentwith amode = 'login' | 'register' | 'closed'modal. Tests live intests/{backend,frontend}/and run vianpm test(jest projects). Pure helpers live next to the component that uses them (e.g.frontend/src/app/features/landing/login-modal.service.ts,frontend/src/app/features/admin/general.pure.ts). - Data Layer: SQLite via TypeORM. The
registrationsEnabledflag is stored in thesettingtable underSETTINGS_KEYS.REGISTRATIONS_ENABLED('true'/'false'). It is read bySystemService.bootstrap()to produce the publicBootstrapPayloadand byAdminGeneralService.getSettings()for the admin view. Theregisterendpoint (POST /api/v1/auth/register) reads the same key to gateREGISTRATIONS_DISABLED. - Test Framework & Structure: Jest (
tests/jest.config.js) with two projects (backend/frontend). Backend tests usesupertest+ an in-memory DB (process.env.DATABASE_PATH=':memory:'). Frontend tests usejsdomandts-jest. Tests are pure (no Angular TestBed); they import only the pure helper functions and the shared event-source contract. Run withnpm testfrom the repo root. - Required Tools & Dependencies: No new runtime dependencies. The fix reuses existing
SseHubService, the publicevent/streamSSE endpoint, the existingBootstrapService.refresh()method, and thelanding-markdown/landing-modalhelpers.setup.shalready installs dev deps (ts-jest,supertest,jsdom,typescript) and rebuildsbetter-sqlite3— no edits needed.
2. Impacted Files
- To Modify:
backend/src/modules/admin/general.service.ts— includeregistrationsEnabledin the emittedgeneralSSE hub payload so listeners can decide whether to refresh bootstrap.backend/src/modules/system/system.controller.ts(eventStream) — flatten thegeneralhub event into a{ topic: 'general', themeKey, registrationsEnabled }shape so the public SSE actually carries the flag instead of dropping it.frontend/src/app/core/services/bootstrap-event.service.ts(NEW) — listed under "To Create" below; the public-facing SSE listener that converts{ topic: 'general' }frames intoBootstrapService.refresh()calls. If the implementer prefers to keep it inline, the wiring lives infrontend/src/app/app.component.tsandfrontend/src/app/features/landing/landing.component.ts.frontend/src/app/app.component.ts— start the public-event listener once at app boot (afterbootstrap.load()) so every route benefits from it.frontend/src/app/features/landing/landing.component.ts— onngOnInit, additionally callbootstrap.refresh()so the modal opens with the latest server state (covers the user who lands on/loginafter the in-memory cache has been mutated by another tab/process).docs/api/system.mdanddocs/guides/landing-page.md— update the public-event flatten description and the "Reacting to admin changes" step so the tester flow matches the new flow.
- To Create:
frontend/src/app/core/services/bootstrap-event.service.ts— pure, provided-in-root service that subscribes to/api/v1/event/stream(via the existingEventSource-style helper orfetch+ReadableStream), filters frames withtopic === 'general', and callsbootstrap.refresh()for each one. Exposesstart()/stop()so the app can hook it to Angular's lifecycle.tests/frontend/bootstrap-event.spec.ts— pure unit tests covering: ageneralframe triggersbootstrap.refresh; a non-generalframe does not; malformed JSON is ignored; the listener is idempotent under repeated server pushes.tests/backend/admin-general-event.spec.ts— backend unit test that assertsAdminGeneralService.updateSettings()emits{ topic: 'general', themeKey, registrationsEnabled }(so the public SSE listener can rely on the shape).tests/backend/events-status-sse-bootstrap.spec.ts— backend integration test that asserts/api/v1/event/streamforwards thegeneralhub payload with thetopicpreserved (independent of the legacy event-status fields).
3. Proposed Changes
3.1 Backend — propagate the general SSE payload with the registrationsEnabled flag
-
backend/src/modules/admin/general.service.ts— change thegeneraltopic emission so it carries both the newthemeKeyand the newregistrationsEnabledboolean, computed from the same payload the handler just persisted:this.hub.emitEvent({ topic: 'general', themeKey: payload.themeKey, registrationsEnabled: payload.registrationsEnabled, });This keeps the existing shape (
topic: 'general') additive — non-breaking for any current consumer. -
backend/src/modules/system/system.controller.ts— rewrite thehub$mapper insideeventStream()so it forwards thegeneraltopic's payload verbatim instead of projecting it onto the legacy event-status fields. The legacy@Public() @Sse('event/stream')consumer (the landing page status badge) must keep receiving its existingstatus/countdownMs/startUtc/endUtc/serverNowUtcshape, so the change is:- Add a
try { … } catch {}wrapper that detects payloads matching{ topic: 'general' }and forwards them as-is (with astartWithso the public stream still emits legacy frames on its 1-second tick). - Keep the existing flattening for non-
generalhub events so the legacy contract is preserved for the scoreboard / event-status consumers. - Include the new
generalpayload in the legacy public stream'sMessageEventso the public tab picks it up.
- Add a
-
No new endpoint — we reuse the existing public
GET /api/v1/event/streamSSE channel and prove via the new test that it now carries thegeneraltopic.
3.2 Frontend — listen to the public SSE and refresh bootstrap on general
-
Create
frontend/src/app/core/services/bootstrap-event.service.ts— a thin, root-provided service that:- Opens
GET /api/v1/event/streamonce viafetch+ReadableStream(mirroringfrontend/src/app/core/services/authenticated-event-source.service.tsbut for a public endpoint, so noAuthorizationheader). - Parses
data: {…}frames, JSON-parses each, and dispatches:- If
payload.topic === 'general', callBootstrapService.refresh()and re-apply the theme (therefresh()already does both — seeBootstrapService.fetchAndApplylines 73-83). - Otherwise, ignore (the frame is the legacy event-status payload, which the existing shell already handles via the authenticated
/events/statusstream).
- If
- Exposes
start()andstop()methods;start()is idempotent (no-op if already running) andstop()aborts the in-flight fetch and clears listeners. - Catches malformed JSON frames and surface errors silently (we never want a broken SSE to break the public landing page).
- Opens
-
Modify
frontend/src/app/app.component.ts— afterawait this.bootstrap.load()andawait this.auth.restoreSession(), callbootstrapEvent.start()so the listener is active for every route, including/login. Stop the listener onDestroyRef.onDestroy(in practice it lives for the life of the SPA, but the explicit teardown avoids leaks in tests). -
Modify
frontend/src/app/features/landing/landing.component.ts— inngOnInit, aftervoid this.landing.refresh(), alsovoid this.bootstrap.refresh(). This is the direct fix for the "reopening the UI modal still exposed no Register control" symptom: even if the cross-tab SSE was either silenced or dropped, the public landing page that the user opens right now fetches the latest bootstrap payload before rendering the modal. TheregistrationsEnabledcomputed will then re-evaluate and the@if (registrationsEnabled())block will render the "Register here" link. -
AuthService.register()andLandingComponent.submitRegister()— no changes are needed since the existing API endpoint already returns201when registrations are enabled, and the existingbuildLoginFailureMessagealready mapsREGISTRATIONS_DISABLEDto the user-facing string. The whole fix is upstream of those.
3.3 Docs — update the contract
docs/api/system.md— replace the "event/streampublic flatten" paragraph with the new contract: the public stream now forwardsgeneraltopic payloads (withtopic,themeKey,registrationsEnabled) interleaved with the legacy event-status frames, and the legacy frames are still emitted on the 1-second tick for the original consumer.docs/guides/landing-page.md— add a "Refresh on admin change" subsection documenting that the public landing page callsbootstrap.refresh()onngOnInitAND on anygeneralSSE frame, so the modal's Register button is consistent with the API.
4. Test Strategy
-
Target Backend Test Files:
tests/backend/admin-general-event.spec.ts— pure service test using a fakeSseHubService(aSubject<any>is captured) that assertsAdminGeneralService.updateSettings()emits{ topic: 'general', themeKey: '...', registrationsEnabled: true }after the admin persists the new value. Mirrors the existingtests/backend/admin-general-service.spec.ts"persists all keys and emits a settings event" test (lines 223-247).tests/backend/events-status-sse-bootstrap.spec.ts— full Nest app integration test (mirrorstests/backend/events-status-sse.spec.ts) that primes a first admin, upserts ageneralevent via the publicSseHubService, subscribes to/api/v1/event/stream, and asserts the SSE delivers adata: {"topic":"general", ...}frame withregistrationsEnabled:trueandthemeKey:classic. Also asserts that the legacy event-status frame is still emitted on the 1-second tick so the contract is not broken.
-
Target Frontend Test Files:
tests/frontend/bootstrap-event.spec.ts— pure unit tests that:- Construct an in-memory EventSource substitute (a
fetchmock returning aReadableStreamof SSE-shaped strings, mirroring theauthenticated-event-source.spec.tsapproach). - Drive a
generalframe (data: {"topic":"general","themeKey":"classic","registrationsEnabled":true}\n\n) and assert the injectedBootstrapService.refresh()has been called. - Drive a non-
generalframe (data: {"status":"running",...}\n\n) and assertrefresh()is NOT called. - Drive a malformed JSON frame and assert the listener does not throw and does not call
refresh(). - Drive two consecutive
generalframes and assertrefresh()is called twice (idempotent under repeated server pushes).
- Construct an in-memory EventSource substitute (a
tests/landing-modal-registration.spec.ts(extendtests/frontend/landing-modal.spec.tsif preferred) — adds a tiny pure helperlandingModalShowsRegister(registrationsEnabled: boolean)that mirrors the@if (registrationsEnabled())predicate and asserts it returnstrueexactly whenregistrationsEnabledistrue. This locks the contract that the modal's switch button is only visible when the bootstrap payload says it should be — i.e. the "modal exposes a Register control iff the API will accept a registration" invariant.
-
Mocking Strategy:
- Backend tests: use the existing
tests/backend/db-helper.tsto seed a single admin viaPOST /api/v1/auth/register-first-admin. TheSseHubServiceis consumed directly (subscribe tohub.event$()) to assert the payload shape without going through the full app — fast and isolated. The full Nest integration test reuses the bootstrapping pattern fromtests/backend/events-status-sse.spec.ts. - Frontend tests: stub
fetchwith aReadableStreamthat emits SSE-encoded frames (mirrorstests/frontend/authenticated-event-source.spec.ts'smakeCaptureableFetch). Inject aBootstrapServicewhoserefresh()is ajest.fn()that resolves to a minimalBootstrapPayload. The AngularDestroyRefis faked with a no-oponDestroycallback so the test owns the listener lifecycle. - No TestBed, no Karma, no visual assertions. All tests run via
npm testfrom the repo root and finish in well under a second each.
- Backend tests: use the existing
-
Reusable test fixtures from
/data: the/data/hipctf/db.sqlitefile is used by the running server, not by the tests. The test fixtures are created on-the-fly bytests/backend/db-helper.tsviainitDb(app)so no persistent state is required. If the implementer prefers to seed from/data, the helper can be extended to copy the SQLite file only whenprocess.env.USE_DATA_DB === 'true'(the plan does not require this).