From da967f1a16c382ec007b0e714c7c9b5e5a0d72ef Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 18 Jul 2026 23:53:40 +0300 Subject: [PATCH 01/23] docs: P4.2 live monitor + SSE design spec Co-Authored-By: Claude Fable 5 --- ...26-07-18-panel-p4.2-live-monitor-design.md | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-18-panel-p4.2-live-monitor-design.md diff --git a/docs/superpowers/specs/2026-07-18-panel-p4.2-live-monitor-design.md b/docs/superpowers/specs/2026-07-18-panel-p4.2-live-monitor-design.md new file mode 100644 index 00000000..1a2f93c7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-panel-p4.2-live-monitor-design.md @@ -0,0 +1,223 @@ +# P4.2 — Live monitor + SSE — Design + +Second of three P4 ("event day") sub-cycles, following the merged P4.1 +check-in loop (PR #77). Parent decomposition: +`docs/superpowers/specs/2026-07-17-panel-p4.1-checkin-loop-design.md` §1. +Board reference: **screen 7e "Tablet monitor"** — extracted to +`.superpowers/sdd/p4.2-board-7e-extract.md` (found fully drawn in the +design source, overturning P4.1's "undesigned" note). P4.3 (equipment hub) +follows. + +## 1. Scope + +Ships: + +- A read-only, glanceable **live monitor** at `/events/$eventId/monitor` + (tablet-landscape, chrome-less) per board 7e: totals with progress, + scans/min + peak + estimated-done, per-zone breakdown, per-station list + with liveness, and a read-only recent-scans feed. +- The codebase's **first SSE infrastructure**: a snapshot endpoint + a + thin-ping SSE stream, backed by Postgres LISTEN/NOTIFY behind an + internal `EventBroker` interface. +- **Home LiveStrip upgrade**: "Open monitor" CTA + per-zone mini-breakdown + (board 1c/1d), consuming the same snapshot + stream. + +Explicitly out of scope: equipment hub (P4.3), historical +charts/analytics beyond peak+ETA, sound notifications, a multi-event +monitor, any `web/` changes, an offline mode for the monitor (it is a +connected display by definition), Redis or any new infrastructure +dependency, and a polling fallback for the stream (user decision: genuine +SSE; on stream failure the monitor shows a reconnecting badge over stale +data and retries with backoff). + +## 2. Decisions (all user-approved during brainstorm) + +1. **Genuine SSE**, not tighter polling and not SSE-with-polling-fallback. +2. **Full board scope** including per-zone AND the board's extra metrics + (peak scans/min with time, estimated-done time) beyond the parent + spec's plain-text wishlist. +3. **LiveStrip upgrade is in-scope** for this phase. +4. **Station staleness threshold: 45s** (heartbeat is 20s; ~2× + jitter + margin; board shows the idiom "stale 40 s" as a text label + amber + dot — label REQUIRED, never color-alone, both for WCAG 1.4.1 and + because amber is overloaded with the not_registered verdict color). +5. **SaaS may run multiple backend replicas** → the broadcast must cross + instances: **Postgres LISTEN/NOTIFY** (no new infra; on-prem stays + compose-simple) behind an **`EventBroker` interface seam** so a future + transport swap (e.g. Redis, if SaaS ever needs it for other reasons) + is localized. Redis now = YAGNI with a permanent on-prem operational + cost. +6. **Thin-ping SSE + snapshot refetch**, not rich deltas: the stream + carries only "something changed for event X"; the client re-reads the + snapshot endpoint (coalesced to ≤1/sec). One source of truth; + reconnect = refetch; no client-side delta assembly or desync risk. + +## 3. Backend + +### 3.1 Snapshot endpoint — `GET /api/events/{event_id}/monitor` + +One request returns everything screen 7e renders. **No new migrations** +— all aggregations read existing tables (`attendees`, `checkin_actions`, +`checkin_stations`, `event_zones`). + +``` +{ + "totals": { + "checked_in": 1284, "total": 2410, + "rate_per_min": 8.2, // sliding window, last 5 minutes + "peak": {"rate": 14.6, "at": "…T09:40:00Z"} | null, // max 1-min bucket today + "est_done_at": "…T12:20:00Z" | null // remaining / rate; null when rate ~0 + }, + "zones": [ {"zone_id", "name", "checked_in"}, … ], + "unattributed": 0, // checked-in with no station→zone chain + "stations": [ {"id", "name", "zone_id", "last_seen_at", "checkin_count"}, … ], + "recent": [ …last 20, same row shape as GET …/checkin-actions… ] +} +``` + +- **Per-zone attribution**: for each currently-checked-in attendee, the + zone of the station of their MOST RECENT `checkin` action (`DISTINCT + ON (attendee_id) … ORDER BY created_at DESC`), joined through + `checkin_stations.zone_id`. Checked-in attendees with no `checkin` + action row (legacy `PUT /api/attendees/{id}`, mobile batch, sync) or a + station-less/zone-less station land in `unattributed`. Invariant: + `sum(zones[].checked_in) + unattributed == totals.checked_in` — the + panel shows the unattributed row only when non-zero, so the zone list + always visibly sums to the total (as on the board). +- **rate_per_min**: `checkin` actions in the last 5 minutes / 5 (undo + and reprint rows excluded). **peak**: max count over 1-minute buckets + since the event's first `checkin` action of the (UTC) day, with the + bucket's start time; null when no actions today. **est_done_at**: + `now + (total - checked_in) / rate_per_min` minutes; null when + `rate_per_min` is ~0 or everyone is in. +- `requireEventOwnership` first, house error shapes, openapi-first with + contract tests + coverage ledger, pgxmock asserting real SQL. + +### 3.2 EventBroker + NOTIFY + +- Internal interface (new package `backend/internal/broker`): + `Publish(ctx, eventID uuid.UUID) error`, + `Subscribe(eventID uuid.UUID) (<-chan struct{}, func())` (unsubscribe + func). The SSE handler and store layer depend only on the interface. +- `pgBroker` implementation: ONE long-lived dedicated `LISTEN + checkin_events` connection per backend instance (not from the pool's + rotation — a pinned connection with reconnect-on-error), local fanout + registry keyed by event_id. `Publish` issues + `NOTIFY checkin_events, ''` (payload = just the uuid, far + under the 8KB limit). NOTIFY is fire-and-forget by design — acceptable + for a glanceable feed because reconnect/refetch always resyncs. +- Publish call sites (after the transaction commits, log-don't-fail): + `CheckInAttendee`, `UndoCheckin`, the reprint `InsertCheckinAction`, + and `HeartbeatCheckinStation` (heartbeats update `last_seen_at`, which + the stations card shows; client-side coalescing absorbs the frequency). +- Cross-replica correctness: every instance LISTENs on the shared + Postgres, so a check-in handled by replica A reaches a monitor + connected to replica B. + +### 3.3 SSE stream — `GET /api/events/{event_id}/monitor/stream` + +- The codebase's FIRST streaming endpoint. `requireEventOwnership` on + connect; then `Content-Type: text/event-stream`, no buffering, flush + after every write. +- Emits: an initial `event: hello` on connect, `event: update` (no data + payload beyond a timestamp) whenever the broker signals this event_id, + and a keep-alive comment line (`: ping`) every ~25s so intermediaries + don't idle-close the connection. +- Terminates cleanly on client disconnect / context cancellation + (unsubscribes from the broker; no goroutine leak — this is the main + correctness risk of the phase and gets dedicated tests). +- openapi: documented as a text/event-stream operation; the kin-openapi + `validateResponse` harness cannot validate a stream — the operation is + registered in the coverage ledger via a documented exception path (the + handler test exercises the real stream via httptest instead). Exact + mechanism decided at plan time against the harness's actual shape. + +## 4. Panel + +### 4.1 SSE client — `useMonitorStream(eventId)` + +- `EventSource` cannot set an `Authorization` header, and a token in the + query string leaks into logs — so the client consumes SSE via + **`fetch` + `ReadableStream`** with the normal Bearer header (MSW v2 + can serve streaming bodies, so the existing test stack covers it). +- The hook: connect on mount, parse SSE frames, on `update` → invalidate + the snapshot query **coalesced to at most once per second**; reconnect + with exponential backoff (with jitter, capped) on error/close; expose + `{status: "connecting"|"live"|"reconnecting"}`. Reconnect success → + immediate snapshot refetch (the resync guarantee). +- Snapshot data flows through a normal TanStack Query + (`useMonitorSnapshot(eventId)`) — the stream only invalidates it. + +### 4.2 Monitor screen — `/events/$eventId/monitor` + +- Top-level chrome-less protected route, registered as a TRUE SIBLING of + `eventWorkspaceRoute` — exactly the P4.1 station-route pattern, + including the routing proof (harness with the real competing sibling + + misregistration counter-example). +- Layout per board 7e: header (LIVE pill reflecting stream status, event + name, "Updated Ns ago", Exit → workspace), 2-column grid — left: + Totals card (big count, %, progress bar, rate/peak/ETA line), By-zone + card (label + mini progress + count per zone, unattributed row only + when non-zero); right: Stations card (dot + name + count per station; + stale stations get amber dot + "stale Ns" TEXT label at the 45s + threshold), Last-scans card (read-only rows: `checkin` rows get the allowed + verdict icon/color via existing `verdictClasses`; `undo` and `reprint` + rows are NOT verdicts — they get neutral muted icons (RotateCcw / + Printer in `text-muted-foreground`), never verdict colors; name, zone, + mono time). NO action buttons anywhere. +- A local 1s ticker drives "Updated Ns ago" and staleness labels — + display-only, no network. +- Stream failure → amber "reconnecting" badge in the header; stale data + stays visible; no polling fallback. + +### 4.3 Home LiveStrip + +- `panel/src/features/home/LiveStrip.tsx` switches from + `useEventStats(poll: 15s)` to `useMonitorSnapshot` + `useMonitorStream` + (running events only; 1-2 concurrent streams is fine), gains the + "Open monitor" CTA (board 1c/1d) and the per-zone mini-breakdown. + Visual shape stays a strip — this is a data-source + CTA upgrade, not + a redesign. + +## 5. Correctness + +- **No goroutine/subscription leaks**: every SSE connection's broker + subscription is released on disconnect; the broker's LISTEN connection + reconnects on error without losing local subscribers (they just miss + pings during the gap — next ping or manual refetch resyncs; document). +- **Coalescing**: N rapid NOTIFYs → ≤1 snapshot refetch/sec per client. +- **Zone invariant**: zones + unattributed always sum to checked_in + (single SQL statement computes both sides where feasible; contract + test pins the invariant). +- **Verdict-color discipline**: only `verdictClasses`; the amber + staleness dot is always paired with a text label. +- i18n EN+RU (flat keys, `monitor*` prefix), keyParity, real Russian. +- web/ untouched. + +## 6. Testing + +- Backend: contract tests for the snapshot (empty event, zones+ + unattributed invariant, zero-rate nulls, peak bucketing); pgxmock with + real SQL text for every new aggregation; broker unit tests against the + interface with a fake; SSE handler tests via httptest reading the real + stream (hello frame, update-on-publish, keep-alive, clean shutdown, no + leak via goroutine-count or done-channel assertions); pgBroker kept + thin — its LISTEN loop is documented as not coverable without real + Postgres (known repo gap; env-gated `TEST_DATABASE_URL` integration + test optional, per the migration-000020 precedent). +- Panel: MSW streaming tests for `useMonitorStream` (connect, ping → + coalesced invalidate, reconnect+backoff, resync-refetch); routed + monitor-page test (rail-less proof + all four cards render from a + seeded snapshot; staleness label at threshold; reconnecting badge); + LiveStrip regression (CTA presence, zone rows, no polling remnants). +- Gates: the standard full set (panel typecheck/test/lint/build, ui + tests, schema drift, backend tests+lint) per Global Constraints of + every prior phase. + +## 7. Open items deliberately deferred + +- Rich SSE deltas, event replay (`Last-Event-ID`), per-scan animations. +- Multi-event/tenant-wide monitor. +- Historical analytics dashboards. +- Any zone-attribution backfill for pre-P4.1 check-ins (unattributed is + the honest bucket for them). From 046ccaad758af898e20bc367dac87199bd6c721a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 18 Jul 2026 23:58:13 +0300 Subject: [PATCH 02/23] docs: P4.2 live monitor implementation plan Co-Authored-By: Claude Fable 5 --- .../2026-07-18-panel-p4.2-live-monitor.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md diff --git a/docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md b/docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md new file mode 100644 index 00000000..62fc3476 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md @@ -0,0 +1,199 @@ +# P4.2 — Live monitor + SSE — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the read-only live monitor (board 7e) at `/events/$eventId/monitor` — one snapshot endpoint + the codebase's first SSE stream (thin pings over Postgres LISTEN/NOTIFY behind an `EventBroker` seam) — plus the Home LiveStrip upgrade (Open-monitor CTA, per-zone mini-breakdown, stream-driven refresh). + +**Architecture:** Backend-first: broker package (Task 1) → snapshot aggregations in the store (Task 2) → snapshot endpoint (Task 3) → SSE endpoint + publish wiring (Task 4). Then panel bottom-up: data layer + SSE frame parser (Task 5) → `useMonitorStream` (Task 6) → monitor route/page left column (Task 7) → right column + liveness + reconnect badge (Task 8) → LiveStrip upgrade (Task 9) → final sweep (Task 10). **Zero migrations** — every aggregation reads existing tables. + +**Tech Stack:** Go/Echo + pgx v5.10 (LISTEN/NOTIFY via a dedicated `pgx.Conn`) + kin-openapi harness; React 19, TanStack Router/Query, `$api`, MSW v2 (streaming bodies), `@idento/ui` (`Progress`, `verdictClasses`). + +**Spec:** `docs/superpowers/specs/2026-07-18-panel-p4.2-live-monitor-design.md`. +**Board extract (Tasks 7-9 implementers MUST read):** `.superpowers/sdd/p4.2-board-7e-extract.md`. + +## Global Constraints + +- Branch: this worktree branch (already created; spec committed on it). Backend tasks: openapi-first → kin-openapi contract test (`validateResponse` + coverage ledger under `OPENAPI_COVERAGE=1`) → `npm run generate:api -w panel` committed in the task that first needs the types. Backend gates every backend task: `cd backend && OPENAPI_COVERAGE=1 go test ./... -count=1` AND `golangci-lint run ./internal/...`. pgxmock asserts REAL SQL text. +- Panel gates every panel task: `npm run typecheck -w panel && npm test -w panel` + `cd panel && npx eslint .`. MSW for ALL HTTP tests. **router.tsx regression guard** (`/register` guards byte-for-byte; only ADD routes). +- i18n EN+RU keyParity, flat keys, `monitor*` prefix (LiveStrip additions may reuse `home*` prefix where they extend existing strip copy), real Russian. +- **Verdict colors only via `@idento/ui` `verdictClasses`**; `undo`/`reprint` feed rows are NOT verdicts — neutral muted icons (`RotateCcw`/`Printer`, `text-muted-foreground`). Amber staleness dot ALWAYS paired with a text label ("stale Ns"). +- **Station staleness threshold: 45s** (constant `STATION_STALE_MS = 45_000`). Heartbeat cadence stays 20s (P4.1, untouched). +- **Thin-ping SSE**: `update` events carry no state; the client re-reads the snapshot, coalesced to ≤1 refetch/sec. Reconnect always triggers an immediate snapshot refetch. NO polling fallback — on stream failure show a reconnecting badge over stale data and keep retrying with backoff. +- **No new dependencies** (backend or panel). No Redis. Zero migrations. +- web/ is frozen. Do NOT touch P4.1's check-in flow/station/settings surfaces except the four publish call sites named in Task 4. +- Commit after every green step. Do NOT commit `.superpowers/sdd/progress.md`. + +## Plan-time facts (verified 2026-07-18) + +1. `LiveStrip.tsx` (137 lines, `panel/src/features/home/LiveStrip.tsx`): `RunningCard` uses `useEventStats(event.id, {poll: true})` (`events/hooks.ts:8-15`, `refetchInterval: 15_000`) and reads `stats.data?.zone_stats` — which is ALWAYS undefined today (that field only appears with a `?zone=` param the hook never sends; it's the unrelated P2 zone-access-control breakdown). Task 9 removes that dead read. +2. pgx v5.10.0; `PGStore.db` is an unexported narrow `dbConn` interface — the pool is NOT accessible outside the store. The broker therefore owns its own connections: `NewPGBroker(ctx, dbURL)` creates one dedicated `pgx.Conn` for the LISTEN loop (reconnect-on-error) and a 2-conn `pgxpool` for NOTIFY publishes (the LISTEN conn blocks in `WaitForNotification` and cannot also publish). +3. `handler.Handler` struct has exactly one field (`Store store.Store`, `handler.go:19-26`); ~70 existing tests construct `&Handler{Store: fs}` literals. **Reconciliation vs spec §3.2:** publishes happen at the HANDLER level (after the store call returns = after commit), via a new nil-safe `Broker` field — `if h.Broker != nil { h.Broker.Publish(...) }` — so every existing test literal stays valid and the store layer stays decoupled. Spec's named operations (check-in, undo, reprint, heartbeat) are unchanged in meaning. +4. Server wiring: `backend/main.go:46` (`store.NewPGStore`), `:68` (`handler.New(pgStore)`), `:128` (`e.Start`). Broker is constructed in main.go and set on the handler; `New(s)` signature stays as-is (field assignment after construction) to avoid touching test constructors. +5. Coverage harness: `validateResponse(t, method, url, rec)` (`openapi_contract_test.go:56`) validates the body against openapi.yaml then marks `coverage[method+" "+route.Path]`. A streamed response can't go through `ValidateResponse` — Task 4 adds a tiny test-file helper that marks the SSE route covered directly (`coverageMu.Lock(); coverage["GET /api/events/:event_id/monitor/stream"] = true; ...`) with a comment explaining the documented exception; the real assertions come from the httptest stream reads. +6. Panel auth token: `getToken()` from `panel/src/shared/api/session.ts` (already used by `http.ts:89` middleware). API base URL: `getApiBaseUrl()` idiom in `http.ts:13` (`window.__ENV__?.API_URL || import.meta.env.VITE_API_URL || "http://localhost:8008"`) — export/reuse it for the fetch-streaming client rather than re-deriving. +7. Router (`panel/src/app/router.tsx`): `eventCheckinRoute` (:150) and `eventCheckinLaunchRoute` (:167) are top-level siblings inside `protectedLayoutRoute.addChildren` (:174-182). `eventMonitorRoute` registers identically. The routing-proof harness pattern lives in `StationPage.test.tsx` (buildCorrectRouter with the REAL competing sibling + misregistration counter-example) — mirror it. +8. `@idento/ui` exports `Progress` (index.ts:22) and `verdictClasses`; MSW is v2.15 (streaming `ReadableStream` bodies supported). +9. `GetCheckinActions(ctx, eventID, limit)` exists (max/default 50, `ORDER BY created_at DESC, id DESC`) — the snapshot's `recent` reuses it with limit 20; no new feed query needed. +10. Existing per-minute-bucket precedent: none — Task 2's bucket SQL (`date_trunc('minute', created_at)`) is new; pgxmock with real SQL text as always. + +--- + +### Task 1: Backend — broker package (interface + memBroker + pgBroker) + +**Files:** +- Create: `backend/internal/broker/broker.go`, `backend/internal/broker/mem_broker.go`, `backend/internal/broker/pg_broker.go`, `backend/internal/broker/broker_test.go` + +**Interfaces:** +- Produces: `type Broker interface { Publish(ctx context.Context, eventID uuid.UUID) error; Subscribe(eventID uuid.UUID) (<-chan struct{}, func()) }`. `Subscribe` returns a 1-buffered channel (a pending signal coalesces — a slow consumer never blocks the fanout; drop-if-full) and an idempotent unsubscribe func. +- Produces: `NewMemBroker() *MemBroker` — pure in-process fanout (map[uuid][]chan + mutex). Used by ALL handler tests AND as the fanout core inside pgBroker. +- Produces: `NewPGBroker(ctx context.Context, dbURL string) (*PGBroker, error)` — wraps a MemBroker for local fanout; `Publish` execs `SELECT pg_notify('checkin_events', $1::text)` via its own small pgxpool (MaxConns 2); a background goroutine holds ONE dedicated `pgx.Conn` in a `LISTEN checkin_events` / `WaitForNotification` loop, parses the uuid payload, and forwards into the MemBroker fanout; on connection error it logs, backs off (1s→30s cap), reconnects, and re-LISTENs. `Close()` stops the loop and closes connections. Malformed payloads are logged and skipped. + +- [ ] **Step 1: Failing tests (MemBroker + fanout semantics).** Subscribe/Publish delivers to the right event's subscribers only; two subscribers both signaled; unsubscribe stops delivery and is idempotent; slow consumer (unread channel) does not block Publish (drop-if-full, buffered-1 coalescing proven: N publishes while unread → exactly 1 pending signal); Publish to an event with no subscribers is a no-op. pgBroker: payload-parse unit test on the exported/internal payload-handling func (valid uuid forwards to fanout; garbage logged-skipped) — the LISTEN loop itself is documented as not coverable without real Postgres (known repo gap, same posture as migration-000020's constraints). +- [ ] **Step 2:** Implement all three files. `go test ./internal/broker/... -count=1` green. +- [ ] **Step 3:** Full backend gates. **Commit:** `feat(backend): event broker — interface, in-memory fanout, pg LISTEN/NOTIFY transport` + +--- + +### Task 2: Backend — monitor snapshot aggregations (store) + +**Files:** +- Create: `backend/internal/store/pg_store_monitor.go`, `backend/internal/store/pg_store_monitor_test.go` +- Modify: `backend/internal/store/interface.go` + +**Interfaces:** +- Consumes: existing tables only (`attendees`, `checkin_actions`, `checkin_stations`, `event_zones`); existing `GetCheckinActions` for the recent feed (NOT re-implemented here). +- Produces (on the Store interface): + - `GetMonitorCounts(ctx, eventID uuid.UUID) (total int, checkedIn int, err error)` — `COUNT(*)` and `COUNT(*) FILTER (WHERE checkin_status)` over non-deleted attendees, ONE query. + - `GetMonitorZones(ctx, eventID uuid.UUID) (zones []MonitorZoneCount, unattributed int, err error)` where `MonitorZoneCount{ZoneID uuid.UUID; Name string; CheckedIn int}`. ONE statement: CTE over currently-checked-in attendees × their most recent `checkin` action (`DISTINCT ON (ca.attendee_id) … ORDER BY ca.attendee_id, ca.created_at DESC, ca.id DESC`) joined through `checkin_stations.zone_id` to `event_zones` (zones listed in `event_zones.order_index` order, zero-count zones INCLUDED); checked-in attendees with no action row / station-less action / zone-less station count into `unattributed`. Invariant `sum(zones)+unattributed == checkedIn` holds by construction (both sides derived from the same checked-in row set in one statement). + - `GetMonitorMinuteBuckets(ctx, eventID uuid.UUID, since time.Time) ([]MinuteBucket, error)` where `MinuteBucket{Minute time.Time; Count int}` — `date_trunc('minute', created_at)` GROUP BY over `checkin_actions WHERE action='checkin' AND created_at >= $2`, ascending. Caller passes UTC start-of-day (peak) — rate reuses the same buckets. + - `GetMonitorStations(ctx, eventID uuid.UUID) ([]MonitorStation, error)` where `MonitorStation{ID uuid.UUID; Name string; ZoneID *uuid.UUID; LastSeenAt time.Time; CheckinCount int}` — stations LEFT JOIN a per-station `checkin`-action count, ordered by name. + +- [ ] **Step 1: Failing pgxmock tests** asserting the REAL SQL text of all four (incl. the `DISTINCT ON` + FILTER clauses, the zones/unattributed single-statement shape, the bucket truncation) plus behavior rows: zero-attendee event → (0,0); zones include zero-count rows; unattributed picks up a checked-in row with no matching action. +- [ ] **Step 2:** Implement. **Step 3:** Gates. **Commit:** `feat(backend): monitor snapshot aggregations` + +--- + +### Task 3: Backend — snapshot endpoint `GET /api/events/{event_id}/monitor` + +**Files:** +- Create: `backend/internal/handler/monitor.go`, `backend/internal/handler/monitor_rates.go`, `backend/internal/handler/monitor_rates_test.go`, `backend/internal/handler/openapi_contract_monitor_p4_test.go` +- Modify: `backend/openapi.yaml`, `backend/internal/handler/handler.go` (route), `backend/internal/handler/testsupport_test.go` (fakeStore func-fields) + +**Interfaces:** +- Consumes: Task 2's four store methods + `GetCheckinActions(ctx, eventID, 20)`. +- Produces: `GetEventMonitor` handler → the spec §3.1 response verbatim (`totals{checked_in,total,rate_per_min,peak{rate,at}|null,est_done_at|null}`, `zones[]`, `unattributed`, `stations[]`, `recent[]`). `requireEventOwnership` first. +- Produces (pure, in `monitor_rates.go`, unit-tested without HTTP): `computeRates(buckets []store.MinuteBucket, now time.Time, total, checkedIn int) (ratePerMin float64, peak *PeakRate, estDoneAt *time.Time)` — rate = sum of buckets within `[now-5m, now)` / 5 (one decimal); peak = max bucket today with its start time (nil when no buckets); estDoneAt = `now + remaining/rate` (nil when `rate < 0.1` or `checkedIn >= total`). + +- [ ] **Step 1: openapi** — `MonitorSnapshot` + sub-schemas, all fields required (`peak`/`est_done_at` nullable), `additionalProperties: false`; house error shapes. +- [ ] **Step 2: Failing tests.** `monitor_rates_test.go`: empty buckets → (0, nil, nil); single bucket now → rate counts it; peak picks the max with timestamp; rate<0.1 → nil ETA; done event → nil ETA; window excludes buckets older than 5m. Contract: seeded fakeStore → 200 matching schema with the zones+unattributed invariant asserted; empty event → zeros + nulls; foreign event → 404. +- [ ] **Step 3:** Implement; regen `panel/src/shared/api/schema.d.ts` + commit; panel typecheck. **Step 4:** Gates. **Commit:** `feat(backend): monitor snapshot endpoint` + +--- + +### Task 4: Backend — SSE stream + broker wiring + publishes + +**Files:** +- Create: `backend/internal/handler/monitor_stream.go`, `backend/internal/handler/monitor_stream_test.go` +- Modify: `backend/internal/handler/handler.go` (add `Broker broker.Broker` field + route), `backend/main.go` (construct PGBroker, assign, Close on shutdown), `backend/internal/handler/checkin.go` (2 publish sites), `backend/internal/handler/attendee_printed.go` (1), `backend/internal/handler/checkin_stations.go` (heartbeat, 1), `backend/openapi.yaml` + +**Interfaces:** +- Consumes: Task 1's `Broker` (handlers use the nil-safe field; tests inject `NewMemBroker()`). +- Produces: `GET /api/events/{event_id}/monitor/stream` (`GetEventMonitorStream`): `requireEventOwnership` → headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`) → write `event: hello\ndata: {}\n\n` + flush → loop `select` on broker channel (→ `event: update\ndata: {"at":""}\n\n` + flush), 25s ticker (→ `: ping\n\n` + flush), and `c.Request().Context().Done()` (→ unsubscribe + return nil). Flush via `c.Response().Flush()`. +- Produces: nil-safe publishes AFTER each successful store call in: `StationCheckin` (ONLY when outcome == `"checked_in"` — an `already_checked_in`/`blocked` response changes no monitor-visible state), `UndoCheckin` (on 200), `MarkAttendeePrinted` (only when a reprint feed row was actually logged), `HeartbeatCheckinStation` (on 204). Pattern: `if h.Broker != nil { if err := h.Broker.Publish(ctx, eventID); err != nil { log.Printf(...) } }` — log-don't-fail, never alters the HTTP response. + +- [ ] **Step 1: openapi** — document the stream op (text/event-stream, prose describing hello/update/ping frames). +- [ ] **Step 2: Failing tests** (fake broker = `NewMemBroker()` on the Handler): httptest with a cancellable request context reading the live recorder body — hello frame arrives first; `Publish` → an `update` frame; context cancel → handler returns and the subscription is released (assert via a second Publish not panicking + goroutine-leak check with `runtime.NumGoroutine` delta or a done-channel); foreign event → 404 before any stream headers. Publish-site tests: each of the four handlers with a MemBroker asserts exactly-one/zero publishes per the rules above (e.g. `already_checked_in` → 0, heartbeat 204 → 1). Coverage: mark the stream route covered via the documented direct-map exception (plan-time fact 5). +- [ ] **Step 3:** Implement + wire main.go (construct after store, `defer broker.Close()`). **Step 4:** Gates. **Commit:** `feat(backend): SSE monitor stream + check-in event publishes` + +--- + +### Task 5: Panel — data layer + SSE frame parser + +**Files:** +- Create: `panel/src/features/monitor/hooks.ts`, `hooks.test.tsx`, `panel/src/features/monitor/parseSse.ts`, `parseSse.test.ts` +- Modify (export only if not already exported): `panel/src/shared/api/http.ts` (`getApiBaseUrl`) + +**Interfaces:** +- Consumes: Task 3's generated types (schema.d.ts already regenerated). +- Produces: `useMonitorSnapshot(eventId)` (`$api.useQuery` on `GET /api/events/{event_id}/monitor`, no refetchInterval) + `MONITOR_SNAPSHOT_KEY(eventId)` (house `[method, path, init]` shape, same discipline as `READINESS_KEY`). +- Produces: `createSseParser(onEvent: (evt: {event: string; data: string}) => void): (chunk: string) => void` — incremental, buffer-carrying parser for `event:`/`data:` frames split on `\n\n`; ignores comment lines (`: ping`); tolerates frames split across chunks. + +- [ ] **Step 1: Failing tests.** Parser: whole frame; frame split mid-line across two chunks; comment-only chunk → no events; two frames in one chunk → two events; data-only frame defaults event to `message`. Hooks: MSW URL/param capture; key helper prefix-invalidation. +- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor data layer + SSE frame parser` + +--- + +### Task 6: Panel — `useMonitorStream` + +**Files:** +- Create: `panel/src/features/monitor/useMonitorStream.ts`, `useMonitorStream.test.tsx` + +**Interfaces:** +- Consumes: `parseSse` + `MONITOR_SNAPSHOT_KEY` (Task 5), `getToken()` from `shared/api/session`, `getApiBaseUrl()`. +- Produces: `useMonitorStream(eventId: string): {status: "connecting" | "live" | "reconnecting"}` — on mount: `fetch(`${base}/api/events/${eventId}/monitor/stream`, {headers: {Authorization: `Bearer ${token}`, Accept: "text/event-stream"}, signal})` → read `res.body` via `getReader()` + TextDecoder → feed `parseSse`. `hello`/first frame → status `live`. `update` → invalidate `MONITOR_SNAPSHOT_KEY(eventId)` COALESCED to ≤1/sec (trailing-edge: a burst schedules exactly one invalidation). Error/close → status `reconnecting`, retry with exponential backoff (1s base, ×2, 30s cap, ±25% jitter) — on successful reconnect, IMMEDIATE snapshot invalidation (resync guarantee) then `live`. AbortController on unmount + eventId change (full reset per scope change — P4.1 round-3 lesson). No polling fallback. + +- [ ] **Step 1: Failing MSW streaming tests** (deferred-promise controlled `ReadableStream` bodies — the Task-13/P4.1 deferred-handler precedent): connect → hello → `live`; update frame → snapshot query invalidated (subscribed-observer refetch proof, the house idiom); 3 updates within 300ms → exactly 1 extra snapshot fetch (coalescing); stream close → `reconnecting` → next connect attempt observed + immediate snapshot refetch on success; unmount aborts (no further fetches); eventId change closes the old stream and opens the new URL. +- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor SSE stream hook with coalescing and backoff` + +--- + +### Task 7: Panel — monitor route + page shell + Totals/By-zone + +**Files:** +- Create: `panel/src/features/monitor/MonitorPage.tsx`, `MonitorPage.test.tsx`, `panel/src/features/monitor/TotalsCard.tsx`, `panel/src/features/monitor/ZonesCard.tsx` +- Modify: `panel/src/app/router.tsx` (add `eventMonitorRoute` — top-level sibling in `protectedLayoutRoute.addChildren`, path `/events/$eventId/monitor`), `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `useMonitorSnapshot`, `useMonitorStream` (status for the header pill), `@idento/ui` `Progress`/`Card`/`Button`. +- Produces: chrome-less page per board 7e — header (LIVE pill driven by stream status: green pulsing when `live`; event name via the existing event query; "Updated Ns ago" from the snapshot's `dataUpdatedAt` + a local 1s ticker; Exit → `/events/$eventId`), 2-col grid (`1.15fr 1fr`), left column: `TotalsCard` (`{checked_in} / {total}`, percent, `Progress`, rate line `X/min · peak Y at HH:MM · est. done HH:MM` — peak/ETA segments omitted when null), `ZonesCard` (per-zone label + mini `Progress` + count; unattributed row labeled via `monitorUnattributed` ONLY when > 0). Right column renders placeholder regions Task 8 fills. Loading → Skeletons; error → explicit error state (never fabricated zeros). + +- [ ] **Step 1: Failing routed tests** (mirror `StationPage.test.tsx`'s harness incl. the REAL competing `eventWorkspaceRoute` sibling + misregistration counter-example): renders rail-less at `/events/evt-1/monitor`; totals/percent/rate line from seeded snapshot; null peak/ETA → segments absent; unattributed row hidden at 0, shown at >0; zones sum visibly equals total. Router guard untouched. +- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor route, page shell, totals and zones cards` + +--- + +### Task 8: Panel — Stations/Last-scans cards + liveness + reconnect badge + +**Files:** +- Create: `panel/src/features/monitor/StationsCard.tsx`, `panel/src/features/monitor/RecentFeedCard.tsx`, `panel/src/features/monitor/liveness.ts`, `liveness.test.ts` +- Modify: `MonitorPage.tsx` (+test), `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: snapshot `stations[]`/`recent[]`, `verdictClasses` (checkin rows → `allowed` icon/color ONLY), `useMonitorStream` status. +- Produces: `liveness.ts`: `STATION_STALE_MS = 45_000`, `stationStaleness(lastSeenAt: string, now: number): {stale: boolean; seconds: number}`. `StationsCard`: dot (green = fresh, amber = stale) + name + count; stale rows ALSO show the text label `monitorStaleFor` ("stale {{s}} s") — never color-alone. `RecentFeedCard`: read-only rows (verdict/neutral icon per Global Constraints, name, zone name when derivable from the station's zone, mono `HH:MM:SS`); NO buttons. Header gains the amber `monitorReconnecting` badge whenever stream status is `reconnecting` (stale data stays rendered). The page's existing 1s ticker drives staleness labels + "Updated Ns ago". + +- [ ] **Step 1: Failing tests.** `liveness.test.ts`: 44.9s → fresh, 45.1s → stale with seconds. Page: station beyond threshold shows amber + "stale Ns" text; fresh station has no label; checkin row uses `verdictClasses.allowed` classes, undo/reprint rows use muted neutral (assert NOT verdict classes); reconnecting status → badge shown, snapshot content still present. +- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor stations liveness and read-only recent feed` + +--- + +### Task 9: Panel — Home LiveStrip upgrade + +**Files:** +- Modify: `panel/src/features/home/LiveStrip.tsx`, `LiveStrip.test.tsx` (or the existing home test file covering it — locate first), `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `useMonitorSnapshot`, `useMonitorStream` (RunningCard only), monitor route path for the CTA. +- Produces: `RunningCard` drops `useEventStats(poll)` + the dead `zone_stats` read; counters/progress come from `useMonitorSnapshot(event.id)` kept fresh by `useMonitorStream(event.id)`; adds the "Open monitor" `Button` (Link to `/events/$eventId/monitor`) beside the existing check-in CTA (board 1c/1d) and a compact per-zone line (name + count, unattributed only when >0). `UpcomingCard` untouched. `useEventStats` itself stays (other consumers may exist — verify with grep; if LiveStrip was the only `poll:true` consumer, leave the hook's poll option in place regardless, out of scope to remove). + +- [ ] **Step 1: Failing tests.** RunningCard renders snapshot counts; "Open monitor" links to the monitor route; zone mini-line renders; no `/api/events/{event_id}/stats` request fires from RunningCard anymore (MSW negative assertion); UpcomingCard regression untouched. +- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): live-strip on monitor snapshot + open-monitor CTA + zone breakdown` + +--- + +### Task 10: Final — i18n sweep + gates + cross-checks + spec walk + +- [ ] **Step 1: i18n sweep.** Every `monitor*` key referenced, EN/RU parity (keyParity test), real Russian, no hardcoded strings in touched files. +- [ ] **Step 2: Full gates.** Panel typecheck/test/eslint/build; `npm test -w packages/ui` untouched-green; `npm run generate:api -w panel` zero drift; backend `OPENAPI_COVERAGE=1 go test ./... -count=1` + `golangci-lint run ./internal/...`. +- [ ] **Step 3: Cross-checks.** `git diff main -- panel/src/app/router.tsx` = ONLY `eventMonitorRoute` added; backend diff = broker package + monitor files + the four publish-site touches + main.go + openapi.yaml ONLY; `git diff main -- web/` EMPTY; zero new entries in either package.json/go.mod dependency lists; zero files under `backend/migrations/`. +- [ ] **Step 4: Spec walk.** §3.1 (snapshot + invariant + rate semantics) → Tasks 2-3; §3.2 (broker/NOTIFY/replica-correctness) → Tasks 1+4; §3.3 (stream frames/keep-alive/clean shutdown) → Task 4; §4.1 (fetch-streaming auth, coalescing, backoff, no polling fallback) → Task 6; §4.2 (7e layout, 45s staleness with text label, verdict discipline, read-only) → Tasks 7-8; §4.3 (LiveStrip) → Task 9; §5 invariants each pinned by a named test; §6 testing map holds. Mark plan checkboxes; controller appends the ledger entry. +- [ ] **Step 5: Commit.** `chore(panel): P4.2 final verification sweep` + +--- + +## Self-review notes + +- Spec §3.2 says publishes live in the store methods; plan-time fact 3 documents the deliberate reconciliation to handler-level publishes (nil-safe field, after-commit semantics preserved, ~70 test constructors untouched). The reviewer of Task 4 should treat handler-level as the governing choice. +- `StationCheckin` publishes ONLY on outcome `checked_in` (an `already_checked_in`/`blocked` response changes no monitor-visible state); heartbeat publishes on every 204 (it changes `last_seen_at`, which the stations card renders); reprint publishes only when the feed row was actually logged. +- Names used consistently across tasks: `Broker`/`MemBroker`/`PGBroker`/`Publish`/`Subscribe`; store `GetMonitorCounts`/`GetMonitorZones`/`GetMonitorMinuteBuckets`/`GetMonitorStations` + `MonitorZoneCount`/`MinuteBucket`/`MonitorStation`; handler `GetEventMonitor`/`GetEventMonitorStream`/`computeRates`; panel `useMonitorSnapshot`/`MONITOR_SNAPSHOT_KEY`/`createSseParser`/`useMonitorStream`/`STATION_STALE_MS`/`stationStaleness`; route `eventMonitorRoute`. +- Deliberate scope guards: no migrations, no new deps, no polling fallback, no rich deltas/Last-Event-ID, web/ frozen, P4.1 surfaces untouched except the four named publish sites. From 86260d4c1bddd8e02f299981072b7566fddefcb7 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 00:10:51 +0300 Subject: [PATCH 03/23] =?UTF-8?q?feat(backend):=20event=20broker=20?= =?UTF-8?q?=E2=80=94=20interface,=20in-memory=20fanout,=20pg=20LISTEN/NOTI?= =?UTF-8?q?FY=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds internal/broker: the Broker seam (Publish/Subscribe with 1-buffered drop-if-full delivery and idempotent unsubscribe), MemBroker as a pure in-process fanout used by handler tests, and PGBroker which wraps a MemBroker and bridges cross-replica delivery via a dedicated LISTEN pgx.Conn plus a small (MaxConns 2) notify pgxpool, with reconnect-on-error backoff (1s doubling to 30s). Lays the foundation for the P4.2 live monitor SSE endpoint (Task 4). --- backend/internal/broker/broker.go | 37 ++++ backend/internal/broker/broker_test.go | 289 +++++++++++++++++++++++++ backend/internal/broker/mem_broker.go | 76 +++++++ backend/internal/broker/pg_broker.go | 239 ++++++++++++++++++++ 4 files changed, 641 insertions(+) create mode 100644 backend/internal/broker/broker.go create mode 100644 backend/internal/broker/broker_test.go create mode 100644 backend/internal/broker/mem_broker.go create mode 100644 backend/internal/broker/pg_broker.go diff --git a/backend/internal/broker/broker.go b/backend/internal/broker/broker.go new file mode 100644 index 00000000..b2857c21 --- /dev/null +++ b/backend/internal/broker/broker.go @@ -0,0 +1,37 @@ +// Package broker provides the event-fanout infrastructure backing the +// P4.2 live monitor's SSE stream — the codebase's first pub/sub seam. Its +// entire job is: let a Publish(eventID) call wake up every goroutine +// currently Subscribed to that event, without ever blocking the publisher. +// +// This package deliberately does not import the store package (and must +// never be imported BY it either) — it knows nothing about attendees, +// check-ins, tenants, or Postgres schema beyond a bare event UUID. That +// keeps the seam reusable and testable in isolation: MemBroker needs no +// database at all, and PGBroker's Postgres-specific logic is confined to +// pg_broker.go. +package broker + +import ( + "context" + + "github.com/google/uuid" +) + +// Broker is the seam the P4.2 monitor SSE handler (and check-in/undo/ +// reprint/heartbeat publish sites) depend on. +type Broker interface { + // Publish signals that eventID's monitor-visible state has changed. + // Implementations must never block on a slow or absent subscriber. + Publish(ctx context.Context, eventID uuid.UUID) error + + // Subscribe registers interest in eventID's changes. The returned + // channel is 1-buffered: a pending signal coalesces with any later + // Publish while it remains unread (drop-if-full) — a slow consumer + // therefore never blocks the fanout and never accumulates an unbounded + // backlog; it just eventually reads one signal and re-syncs from + // scratch (this is what the SSE handler pairs with a full snapshot + // re-fetch on every "update" frame, so a coalesced signal never means + // stale data). The returned unsubscribe func is idempotent and safe to + // call concurrently with Publish and with itself. + Subscribe(eventID uuid.UUID) (<-chan struct{}, func()) +} diff --git a/backend/internal/broker/broker_test.go b/backend/internal/broker/broker_test.go new file mode 100644 index 00000000..c32f764e --- /dev/null +++ b/backend/internal/broker/broker_test.go @@ -0,0 +1,289 @@ +package broker + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" +) + +// --- MemBroker: routing ------------------------------------------------ + +func TestMemBroker_PublishOnlyDeliversToItsOwnEvent(t *testing.T) { + b := NewMemBroker() + eventA := uuid.New() + eventB := uuid.New() + + chA, unsubA := b.Subscribe(eventA) + defer unsubA() + chB, unsubB := b.Subscribe(eventB) + defer unsubB() + + if err := b.Publish(context.Background(), eventA); err != nil { + t.Fatalf("Publish: %v", err) + } + + select { + case <-chA: + default: + t.Fatal("expected eventA subscriber to receive a signal") + } + + select { + case <-chB: + t.Fatal("eventB subscriber must not receive eventA's publish") + default: + } +} + +func TestMemBroker_PublishSignalsAllSubscribersOfSameEvent(t *testing.T) { + b := NewMemBroker() + eventID := uuid.New() + + ch1, unsub1 := b.Subscribe(eventID) + defer unsub1() + ch2, unsub2 := b.Subscribe(eventID) + defer unsub2() + + if err := b.Publish(context.Background(), eventID); err != nil { + t.Fatalf("Publish: %v", err) + } + + for i, ch := range []<-chan struct{}{ch1, ch2} { + select { + case <-ch: + default: + t.Fatalf("subscriber %d did not receive a signal", i) + } + } +} + +func TestMemBroker_PublishWithNoSubscribersIsNoop(t *testing.T) { + b := NewMemBroker() + + if err := b.Publish(context.Background(), uuid.New()); err != nil { + t.Fatalf("Publish to an event with no subscribers should be a no-op, got error: %v", err) + } +} + +// --- MemBroker: unsubscribe --------------------------------------------- + +func TestMemBroker_UnsubscribeStopsDelivery(t *testing.T) { + b := NewMemBroker() + eventID := uuid.New() + + ch, unsubscribe := b.Subscribe(eventID) + unsubscribe() + + if err := b.Publish(context.Background(), eventID); err != nil { + t.Fatalf("Publish: %v", err) + } + + select { + case <-ch: + t.Fatal("unsubscribed channel should not receive a signal") + default: + } +} + +func TestMemBroker_UnsubscribeIsIdempotent(t *testing.T) { + b := NewMemBroker() + eventID := uuid.New() + + _, unsubscribe := b.Subscribe(eventID) + + unsubscribe() + unsubscribe() // must not panic +} + +func TestMemBroker_UnsubscribeIsIdempotentConcurrently(t *testing.T) { + b := NewMemBroker() + eventID := uuid.New() + + _, unsubscribe := b.Subscribe(eventID) + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + unsubscribe() + }() + } + wg.Wait() +} + +func TestMemBroker_UnsubscribeOneLeavesOthersOfSameEventIntact(t *testing.T) { + b := NewMemBroker() + eventID := uuid.New() + + ch1, unsub1 := b.Subscribe(eventID) + ch2, unsub2 := b.Subscribe(eventID) + defer unsub2() + + unsub1() + + if err := b.Publish(context.Background(), eventID); err != nil { + t.Fatalf("Publish: %v", err) + } + + select { + case <-ch1: + t.Fatal("unsubscribed subscriber must not receive a signal") + default: + } + select { + case <-ch2: + default: + t.Fatal("remaining subscriber should still receive a signal") + } +} + +// --- MemBroker: drop-if-full / non-blocking coalescing ------------------- + +// TestMemBroker_PublishNeverBlocksOnFullChannel is the load-bearing +// concurrency property of the whole P4.2 phase: a slow/absent consumer +// must never make Publish block, and a burst of publishes while unread +// must coalesce into exactly one pending signal (buffered-1, drop-if-full). +func TestMemBroker_PublishNeverBlocksOnFullChannel(t *testing.T) { + b := NewMemBroker() + eventID := uuid.New() + + ch, unsubscribe := b.Subscribe(eventID) + defer unsubscribe() + + const publishes = 5 + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < publishes; i++ { + if err := b.Publish(context.Background(), eventID); err != nil { + t.Errorf("Publish %d: %v", i, err) + } + } + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Publish blocked on an unread, full subscriber channel") + } + + // Exactly one pending signal despite N publishes. + select { + case <-ch: + default: + t.Fatal("expected exactly one pending signal after N publishes while unread") + } + select { + case <-ch: + t.Fatal("expected the channel to be empty after draining the single coalesced signal") + default: + } +} + +// --- MemBroker: concurrency / race safety -------------------------------- + +func TestMemBroker_ConcurrentSubscribePublishUnsubscribe(t *testing.T) { + b := NewMemBroker() + eventIDs := []uuid.UUID{uuid.New(), uuid.New(), uuid.New()} + + const iterations = 200 + var wg sync.WaitGroup + + // Subscribers: repeatedly subscribe, optionally drain, then unsubscribe + // (twice, to also exercise idempotency under concurrent Publish). + for w := 0; w < 5; w++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + for i := 0; i < iterations; i++ { + eventID := eventIDs[(worker+i)%len(eventIDs)] + ch, unsubscribe := b.Subscribe(eventID) + select { + case <-ch: + default: + } + unsubscribe() + unsubscribe() + } + }(w) + } + + // Publishers: hammer Publish concurrently across the same event set. + for w := 0; w < 5; w++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + for i := 0; i < iterations; i++ { + eventID := eventIDs[(worker+i)%len(eventIDs)] + if err := b.Publish(context.Background(), eventID); err != nil { + t.Errorf("Publish: %v", err) + } + } + }(w) + } + + wg.Wait() +} + +// --- Broker interface compliance ----------------------------------------- + +func TestMemBroker_SatisfiesBrokerInterface(t *testing.T) { + var _ Broker = NewMemBroker() +} + +// --- pgBroker payload handling (unit-testable without live Postgres) ----- + +func TestHandleNotification_ValidUUIDForwardsToFanout(t *testing.T) { + mem := NewMemBroker() + eventID := uuid.New() + + ch, unsubscribe := mem.Subscribe(eventID) + defer unsubscribe() + + handleNotification(mem, eventID.String()) + + select { + case <-ch: + default: + t.Fatal("expected a valid uuid payload to forward into the fanout") + } +} + +func TestHandleNotification_MalformedPayloadIsLoggedAndSkipped(t *testing.T) { + mem := NewMemBroker() + eventID := uuid.New() + + ch, unsubscribe := mem.Subscribe(eventID) + defer unsubscribe() + + // Garbage payload must not panic, must not forward anything to ANY + // subscriber (there's no valid event to attribute it to). + handleNotification(mem, "not-a-uuid") + + select { + case <-ch: + t.Fatal("malformed payload must not forward any signal") + default: + } +} + +func TestHandleNotification_EmptyPayloadIsLoggedAndSkipped(t *testing.T) { + mem := NewMemBroker() + eventID := uuid.New() + + ch, unsubscribe := mem.Subscribe(eventID) + defer unsubscribe() + + handleNotification(mem, "") + + select { + case <-ch: + t.Fatal("empty payload must not forward any signal") + default: + } +} diff --git a/backend/internal/broker/mem_broker.go b/backend/internal/broker/mem_broker.go new file mode 100644 index 00000000..70535941 --- /dev/null +++ b/backend/internal/broker/mem_broker.go @@ -0,0 +1,76 @@ +package broker + +import ( + "context" + "sync" + + "github.com/google/uuid" +) + +var _ Broker = (*MemBroker)(nil) + +// MemBroker is a pure in-process Broker: Publish fans a signal out to +// every channel currently Subscribed to that event, entirely in memory +// (map[uuid.UUID][]chan struct{} guarded by a mutex). It is used directly +// by every handler test that needs a Broker, and PGBroker wraps one to do +// its local, in-process delivery once a Postgres NOTIFY has round-tripped +// back to this process. +type MemBroker struct { + mu sync.Mutex + subs map[uuid.UUID]map[chan struct{}]struct{} +} + +// NewMemBroker constructs an empty MemBroker. +func NewMemBroker() *MemBroker { + return &MemBroker{ + subs: make(map[uuid.UUID]map[chan struct{}]struct{}), + } +} + +// Publish signals every current subscriber of eventID. Delivery is +// drop-if-full: a subscriber whose 1-buffered channel already holds an +// unread signal simply coalesces — Publish never blocks, never spawns a +// goroutine, and never queues beyond that single buffered slot. Publishing +// to an event with no subscribers is a no-op. Publish itself never fails +// (the error return exists solely to satisfy Broker — PGBroker's Publish +// can fail on the network). +func (b *MemBroker) Publish(_ context.Context, eventID uuid.UUID) error { + b.mu.Lock() + defer b.mu.Unlock() + + for ch := range b.subs[eventID] { + select { + case ch <- struct{}{}: + default: + // Already has a pending, unread signal — coalesce. + } + } + return nil +} + +// Subscribe registers a new 1-buffered channel for eventID. See +// Broker.Subscribe for the coalescing and idempotent-unsubscribe contract. +func (b *MemBroker) Subscribe(eventID uuid.UUID) (<-chan struct{}, func()) { + ch := make(chan struct{}, 1) + + b.mu.Lock() + if b.subs[eventID] == nil { + b.subs[eventID] = make(map[chan struct{}]struct{}) + } + b.subs[eventID][ch] = struct{}{} + b.mu.Unlock() + + var once sync.Once + unsubscribe := func() { + once.Do(func() { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.subs[eventID], ch) + if len(b.subs[eventID]) == 0 { + delete(b.subs, eventID) + } + }) + } + + return ch, unsubscribe +} diff --git a/backend/internal/broker/pg_broker.go b/backend/internal/broker/pg_broker.go new file mode 100644 index 00000000..d53bd42a --- /dev/null +++ b/backend/internal/broker/pg_broker.go @@ -0,0 +1,239 @@ +package broker + +import ( + "context" + "log" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// listenStatement/notifyStatement are the exact SQL PGBroker issues on its +// two connection resources: LISTEN on the dedicated conn, NOTIFY through +// the small pool. Both sides agree on the same bare-UUID payload +// convention; see handleNotification. +const ( + listenStatement = "LISTEN checkin_events" + notifyStatement = "SELECT pg_notify('checkin_events', $1::text)" +) + +// Backoff bounds for the LISTEN connection's reconnect loop: starts at 1s +// and doubles on each consecutive failure up to a 30s cap. +const ( + reconnectBackoffInitial = 1 * time.Second + reconnectBackoffMax = 30 * time.Second +) + +var _ Broker = (*PGBroker)(nil) + +// PGBroker is a Broker backed by Postgres LISTEN/NOTIFY. It wraps a +// MemBroker for its local (in-process) fanout: Publish sends a NOTIFY +// through the broker's own small pool, and a single dedicated LISTEN +// connection running in a background goroutine forwards every notification +// it receives — including ones this very process published — back into +// the MemBroker. That round trip through Postgres is what lets a Publish +// issued on one replica reach Subscribers registered on any OTHER +// replica's PGBroker. +// +// PGStore's connection pool is not reachable from here — it's an +// unexported field behind a narrow dbConn interface, by design (plan-time +// fact 2 of docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md) +// — so PGBroker owns two connection resources of its own: the dedicated +// LISTEN pgx.Conn (which spends its whole life blocked in +// WaitForNotification and therefore cannot also run the NOTIFY query), and +// a MaxConns-2 pgxpool used exclusively for Publish. +type PGBroker struct { + mem *MemBroker + pool *pgxpool.Pool + cancel context.CancelFunc + done chan struct{} +} + +// NewPGBroker connects a dedicated LISTEN connection and a small (MaxConns +// 2) notify pool against dbURL, issues the initial LISTEN, starts the +// background forwarding loop, and returns. It does not retry on initial +// connection failure — that decision belongs to the caller (main.go, at +// process startup); listenLoop's own reconnect-with-backoff only takes +// over once the loop is already running. +func NewPGBroker(ctx context.Context, dbURL string) (*PGBroker, error) { + poolCfg, err := pgxpool.ParseConfig(dbURL) + if err != nil { + return nil, err + } + poolCfg.MaxConns = 2 + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + + conn, err := pgx.Connect(ctx, dbURL) + if err != nil { + pool.Close() + return nil, err + } + if _, err := conn.Exec(ctx, listenStatement); err != nil { + closeConn(conn) + pool.Close() + return nil, err + } + + // The loop's lifetime is governed by Close(), not by the ctx passed in + // here (which may be request- or startup-scoped and could be cancelled + // long before the broker should stop). + loopCtx, cancel := context.WithCancel(context.Background()) + b := &PGBroker{ + mem: NewMemBroker(), + pool: pool, + cancel: cancel, + done: make(chan struct{}), + } + + go b.listenLoop(loopCtx, conn, dbURL) + + return b, nil +} + +// Publish executes `SELECT pg_notify('checkin_events', $1::text)` through +// the notify pool. It never touches the LISTEN connection (which is +// permanently blocked inside WaitForNotification and cannot also send a +// query on the same wire). +func (b *PGBroker) Publish(ctx context.Context, eventID uuid.UUID) error { + _, err := b.pool.Exec(ctx, notifyStatement, eventID.String()) + return err +} + +// Subscribe delegates directly to the wrapped MemBroker for local, +// in-process delivery. See Broker.Subscribe for the coalescing / +// idempotent-unsubscribe contract. +func (b *PGBroker) Subscribe(eventID uuid.UUID) (<-chan struct{}, func()) { + return b.mem.Subscribe(eventID) +} + +// Close stops the LISTEN loop (via context cancellation), waits for it to +// fully exit — which also closes the LISTEN connection — and then closes +// the notify pool. Intended to be called once (e.g. via defer in main.go, +// matching store.PGStore.Close's convention, pg_store.go); calling it +// again happens to be harmless too, since context.CancelFunc, reading from +// an already-closed channel, and pgxpool.Pool.Close are all safe to repeat. +func (b *PGBroker) Close() { + b.cancel() + <-b.done + b.pool.Close() +} + +// listenLoop owns the single dedicated LISTEN connection for the rest of +// PGBroker's life: it blocks in WaitForNotification, forwards each payload +// via handleNotification (the loop's only unit-testable logic — see that +// func's doc comment), and on any connection error closes the dead conn, +// backs off (1s, doubling to a 30s cap), reconnects, and re-issues LISTEN +// — indefinitely, until ctx is cancelled by Close. +// +// This loop itself is NOT unit-tested: exercising it requires a real +// Postgres connection to NOTIFY against (WaitForNotification blocks on a +// live wire-protocol read, and pgx.Conn has no fake/in-memory +// substitute), and this repo has no real-database CI harness for that — +// the same accepted posture as +// pg_store_checkin_composite_fk_integration_test.go's TEST_DATABASE_URL- +// gated (skip-not-fail) test. handleNotification carries everything here +// that IS unit-tested. +func (b *PGBroker) listenLoop(ctx context.Context, conn *pgx.Conn, dbURL string) { + defer close(b.done) + defer func() { closeConn(conn) }() + + for { + notification, err := conn.WaitForNotification(ctx) + if err != nil { + if ctx.Err() != nil { + // Close() was called; shut down cleanly. + return + } + + log.Printf("broker: LISTEN connection error, reconnecting: %v", err) + closeConn(conn) + + var ok bool + conn, ok = b.reconnect(ctx, dbURL) + if !ok { + // ctx was cancelled while reconnecting. + return + } + continue + } + + handleNotification(b.mem, notification.Payload) + } +} + +// reconnect retries pgx.Connect + LISTEN against dbURL with exponential +// backoff (1s doubling to a 30s cap) until it succeeds or ctx is +// cancelled. The bool return is false only when ctx was cancelled first. +func (b *PGBroker) reconnect(ctx context.Context, dbURL string) (*pgx.Conn, bool) { + backoff := reconnectBackoffInitial + for { + select { + case <-ctx.Done(): + return nil, false + case <-time.After(backoff): + } + + conn, err := pgx.Connect(ctx, dbURL) + if err != nil { + log.Printf("broker: reconnect failed: %v", err) + backoff = nextBackoff(backoff) + continue + } + if _, err := conn.Exec(ctx, listenStatement); err != nil { + log.Printf("broker: re-LISTEN failed: %v", err) + closeConn(conn) + backoff = nextBackoff(backoff) + continue + } + + return conn, true + } +} + +func nextBackoff(cur time.Duration) time.Duration { + next := cur * 2 + if next > reconnectBackoffMax { + return reconnectBackoffMax + } + return next +} + +func closeConn(conn *pgx.Conn) { + if conn == nil { + return + } + if err := conn.Close(context.Background()); err != nil { + log.Printf("broker: error closing LISTEN connection: %v", err) + } +} + +// handleNotification parses payload as a uuid.UUID and forwards it into +// mem's fanout. It is factored out of listenLoop specifically so it's +// unit-testable without a live Postgres connection — see listenLoop's doc +// comment for why the loop itself isn't. A malformed (non-UUID, including +// empty) payload is logged and skipped, never propagated as an error or a +// panic: one bad NOTIFY payload must not take down the loop. +func handleNotification(mem *MemBroker, payload string) { + eventID, err := uuid.Parse(payload) + if err != nil { + log.Printf("broker: skipping malformed notification payload %q: %v", payload, err) + return + } + + // MemBroker.Publish never actually returns a non-nil error today (see + // its doc comment); checked here defensively so a future change can't + // silently drop a forwarded signal without at least being logged. + if err := mem.Publish(context.Background(), eventID); err != nil { + log.Printf("broker: local fanout publish failed for %s: %v", eventID, err) + } +} From 90f58f3a59a1795c7e3b98e9a121997361c02b35 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 00:33:23 +0300 Subject: [PATCH 04/23] feat(backend): monitor snapshot aggregations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the store-layer aggregations backing the P4.2 live monitor snapshot: GetMonitorCounts (total/checked-in via one COUNT(*) FILTER query), GetMonitorZones (per-zone checked-in counts + unattributed via one DISTINCT ON + UNION ALL statement, so sum(zones)+unattributed==checkedIn holds by construction), GetMonitorMinuteBuckets (date_trunc('minute', ...) buckets shared by the rate/peak computation), and GetMonitorStations (per-station checkin counts). Zero migrations — reads existing tables only. pgxmock tests assert the exact SQL text; a TEST_DATABASE_URL-gated integration test proves the DISTINCT ON tie-breaker and the invariant against real Postgres, since pgxmock can only echo canned rows. --- backend/internal/store/interface.go | 77 +++++ backend/internal/store/pg_store_monitor.go | 177 ++++++++++ .../pg_store_monitor_integration_test.go | 252 ++++++++++++++ .../internal/store/pg_store_monitor_test.go | 318 ++++++++++++++++++ 4 files changed, 824 insertions(+) create mode 100644 backend/internal/store/pg_store_monitor.go create mode 100644 backend/internal/store/pg_store_monitor_integration_test.go create mode 100644 backend/internal/store/pg_store_monitor_test.go diff --git a/backend/internal/store/interface.go b/backend/internal/store/interface.go index 314b5af6..01371ae1 100644 --- a/backend/internal/store/interface.go +++ b/backend/internal/store/interface.go @@ -243,6 +243,51 @@ type Store interface { // failure here as best-effort/non-fatal). InsertCheckinAction(ctx context.Context, eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error + // GetMonitorCounts returns the total non-deleted attendee count and the + // currently-checked-in count for eventID (P4.2 Task 2) from ONE query — + // COUNT(*) and COUNT(*) FILTER (WHERE checkin_status) over the same + // attendees row set — so the two numbers can never disagree the way two + // separate queries against a live, concurrently-changing event could (a + // check-in landing between them). Backs the monitor snapshot's + // totals.total/totals.checked_in. + GetMonitorCounts(ctx context.Context, eventID uuid.UUID) (total int, checkedIn int, err error) + + // GetMonitorZones returns every zone's currently-checked-in count, plus + // the count of checked-in attendees that can't be attributed to any zone + // (unattributed) — from ONE statement (P4.2 Task 2), so + // sum(zones[].CheckedIn) + unattributed == checkedIn holds BY + // CONSTRUCTION: both numbers are aggregated from the SAME "attributed" + // CTE (one row per currently-checked-in attendee — checkin_status = true + // AND deleted_at IS NULL — carrying that attendee's zone, or NULL) + // rather than from two independently-issued queries that a future edit + // could let drift apart. An attendee's zone comes from their MOST RECENT + // 'checkin' action — DISTINCT ON (ca.attendee_id) ... ORDER BY + // ca.attendee_id, ca.created_at DESC, ca.id DESC, the same id + // tie-breaker as GetCheckinActions (PR #77 bot-review round, Finding E) + // — joined through checkin_stations.zone_id to event_zones; a checked-in + // attendee with no 'checkin' action row, a station-less action, or a + // zone-less station all count into unattributed rather than any zone. + // Zones are listed in event_zones.order_index order and INCLUDE + // zero-count zones (LEFT JOIN FROM event_zones, not the other way + // around, so an empty zone never silently disappears from the list). + GetMonitorZones(ctx context.Context, eventID uuid.UUID) (zones []MonitorZoneCount, unattributed int, err error) + + // GetMonitorMinuteBuckets returns one row per minute (ascending) holding + // the count of 'checkin' actions in that minute, for created_at >= since + // (P4.2 Task 2) — a date_trunc('minute', created_at) GROUP BY. The + // caller passes a UTC start-of-day for the monitor's today's-peak + // computation; the per-5-minute rate reuses these SAME buckets rather + // than issuing a second query. + GetMonitorMinuteBuckets(ctx context.Context, eventID uuid.UUID, since time.Time) ([]MinuteBucket, error) + + // GetMonitorStations returns every check-in station for eventID (P4.2 + // Task 2) with its LEFT JOINed 'checkin'-action count — COUNT(...) + // FILTER (WHERE ca.action = 'checkin'), so 'undo'/'reprint' rows sharing + // the same station_id don't inflate the count — ordered by name, the + // same deterministic-listing convention as ListCheckinStations, just + // with the running count attached for the monitor's stations card. + GetMonitorStations(ctx context.Context, eventID uuid.UUID) ([]MonitorStation, error) + CreateAttendee(ctx context.Context, attendee *models.Attendee) error // GetAttendeesByEventID lists attendees for an event; code/search are // optional filters ("" skips the filter) — code does an exact match, @@ -422,3 +467,35 @@ type CheckinActionRow struct { CreatedAt time.Time `json:"created_at"` Attendee CheckinActionAttendee `json:"attendee"` } + +// MonitorZoneCount is one zone's currently-checked-in count, one element of +// GetMonitorZones' result (P4.2 Task 2). Zero-count zones are included, so +// this is never a sparse/omit-if-empty list — the caller (the monitor +// endpoint, Task 3) reshapes this into the wire schema. +type MonitorZoneCount struct { + ZoneID uuid.UUID + Name string + CheckedIn int +} + +// MinuteBucket is one date_trunc('minute', created_at) bucket from +// GetMonitorMinuteBuckets (P4.2 Task 2) — the single shared source for both +// the monitor's per-5-minute check-in rate and its today's-peak computation +// (Task 3's computeRates), so the two numbers are always reading the same +// underlying data. +type MinuteBucket struct { + Minute time.Time + Count int +} + +// MonitorStation is one check-in station plus its running 'checkin'-action +// count, from GetMonitorStations (P4.2 Task 2) — backs the monitor's +// stations card (name, zone, last-seen staleness, and how many check-ins it +// has processed so far). +type MonitorStation struct { + ID uuid.UUID + Name string + ZoneID *uuid.UUID + LastSeenAt time.Time + CheckinCount int +} diff --git a/backend/internal/store/pg_store_monitor.go b/backend/internal/store/pg_store_monitor.go new file mode 100644 index 00000000..0438b1cb --- /dev/null +++ b/backend/internal/store/pg_store_monitor.go @@ -0,0 +1,177 @@ +package store + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// GetMonitorCounts returns the total non-deleted attendee count and the +// currently-checked-in count for eventID (P4.2 Task 2) from ONE query — +// COUNT(*) and COUNT(*) FILTER (WHERE checkin_status) over the same +// attendees row set — so the two numbers can never disagree the way two +// separately-issued queries against a live, concurrently-changing event +// could (a check-in landing between them). +func (s *PGStore) GetMonitorCounts(ctx context.Context, eventID uuid.UUID) (total int, checkedIn int, err error) { + err = s.db.QueryRow(ctx, + `SELECT COUNT(*), COUNT(*) FILTER (WHERE checkin_status) FROM attendees WHERE event_id = $1 AND deleted_at IS NULL`, + eventID, + ).Scan(&total, &checkedIn) + if err != nil { + return 0, 0, err + } + return total, checkedIn, nil +} + +// monitorZonesSQL is GetMonitorZones' single statement. It has three +// parts: +// +// 1. latest_checkin: each attendee's MOST RECENT 'checkin' action — +// DISTINCT ON (ca.attendee_id) ... ORDER BY ca.attendee_id, +// ca.created_at DESC, ca.id DESC. The id tie-breaker matches +// GetCheckinActions' ordering (PR #77 bot-review round, Finding E): two +// 'checkin' actions for the same attendee sharing a created_at value +// would otherwise have no deterministic "most recent" pick. +// 2. attributed: one row per CURRENTLY checked-in attendee (checkin_status +// = true AND deleted_at IS NULL), LEFT JOINed to latest_checkin and then +// to checkin_stations, carrying that attendee's zone_id — or NULL when +// there's no 'checkin' action row, the action's station_id is NULL, or +// the station's zone_id is NULL. This is the row set BOTH halves of the +// final result are aggregated from. +// 3. The final SELECT/UNION ALL: one row per event_zones row (LEFT JOIN +// FROM event_zones so a zone with zero currently-checked-in attendees +// still appears with checked_in = 0), UNION ALL with exactly one more +// row — COUNT(*) over attributed WHERE zone_id IS NULL — for the +// unattributed count. Because both halves aggregate the SAME attributed +// CTE, sum(zone rows) + unattributed always equals COUNT(attributed.*), +// which is exactly the checked-in population: the invariant holds by +// construction, not by two queries that happen to agree today. +const monitorZonesSQL = ` + WITH latest_checkin AS ( + SELECT DISTINCT ON (ca.attendee_id) ca.attendee_id, ca.station_id + FROM checkin_actions ca + WHERE ca.event_id = $1 AND ca.action = 'checkin' + ORDER BY ca.attendee_id, ca.created_at DESC, ca.id DESC + ), + attributed AS ( + SELECT a.id AS attendee_id, cs.zone_id AS zone_id + FROM attendees a + LEFT JOIN latest_checkin lc ON lc.attendee_id = a.id + LEFT JOIN checkin_stations cs ON cs.id = lc.station_id + WHERE a.event_id = $1 AND a.checkin_status = true AND a.deleted_at IS NULL + ) + SELECT ez.id AS zone_id, ez.name, COUNT(attributed.attendee_id) AS checked_in, ez.order_index AS sort_key + FROM event_zones ez + LEFT JOIN attributed ON attributed.zone_id = ez.id + WHERE ez.event_id = $1 + GROUP BY ez.id, ez.name, ez.order_index + + UNION ALL + + SELECT NULL, NULL, COUNT(*), NULL + FROM attributed + WHERE attributed.zone_id IS NULL + + ORDER BY sort_key NULLS LAST` + +// GetMonitorZones returns every zone's currently-checked-in count, plus the +// count of checked-in attendees that can't be attributed to any zone +// (unattributed) — see monitorZonesSQL for how the single statement makes +// sum(zones)+unattributed == checkedIn hold by construction. Each result +// row's zone_id is NULL for exactly one row (the unattributed total, +// always present — COUNT(*) with no GROUP BY returns one row even when it +// counts zero); every other row is a zone, in event_zones.order_index +// order, zero-count zones included. +func (s *PGStore) GetMonitorZones(ctx context.Context, eventID uuid.UUID) ([]MonitorZoneCount, int, error) { + rows, err := s.db.Query(ctx, monitorZonesSQL, eventID) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var zones []MonitorZoneCount + unattributed := 0 + for rows.Next() { + var zoneID *uuid.UUID + var name *string + var checkedIn int + var sortKey *int + if err := rows.Scan(&zoneID, &name, &checkedIn, &sortKey); err != nil { + return nil, 0, err + } + if zoneID == nil { + unattributed = checkedIn + continue + } + zones = append(zones, MonitorZoneCount{ZoneID: *zoneID, Name: *name, CheckedIn: checkedIn}) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return zones, unattributed, nil +} + +// GetMonitorMinuteBuckets returns one row per minute (ascending) holding +// the count of 'checkin' actions in that minute, for created_at >= since +// (P4.2 Task 2). The caller passes a UTC start-of-day for the monitor's +// today's-peak computation; the per-5-minute rate reuses these SAME buckets +// rather than issuing a second query. +func (s *PGStore) GetMonitorMinuteBuckets(ctx context.Context, eventID uuid.UUID, since time.Time) ([]MinuteBucket, error) { + rows, err := s.db.Query(ctx, ` + SELECT date_trunc('minute', created_at) AS minute, COUNT(*) + FROM checkin_actions + WHERE event_id = $1 AND action = 'checkin' AND created_at >= $2 + GROUP BY minute + ORDER BY minute ASC`, eventID, since) + if err != nil { + return nil, err + } + defer rows.Close() + + var buckets []MinuteBucket + for rows.Next() { + var b MinuteBucket + if err := rows.Scan(&b.Minute, &b.Count); err != nil { + return nil, err + } + buckets = append(buckets, b) + } + if err := rows.Err(); err != nil { + return nil, err + } + return buckets, nil +} + +// GetMonitorStations returns every check-in station for eventID with its +// LEFT JOINed 'checkin'-action count — COUNT(...) FILTER (WHERE ca.action = +// 'checkin'), so 'undo'/'reprint' rows sharing the same station_id don't +// inflate the count — ordered by name, the same deterministic-listing +// convention as ListCheckinStations, with the running count attached for +// the monitor's stations card. +func (s *PGStore) GetMonitorStations(ctx context.Context, eventID uuid.UUID) ([]MonitorStation, error) { + rows, err := s.db.Query(ctx, ` + SELECT cs.id, cs.name, cs.zone_id, cs.last_seen_at, COUNT(ca.id) FILTER (WHERE ca.action = 'checkin') + FROM checkin_stations cs + LEFT JOIN checkin_actions ca ON ca.station_id = cs.id + WHERE cs.event_id = $1 + GROUP BY cs.id, cs.name, cs.zone_id, cs.last_seen_at + ORDER BY cs.name`, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + + var stations []MonitorStation + for rows.Next() { + var st MonitorStation + if err := rows.Scan(&st.ID, &st.Name, &st.ZoneID, &st.LastSeenAt, &st.CheckinCount); err != nil { + return nil, err + } + stations = append(stations, st) + } + if err := rows.Err(); err != nil { + return nil, err + } + return stations, nil +} diff --git a/backend/internal/store/pg_store_monitor_integration_test.go b/backend/internal/store/pg_store_monitor_integration_test.go new file mode 100644 index 00000000..a4bb8d13 --- /dev/null +++ b/backend/internal/store/pg_store_monitor_integration_test.go @@ -0,0 +1,252 @@ +package store + +import ( + "context" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction proves, +// against a REAL Postgres database, the load-bearing correctness property +// of GetMonitorZones: sum(zones[].CheckedIn) + unattributed == checkedIn. +// pgxmock (used by every other test in this file) only echoes back rows +// it's told to return — it can prove the SQL's exact text and this +// package's row-scanning logic, but it cannot execute the DISTINCT ON +// most-recent-action tie-breaking or the UNION ALL aggregation for real, so +// it cannot prove the query is even syntactically valid, let alone that it +// picks the right zone per attendee. This is the only test in the repo that +// can (same rationale as +// TestCheckinCompositeForeignKeys_RejectCrossEventReferences). +// +// The fixture also proves the DISTINCT ON tie-breaker matters: attendee A1 +// has an OLDER 'checkin' action pointing at Station 2 / Zone Two, then a +// NEWER one pointing at Station 1 / Zone One — GetMonitorZones must +// attribute A1 to Zone One (the most recent), not Zone Two. +// +// Gated behind TEST_DATABASE_URL (this codebase has no real-database CI +// harness — see pg_store_attendees_page_integration_test.go) and SKIPS, not +// fails, when it's unset. To run it locally against the docker-compose db: +// +// TEST_DATABASE_URL="postgres://idento:idento_password@localhost:5438/idento_db?sslmode=disable" \ +// go test ./internal/store/ -run TestGetMonitorZones_RealPostgres -v +func TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction(t *testing.T) { + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + t.Skip("TEST_DATABASE_URL not set; skipping real-Postgres monitor-aggregation test (see doc comment for how to run it)") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + t.Fatalf("pgxpool.New: %v", err) + } + t.Cleanup(pool.Close) + if err := pool.Ping(ctx); err != nil { + t.Fatalf("Ping: %v", err) + } + + s := &PGStore{db: pool} + if err := s.RunMigrations(); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + + tenantID := uuid.New() + eventID := uuid.New() + now := time.Now() + + if _, err := pool.Exec(ctx, + `INSERT INTO tenants (id, name, created_at, updated_at) VALUES ($1, $2, $3, $3)`, + tenantID, "Monitor Aggregation Test Tenant "+tenantID.String(), now, + ); err != nil { + t.Fatalf("insert tenant: %v", err) + } + t.Cleanup(func() { + // Cascades through events -> event_zones/attendees/checkin_stations/checkin_actions. + cctx, ccancel := context.WithTimeout(context.Background(), 15*time.Second) + defer ccancel() + if _, err := pool.Exec(cctx, `DELETE FROM tenants WHERE id = $1`, tenantID); err != nil { + t.Logf("cleanup: failed to delete tenant %s: %v", tenantID, err) + } + }) + + if _, err := pool.Exec(ctx, + `INSERT INTO events (id, tenant_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $4)`, + eventID, tenantID, "Monitor Aggregation Test Event", now, + ); err != nil { + t.Fatalf("insert event: %v", err) + } + + zoneOne := uuid.New() + zoneTwo := uuid.New() + zoneEmpty := uuid.New() + for _, z := range []struct { + id uuid.UUID + name string + orderIndex int + }{ + {zoneTwo, "Zone Two", 1}, + {zoneOne, "Zone One", 2}, + {zoneEmpty, "Zone Three (empty)", 3}, + } { + if _, err := pool.Exec(ctx, + `INSERT INTO event_zones (id, event_id, name, order_index, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)`, + z.id, eventID, z.name, z.orderIndex, now, + ); err != nil { + t.Fatalf("insert zone %s: %v", z.name, err) + } + } + + station1 := uuid.New() + station2 := uuid.New() + stationless := uuid.New() + for _, st := range []struct { + id uuid.UUID + name string + zoneID *uuid.UUID + }{ + {station1, "Station 1", &zoneOne}, + {station2, "Station 2", &zoneTwo}, + {stationless, "Station Stationless", nil}, + } { + if _, err := pool.Exec(ctx, + `INSERT INTO checkin_stations (id, event_id, name, zone_id, created_at, last_seen_at) VALUES ($1, $2, $3, $4, $5, $5)`, + st.id, eventID, st.name, st.zoneID, now, + ); err != nil { + t.Fatalf("insert station %s: %v", st.name, err) + } + } + + // A1: checked in, most-recent action -> station1/zoneOne (an OLDER + // action pointing at station2/zoneTwo must be superseded). + // A2: checked in, single action -> station2/zoneTwo. + // A3: checked in, action via the station-less station -> unattributed. + // A4: checked in, only an 'undo' action (no 'checkin' row at all) -> unattributed. + // A5: NOT checked in -> excluded entirely from every count. + // A6: soft-deleted -> excluded entirely from every count. + attendees := []struct { + id uuid.UUID + checkedIn bool + deleted bool + }{ + {uuid.New(), true, false}, // A1 + {uuid.New(), true, false}, // A2 + {uuid.New(), true, false}, // A3 + {uuid.New(), true, false}, // A4 + {uuid.New(), false, false}, // A5 + {uuid.New(), true, true}, // A6 + } + for i, a := range attendees { + var deletedAt *time.Time + if a.deleted { + deletedAt = &now + } + if _, err := pool.Exec(ctx, + `INSERT INTO attendees (id, event_id, first_name, last_name, code, checkin_status, checked_in_at, deleted_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)`, + a.id, eventID, "A", uuid.New().String()[:8], "CODE-"+uuid.New().String()[:8], a.checkedIn, now, deletedAt, now, + ); err != nil { + t.Fatalf("insert attendee[%d]: %v", i, err) + } + } + a1, a2, a3, a4 := attendees[0].id, attendees[1].id, attendees[2].id, attendees[3].id + + insertAction := func(attendeeID uuid.UUID, stationID *uuid.UUID, action string, createdAt time.Time) { + t.Helper() + if _, err := pool.Exec(ctx, + `INSERT INTO checkin_actions (id, event_id, attendee_id, station_id, action, created_at) VALUES ($1, $2, $3, $4, $5, $6)`, + uuid.New(), eventID, attendeeID, stationID, action, createdAt, + ); err != nil { + t.Fatalf("insert checkin_action(attendee=%s, action=%s): %v", attendeeID, action, err) + } + } + insertAction(a1, &station2, "checkin", now.Add(-10*time.Minute)) // superseded + insertAction(a1, &station1, "checkin", now.Add(-1*time.Minute)) // wins: most recent + insertAction(a2, &station2, "checkin", now.Add(-5*time.Minute)) + insertAction(a3, &stationless, "checkin", now.Add(-3*time.Minute)) + insertAction(a4, &station1, "undo", now.Add(-2*time.Minute)) // no 'checkin' row for A4 at all + + total, checkedIn, err := s.GetMonitorCounts(ctx, eventID) + if err != nil { + t.Fatalf("GetMonitorCounts: %v", err) + } + if total != 5 { + t.Errorf("total = %d, want 5 (excludes the soft-deleted A6)", total) + } + if checkedIn != 4 { + t.Errorf("checkedIn = %d, want 4 (A1-A4; A5 not checked in, A6 soft-deleted)", checkedIn) + } + + zones, unattributed, err := s.GetMonitorZones(ctx, eventID) + if err != nil { + t.Fatalf("GetMonitorZones: %v", err) + } + if len(zones) != 3 { + t.Fatalf("len(zones) = %d, want 3 (all event_zones, including the empty one)", len(zones)) + } + // order_index order: Zone Two (1), Zone One (2), Zone Three (3). + if zones[0].ZoneID != zoneTwo || zones[0].CheckedIn != 1 { + t.Errorf("zones[0] = %+v, want Zone Two with CheckedIn=1 (A2)", zones[0]) + } + if zones[1].ZoneID != zoneOne || zones[1].CheckedIn != 1 { + t.Errorf("zones[1] = %+v, want Zone One with CheckedIn=1 (A1, via its MOST RECENT action)", zones[1]) + } + if zones[2].ZoneID != zoneEmpty || zones[2].CheckedIn != 0 { + t.Errorf("zones[2] = %+v, want the empty zone with CheckedIn=0 (zero-count zones must still be listed)", zones[2]) + } + if unattributed != 2 { + t.Errorf("unattributed = %d, want 2 (A3: station-less action; A4: no 'checkin' action row at all)", unattributed) + } + + sum := 0 + for _, z := range zones { + sum += z.CheckedIn + } + if sum+unattributed != checkedIn { + t.Errorf("sum(zones)+unattributed = %d+%d = %d, want %d (checkedIn) — invariant broken", sum, unattributed, sum+unattributed, checkedIn) + } + + buckets, err := s.GetMonitorMinuteBuckets(ctx, eventID, now.Add(-24*time.Hour)) + if err != nil { + t.Fatalf("GetMonitorMinuteBuckets: %v", err) + } + bucketTotal := 0 + for i, b := range buckets { + bucketTotal += b.Count + if i > 0 && !buckets[i-1].Minute.Before(b.Minute) { + t.Errorf("buckets not strictly ascending at index %d: %v then %v", i, buckets[i-1].Minute, b.Minute) + } + } + if bucketTotal != 4 { + t.Errorf("sum of bucket counts = %d, want 4 (A1 has 2 'checkin' rows, A2 and A3 have 1 each; A4's only action is 'undo', excluded)", bucketTotal) + } + + stations, err := s.GetMonitorStations(ctx, eventID) + if err != nil { + t.Fatalf("GetMonitorStations: %v", err) + } + if len(stations) != 3 { + t.Fatalf("len(stations) = %d, want 3", len(stations)) + } + byID := map[uuid.UUID]MonitorStation{} + for _, st := range stations { + byID[st.ID] = st + } + // Station 1 received A1's newer 'checkin' AND A4's 'undo' — the FILTER + // must count only the 'checkin' row. + if got := byID[station1].CheckinCount; got != 1 { + t.Errorf("station1.CheckinCount = %d, want 1 (the 'undo' row must not inflate it)", got) + } + // Station 2 received A1's older 'checkin' AND A2's 'checkin'. + if got := byID[station2].CheckinCount; got != 2 { + t.Errorf("station2.CheckinCount = %d, want 2", got) + } + if got := byID[stationless].CheckinCount; got != 1 { + t.Errorf("stationless.CheckinCount = %d, want 1", got) + } +} diff --git a/backend/internal/store/pg_store_monitor_test.go b/backend/internal/store/pg_store_monitor_test.go new file mode 100644 index 00000000..21bcc84f --- /dev/null +++ b/backend/internal/store/pg_store_monitor_test.go @@ -0,0 +1,318 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + pgxmock "github.com/pashagolub/pgxmock/v4" +) + +// --- P4.2 Task 2: monitor snapshot aggregations --- + +// getMonitorCountsSQL matches GetMonitorCounts' exact SELECT — ONE query +// for both total and checked-in counts, so the two numbers can never +// disagree the way two separately-issued queries against a live event +// could (a check-in landing between them). +const getMonitorCountsSQL = `SELECT COUNT\(\*\), COUNT\(\*\) FILTER \(WHERE checkin_status\) FROM attendees WHERE event_id = \$1 AND deleted_at IS NULL` + +func TestGetMonitorCountsReturnsTotalsFromOneQuery(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + mock.ExpectQuery(getMonitorCountsSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"count", "count"}).AddRow(42, 17)) + + s := &PGStore{db: mock} + total, checkedIn, err := s.GetMonitorCounts(context.Background(), eventID) + if err != nil { + t.Fatalf("GetMonitorCounts: %v", err) + } + if total != 42 || checkedIn != 17 { + t.Errorf("total=%d checkedIn=%d, want 42, 17", total, checkedIn) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestGetMonitorCountsZeroAttendeeEventReturnsZeroZero proves a +// freshly-created event with no attendees at all reports (0, 0), not an +// error — COUNT(*) over an empty row set is 0, never NULL. +func TestGetMonitorCountsZeroAttendeeEventReturnsZeroZero(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + mock.ExpectQuery(getMonitorCountsSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"count", "count"}).AddRow(0, 0)) + + s := &PGStore{db: mock} + total, checkedIn, err := s.GetMonitorCounts(context.Background(), eventID) + if err != nil { + t.Fatalf("GetMonitorCounts: %v", err) + } + if total != 0 || checkedIn != 0 { + t.Errorf("total=%d checkedIn=%d, want 0, 0", total, checkedIn) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// getMonitorZonesSQL matches GetMonitorZones' exact single statement: a +// latest_checkin CTE (DISTINCT ON (ca.attendee_id) ... ORDER BY +// ca.attendee_id, ca.created_at DESC, ca.id DESC — the same id tie-breaker +// as GetCheckinActions, PR #77 bot-review round Finding E) feeding an +// attributed CTE (one row per currently-checked-in attendee, carrying its +// zone or NULL), which the final SELECT/UNION ALL turns into the +// zones-with-zero-count list (LEFT JOIN FROM event_zones) plus a single +// unattributed-count row (zone_id IS NULL) — so sum(zones)+unattributed == +// checkedIn holds by construction: both come out of the SAME attributed CTE +// in the SAME statement. +const getMonitorZonesSQL = `WITH latest_checkin AS \(\s+SELECT DISTINCT ON \(ca\.attendee_id\) ca\.attendee_id, ca\.station_id\s+FROM checkin_actions ca\s+WHERE ca\.event_id = \$1 AND ca\.action = 'checkin'\s+ORDER BY ca\.attendee_id, ca\.created_at DESC, ca\.id DESC\s+\),\s+attributed AS \(\s+SELECT a\.id AS attendee_id, cs\.zone_id AS zone_id\s+FROM attendees a\s+LEFT JOIN latest_checkin lc ON lc\.attendee_id = a\.id\s+LEFT JOIN checkin_stations cs ON cs\.id = lc\.station_id\s+WHERE a\.event_id = \$1 AND a\.checkin_status = true AND a\.deleted_at IS NULL\s+\)\s+SELECT ez\.id AS zone_id, ez\.name, COUNT\(attributed\.attendee_id\) AS checked_in, ez\.order_index AS sort_key\s+FROM event_zones ez\s+LEFT JOIN attributed ON attributed\.zone_id = ez\.id\s+WHERE ez\.event_id = \$1\s+GROUP BY ez\.id, ez\.name, ez\.order_index\s+UNION ALL\s+SELECT NULL, NULL, COUNT\(\*\), NULL\s+FROM attributed\s+WHERE attributed\.zone_id IS NULL\s+ORDER BY sort_key NULLS LAST` + +// TestGetMonitorZonesIncludesZeroCountZonesAndUnattributed proves the exact +// SQL and the scanning discriminator: a row with a non-nil zone_id is a +// MonitorZoneCount (including a zero-count zone, which must still appear in +// the slice — LEFT JOIN FROM event_zones, not the other way around), and +// the single row with a nil zone_id/name is the unattributed count — the +// row a checked-in attendee with no matching 'checkin' action row falls +// into. Also proves the load-bearing invariant sum(zones)+unattributed == +// checkedIn on this fixture. +func TestGetMonitorZonesIncludesZeroCountZonesAndUnattributed(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + zoneA := uuid.New() + zoneB := uuid.New() + zoneEmpty := uuid.New() + + mock.ExpectQuery(getMonitorZonesSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"zone_id", "name", "checked_in", "sort_key"}). + AddRow(&zoneB, strPtr("Zone B"), 1, intPtr(1)). + AddRow(&zoneA, strPtr("Zone A"), 2, intPtr(2)). + AddRow(&zoneEmpty, strPtr("Zone Empty"), 0, intPtr(3)). + AddRow((*uuid.UUID)(nil), (*string)(nil), 1, (*int)(nil))) + + s := &PGStore{db: mock} + zones, unattributed, err := s.GetMonitorZones(context.Background(), eventID) + if err != nil { + t.Fatalf("GetMonitorZones: %v", err) + } + if len(zones) != 3 { + t.Fatalf("len(zones) = %d, want 3", len(zones)) + } + if zones[0].ZoneID != zoneB || zones[0].Name != "Zone B" || zones[0].CheckedIn != 1 { + t.Errorf("zones[0] = %+v, unexpected", zones[0]) + } + if zones[2].ZoneID != zoneEmpty || zones[2].CheckedIn != 0 { + t.Errorf("zones[2] (zero-count zone) = %+v, want CheckedIn=0", zones[2]) + } + if unattributed != 1 { + t.Errorf("unattributed = %d, want 1", unattributed) + } + + sum := 0 + for _, z := range zones { + sum += z.CheckedIn + } + const checkedIn = 4 // must match this fixture's total checked-in population + if sum+unattributed != checkedIn { + t.Errorf("sum(zones)+unattributed = %d, want %d (invariant broken)", sum+unattributed, checkedIn) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestGetMonitorZonesNoZonesOnlyUnattributedRow proves an event with zero +// zones still gets exactly the unattributed row back (COUNT(*) over an +// aggregate with no GROUP BY always returns one row, even 0) rather than an +// empty/error result. +func TestGetMonitorZonesNoZonesOnlyUnattributedRow(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + mock.ExpectQuery(getMonitorZonesSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"zone_id", "name", "checked_in", "sort_key"}). + AddRow((*uuid.UUID)(nil), (*string)(nil), 0, (*int)(nil))) + + s := &PGStore{db: mock} + zones, unattributed, err := s.GetMonitorZones(context.Background(), eventID) + if err != nil { + t.Fatalf("GetMonitorZones: %v", err) + } + if len(zones) != 0 { + t.Errorf("len(zones) = %d, want 0", len(zones)) + } + if unattributed != 0 { + t.Errorf("unattributed = %d, want 0", unattributed) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// getMonitorMinuteBucketsSQL matches GetMonitorMinuteBuckets' exact SELECT +// — date_trunc('minute', created_at) GROUP BY, restricted to 'checkin' +// actions at/after $2, ascending. +const getMonitorMinuteBucketsSQL = `SELECT date_trunc\('minute', created_at\) AS minute, COUNT\(\*\)\s+FROM checkin_actions\s+WHERE event_id = \$1 AND action = 'checkin' AND created_at >= \$2\s+GROUP BY minute\s+ORDER BY minute ASC` + +func TestGetMonitorMinuteBucketsReturnsAscendingBuckets(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + since := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC) + m1 := time.Date(2026, 7, 18, 9, 30, 0, 0, time.UTC) + m2 := time.Date(2026, 7, 18, 9, 31, 0, 0, time.UTC) + + mock.ExpectQuery(getMonitorMinuteBucketsSQL). + WithArgs(eventID, since). + WillReturnRows(pgxmock.NewRows([]string{"minute", "count"}). + AddRow(m1, 3). + AddRow(m2, 5)) + + s := &PGStore{db: mock} + got, err := s.GetMonitorMinuteBuckets(context.Background(), eventID, since) + if err != nil { + t.Fatalf("GetMonitorMinuteBuckets: %v", err) + } + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if !got[0].Minute.Equal(m1) || got[0].Count != 3 { + t.Errorf("got[0] = %+v, unexpected", got[0]) + } + if !got[1].Minute.Equal(m2) || got[1].Count != 5 { + t.Errorf("got[1] = %+v, unexpected", got[1]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestGetMonitorMinuteBucketsNoneReturnsEmpty proves an event with no +// 'checkin' actions since the cutoff gets a nil/empty slice, not an error. +func TestGetMonitorMinuteBucketsNoneReturnsEmpty(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + since := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC) + mock.ExpectQuery(getMonitorMinuteBucketsSQL). + WithArgs(eventID, since). + WillReturnRows(pgxmock.NewRows([]string{"minute", "count"})) + + s := &PGStore{db: mock} + got, err := s.GetMonitorMinuteBuckets(context.Background(), eventID, since) + if err != nil { + t.Fatalf("GetMonitorMinuteBuckets: %v", err) + } + if len(got) != 0 { + t.Errorf("len(got) = %d, want 0", len(got)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// getMonitorStationsSQL matches GetMonitorStations' exact SELECT — a +// per-station 'checkin'-action count via FILTER (so 'undo'/'reprint' rows +// sharing the same station_id don't inflate it), ordered by name. +const getMonitorStationsSQL = `SELECT cs\.id, cs\.name, cs\.zone_id, cs\.last_seen_at, COUNT\(ca\.id\) FILTER \(WHERE ca\.action = 'checkin'\)\s+FROM checkin_stations cs\s+LEFT JOIN checkin_actions ca ON ca\.station_id = cs\.id\s+WHERE cs\.event_id = \$1\s+GROUP BY cs\.id, cs\.name, cs\.zone_id, cs\.last_seen_at\s+ORDER BY cs\.name` + +func TestGetMonitorStationsReturnsNameOrderedWithCounts(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + stationA := uuid.New() + stationB := uuid.New() + zoneID := uuid.New() + lastSeenA := time.Now().Add(-10 * time.Second) + lastSeenB := time.Now().Add(-90 * time.Second) + + mock.ExpectQuery(getMonitorStationsSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"id", "name", "zone_id", "last_seen_at", "count"}). + AddRow(stationA, "Station A", &zoneID, lastSeenA, 7). + AddRow(stationB, "Station B (no zone, no scans)", (*uuid.UUID)(nil), lastSeenB, 0)) + + s := &PGStore{db: mock} + got, err := s.GetMonitorStations(context.Background(), eventID) + if err != nil { + t.Fatalf("GetMonitorStations: %v", err) + } + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if got[0].ID != stationA || got[0].Name != "Station A" || got[0].ZoneID == nil || *got[0].ZoneID != zoneID || got[0].CheckinCount != 7 { + t.Errorf("got[0] = %+v, unexpected", got[0]) + } + if got[1].ID != stationB || got[1].ZoneID != nil || got[1].CheckinCount != 0 { + t.Errorf("got[1] = %+v, unexpected", got[1]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestGetMonitorStationsNoneRegisteredReturnsEmpty proves an event with no +// check-in stations registered gets a nil/empty slice, not an error. +func TestGetMonitorStationsNoneRegisteredReturnsEmpty(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + mock.ExpectQuery(getMonitorStationsSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"id", "name", "zone_id", "last_seen_at", "count"})) + + s := &PGStore{db: mock} + got, err := s.GetMonitorStations(context.Background(), eventID) + if err != nil { + t.Fatalf("GetMonitorStations: %v", err) + } + if len(got) != 0 { + t.Errorf("len(got) = %d, want 0", len(got)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +func intPtr(n int) *int { return &n } From 822ac4d1bf7e8cd288c0b653c64b1248b0701d0c Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 00:51:17 +0300 Subject: [PATCH 05/23] feat(backend): monitor snapshot endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/events/{event_id}/monitor composes Task 2's four store aggregations (GetMonitorCounts/Zones/MinuteBuckets/Stations) plus the existing GetCheckinActions feed into the spec §3.1 snapshot response: totals (checked_in/total/rate_per_min/peak/est_done_at), zones[], unattributed, stations[], recent[]. Rate/peak/ETA math lives in a pure computeRates(buckets, now, total, checkedIn) in monitor_rates.go, unit-tested without HTTP: 5-minute sliding window for rate_per_min, today's max bucket for peak, and est_done_at nulled out when rate < 0.1/min or the event is already fully checked in. openapi.yaml gains MonitorSnapshot + sub-schemas (required fields, nullable peak/est_done_at, additionalProperties: false) and the new path; panel/src/shared/api/schema.d.ts regenerated to match. Co-Authored-By: Claude Sonnet 5 --- backend/internal/handler/handler.go | 1 + backend/internal/handler/monitor.go | 142 +++++++++++ backend/internal/handler/monitor_rates.go | 80 +++++++ .../internal/handler/monitor_rates_test.go | 169 +++++++++++++ .../openapi_contract_monitor_p4_test.go | 225 ++++++++++++++++++ backend/internal/handler/testsupport_test.go | 16 ++ backend/openapi.yaml | 124 ++++++++++ panel/src/shared/api/schema.d.ts | 116 +++++++++ 8 files changed, 873 insertions(+) create mode 100644 backend/internal/handler/monitor.go create mode 100644 backend/internal/handler/monitor_rates.go create mode 100644 backend/internal/handler/monitor_rates_test.go create mode 100644 backend/internal/handler/openapi_contract_monitor_p4_test.go diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 45c45aad..84ad53ba 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -86,6 +86,7 @@ func (h *Handler) RegisterRoutes(e *echo.Echo, mode string) { api.POST("/events/:event_id/checkin", h.StationCheckin) api.POST("/events/:event_id/checkin/undo", h.UndoCheckin) api.GET("/events/:event_id/checkin-actions", h.GetCheckinActions) + api.GET("/events/:event_id/monitor", h.GetEventMonitor) api.GET("/events/:id/readiness", h.GetEventReadiness) api.GET("/events/:event_id/stats", h.GetEventStats) api.GET("/events/:event_id/staff", h.GetEventStaff) diff --git a/backend/internal/handler/monitor.go b/backend/internal/handler/monitor.go new file mode 100644 index 00000000..e158e6e1 --- /dev/null +++ b/backend/internal/handler/monitor.go @@ -0,0 +1,142 @@ +package handler + +import ( + "net/http" + "time" + + "idento/backend/internal/store" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// monitorRecentLimit mirrors GetCheckinActions' rail default (P4.1 Task 3) +// — the monitor's recent feed always shows the newest 20 rows, per spec +// §3.1. +const monitorRecentLimit = 20 + +// MonitorTotals is the monitor snapshot's totals block (P4.2 Task 3, spec +// §3.1). Peak and EstDoneAt are nil (JSON null) rather than omitted — +// MonitorSnapshot's openapi schema marks both required-but-nullable so a +// client always sees the key. +type MonitorTotals struct { + CheckedIn int `json:"checked_in"` + Total int `json:"total"` + RatePerMin float64 `json:"rate_per_min"` + Peak *PeakRate `json:"peak"` + EstDoneAt *time.Time `json:"est_done_at"` +} + +// MonitorZone is one zone's currently-checked-in count in the monitor +// snapshot's zones[] — the wire reshaping of store.MonitorZoneCount. +type MonitorZone struct { + ZoneID uuid.UUID `json:"zone_id"` + Name string `json:"name"` + CheckedIn int `json:"checked_in"` +} + +// MonitorStationRow is one check-in station's liveness + running count in +// the monitor snapshot's stations[] — the wire reshaping of +// store.MonitorStation. +type MonitorStationRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + ZoneID *uuid.UUID `json:"zone_id"` + LastSeenAt time.Time `json:"last_seen_at"` + CheckinCount int `json:"checkin_count"` +} + +// MonitorSnapshot is the response envelope for GET +// /api/events/{event_id}/monitor (P4.2 Task 3, spec §3.1) — everything +// screen 7e (the live monitor) renders in one request. Invariant: +// sum(Zones[].CheckedIn) + Unattributed == Totals.CheckedIn, which holds by +// construction because both halves come from the SAME GetMonitorZones call +// (see store.GetMonitorZones' doc comment). +type MonitorSnapshot struct { + Totals MonitorTotals `json:"totals"` + Zones []MonitorZone `json:"zones"` + Unattributed int `json:"unattributed"` + Stations []MonitorStationRow `json:"stations"` + Recent []store.CheckinActionRow `json:"recent"` +} + +// GetEventMonitor composes Task 2's four monitor aggregations plus the +// existing check-in feed into one snapshot (P4.2 Task 3, spec §3.1) — +// everything the live monitor screen and the Home LiveStrip need in a +// single request. +func (h *Handler) GetEventMonitor(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("event_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + ctx := c.Request().Context() + + total, checkedIn, err := h.Store.GetMonitorCounts(ctx, eventID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor counts"}) + } + + zoneCounts, unattributed, err := h.Store.GetMonitorZones(ctx, eventID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor zones"}) + } + + // since is UTC start-of-day: the domain for "today's" peak bucket + // (spec §3.1); computeRates derives the 5-minute rate window from + // this SAME bucket set rather than issuing a second query. + now := time.Now().UTC() + since := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + buckets, err := h.Store.GetMonitorMinuteBuckets(ctx, eventID, since) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor rate buckets"}) + } + + stations, err := h.Store.GetMonitorStations(ctx, eventID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor stations"}) + } + + recent, err := h.Store.GetCheckinActions(ctx, eventID, monitorRecentLimit) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch recent check-in actions"}) + } + if recent == nil { + recent = []store.CheckinActionRow{} + } + + ratePerMin, peak, estDoneAt := computeRates(buckets, now, total, checkedIn) + + zones := make([]MonitorZone, 0, len(zoneCounts)) + for _, z := range zoneCounts { + zones = append(zones, MonitorZone{ZoneID: z.ZoneID, Name: z.Name, CheckedIn: z.CheckedIn}) + } + + stationRows := make([]MonitorStationRow, 0, len(stations)) + for _, s := range stations { + stationRows = append(stationRows, MonitorStationRow{ + ID: s.ID, + Name: s.Name, + ZoneID: s.ZoneID, + LastSeenAt: s.LastSeenAt, + CheckinCount: s.CheckinCount, + }) + } + + return c.JSON(http.StatusOK, MonitorSnapshot{ + Totals: MonitorTotals{ + CheckedIn: checkedIn, + Total: total, + RatePerMin: ratePerMin, + Peak: peak, + EstDoneAt: estDoneAt, + }, + Zones: zones, + Unattributed: unattributed, + Stations: stationRows, + Recent: recent, + }) +} diff --git a/backend/internal/handler/monitor_rates.go b/backend/internal/handler/monitor_rates.go new file mode 100644 index 00000000..ff3e17f8 --- /dev/null +++ b/backend/internal/handler/monitor_rates.go @@ -0,0 +1,80 @@ +package handler + +import ( + "math" + "time" + + "idento/backend/internal/store" +) + +// PeakRate is the wire shape of a monitor snapshot's totals.peak — the +// highest-count one-minute check-in bucket "today" (UTC), paired with that +// bucket's start time (P4.2 Task 3, spec §3.1). Produced by computeRates; +// nil when the caller passed no buckets (no check-ins today). +type PeakRate struct { + Rate float64 `json:"rate"` + At time.Time `json:"at"` +} + +// rateWindow is the sliding window computeRates sums bucket counts over for +// totals.rate_per_min — the last 5 minutes, per spec §3.1. +const rateWindow = 5 * time.Minute + +// minRateForETA is the floor below which est_done_at is considered +// meaningless (spec §3.1: "null when rate_per_min is ~0") — a near-zero +// rate would project an estimated-done time that's not a useful signal. +const minRateForETA = 0.1 + +// computeRates derives the monitor snapshot's rate/peak/ETA trio from ONE +// shared set of minute buckets (P4.2 Task 3, spec §3.1) — buckets is +// expected to already be scoped by the caller to "today" (UTC), the same +// set peak is computed over: +// +// - ratePerMin: sum of bucket counts within the half-open window +// [now-5m, now) divided by 5, rounded to one decimal — "checkins in the +// last 5 minutes, per minute". +// - peak: the bucket with the highest count among ALL buckets passed in +// (not limited to the rate window — a spike from earlier today still +// wins), paired with that bucket's start time; nil when buckets is +// empty. Ties keep the first (earliest) bucket, since the store returns +// buckets in ascending time order. +// - estDoneAt: now + (total-checkedIn)/ratePerMin minutes; nil when +// ratePerMin is below minRateForETA or checkedIn >= total (the event is +// stalled or already fully checked in — no meaningful projection). +func computeRates(buckets []store.MinuteBucket, now time.Time, total, checkedIn int) (ratePerMin float64, peak *PeakRate, estDoneAt *time.Time) { + windowStart := now.Add(-rateWindow) + + var windowSum int + var peakBucket *store.MinuteBucket + for i := range buckets { + b := &buckets[i] + if !b.Minute.Before(windowStart) && b.Minute.Before(now) { + windowSum += b.Count + } + if peakBucket == nil || b.Count > peakBucket.Count { + peakBucket = b + } + } + + ratePerMin = roundToOneDecimal(float64(windowSum) / 5.0) + + if peakBucket != nil { + peak = &PeakRate{Rate: float64(peakBucket.Count), At: peakBucket.Minute} + } + + remaining := total - checkedIn + if ratePerMin >= minRateForETA && remaining > 0 { + minutesRemaining := float64(remaining) / ratePerMin + eta := now.Add(time.Duration(minutesRemaining * float64(time.Minute))) + estDoneAt = &eta + } + + return ratePerMin, peak, estDoneAt +} + +// roundToOneDecimal guards against floating-point noise (e.g. +// 1.4000000000000001 from repeated float division) so rate_per_min always +// carries exactly the one decimal digit the spec's wire example shows. +func roundToOneDecimal(v float64) float64 { + return math.Round(v*10) / 10 +} diff --git a/backend/internal/handler/monitor_rates_test.go b/backend/internal/handler/monitor_rates_test.go new file mode 100644 index 00000000..7941b6ea --- /dev/null +++ b/backend/internal/handler/monitor_rates_test.go @@ -0,0 +1,169 @@ +package handler + +import ( + "testing" + "time" + + "idento/backend/internal/store" +) + +// fixedNow is a stable reference instant used across computeRates tests so +// bucket offsets ("2 minutes ago") are unambiguous and tests never depend on +// wall-clock time. +var fixedNow = time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + +func TestComputeRates_EmptyBuckets(t *testing.T) { + rate, peak, estDoneAt := computeRates(nil, fixedNow, 100, 0) + + if rate != 0 { + t.Errorf("rate = %v, want 0", rate) + } + if peak != nil { + t.Errorf("peak = %+v, want nil", peak) + } + if estDoneAt != nil { + t.Errorf("estDoneAt = %v, want nil", *estDoneAt) + } +} + +func TestComputeRates_RecentBucketCountsTowardRate(t *testing.T) { + bucketStart := fixedNow.Add(-1 * time.Minute) + buckets := []store.MinuteBucket{{Minute: bucketStart, Count: 5}} + + rate, peak, estDoneAt := computeRates(buckets, fixedNow, 1000, 10) + + // 5 check-ins somewhere in the last 5 minutes / 5 = 1.0/min. + if rate != 1.0 { + t.Errorf("rate = %v, want 1.0", rate) + } + if peak == nil { + t.Fatalf("peak = nil, want a peak for the only bucket") + } + if peak.Rate != 5 || !peak.At.Equal(bucketStart) { + t.Errorf("peak = %+v, want {Rate:5 At:%v}", peak, bucketStart) + } + if estDoneAt == nil { + t.Fatalf("estDoneAt = nil, want non-nil (rate above floor, remaining > 0)") + } +} + +func TestComputeRates_PeakPicksMaxBucketAcrossWholeDayNotJustWindow(t *testing.T) { + // An early-morning spike (09:40) far outside the last-5-minutes rate + // window should still win peak — peak considers every bucket the + // caller passed in (the store already scopes the query to "today"), + // not just the rate's sliding window. + morningSpike := time.Date(2026, 7, 18, 9, 40, 0, 0, time.UTC) + recentSmall := fixedNow.Add(-2 * time.Minute) + buckets := []store.MinuteBucket{ + {Minute: morningSpike, Count: 14}, + {Minute: recentSmall, Count: 3}, + } + + rate, peak, _ := computeRates(buckets, fixedNow, 1000, 100) + + if rate != 0.6 { // 3/5 = 0.6 + t.Errorf("rate = %v, want 0.6", rate) + } + if peak == nil { + t.Fatalf("peak = nil, want the morning spike") + } + if peak.Rate != 14 || !peak.At.Equal(morningSpike) { + t.Errorf("peak = %+v, want {Rate:14 At:%v}", peak, morningSpike) + } +} + +func TestComputeRates_WindowExcludesBucketsOlderThanFiveMinutes(t *testing.T) { + // A bucket 6 minutes old (older than the 5-minute rate window) must not + // contribute to rate_per_min, even though it's the only bucket for the + // day (and therefore still becomes peak). + oldBucket := fixedNow.Add(-6 * time.Minute) + buckets := []store.MinuteBucket{{Minute: oldBucket, Count: 100}} + + rate, peak, estDoneAt := computeRates(buckets, fixedNow, 1000, 0) + + if rate != 0 { + t.Errorf("rate = %v, want 0 (bucket is outside the 5-minute window)", rate) + } + if peak == nil || peak.Rate != 100 { + t.Errorf("peak = %+v, want the old bucket still counted for peak", peak) + } + // rate is 0 (below the ETA floor), so estDoneAt must be nil even though + // remaining > 0. + if estDoneAt != nil { + t.Errorf("estDoneAt = %v, want nil (rate below floor)", *estDoneAt) + } +} + +func TestComputeRates_RateBelowFloor_NilETA(t *testing.T) { + // No buckets in the rate window → rate 0, which is below the 0.1 + // minimum needed to project a meaningful est_done_at, even though + // remaining (total-checkedIn) is positive. + buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-30 * time.Minute), Count: 2}} + + rate, _, estDoneAt := computeRates(buckets, fixedNow, 500, 100) + + if rate != 0 { + t.Errorf("rate = %v, want 0", rate) + } + if estDoneAt != nil { + t.Errorf("estDoneAt = %v, want nil (rate below 0.1 floor)", *estDoneAt) + } +} + +func TestComputeRates_EventDone_NilETA(t *testing.T) { + // checkedIn >= total: everyone is already in, even though the rate is + // healthy — no meaningful "done at" projection remains. + buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-1 * time.Minute), Count: 10}} + + rate, _, estDoneAt := computeRates(buckets, fixedNow, 200, 200) + + if rate != 2.0 { // 10/5 + t.Errorf("rate = %v, want 2.0", rate) + } + if estDoneAt != nil { + t.Errorf("estDoneAt = %v, want nil (checkedIn >= total)", *estDoneAt) + } +} + +func TestComputeRates_EventOverCheckedIn_NilETA(t *testing.T) { + // checkedIn > total is a defensive edge (shouldn't happen, but a + // negative "remaining" must never produce a bogus estDoneAt). + buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-1 * time.Minute), Count: 10}} + + _, _, estDoneAt := computeRates(buckets, fixedNow, 100, 105) + + if estDoneAt != nil { + t.Errorf("estDoneAt = %v, want nil (checkedIn > total)", *estDoneAt) + } +} + +func TestComputeRates_EstDoneAtProjection(t *testing.T) { + // rate = 10 checkins over 5 minutes = 2.0/min; remaining = 100; ETA = + // now + 50 minutes. + buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-1 * time.Minute), Count: 10}} + + rate, _, estDoneAt := computeRates(buckets, fixedNow, 300, 200) + + if rate != 2.0 { + t.Fatalf("rate = %v, want 2.0", rate) + } + if estDoneAt == nil { + t.Fatalf("estDoneAt = nil, want a projection") + } + want := fixedNow.Add(50 * time.Minute) + if !estDoneAt.Equal(want) { + t.Errorf("estDoneAt = %v, want %v", *estDoneAt, want) + } +} + +func TestComputeRates_RateRoundedToOneDecimal(t *testing.T) { + // 7 check-ins / 5 minutes = 1.4/min exactly — proves the one-decimal + // contract holds for a non-trivial (non-multiple-of-5) count. + buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-30 * time.Second), Count: 7}} + + rate, _, _ := computeRates(buckets, fixedNow, 1000, 0) + + if rate != 1.4 { + t.Errorf("rate = %v, want 1.4", rate) + } +} diff --git a/backend/internal/handler/openapi_contract_monitor_p4_test.go b/backend/internal/handler/openapi_contract_monitor_p4_test.go new file mode 100644 index 00000000..ca1e50df --- /dev/null +++ b/backend/internal/handler/openapi_contract_monitor_p4_test.go @@ -0,0 +1,225 @@ +package handler + +import ( + "net/http" + "testing" + "time" + + "idento/backend/internal/models" + "idento/backend/internal/store" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +func monitorPath(eventID uuid.UUID) string { + return "/api/events/" + eventID.String() + "/monitor" +} + +func setMonitorPathParams(c echo.Context, eventID uuid.UUID) { + c.SetPath("/api/events/:event_id/monitor") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) +} + +// TestOpenAPIContract_GetEventMonitor_SeededInvariantHolds proves a fully +// seeded snapshot validates against the schema AND that the +// zones+unattributed invariant (spec §3.1) holds in the wire response: +// sum(zones[].checked_in) + unattributed == totals.checked_in. +func TestOpenAPIContract_GetEventMonitor_SeededInvariantHolds(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + zoneAID := uuid.New() + zoneBID := uuid.New() + stationID := uuid.New() + now := time.Now().UTC() + + recentRows := []store.CheckinActionRow{ + { + ID: uuid.New(), + Action: "checkin", + StationID: &stationID, + CreatedAt: now, + Attendee: store.CheckinActionAttendee{ID: uuid.New(), FirstName: "Ada", LastName: "Lovelace", Code: "CODE1"}, + }, + } + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getMonitorCounts: func(eventID uuid.UUID) (int, int, error) { + if eventID != event.ID { + t.Fatalf("GetMonitorCounts eventID = %s, want %s", eventID, event.ID) + } + return 100, 60, nil + }, + getMonitorZones: func(eventID uuid.UUID) ([]store.MonitorZoneCount, int, error) { + if eventID != event.ID { + t.Fatalf("GetMonitorZones eventID = %s, want %s", eventID, event.ID) + } + // 25 + 30 + 5 (unattributed) == 60 (checked_in) — the + // invariant the response is expected to preserve verbatim. + return []store.MonitorZoneCount{ + {ZoneID: zoneAID, Name: "Zone A", CheckedIn: 25}, + {ZoneID: zoneBID, Name: "Zone B", CheckedIn: 30}, + }, 5, nil + }, + getMonitorMinuteBuckets: func(eventID uuid.UUID, since time.Time) ([]store.MinuteBucket, error) { + if eventID != event.ID { + t.Fatalf("GetMonitorMinuteBuckets eventID = %s, want %s", eventID, event.ID) + } + return []store.MinuteBucket{{Minute: now.Add(-1 * time.Minute), Count: 4}}, nil + }, + getMonitorStations: func(eventID uuid.UUID) ([]store.MonitorStation, error) { + if eventID != event.ID { + t.Fatalf("GetMonitorStations eventID = %s, want %s", eventID, event.ID) + } + return []store.MonitorStation{ + {ID: stationID, Name: "Main Entrance", ZoneID: &zoneAID, LastSeenAt: now, CheckinCount: 30}, + }, nil + }, + getCheckinActions: func(eventID uuid.UUID, limit int) ([]store.CheckinActionRow, error) { + if eventID != event.ID { + t.Fatalf("GetCheckinActions eventID = %s, want %s", eventID, event.ID) + } + if limit != 20 { + t.Fatalf("GetCheckinActions limit = %d, want 20", limit) + } + return recentRows, nil + }, + }) + + e := echo.New() + path := monitorPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", tenantID.String(), "staff") + setMonitorPathParams(c, event.ID) + + if err := h.GetEventMonitor(c); err != nil { + t.Fatalf("GetEventMonitor: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + + var got MonitorSnapshot + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Totals.CheckedIn != 60 || got.Totals.Total != 100 { + t.Fatalf("totals = %+v, want checked_in=60 total=100", got.Totals) + } + if got.Unattributed != 5 { + t.Fatalf("unattributed = %d, want 5", got.Unattributed) + } + if len(got.Zones) != 2 { + t.Fatalf("zones = %+v, want 2 entries", got.Zones) + } + sum := got.Unattributed + for _, z := range got.Zones { + sum += z.CheckedIn + } + if sum != got.Totals.CheckedIn { + t.Fatalf("sum(zones)+unattributed = %d, want == totals.checked_in %d", sum, got.Totals.CheckedIn) + } + if len(got.Stations) != 1 || got.Stations[0].Name != "Main Entrance" { + t.Fatalf("stations = %+v, want the seeded station", got.Stations) + } + if len(got.Recent) != 1 || got.Recent[0].Action != "checkin" { + t.Fatalf("recent = %+v, want the seeded checkin row", got.Recent) + } + + validateResponse(t, http.MethodGet, path, rec) +} + +// TestOpenAPIContract_GetEventMonitor_EmptyEventZerosAndNulls proves a +// brand-new event with no attendees/zones/stations/actions comes back as +// zeroed counts and null peak/est_done_at — never fabricated values. +func TestOpenAPIContract_GetEventMonitor_EmptyEventZerosAndNulls(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Fresh Event") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getMonitorCounts: func(uuid.UUID) (int, int, error) { return 0, 0, nil }, + getMonitorZones: func(uuid.UUID) ([]store.MonitorZoneCount, int, error) { return nil, 0, nil }, + getMonitorMinuteBuckets: func(uuid.UUID, time.Time) ([]store.MinuteBucket, error) { return nil, nil }, + getMonitorStations: func(uuid.UUID) ([]store.MonitorStation, error) { return nil, nil }, + getCheckinActions: func(uuid.UUID, int) ([]store.CheckinActionRow, error) { return nil, nil }, + }) + + e := echo.New() + path := monitorPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", tenantID.String(), "staff") + setMonitorPathParams(c, event.ID) + + if err := h.GetEventMonitor(c); err != nil { + t.Fatalf("GetEventMonitor: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + + var got MonitorSnapshot + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Totals.CheckedIn != 0 || got.Totals.Total != 0 { + t.Fatalf("totals = %+v, want zeros", got.Totals) + } + if got.Totals.RatePerMin != 0 { + t.Fatalf("rate_per_min = %v, want 0", got.Totals.RatePerMin) + } + if got.Totals.Peak != nil { + t.Fatalf("peak = %+v, want nil", got.Totals.Peak) + } + if got.Totals.EstDoneAt != nil { + t.Fatalf("est_done_at = %v, want nil", got.Totals.EstDoneAt) + } + if got.Unattributed != 0 { + t.Fatalf("unattributed = %d, want 0", got.Unattributed) + } + if len(got.Zones) != 0 { + t.Fatalf("zones = %+v, want empty", got.Zones) + } + if len(got.Stations) != 0 { + t.Fatalf("stations = %+v, want empty", got.Stations) + } + if len(got.Recent) != 0 { + t.Fatalf("recent = %+v, want empty", got.Recent) + } + + validateResponse(t, http.MethodGet, path, rec) +} + +// TestOpenAPIContract_GetEventMonitor_ForeignEvent404 proves +// requireEventOwnership short-circuits before any monitor aggregation is +// queried — a cross-tenant caller gets a masked 404, not data. +func TestOpenAPIContract_GetEventMonitor_ForeignEvent404(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + foreignTenantID := uuid.New() + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getMonitorCounts: func(uuid.UUID) (int, int, error) { + t.Fatalf("GetMonitorCounts should not be called for a foreign event") + return 0, 0, nil + }, + }) + + e := echo.New() + path := monitorPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", foreignTenantID.String(), "staff") + setMonitorPathParams(c, event.ID) + + if err := h.GetEventMonitor(c); err != nil { + t.Fatalf("GetEventMonitor: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + + validateResponse(t, http.MethodGet, path, rec) +} diff --git a/backend/internal/handler/testsupport_test.go b/backend/internal/handler/testsupport_test.go index 6b5b609b..41a20136 100644 --- a/backend/internal/handler/testsupport_test.go +++ b/backend/internal/handler/testsupport_test.go @@ -74,6 +74,10 @@ type fakeStore struct { undoCheckin func(eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error) getCheckinActions func(eventID uuid.UUID, limit int) ([]store.CheckinActionRow, error) insertCheckinAction func(eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error + getMonitorCounts func(eventID uuid.UUID) (int, int, error) + getMonitorZones func(eventID uuid.UUID) ([]store.MonitorZoneCount, int, error) + getMonitorMinuteBuckets func(eventID uuid.UUID, since time.Time) ([]store.MinuteBucket, error) + getMonitorStations func(eventID uuid.UUID) ([]store.MonitorStation, error) createTenantWithDefaultSubscription func(tenant *models.Tenant) error provisionTenantWithAdmin func(tenantName, email, password string) (*models.Tenant, *models.User, error) @@ -313,6 +317,18 @@ func (f *fakeStore) GetCheckinActions(_ context.Context, eventID uuid.UUID, limi func (f *fakeStore) InsertCheckinAction(_ context.Context, eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error { return f.insertCheckinAction(eventID, attendeeID, action, stationID, staffUserID) } +func (f *fakeStore) GetMonitorCounts(_ context.Context, eventID uuid.UUID) (int, int, error) { + return f.getMonitorCounts(eventID) +} +func (f *fakeStore) GetMonitorZones(_ context.Context, eventID uuid.UUID) ([]store.MonitorZoneCount, int, error) { + return f.getMonitorZones(eventID) +} +func (f *fakeStore) GetMonitorMinuteBuckets(_ context.Context, eventID uuid.UUID, since time.Time) ([]store.MinuteBucket, error) { + return f.getMonitorMinuteBuckets(eventID, since) +} +func (f *fakeStore) GetMonitorStations(_ context.Context, eventID uuid.UUID) ([]store.MonitorStation, error) { + return f.getMonitorStations(eventID) +} func (f *fakeStore) CreateTenantWithDefaultSubscription(_ context.Context, tenant *models.Tenant) error { return f.createTenantWithDefaultSubscription(tenant) diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 662fd49a..636db0d7 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -464,6 +464,87 @@ components: type: array items: { $ref: "#/components/schemas/CheckinActionRow" } required: [actions] + MonitorPeak: + type: object + description: > + The highest one-minute check-in bucket "today" (UTC) — totals.peak + (P4.2 Task 3, spec §3.1) — paired with that bucket's start time. + totals.peak is null instead when there have been no 'checkin' + actions today. + properties: + rate: { type: number } + at: { type: string, format: date-time } + required: [rate, at] + additionalProperties: false + MonitorTotals: + type: object + description: > + Monitor snapshot's totals block (P4.2 Task 3, spec §3.1). + rate_per_min is the sum of 'checkin' actions in the last 5 minutes + divided by 5, rounded to one decimal. peak is null when there have + been no check-ins today. est_done_at is null when rate_per_min is + effectively zero (< 0.1) or checked_in >= total (event already + fully checked in). + properties: + checked_in: { type: integer } + total: { type: integer } + rate_per_min: { type: number } + peak: + nullable: true + allOf: + - $ref: "#/components/schemas/MonitorPeak" + est_done_at: { type: string, format: date-time, nullable: true } + required: [checked_in, total, rate_per_min, peak, est_done_at] + additionalProperties: false + MonitorZone: + type: object + description: > + One zone's currently-checked-in count for the monitor snapshot's + zones[] (P4.2 Task 3) — mirrors store.MonitorZoneCount. Zero-count + zones are included, in event_zones.order_index order. + properties: + zone_id: { type: string, format: uuid } + name: { type: string } + checked_in: { type: integer } + required: [zone_id, name, checked_in] + additionalProperties: false + MonitorStationRow: + type: object + description: > + One check-in station's liveness + running count for the monitor + snapshot's stations[] (P4.2 Task 3) — mirrors store.MonitorStation, + ordered by name. + properties: + id: { type: string, format: uuid } + name: { type: string } + zone_id: { type: string, format: uuid, nullable: true } + last_seen_at: { type: string, format: date-time } + checkin_count: { type: integer } + required: [id, name, zone_id, last_seen_at, checkin_count] + additionalProperties: false + MonitorSnapshot: + type: object + description: > + GET /api/events/{event_id}/monitor's response (P4.2 Task 3, spec + §3.1) — everything the live monitor screen (board 7e) renders in + one request. Invariant: sum(zones[].checked_in) + unattributed == + totals.checked_in (see store.GetMonitorZones). recent reuses the + same CheckinActionRow shape as GET + /api/events/{event_id}/checkin-actions (last 20, newest first). + properties: + totals: { $ref: "#/components/schemas/MonitorTotals" } + zones: + type: array + items: { $ref: "#/components/schemas/MonitorZone" } + unattributed: { type: integer } + stations: + type: array + items: { $ref: "#/components/schemas/MonitorStationRow" } + recent: + type: array + items: { $ref: "#/components/schemas/CheckinActionRow" } + required: [totals, zones, unattributed, stations, recent] + additionalProperties: false MarkAttendeePrintedRequest: type: object description: > @@ -2157,6 +2238,49 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Error" } + /api/events/{event_id}/monitor: + get: + operationId: getEventMonitor + summary: > + Live monitor snapshot (P4.2 Task 3, spec §3.1) — totals with + progress, scans/min + peak + estimated-done, per-zone breakdown, + per-station liveness, and the last 20 check-in/undo/reprint feed + rows. Backs the tablet monitor screen (board 7e) and the Home + LiveStrip. + security: [{ bearerAuth: [] }] + parameters: + - name: event_id + in: path + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: The event's current monitor snapshot. + content: + application/json: + schema: { $ref: "#/components/schemas/MonitorSnapshot" } + "400": + description: event_id is not a UUID. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist, or belongs to a different tenant + (requireEventOwnership masks "foreign" as "missing"). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: Store failure resolving event ownership or any aggregation. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } /api/events/{id}/readiness: get: operationId: getEventReadiness diff --git a/panel/src/shared/api/schema.d.ts b/panel/src/shared/api/schema.d.ts index c17ace41..bd897ed1 100644 --- a/panel/src/shared/api/schema.d.ts +++ b/panel/src/shared/api/schema.d.ts @@ -382,6 +382,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/events/{event_id}/monitor": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Live monitor snapshot (P4.2 Task 3, spec §3.1) — totals with progress, scans/min + peak + estimated-done, per-zone breakdown, per-station liveness, and the last 20 check-in/undo/reprint feed rows. Backs the tablet monitor screen (board 7e) and the Home LiveStrip. */ + get: operations["getEventMonitor"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/events/{id}/readiness": { parameters: { query?: never; @@ -1272,6 +1289,47 @@ export interface components { CheckinActionsResponse: { actions: components["schemas"]["CheckinActionRow"][]; }; + /** @description The highest one-minute check-in bucket "today" (UTC) — totals.peak (P4.2 Task 3, spec §3.1) — paired with that bucket's start time. totals.peak is null instead when there have been no 'checkin' actions today. */ + MonitorPeak: { + rate: number; + /** Format: date-time */ + at: string; + }; + /** @description Monitor snapshot's totals block (P4.2 Task 3, spec §3.1). rate_per_min is the sum of 'checkin' actions in the last 5 minutes divided by 5, rounded to one decimal. peak is null when there have been no check-ins today. est_done_at is null when rate_per_min is effectively zero (< 0.1) or checked_in >= total (event already fully checked in). */ + MonitorTotals: { + checked_in: number; + total: number; + rate_per_min: number; + peak: components["schemas"]["MonitorPeak"] | null; + /** Format: date-time */ + est_done_at: string | null; + }; + /** @description One zone's currently-checked-in count for the monitor snapshot's zones[] (P4.2 Task 3) — mirrors store.MonitorZoneCount. Zero-count zones are included, in event_zones.order_index order. */ + MonitorZone: { + /** Format: uuid */ + zone_id: string; + name: string; + checked_in: number; + }; + /** @description One check-in station's liveness + running count for the monitor snapshot's stations[] (P4.2 Task 3) — mirrors store.MonitorStation, ordered by name. */ + MonitorStationRow: { + /** Format: uuid */ + id: string; + name: string; + /** Format: uuid */ + zone_id: string | null; + /** Format: date-time */ + last_seen_at: string; + checkin_count: number; + }; + /** @description GET /api/events/{event_id}/monitor's response (P4.2 Task 3, spec §3.1) — everything the live monitor screen (board 7e) renders in one request. Invariant: sum(zones[].checked_in) + unattributed == totals.checked_in (see store.GetMonitorZones). recent reuses the same CheckinActionRow shape as GET /api/events/{event_id}/checkin-actions (last 20, newest first). */ + MonitorSnapshot: { + totals: components["schemas"]["MonitorTotals"]; + zones: components["schemas"]["MonitorZone"][]; + unattributed: number; + stations: components["schemas"]["MonitorStationRow"][]; + recent: components["schemas"]["CheckinActionRow"][]; + }; /** @description POST /api/attendees/{attendee_id}/printed's OPTIONAL request body (P4.1 Task 4). Both fields are optional, but NOT independent of each other — the handler (attendee_printed.go) enforces two dependency/consistency constraints the schema below cannot express structurally (OpenAPI 3.0 has no clean native "field A requires field B" construct), documented in prose on each field and on the endpoint's 400 response instead: (1) station_id requires event_id to also be present — a station_id with no event_id is rejected with 400, not silently discarded (PR #77 bot-review round 1, Finding D); (2) event_id, when present, must match the attendee's actual event — a mismatched event_id is rejected with 400, never silently substituted (checked since this endpoint's reprint-logging was first built, Task 4). event_id is what actually gates the reprint-logging behavior — station_id is only meaningful (recorded on the feed row) when a validated event_id is also present. Absent entirely (or an absent/empty body) is the pre-existing back-compat path: counter-only, no checkin_actions row (the badge-editor's bulk print sends no body at all). */ MarkAttendeePrintedRequest: { /** @@ -3218,6 +3276,64 @@ export interface operations { }; }; }; + getEventMonitor: { + parameters: { + query?: never; + header?: never; + path: { + event_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The event's current monitor snapshot. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MonitorSnapshot"]; + }; + }; + /** @description event_id is not a UUID. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description tenant_suspended from the tenant gate. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Event does not exist, or belongs to a different tenant (requireEventOwnership masks "foreign" as "missing"). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Store failure resolving event ownership or any aggregation. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getEventReadiness: { parameters: { query?: never; From 4b013d72190e9860fa110afd218fa4ccbf866612 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 00:58:28 +0300 Subject: [PATCH 06/23] test(backend): pin computeRates window boundaries and peak tie-break --- .../internal/handler/monitor_rates_test.go | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/backend/internal/handler/monitor_rates_test.go b/backend/internal/handler/monitor_rates_test.go index 7941b6ea..863940bd 100644 --- a/backend/internal/handler/monitor_rates_test.go +++ b/backend/internal/handler/monitor_rates_test.go @@ -167,3 +167,65 @@ func TestComputeRates_RateRoundedToOneDecimal(t *testing.T) { t.Errorf("rate = %v, want 1.4", rate) } } + +func TestComputeRates_RateWindowBoundaryPins(t *testing.T) { + // The rate window is half-open [now-5m, now): a bucket at EXACTLY now-5m + // IS counted (inclusive boundary); a bucket at EXACTLY now is NOT counted + // (exclusive boundary). These tests pin the contract against future off-by-one refactors. + windowStart := fixedNow.Add(-5 * time.Minute) + + tests := []struct { + name string + bucketTime time.Time + bucketCount int + wantRate float64 + }{ + { + name: "bucket at exactly now-5m (windowStart) IS counted", + bucketTime: windowStart, + bucketCount: 5, + wantRate: 1.0, // 5/5 = 1.0 + }, + { + name: "bucket at exactly now is NOT counted", + bucketTime: fixedNow, + bucketCount: 5, + wantRate: 0.0, // excluded from window + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buckets := []store.MinuteBucket{{Minute: tt.bucketTime, Count: tt.bucketCount}} + rate, _, _ := computeRates(buckets, fixedNow, 1000, 0) + if rate != tt.wantRate { + t.Errorf("rate = %v, want %v", rate, tt.wantRate) + } + }) + } +} + +func TestComputeRates_PeakTieBreakerPin(t *testing.T) { + // When two buckets have equal counts, peak returns the earlier bucket's + // timestamp (first-wins convention, since buckets are iterated in ascending + // time order and peak only updates on strictly greater count, not equal). + earlierBucket := fixedNow.Add(-3 * time.Minute) + laterBucket := fixedNow.Add(-2 * time.Minute) + + buckets := []store.MinuteBucket{ + {Minute: earlierBucket, Count: 10}, + {Minute: laterBucket, Count: 10}, + } + + _, peak, _ := computeRates(buckets, fixedNow, 1000, 0) + + if peak == nil { + t.Fatalf("peak = nil, want non-nil") + } + if peak.Rate != 10 { + t.Errorf("peak.Rate = %v, want 10", peak.Rate) + } + if !peak.At.Equal(earlierBucket) { + t.Errorf("peak.At = %v, want %v (earlier bucket wins on tie)", peak.At, earlierBucket) + } +} From ee3b254537103e91c341bb1e26a3ef88e5e09cbe Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 01:21:47 +0300 Subject: [PATCH 07/23] feat(backend): SSE monitor stream + check-in event publishes Adds the codebase's first Server-Sent Events endpoint, GET /api/events/{event_id}/monitor/stream, plus the four broker publish call sites (check-in, undo, reprint, station heartbeat) that feed it. Co-Authored-By: Claude Sonnet 5 --- backend/internal/broker/broker.go | 6 + backend/internal/handler/attendee_printed.go | 10 + .../handler/attendee_printed_publish_test.go | 114 ++++++ backend/internal/handler/checkin.go | 25 ++ .../internal/handler/checkin_publish_test.go | 244 ++++++++++++ backend/internal/handler/checkin_stations.go | 13 + .../handler/checkin_stations_publish_test.go | 106 ++++++ backend/internal/handler/handler.go | 14 +- backend/internal/handler/monitor_stream.go | 106 ++++++ .../internal/handler/monitor_stream_test.go | 353 ++++++++++++++++++ backend/main.go | 12 + backend/openapi.yaml | 74 ++++ panel/src/shared/api/schema.d.ts | 75 ++++ 13 files changed, 1151 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/attendee_printed_publish_test.go create mode 100644 backend/internal/handler/checkin_publish_test.go create mode 100644 backend/internal/handler/checkin_stations_publish_test.go create mode 100644 backend/internal/handler/monitor_stream.go create mode 100644 backend/internal/handler/monitor_stream_test.go diff --git a/backend/internal/broker/broker.go b/backend/internal/broker/broker.go index b2857c21..bc777702 100644 --- a/backend/internal/broker/broker.go +++ b/backend/internal/broker/broker.go @@ -33,5 +33,11 @@ type Broker interface { // re-fetch on every "update" frame, so a coalesced signal never means // stale data). The returned unsubscribe func is idempotent and safe to // call concurrently with Publish and with itself. + // + // The channel is NEVER closed — not by Publish, not by unsubscribe, not + // by the broker shutting down. A consumer MUST therefore select on it + // alongside at least one other case (a request context's Done() and/or + // a keep-alive ticker, as the P4.2 SSE handler does) and never `range` + // over it, which would block forever instead of observing shutdown. Subscribe(eventID uuid.UUID) (<-chan struct{}, func()) } diff --git a/backend/internal/handler/attendee_printed.go b/backend/internal/handler/attendee_printed.go index 1ff15eb7..5f347ed3 100644 --- a/backend/internal/handler/attendee_printed.go +++ b/backend/internal/handler/attendee_printed.go @@ -166,6 +166,16 @@ func (h *Handler) MarkAttendeePrinted(c echo.Context) error { log.Printf("mark attendee printed: skip reprint log, invalid staff user id: %v", err) } else if err := h.Store.InsertCheckinAction(c.Request().Context(), *eventID, attendeeID, "reprint", stationID, staffUserID); err != nil { log.Printf("mark attendee printed: failed to log reprint checkin_actions row: %v", err) + } else if h.Broker != nil { + // Publish ONLY reached when the reprint feed row was actually + // logged (P4.2 Task 4) — a skipped log (no claims, bad staff + // id, or a store failure above) means the monitor's recent-feed + // wouldn't show anything new anyway, so signaling it to + // re-fetch would be a pointless round trip. Nil-safe, + // best-effort, after the row already committed. + if pubErr := h.Broker.Publish(c.Request().Context(), *eventID); pubErr != nil { + log.Printf("mark attendee printed: broker publish failed: %v", pubErr) + } } } diff --git a/backend/internal/handler/attendee_printed_publish_test.go b/backend/internal/handler/attendee_printed_publish_test.go new file mode 100644 index 00000000..553d37a2 --- /dev/null +++ b/backend/internal/handler/attendee_printed_publish_test.go @@ -0,0 +1,114 @@ +package handler + +import ( + "errors" + "net/http" + "testing" + + "idento/backend/internal/broker" + "idento/backend/internal/models" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// --- P4.2 Task 4: MarkAttendeePrinted broker publish site --- + +// TestMarkAttendeePrinted_PublishesOnlyWhenReprintLogged proves the +// narrowest of the four publish rules (P4.2 Task 4): a publish only +// happens when a 'reprint' checkin_actions row was ACTUALLY logged, not +// merely attempted — a no-body call (back-compat counter-only path) and an +// InsertCheckinAction failure both increment printed_count (200) without +// ever signaling the monitor. +func TestMarkAttendeePrinted_PublishesOnlyWhenReprintLogged(t *testing.T) { + tests := []struct { + name string + body string + insertCheckinAction func(eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error + wantPublish bool + }{ + { + name: "reprint row logged successfully publishes", + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + return nil + }, + wantPublish: true, + }, + { + name: "InsertCheckinAction failure does not publish", + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + return errors.New("boom") + }, + wantPublish: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { return 1, nil }, + insertCheckinAction: tc.insertCheckinAction, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := markPrintedPath(attendee.ID) + body := `{"event_id":"` + event.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if got := pendingSignal(ch); got != tc.wantPublish { + t.Fatalf("publish signal = %v, want %v", got, tc.wantPublish) + } + }) + } +} + +// TestMarkAttendeePrinted_NoBodyDoesNotPublish proves the pre-existing +// body-less badge-editor bulk-print path (no event_id, so no reprint row +// is even attempted) never signals the monitor. +func TestMarkAttendeePrinted_NoBodyDoesNotPublish(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { return 1, nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := markPrintedPath(attendee.ID) + c, rec := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false for a body-less call (no reprint row logged)") + } +} diff --git a/backend/internal/handler/checkin.go b/backend/internal/handler/checkin.go index de874370..e0dd5374 100644 --- a/backend/internal/handler/checkin.go +++ b/backend/internal/handler/checkin.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "log" "net/http" "strconv" "time" @@ -170,6 +171,19 @@ func (h *Handler) StationCheckin(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check in attendee"}) } + // Publish only on outcome "checked_in" (P4.2 Task 4, plan-time fact 3 + + // self-review notes): "already_checked_in" and "blocked" both leave the + // monitor-visible state completely unchanged, so signaling the monitor + // to re-fetch a snapshot that will come back identical would just be + // wasted work on every subscriber. Nil-safe, best-effort, AFTER the + // store call already committed — a publish failure never turns a + // successful check-in into an error response. + if outcome == "checked_in" && h.Broker != nil { + if pubErr := h.Broker.Publish(c.Request().Context(), eventID); pubErr != nil { + log.Printf("station checkin: broker publish failed: %v", pubErr) + } + } + var checkin *CheckinInfo if updated.CheckedInAt != nil { byEmail := "" @@ -231,6 +245,17 @@ func (h *Handler) UndoCheckin(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to undo check-in"}) } + // Publish on every 200 (P4.2 Task 4) — including the idempotent + // already-clear case: a redundant signal just costs subscribers one + // harmless snapshot re-fetch that comes back unchanged, which is + // strictly cheaper than trying to detect "did this undo actually + // change anything" here. Nil-safe, best-effort, AFTER the store call. + if h.Broker != nil { + if pubErr := h.Broker.Publish(c.Request().Context(), eventID); pubErr != nil { + log.Printf("undo checkin: broker publish failed: %v", pubErr) + } + } + return c.JSON(http.StatusOK, UndoCheckinResponse{Attendee: updated}) } diff --git a/backend/internal/handler/checkin_publish_test.go b/backend/internal/handler/checkin_publish_test.go new file mode 100644 index 00000000..58b5c6b7 --- /dev/null +++ b/backend/internal/handler/checkin_publish_test.go @@ -0,0 +1,244 @@ +package handler + +import ( + "net/http" + "testing" + "time" + + "idento/backend/internal/broker" + "idento/backend/internal/models" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// --- P4.2 Task 4: StationCheckin / UndoCheckin broker publish sites --- +// +// pendingSignal is a non-blocking drain of a MemBroker subscription +// channel: true means Publish fired for that event since the channel was +// created (or since the last drain), false means it didn't. Because +// MemBroker.Subscribe's channel is 1-buffered and coalescing (broker.go), +// this can't distinguish "published once" from "published N>1 times" — +// but every publish site in this package fires at most once per handler +// call, so 0-vs-"at least one" is exactly the distinction these tests +// need, using the real exported Broker API rather than reaching into +// MemBroker's unexported fields. +func pendingSignal(ch <-chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +// TestStationCheckin_PublishesOnlyOnCheckedIn proves the outcome-gated +// publish rule (P4.2 Task 4, self-review notes): "checked_in" is the only +// outcome that changes monitor-visible state, so it's the only one that +// should wake up a monitor subscriber. +func TestStationCheckin_PublishesOnlyOnCheckedIn(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + checkIn func(eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID, staffEmail, stationName string) (string, *models.Attendee, error) + blocked bool + wantOutcome string + wantPublish bool + }{ + { + name: "checked_in publishes", + checkIn: func(_, attendeeID uuid.UUID, _ *uuid.UUID, _ uuid.UUID, staffEmail, stationName string) (string, *models.Attendee, error) { + return "checked_in", &models.Attendee{ID: attendeeID, CheckinStatus: true, CheckedInAt: &now, CheckedInByEmail: &staffEmail, CheckedInPointName: &stationName}, nil + }, + wantOutcome: "checked_in", + wantPublish: true, + }, + { + name: "already_checked_in does not publish", + checkIn: func(_, attendeeID uuid.UUID, _ *uuid.UUID, _ uuid.UUID, staffEmail, stationName string) (string, *models.Attendee, error) { + return "already_checked_in", &models.Attendee{ID: attendeeID, CheckinStatus: true, CheckedInAt: &now, CheckedInByEmail: &staffEmail, CheckedInPointName: &stationName}, nil + }, + wantOutcome: "already_checked_in", + wantPublish: false, + }, + { + name: "blocked does not publish", + blocked: true, + wantOutcome: "blocked", + wantPublish: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + attendee.Blocked = tc.blocked + staffID := uuid.New() + + h := newStationCheckinHandler(event, attendee, + func(uuid.UUID) (*models.User, error) { + return &models.User{ID: staffID, Email: "staff@example.com"}, nil + }, + nil, + tc.checkIn, + nil, + ) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `"}` + c, rec := newAuthedContextWithUserID(e, http.MethodPost, path, body, tenantID.String(), staffID, "staff") + setCheckinPathParams(c, event.ID) + + if err := h.StationCheckin(c); err != nil { + t.Fatalf("StationCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + + var got StationCheckinResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Outcome != tc.wantOutcome { + t.Fatalf("outcome = %q, want %q", got.Outcome, tc.wantOutcome) + } + + if got := pendingSignal(ch); got != tc.wantPublish { + t.Fatalf("publish signal = %v, want %v for outcome %q", got, tc.wantPublish, tc.wantOutcome) + } + }) + } +} + +// TestStationCheckin_NilBrokerDoesNotPanic proves the nil-safe guard: a +// Handler with no Broker set (the ~70 existing `&Handler{Store: fs}` test +// literals across this package) still completes a checked_in scan without +// panicking on a nil Broker.Publish call. +func TestStationCheckin_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + staffID := uuid.New() + now := time.Now() + + h := newStationCheckinHandler(event, attendee, + func(uuid.UUID) (*models.User, error) { + return &models.User{ID: staffID, Email: "staff@example.com"}, nil + }, + nil, + func(_, attendeeID uuid.UUID, _ *uuid.UUID, _ uuid.UUID, staffEmail, stationName string) (string, *models.Attendee, error) { + return "checked_in", &models.Attendee{ID: attendeeID, CheckinStatus: true, CheckedInAt: &now, CheckedInByEmail: &staffEmail, CheckedInPointName: &stationName}, nil + }, + nil, + ) + // h.Broker intentionally left nil. + + e := echo.New() + path := checkinPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `"}` + c, rec := newAuthedContextWithUserID(e, http.MethodPost, path, body, tenantID.String(), staffID, "staff") + setCheckinPathParams(c, event.ID) + + if err := h.StationCheckin(c); err != nil { + t.Fatalf("StationCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestUndoCheckin_PublishesOn200 proves UndoCheckin publishes unconditionally +// on every successful (200) call, including the idempotent already-clear +// case (P4.2 Task 4: "UndoCheckin (on 200)", no outcome-based carve-out +// unlike StationCheckin). +func TestUndoCheckin_PublishesOn200(t *testing.T) { + tests := []struct { + name string + undo func(eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error) + }{ + { + name: "clears an active checkin", + undo: func(_, attendeeID uuid.UUID, _ *uuid.UUID, _ uuid.UUID) (*models.Attendee, error) { + return &models.Attendee{ID: attendeeID, CheckinStatus: false}, nil + }, + }, + { + name: "idempotent already-clear undo still publishes", + undo: func(_, attendeeID uuid.UUID, _ *uuid.UUID, _ uuid.UUID) (*models.Attendee, error) { + return &models.Attendee{ID: attendeeID, CheckinStatus: false}, nil + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := newStationCheckinHandler(event, attendee, nil, nil, nil, tc.undo) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinUndoPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinUndoPathParams(c, event.ID) + + if err := h.UndoCheckin(c); err != nil { + t.Fatalf("UndoCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on a successful undo") + } + }) + } +} + +// TestUndoCheckin_UnknownAttendee404DoesNotPublish proves the 404 path +// never signals the monitor — nothing changed, so there's nothing for a +// subscriber to re-fetch. +func TestUndoCheckin_UnknownAttendee404DoesNotPublish(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return nil, nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinUndoPath(event.ID) + body := `{"attendee_id":"` + uuid.New().String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinUndoPathParams(c, event.ID) + + if err := h.UndoCheckin(c); err != nil { + t.Fatalf("UndoCheckin: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false on a 404") + } +} diff --git a/backend/internal/handler/checkin_stations.go b/backend/internal/handler/checkin_stations.go index 5dc95323..336c4af1 100644 --- a/backend/internal/handler/checkin_stations.go +++ b/backend/internal/handler/checkin_stations.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "log" "net/http" "strings" @@ -116,6 +117,18 @@ func (h *Handler) HeartbeatCheckinStation(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update check-in station"}) } + // Publish on every successful heartbeat (P4.2 Task 4, self-review + // notes): unlike check-in/undo/reprint, a heartbeat's ONLY effect is + // bumping last_seen_at — but the monitor's stations card renders that + // exact field (liveness dot + staleness), so it IS monitor-visible + // state and every 204 here is worth a signal. Nil-safe, best-effort, + // after the store call already committed. + if h.Broker != nil { + if pubErr := h.Broker.Publish(c.Request().Context(), eventID); pubErr != nil { + log.Printf("heartbeat checkin station: broker publish failed: %v", pubErr) + } + } + return c.NoContent(http.StatusNoContent) } diff --git a/backend/internal/handler/checkin_stations_publish_test.go b/backend/internal/handler/checkin_stations_publish_test.go new file mode 100644 index 00000000..0739b50a --- /dev/null +++ b/backend/internal/handler/checkin_stations_publish_test.go @@ -0,0 +1,106 @@ +package handler + +import ( + "net/http" + "testing" + + "idento/backend/internal/broker" + "idento/backend/internal/store" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// --- P4.2 Task 4: HeartbeatCheckinStation broker publish site --- + +// TestHeartbeatCheckinStation_PublishesOn204 proves a successful heartbeat +// (204) signals the monitor — unlike the other three publish sites, +// heartbeat's only effect is last_seen_at, but the monitor's stations card +// renders exactly that field (P4.2 Task 4, self-review notes). +func TestHeartbeatCheckinStation_PublishesOn204(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + + h := newCheckinStationHandler(event, nil, nil, + func(uuid.UUID, uuid.UUID) error { return nil }, + nil, + ) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinStationHeartbeatPath(event.ID, stationID) + c, rec := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c, event.ID, stationID) + + if err := h.HeartbeatCheckinStation(c); err != nil { + t.Fatalf("HeartbeatCheckinStation: %v", err) + } + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on a successful heartbeat") + } +} + +// TestHeartbeatCheckinStation_Unknown404DoesNotPublish proves an unknown +// station id (404) never signals the monitor. +func TestHeartbeatCheckinStation_Unknown404DoesNotPublish(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + + h := newCheckinStationHandler(event, nil, nil, + func(uuid.UUID, uuid.UUID) error { return store.ErrCheckinStationNotFound }, + nil, + ) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinStationHeartbeatPath(event.ID, stationID) + c, rec := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c, event.ID, stationID) + + if err := h.HeartbeatCheckinStation(c); err != nil { + t.Fatalf("HeartbeatCheckinStation: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false on a 404") + } +} + +// TestHeartbeatCheckinStation_NilBrokerDoesNotPanic proves the nil-safe +// guard for the heartbeat publish site. +func TestHeartbeatCheckinStation_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + + h := newCheckinStationHandler(event, nil, nil, + func(uuid.UUID, uuid.UUID) error { return nil }, + nil, + ) + // h.Broker intentionally left nil. + + e := echo.New() + path := checkinStationHeartbeatPath(event.ID, stationID) + c, rec := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c, event.ID, stationID) + + if err := h.HeartbeatCheckinStation(c); err != nil { + t.Fatalf("HeartbeatCheckinStation: %v", err) + } + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d, body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 84ad53ba..4f650d96 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -6,6 +6,7 @@ package handler import ( "time" + "idento/backend/internal/broker" "idento/backend/internal/config" "idento/backend/internal/middleware" "idento/backend/internal/store" @@ -16,8 +17,18 @@ import ( ) // Handler holds dependencies (e.g. Store) and implements HTTP handlers for the API. +// +// Broker is nil-safe by design (P4.2 Task 4, plan-time fact 3): it is set +// AFTER construction (main.go wires a *broker.PGBroker onto it once the +// store exists), never via New's signature — so the ~70 existing +// `&Handler{Store: fs}` test literals across this package stay valid with a +// nil Broker. Every publish call site must guard with `if h.Broker != nil` +// and log-don't-fail on a Publish error; only the monitor SSE stream itself +// requires a non-nil Broker to be useful (a nil Broker still serves a valid +// stream — see monitor_stream.go — it just never emits "update" frames). type Handler struct { - Store store.Store + Store store.Store + Broker broker.Broker } // New returns a new Handler with the given store. @@ -87,6 +98,7 @@ func (h *Handler) RegisterRoutes(e *echo.Echo, mode string) { api.POST("/events/:event_id/checkin/undo", h.UndoCheckin) api.GET("/events/:event_id/checkin-actions", h.GetCheckinActions) api.GET("/events/:event_id/monitor", h.GetEventMonitor) + api.GET("/events/:event_id/monitor/stream", h.GetEventMonitorStream) api.GET("/events/:id/readiness", h.GetEventReadiness) api.GET("/events/:event_id/stats", h.GetEventStats) api.GET("/events/:event_id/staff", h.GetEventStaff) diff --git a/backend/internal/handler/monitor_stream.go b/backend/internal/handler/monitor_stream.go new file mode 100644 index 00000000..12370e79 --- /dev/null +++ b/backend/internal/handler/monitor_stream.go @@ -0,0 +1,106 @@ +package handler + +import ( + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// monitorStreamPingInterval is the SSE keep-alive cadence (P4.2 Task 4, +// spec §3.3): a `: ping\n\n` comment line is written+flushed on this tick +// so an idle-but-live connection never looks abandoned to an intermediary +// proxy/load balancer. A package var, not a const, so +// monitor_stream_test.go can shrink it to exercise the ping branch without +// a real 25-second sleep. +var monitorStreamPingInterval = 25 * time.Second + +// GetEventMonitorStream serves GET /api/events/{event_id}/monitor/stream +// (P4.2 Task 4, spec §3.3) — the codebase's first Server-Sent Events +// endpoint. It is a deliberately "thin-ping" stream: frames carry no +// monitor state themselves, only a signal telling the client to re-fetch +// Task 3's GET .../monitor snapshot. Order matters: requireEventOwnership +// runs BEFORE any stream header is written, so a foreign/missing event +// still gets a plain 404 JSON body (never a half-open event-stream +// response the client would have to notice and abandon). +// +// The handler blocks for the lifetime of the connection — that's the +// correct shape for a streaming handler, not a goroutine leak: it returns +// (unsubscribing on the way out via the deferred call) the moment the +// request context is cancelled, i.e. the client disconnects. +func (h *Handler) GetEventMonitorStream(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("event_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + res := c.Response() + res.Header().Set(echo.HeaderContentType, "text/event-stream") + res.Header().Set("Cache-Control", "no-cache") + res.Header().Set("Connection", "keep-alive") + res.WriteHeader(http.StatusOK) + + // Subscribe BEFORE writing the hello frame: that ordering guarantees + // that by the time a client has observed hello, the subscription is + // already live, so a Publish landing the instant afterward can never + // be missed — see broker.Broker.Subscribe's coalescing contract + // (1-buffered, drop-if-full) for why a signal delivered before the + // select loop below starts running is still safely picked up once it + // does. Nil-safe (Handler.Broker doc comment, handler.go): a Handler + // built without a Broker still serves a valid stream — ch stays a + // permanently-nil channel, so its select case simply never fires; the + // client still gets hello + keep-alive pings, just no "update" frames. + var ch <-chan struct{} + unsubscribe := func() {} + if h.Broker != nil { + ch, unsubscribe = h.Broker.Subscribe(eventID) + } + defer unsubscribe() + + if !writeSSEFrame(res, "event: hello\ndata: {}\n\n") { + return nil + } + + ticker := time.NewTicker(monitorStreamPingInterval) + defer ticker.Stop() + + ctx := c.Request().Context() + for { + select { + case <-ctx.Done(): + // Client disconnected (or the server is shutting down): clean + // return, deferred unsubscribe/ticker.Stop() run on the way out. + return nil + case <-ch: + frame := fmt.Sprintf("event: update\ndata: {\"at\":%q}\n\n", time.Now().UTC().Format(time.RFC3339)) + if !writeSSEFrame(res, frame) { + return nil + } + case <-ticker.C: + if !writeSSEFrame(res, ": ping\n\n") { + return nil + } + } + } +} + +// writeSSEFrame writes one SSE frame and immediately flushes it (every +// frame must be individually flushed — the whole point of a thin-ping +// stream is that the client sees each signal the moment it's published, +// not whenever some buffer happens to fill). It returns false, swallowing +// the error, on a write failure: on a live HTTP connection that means the +// client already disconnected — the ordinary way an SSE stream ends, not +// an error worth surfacing (there is no response body left to report one +// into). The caller treats false as "return nil now." +func writeSSEFrame(res *echo.Response, frame string) bool { + if _, err := res.Write([]byte(frame)); err != nil { + return false + } + res.Flush() + return true +} diff --git a/backend/internal/handler/monitor_stream_test.go b/backend/internal/handler/monitor_stream_test.go new file mode 100644 index 00000000..8a843d6c --- /dev/null +++ b/backend/internal/handler/monitor_stream_test.go @@ -0,0 +1,353 @@ +package handler + +import ( + "bufio" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "idento/backend/internal/broker" + "idento/backend/internal/models" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// --- P4.2 Task 4: SSE monitor stream --- +// +// httptest.NewRecorder() cannot back these tests: it buffers the whole +// response into a bytes.Buffer with no way for a test goroutine to read +// frames as they're written without racing the handler goroutine's writes +// (a `go test -race` failure waiting to happen), and it has no real +// http.Flusher semantics anyway. Instead, every streaming test here runs +// the handler behind a REAL httptest.Server (echo.Context wraps the real +// net/http ResponseWriter, so Flush/WriteHeader/the request's +// cancel-on-disconnect Context all behave exactly as they do in +// production) and reads frames incrementally through a real http.Client + +// bufio.Reader — the httptest.NewServer approach the task brief called +// out as composing best with this package's existing echo test-context +// idiom (newAuthedContext et al.), which the ForeignEvent404 test below +// still uses directly since that path returns before any stream write. + +// newMonitorStreamTestServer wires h.GetEventMonitorStream behind a real +// HTTP server (bypassing echo's router entirely — event_id and the "user" +// JWT claims are set directly on the echo.Context, mirroring +// newAuthedContext's claims shape). The returned channel is closed the +// moment the handler call returns, which is this file's proof that a +// disconnected/cancelled stream actually unwinds instead of leaking its +// goroutine. +func newMonitorStreamTestServer(t *testing.T, h *Handler, eventID, tenantID uuid.UUID) (*httptest.Server, <-chan struct{}) { + t.Helper() + e := echo.New() + done := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(done) + c := e.NewContext(r, w) + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) + c.Set("user", &models.JWTCustomClaims{ + UserID: uuid.New().String(), + TenantID: tenantID.String(), + Role: "staff", + }) + if err := h.GetEventMonitorStream(c); err != nil { + t.Errorf("GetEventMonitorStream: %v", err) + } + })) + t.Cleanup(srv.Close) + return srv, done +} + +// readSSEFrame reads one SSE frame (accumulated lines up to and including +// the blank line that terminates it) off r. The read happens on its own +// goroutine so a hung/broken stream fails the test via the timeout instead +// of hanging the whole suite; on timeout that goroutine simply blocks +// until t.Cleanup's srv.Close() forces the connection closed, at which +// point it sends into the (buffered, so non-blocking) result channel and +// exits — not a real leak beyond the test's own lifetime. +func readSSEFrame(t *testing.T, r *bufio.Reader, timeout time.Duration) string { + t.Helper() + type result struct { + frame string + err error + } + ch := make(chan result, 1) + go func() { + var sb strings.Builder + for { + line, err := r.ReadString('\n') + sb.WriteString(line) + if err != nil { + ch <- result{frame: sb.String(), err: err} + return + } + if line == "\n" { + ch <- result{frame: sb.String()} + return + } + } + }() + select { + case res := <-ch: + if res.err != nil { + t.Fatalf("read SSE frame: %v (partial: %q)", res.err, res.frame) + } + return res.frame + case <-time.After(timeout): + t.Fatalf("timed out after %s waiting for an SSE frame", timeout) + return "" + } +} + +// TestGetEventMonitorStream_HelloFrameFirst proves the connection opens +// with the correct SSE headers and that the very first thing written is +// the hello frame, verbatim. +func TestGetEventMonitorStream_HelloFrameFirst(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + mem := broker.NewMemBroker() + h := New(&fakeStore{getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }}) + h.Broker = mem + + srv, _ := newMonitorStreamTestServer(t, h, event.ID, tenantID) + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET stream: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("Content-Type = %q, want text/event-stream", ct) + } + if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" { + t.Fatalf("Cache-Control = %q, want no-cache", cc) + } + + r := bufio.NewReader(resp.Body) + frame := readSSEFrame(t, r, 2*time.Second) + if frame != "event: hello\ndata: {}\n\n" { + t.Fatalf("first frame = %q, want the hello frame", frame) + } +} + +// TestGetEventMonitorStream_PublishTriggersUpdateFrame proves a +// broker.Publish for this event produces an "update" frame carrying an +// RFC3339 "at" timestamp. Publish is issued only AFTER the hello frame has +// been read — by that point the handler has already Subscribed (it +// subscribes before writing hello, see GetEventMonitorStream's doc +// comment), so this ordering can never race a not-yet-registered +// subscription. +func TestGetEventMonitorStream_PublishTriggersUpdateFrame(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + mem := broker.NewMemBroker() + h := New(&fakeStore{getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }}) + h.Broker = mem + + srv, _ := newMonitorStreamTestServer(t, h, event.ID, tenantID) + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET stream: %v", err) + } + defer resp.Body.Close() + + r := bufio.NewReader(resp.Body) + _ = readSSEFrame(t, r, 2*time.Second) // hello + + if err := mem.Publish(context.Background(), event.ID); err != nil { + t.Fatalf("Publish: %v", err) + } + + frame := readSSEFrame(t, r, 2*time.Second) + if !strings.HasPrefix(frame, "event: update\ndata: {\"at\":\"") || !strings.HasSuffix(frame, "\"}\n\n") { + t.Fatalf("update frame = %q, want event: update with an \"at\" RFC3339 timestamp", frame) + } + at := strings.TrimSuffix(strings.TrimPrefix(frame, "event: update\ndata: {\"at\":\""), "\"}\n\n") + if _, err := time.Parse(time.RFC3339, at); err != nil { + t.Fatalf("update frame's at = %q is not RFC3339: %v", at, err) + } +} + +// TestGetEventMonitorStream_PingKeepAlive proves the 25s-ticker keep-alive +// comment's exact wire shape, using a shrunk ping interval (restored via +// t.Cleanup) so the test doesn't need a real 25-second sleep. +func TestGetEventMonitorStream_PingKeepAlive(t *testing.T) { + orig := monitorStreamPingInterval + monitorStreamPingInterval = 20 * time.Millisecond + t.Cleanup(func() { monitorStreamPingInterval = orig }) + + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + mem := broker.NewMemBroker() + h := New(&fakeStore{getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }}) + h.Broker = mem + + srv, _ := newMonitorStreamTestServer(t, h, event.ID, tenantID) + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET stream: %v", err) + } + defer resp.Body.Close() + + r := bufio.NewReader(resp.Body) + _ = readSSEFrame(t, r, 2*time.Second) // hello + + frame := readSSEFrame(t, r, 2*time.Second) + if frame != ": ping\n\n" { + t.Fatalf("frame = %q, want the ping keep-alive comment", frame) + } +} + +// TestGetEventMonitorStream_NilBrokerServesHelloAndPing proves the +// nil-safe design: a Handler built without a Broker (an older +// &Handler{Store: fs} test literal, or a genuine misconfiguration) still +// serves a valid stream — hello and pings keep flowing, there is just +// never an "update" frame (nothing to prove a negative on within a bounded +// test, so this only asserts the two frames that DO still arrive). +func TestGetEventMonitorStream_NilBrokerServesHelloAndPing(t *testing.T) { + orig := monitorStreamPingInterval + monitorStreamPingInterval = 20 * time.Millisecond + t.Cleanup(func() { monitorStreamPingInterval = orig }) + + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := New(&fakeStore{getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }}) + // h.Broker intentionally left nil. + + srv, _ := newMonitorStreamTestServer(t, h, event.ID, tenantID) + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET stream: %v", err) + } + defer resp.Body.Close() + + r := bufio.NewReader(resp.Body) + hello := readSSEFrame(t, r, 2*time.Second) + if hello != "event: hello\ndata: {}\n\n" { + t.Fatalf("hello frame = %q", hello) + } + ping := readSSEFrame(t, r, 2*time.Second) + if ping != ": ping\n\n" { + t.Fatalf("ping frame = %q", ping) + } +} + +// TestGetEventMonitorStream_ClientDisconnectUnsubscribesCleanly proves the +// no-goroutine-leak requirement: once the client disconnects, +// GetEventMonitorStream must actually return (proven by the done channel +// newMonitorStreamTestServer closes right after the handler call), and its +// deferred unsubscribe must have run cleanly — proven by a second Publish +// to the same event not panicking (a broken unsubscribe leaving a stale +// entry in MemBroker's fanout map, e.g. a double-close or a send on a +// channel nobody drains anymore, is exactly the class of bug this would +// catch). +func TestGetEventMonitorStream_ClientDisconnectUnsubscribesCleanly(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + mem := broker.NewMemBroker() + h := New(&fakeStore{getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }}) + h.Broker = mem + + srv, done := newMonitorStreamTestServer(t, h, event.ID, tenantID) + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET stream: %v", err) + } + + r := bufio.NewReader(resp.Body) + _ = readSSEFrame(t, r, 2*time.Second) // hello — subscription is live by now. + + // Simulate the client going away: closing the body before the stream + // naturally ends drops the underlying connection, which the server + // notices via the request context's cancel-on-disconnect wiring (see + // net/http.Request.Context's documented behavior). + resp.Body.Close() + + select { + case <-done: + // GetEventMonitorStream returned — clean exit, deferred + // unsubscribe/ticker.Stop() already ran on the way out. + case <-time.After(2 * time.Second): + t.Fatal("handler did not return after client disconnect (goroutine leak / stuck select)") + } + + if err := mem.Publish(context.Background(), event.ID); err != nil { + t.Fatalf("Publish after disconnect: %v", err) + } +} + +// monitorStreamPath/setMonitorStreamPathParams mirror monitorPath/ +// setMonitorPathParams (openapi_contract_monitor_p4_test.go) for the +// stream's sibling route. +func monitorStreamPath(eventID uuid.UUID) string { + return "/api/events/" + eventID.String() + "/monitor/stream" +} + +func setMonitorStreamPathParams(c echo.Context, eventID uuid.UUID) { + c.SetPath("/api/events/:event_id/monitor/stream") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) +} + +// TestOpenAPIContract_GetEventMonitorStream_ForeignEvent404 proves +// requireEventOwnership short-circuits BEFORE any stream header is +// written: a cross-tenant caller gets a masked, plain-JSON 404 — never a +// half-open text/event-stream response. Unlike the streaming tests above, +// this path returns synchronously without ever touching the response +// writer's Flush, so the ordinary httptest.NewRecorder()+validateResponse +// idiom this package uses everywhere else is safe here. +func TestOpenAPIContract_GetEventMonitorStream_ForeignEvent404(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + foreignTenantID := uuid.New() + mem := broker.NewMemBroker() + + h := New(&fakeStore{getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }}) + h.Broker = mem + + e := echo.New() + path := monitorStreamPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", foreignTenantID.String(), "staff") + setMonitorStreamPathParams(c, event.ID) + + if err := h.GetEventMonitorStream(c); err != nil { + t.Fatalf("GetEventMonitorStream: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("Content-Type = %q — stream headers must never be set before requireEventOwnership passes", ct) + } + validateResponse(t, http.MethodGet, path, rec) +} + +// TestOpenAPIContract_GetEventMonitorStream_CoverageException documents +// and satisfies the coverage-ledger exception for this route (P4.2 Task 4, +// plan-time fact 5, docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md): +// openapi3filter.ValidateResponse validates ONE complete response body +// against a schema; GetEventMonitorStream's 200 response is an indefinite +// sequence of frames read incrementally, which has no "complete body" to +// hand it. The 404 path above DOES run through validateResponse normally +// (it never streams). For the 200 path, the real behavioral assertions are +// the streaming tests above (hello frame, update-on-publish, the ping +// cadence, the nil-Broker fallback, and clean unsubscribe-on-disconnect); +// this test's only job is to mark the route covered in the SAME map +// validateResponse itself writes to, so assertSpecCoverage (gated by +// OPENAPI_COVERAGE=1) doesn't flag an untested documented operation. +func TestOpenAPIContract_GetEventMonitorStream_CoverageException(t *testing.T) { + coverageMu.Lock() + coverage["GET /api/events/{event_id}/monitor/stream"] = true + coverageMu.Unlock() +} diff --git a/backend/main.go b/backend/main.go index 16437fbb..88237494 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,6 +4,7 @@ import ( "context" _ "embed" "idento/backend/internal/bootstrap" + "idento/backend/internal/broker" "idento/backend/internal/config" "idento/backend/internal/handler" "idento/backend/internal/retention" @@ -49,6 +50,16 @@ func main() { } defer pgStore.Close() + // Initialize the P4.2 live-monitor event broker (LISTEN/NOTIFY + // transport) — constructed right after the store, closed the same way + // pgStore.Close() is (plan-time fact 4): a dedicated LISTEN connection + // plus a small notify pool, both against the same DatabaseURL. + eventBroker, err := broker.NewPGBroker(context.Background(), cfg.DatabaseURL) + if err != nil { + log.Fatalf("Unable to start event broker: %v\n", err) + } + defer eventBroker.Close() + // Run migrations on startup (already-applied migrations are skipped and logged) if err := pgStore.RunMigrations(); err != nil { log.Fatalf("Migrations failed: %v", err) @@ -66,6 +77,7 @@ func main() { // Initialize Handler h := handler.New(pgStore) + h.Broker = eventBroker // Tenant retention purge (P1.4 soft-delete): first pass a minute after // boot, then daily. Logs and no-ops when retention is 0. diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 636db0d7..27a05f3b 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -2281,6 +2281,80 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Error" } + /api/events/{event_id}/monitor/stream: + get: + operationId: getEventMonitorStream + summary: > + Live monitor SSE stream (P4.2 Task 4, spec §3.3) — the codebase's + first Server-Sent Events endpoint. This is a deliberately + "thin-ping" stream: it never carries monitor state itself, only + signals telling the client when to re-fetch GET + /api/events/{event_id}/monitor (this operation's sibling above). + requireEventOwnership is checked BEFORE any stream header is + written, so a foreign/missing event still gets a plain 404 JSON + body rather than a half-open event-stream response. + security: [{ bearerAuth: [] }] + parameters: + - name: event_id + in: path + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: > + An open text/event-stream connection that stays open until the + client disconnects or the request context is cancelled. Three + frame types, each terminated by a blank line (`\n\n`) and + flushed individually the moment it's written: (1) + `event: hello\ndata: {}\n\n` — sent once, immediately, so the + client can confirm the connection is live; (2) + `event: update\ndata: {"at":""}\n\n` — sent whenever + the broker publishes a change for this event (check-in, undo, + reprint, or station heartbeat); the timestamp is informational + only; the client always responds by re-fetching the snapshot + endpoint above, never by trying to derive state from this + payload; (3) `: ping\n\n` — a comment line (no `event:` field, + so it is invisible to an EventSource's message handlers) sent + every 25 seconds as a keep-alive, purely to stop an + intermediary proxy/load balancer from reaping an idle-looking + connection. This operation's contract test cannot run the + streamed body through openapi3filter.ValidateResponse, which + validates one complete response, not an indefinite byte + sequence — see the documented direct-coverage-map exception in + monitor_stream_test.go; the real frame-by-frame assertions + live in that same file's httptest.Server-backed tests. + content: + text/event-stream: + schema: + type: string + description: > + A sequence of hello / update / ping SSE frames as + described above — not a single JSON document, and not + validated against this schema by the contract harness + (see the "200" description). + "400": + description: event_id is not a UUID. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist, or belongs to a different tenant + (requireEventOwnership masks "foreign" as "missing") — checked + before any stream header is written. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: Store failure resolving event ownership. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } /api/events/{id}/readiness: get: operationId: getEventReadiness diff --git a/panel/src/shared/api/schema.d.ts b/panel/src/shared/api/schema.d.ts index bd897ed1..8962dd2e 100644 --- a/panel/src/shared/api/schema.d.ts +++ b/panel/src/shared/api/schema.d.ts @@ -399,6 +399,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/events/{event_id}/monitor/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Live monitor SSE stream (P4.2 Task 4, spec §3.3) — the codebase's first Server-Sent Events endpoint. This is a deliberately "thin-ping" stream: it never carries monitor state itself, only signals telling the client when to re-fetch GET /api/events/{event_id}/monitor (this operation's sibling above). requireEventOwnership is checked BEFORE any stream header is written, so a foreign/missing event still gets a plain 404 JSON body rather than a half-open event-stream response. */ + get: operations["getEventMonitorStream"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/events/{id}/readiness": { parameters: { query?: never; @@ -3334,6 +3351,64 @@ export interface operations { }; }; }; + getEventMonitorStream: { + parameters: { + query?: never; + header?: never; + path: { + event_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description An open text/event-stream connection that stays open until the client disconnects or the request context is cancelled. Three frame types, each terminated by a blank line (`\n\n`) and flushed individually the moment it's written: (1) `event: hello\ndata: {}\n\n` — sent once, immediately, so the client can confirm the connection is live; (2) `event: update\ndata: {"at":""}\n\n` — sent whenever the broker publishes a change for this event (check-in, undo, reprint, or station heartbeat); the timestamp is informational only; the client always responds by re-fetching the snapshot endpoint above, never by trying to derive state from this payload; (3) `: ping\n\n` — a comment line (no `event:` field, so it is invisible to an EventSource's message handlers) sent every 25 seconds as a keep-alive, purely to stop an intermediary proxy/load balancer from reaping an idle-looking connection. This operation's contract test cannot run the streamed body through openapi3filter.ValidateResponse, which validates one complete response, not an indefinite byte sequence — see the documented direct-coverage-map exception in monitor_stream_test.go; the real frame-by-frame assertions live in that same file's httptest.Server-backed tests. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": string; + }; + }; + /** @description event_id is not a UUID. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description tenant_suspended from the tenant gate. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Event does not exist, or belongs to a different tenant (requireEventOwnership masks "foreign" as "missing") — checked before any stream header is written. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Store failure resolving event ownership. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getEventReadiness: { parameters: { query?: never; From 99a73f7a7bbbca823374acaf776d5c3074e0cee2 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 01:33:13 +0300 Subject: [PATCH 08/23] feat(panel): monitor data layer + SSE frame parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useMonitorSnapshot(eventId) + MONITOR_SNAPSHOT_KEY(eventId) over GET /api/events/{event_id}/monitor, mirroring READINESS_KEY's [method, path, init] query-key discipline (no refetchInterval — Task 6's useMonitorStream keeps it fresh via SSE-driven invalidation). - createSseParser: pure, incremental event:/data: frame parser split on "\n\n", buffering across chunk boundaries, ignoring comment lines (": ping"), defaulting event to "message" for data-only frames. - getApiBaseUrl (http.ts:13) was already exported — no change needed there. Co-Authored-By: Claude Sonnet 5 --- panel/src/features/monitor/hooks.test.tsx | 93 +++++++++++++++++++++ panel/src/features/monitor/hooks.ts | 28 +++++++ panel/src/features/monitor/parseSse.test.ts | 58 +++++++++++++ panel/src/features/monitor/parseSse.ts | 43 ++++++++++ 4 files changed, 222 insertions(+) create mode 100644 panel/src/features/monitor/hooks.test.tsx create mode 100644 panel/src/features/monitor/hooks.ts create mode 100644 panel/src/features/monitor/parseSse.test.ts create mode 100644 panel/src/features/monitor/parseSse.ts diff --git a/panel/src/features/monitor/hooks.test.tsx b/panel/src/features/monitor/hooks.test.tsx new file mode 100644 index 00000000..9ad19171 --- /dev/null +++ b/panel/src/features/monitor/hooks.test.tsx @@ -0,0 +1,93 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { startMswServer } from "../../test/msw"; +import { MONITOR_SNAPSHOT_KEY, useMonitorSnapshot } from "./hooks"; + +let monitorGetCount = 0; +let capturedEventId: string | undefined; + +function snapshotBody() { + return { + totals: { checked_in: 3, total: 10, rate_per_min: 1.2, peak: null, est_done_at: null }, + zones: [], + unattributed: 0, + stations: [], + recent: [], + }; +} + +const server = startMswServer( + http.get("http://api.test/api/events/:eventId/monitor", ({ params }) => { + monitorGetCount += 1; + capturedEventId = params.eventId as string; + return HttpResponse.json(snapshotBody()); + }), +); +void server; + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { + qc, + Wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +} + +describe("monitor hooks", () => { + beforeEach(() => { + monitorGetCount = 0; + capturedEventId = undefined; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + it("useMonitorSnapshot requests the event's monitor snapshot by id", async () => { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useMonitorSnapshot("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(capturedEventId).toBe("evt-1"); + expect(result.current.data?.totals.checked_in).toBe(3); + expect(result.current.data?.totals.total).toBe(10); + }); + + // Mirrors READINESS_KEY's describe block (events/hooks.test.tsx): the key + // must actually match useMonitorSnapshot's real registered query key so + // invalidateQueries (driven by Task 6's SSE 'update' frames) refetches it. + describe("MONITOR_SNAPSHOT_KEY", () => { + it("matches useMonitorSnapshot's query for the same event, so invalidateQueries refetches it", async () => { + const { qc, Wrapper } = makeWrapper(); + + const { result } = renderHook(() => useMonitorSnapshot("evt-1"), { wrapper: Wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(monitorGetCount).toBe(1); + + await qc.invalidateQueries({ queryKey: MONITOR_SNAPSHOT_KEY("evt-1") }); + + await waitFor(() => expect(monitorGetCount).toBe(2)); + }); + + it("does not match a different event's monitor query", async () => { + const { qc, Wrapper } = makeWrapper(); + + const { result: evt1 } = renderHook(() => useMonitorSnapshot("evt-1"), { wrapper: Wrapper }); + const { result: evt2 } = renderHook(() => useMonitorSnapshot("evt-2"), { wrapper: Wrapper }); + await waitFor(() => expect(evt1.current.isSuccess).toBe(true)); + await waitFor(() => expect(evt2.current.isSuccess).toBe(true)); + expect(monitorGetCount).toBe(2); + + await qc.invalidateQueries({ queryKey: MONITOR_SNAPSHOT_KEY("evt-1") }); + + // Only evt-1's query should refetch; give evt-2 a beat to (not) refetch. + await waitFor(() => expect(monitorGetCount).toBe(3)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(monitorGetCount).toBe(3); + }); + }); +}); diff --git a/panel/src/features/monitor/hooks.ts b/panel/src/features/monitor/hooks.ts new file mode 100644 index 00000000..dce99a97 --- /dev/null +++ b/panel/src/features/monitor/hooks.ts @@ -0,0 +1,28 @@ +import { $api } from "../../shared/api/query"; + +// Live monitor snapshot — GET /api/events/{event_id}/monitor (P4.2 Task 3, +// spec §3.1). No refetchInterval: this is a read-once-then-invalidate model, +// not a poller — Task 6's useMonitorStream keeps it fresh by invalidating +// MONITOR_SNAPSHOT_KEY below whenever the SSE stream's thin "update" pings +// arrive (coalesced to <=1/sec), and on reconnect. Unlike useEventStats +// (events/hooks.ts:8, `refetchInterval: opts?.poll ? 15_000 : undefined`), +// there's no polling fallback here per the plan's global constraints — a +// dead stream shows a "reconnecting" badge over stale data instead. +export function useMonitorSnapshot(eventId: string) { + return $api.useQuery("get", "/api/events/{event_id}/monitor", { params: { path: { event_id: eventId } } }); +} + +// Query-key for GET /api/events/{event_id}/monitor, matching +// useMonitorSnapshot's exact params shape. Same verified [method, path, +// init] shape READINESS_KEY documents (events/hooks.ts:33) — see that +// comment for the underlying openapi-react-query queryKey mechanics +// (queryKey: [method, path, init]) and TanStack Query's partial-match +// invalidateQueries semantics this relies on: a filter key of exactly this +// shape (no extra query sub-key on this endpoint, so there's nothing looser +// to match on) matches only the same event's monitor query. Task 6's +// useMonitorStream is the intended (and, at this task, only) consumer — +// every SSE "update" frame invalidates this key to trigger a re-fetch. +// Covered by the "MONITOR_SNAPSHOT_KEY" describe block in hooks.test.tsx. +export function MONITOR_SNAPSHOT_KEY(eventId: string) { + return ["get", "/api/events/{event_id}/monitor", { params: { path: { event_id: eventId } } }] as const; +} diff --git a/panel/src/features/monitor/parseSse.test.ts b/panel/src/features/monitor/parseSse.test.ts new file mode 100644 index 00000000..104c4bbe --- /dev/null +++ b/panel/src/features/monitor/parseSse.test.ts @@ -0,0 +1,58 @@ +import { createSseParser } from "./parseSse"; + +// Pure incremental SSE frame parser — no fetch, no React. Test matrix per +// task-5-brief.md: whole frame; frame split mid-line across chunks; +// comment-only chunk -> no events; two frames in one chunk -> two events; +// data-only frame defaults event to "message". +describe("createSseParser", () => { + it("parses a whole frame delivered in a single chunk", () => { + const events: { event: string; data: string }[] = []; + const feed = createSseParser((evt) => events.push(evt)); + + feed("event: hello\ndata: {}\n\n"); + + expect(events).toEqual([{ event: "hello", data: "{}" }]); + }); + + it("tolerates a frame split mid-line across two chunks", () => { + const events: { event: string; data: string }[] = []; + const feed = createSseParser((evt) => events.push(evt)); + + // "event: update" is split right in the middle of the field name. + feed("event: upd"); + expect(events).toEqual([]); // nothing dispatched until the frame closes + feed('ate\ndata: {"at":"2026-07-18T00:00:00Z"}\n\n'); + + expect(events).toEqual([{ event: "update", data: '{"at":"2026-07-18T00:00:00Z"}' }]); + }); + + it("ignores a comment-only chunk and dispatches no events", () => { + const events: { event: string; data: string }[] = []; + const feed = createSseParser((evt) => events.push(evt)); + + feed(": ping\n\n"); + + expect(events).toEqual([]); + }); + + it("dispatches two events when two frames arrive in one chunk", () => { + const events: { event: string; data: string }[] = []; + const feed = createSseParser((evt) => events.push(evt)); + + feed("event: hello\ndata: {}\n\nevent: update\ndata: {}\n\n"); + + expect(events).toEqual([ + { event: "hello", data: "{}" }, + { event: "update", data: "{}" }, + ]); + }); + + it("defaults a data-only frame's event to 'message'", () => { + const events: { event: string; data: string }[] = []; + const feed = createSseParser((evt) => events.push(evt)); + + feed("data: just-data\n\n"); + + expect(events).toEqual([{ event: "message", data: "just-data" }]); + }); +}); diff --git a/panel/src/features/monitor/parseSse.ts b/panel/src/features/monitor/parseSse.ts new file mode 100644 index 00000000..86689575 --- /dev/null +++ b/panel/src/features/monitor/parseSse.ts @@ -0,0 +1,43 @@ +// Pure, incremental Server-Sent Events frame parser. No fetch, no React — +// Task 6's useMonitorStream owns the fetch/ReadableStream plumbing and just +// feeds decoded text chunks into the function this returns. +// +// SSE frames are separated by a blank line ("\n\n"); a chunk boundary can +// land anywhere (mid-line, mid-frame, mid-separator), so this buffers +// everything it's been fed and only extracts+dispatches complete frames +// (i.e. up to and including a "\n\n" it has actually seen). Within a frame, +// "event:" sets the event name (defaulting to "message" per the SSE spec +// when a frame carries only "data:" lines — the backend's "update" frames +// always send an explicit "event:", but "hello" test fixtures and any +// data-only frame rely on this default), "data:" lines are collected and +// joined with "\n", and lines starting with ":" are comments (the backend's +// 25s ": ping\n\n" keep-alive) — ignored entirely, never producing a +// dispatch on their own. +export function createSseParser(onEvent: (evt: { event: string; data: string }) => void): (chunk: string) => void { + let buffer = ""; + + return (chunk: string) => { + buffer += chunk; + + let separatorIndex: number; + while ((separatorIndex = buffer.indexOf("\n\n")) !== -1) { + const frame = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + + let event = "message"; + const dataLines: string[] = []; + for (const line of frame.split("\n")) { + if (line.startsWith(":")) continue; // comment line (e.g. ": ping") — ignored + if (line.startsWith("event:")) { + event = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trim()); + } + } + + if (dataLines.length > 0) { + onEvent({ event, data: dataLines.join("\n") }); + } + } + }; +} From d9061470cf1e38fa799d4819e72d241e34c568ac Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 01:45:41 +0300 Subject: [PATCH 09/23] feat(panel): monitor SSE stream hook with coalescing and backoff Adds useMonitorStream (P4.2 Task 6): fetch-streams GET /api/events/{eventId}/monitor/stream, feeding decoded chunks into Task 5's createSseParser. hello -> "live"; update frames invalidate MONITOR_SNAPSHOT_KEY coalesced to <=1/sec (trailing-edge, one invalidation per burst); any disconnect -> "reconnecting" with 1s/x2/30s-cap +-25%-jitter backoff, and a successful reconnect immediately re-invalidates the snapshot (resync guarantee) ahead of the new connection's own hello. AbortController resets fully on unmount and on eventId change. No polling fallback, no new dependencies. Co-Authored-By: Claude Sonnet 5 --- .../monitor/useMonitorStream.test.tsx | 245 ++++++++++++++++++ .../src/features/monitor/useMonitorStream.ts | 172 ++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 panel/src/features/monitor/useMonitorStream.test.tsx create mode 100644 panel/src/features/monitor/useMonitorStream.ts diff --git a/panel/src/features/monitor/useMonitorStream.test.tsx b/panel/src/features/monitor/useMonitorStream.test.tsx new file mode 100644 index 00000000..069c1514 --- /dev/null +++ b/panel/src/features/monitor/useMonitorStream.test.tsx @@ -0,0 +1,245 @@ +// P4.2 Task 6 -- useMonitorStream tests. Test matrix per task-6-brief.md: +// connect->hello->live; update->invalidated (subscribed-observer refetch, +// the house idiom -- hooks.test.tsx's MONITOR_SNAPSHOT_KEY describe block); +// 3 updates within 300ms -> exactly 1 extra snapshot fetch (coalescing); +// stream close -> reconnecting -> next connect attempt observed + immediate +// refetch on success; unmount aborts (no further fetches); eventId change +// closes the old stream and opens the new URL. +// +// Real timers throughout (never fake) -- this feature's repeatedly- +// documented convention, since fake timers + MSW streaming is exactly the +// interaction the codebase avoids (see this file's sibling task briefs and +// useHeartbeat.test.tsx's own comment on the one hook where the fake clock +// WAS judged worth the risk; a raw ReadableStream body is a strictly harder +// case than that hook's plain JSON mutation, so real timers + bounded +// `waitFor`s here instead). The exponential backoff test therefore waits +// out one real ~1s(+/-25%) backoff window -- bounded via `waitFor`'s own +// `timeout` option, never an unbounded `await`. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, renderHook, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { startMswServer } from "../../test/msw"; +import { useMonitorSnapshot } from "./hooks"; +import { useMonitorStream } from "./useMonitorStream"; + +// --------------------------------------------------------------------------- +// Deferred/controlled SSE stream helper. Each `monitor/stream` GET the hook +// makes (the initial connect AND every reconnect) is a SEPARATE HTTP request +// MSW intercepts, resolved with its OWN ReadableStream this test drives by +// hand -- the same "manually-controlled deferred, released only after +// asserting the intermediate state" idiom StationPage.test.tsx's printer- +// waiting test uses for a regular JSON response (see that file's own +// comment, "PR #77 bot-review round 3, Finding 7"), applied here to a +// streaming body per the plan's own precedent note ("MSW v2.15, streaming +// ReadableStream bodies supported"). +// --------------------------------------------------------------------------- +function makeSseStream() { + let controllerRef!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller; + }, + }); + const encoder = new TextEncoder(); + return { + stream, + push(frame: string) { + controllerRef.enqueue(encoder.encode(frame)); + }, + close() { + controllerRef.close(); + }, + error(err: unknown = new Error("stream error")) { + controllerRef.error(err); + }, + }; +} + +type Connection = ReturnType & { url: string; authHeader: string | null }; + +let connections: Connection[] = []; +let snapshotGetCount = 0; + +function snapshotBody() { + return { + totals: { checked_in: 0, total: 0, rate_per_min: 0, peak: null, est_done_at: null }, + zones: [], + unattributed: 0, + stations: [], + recent: [], + }; +} + +const server = startMswServer( + http.get("http://api.test/api/events/:eventId/monitor/stream", ({ request }) => { + const conn = makeSseStream() as Connection; + conn.url = request.url; + conn.authHeader = request.headers.get("authorization"); + connections.push(conn); + return new HttpResponse(conn.stream, { headers: { "Content-Type": "text/event-stream" } }); + }), + http.get("http://api.test/api/events/:eventId/monitor", () => { + snapshotGetCount += 1; + return HttpResponse.json(snapshotBody()); + }), +); +void server; + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { + qc, + Wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +} + +// Renders both the stream hook AND a SUBSCRIBED useMonitorSnapshot observer +// on the SAME QueryClient -- the house idiom for proving invalidation +// actually happened (hooks.test.tsx's MONITOR_SNAPSHOT_KEY describe block): +// count the observer's own refetches rather than spying on +// invalidateQueries. +function Harness({ eventId }: { eventId: string }) { + const streamState = useMonitorStream(eventId); + useMonitorSnapshot(eventId); + return
{streamState.status}
; +} + +describe("useMonitorStream", () => { + beforeEach(() => { + connections = []; + snapshotGetCount = 0; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + it("connects, requests with the auth header, and transitions connecting -> live once the hello frame arrives", async () => { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useMonitorStream("evt-1"), { wrapper: Wrapper }); + + expect(result.current.status).toBe("connecting"); + await waitFor(() => expect(connections.length).toBe(1)); + expect(connections[0].url).toBe("http://api.test/api/events/evt-1/monitor/stream"); + expect(connections[0].authHeader).toBe("Bearer jwt-test"); + + connections[0].push("event: hello\ndata: {}\n\n"); + + await waitFor(() => expect(result.current.status).toBe("live")); + }); + + it("invalidates the snapshot query when an update frame arrives, so a subscribed observer refetches", async () => { + const { Wrapper } = makeWrapper(); + const { getByTestId } = render(, { wrapper: Wrapper }); + + await waitFor(() => expect(snapshotGetCount).toBe(1)); // useMonitorSnapshot's own initial fetch + await waitFor(() => expect(connections.length).toBe(1)); + connections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(getByTestId("status")).toHaveTextContent("live")); + + connections[0].push('event: update\ndata: {"at":"2026-07-18T00:00:00Z"}\n\n'); + + await waitFor(() => expect(snapshotGetCount).toBe(2), { timeout: 2000 }); + }); + + it("coalesces a burst of update frames within the same window into exactly one extra snapshot fetch", async () => { + const { Wrapper } = makeWrapper(); + render(, { wrapper: Wrapper }); + + await waitFor(() => expect(snapshotGetCount).toBe(1)); + await waitFor(() => expect(connections.length).toBe(1)); + connections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(snapshotGetCount).toBe(1)); // hello alone triggers no fetch + + connections[0].push('event: update\ndata: {"at":"t1"}\n\n'); + await new Promise((resolve) => setTimeout(resolve, 100)); + connections[0].push('event: update\ndata: {"at":"t2"}\n\n'); + await new Promise((resolve) => setTimeout(resolve, 100)); + connections[0].push('event: update\ndata: {"at":"t3"}\n\n'); + // All three landed within ~300ms -- well inside the 1s coalescing + // window (COALESCE_MS in useMonitorStream.ts). + + await waitFor(() => expect(snapshotGetCount).toBe(2), { timeout: 3000 }); + // Give it a further beat to make sure the burst didn't ALSO schedule a + // second/third invalidation that lands later. + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(snapshotGetCount).toBe(2); + }); + + it( + "reconnects with backoff after a clean stream close, and immediately re-invalidates the snapshot on successful reconnect", + async () => { + const { Wrapper } = makeWrapper(); + const { getByTestId } = render(, { wrapper: Wrapper }); + + await waitFor(() => expect(snapshotGetCount).toBe(1)); + await waitFor(() => expect(connections.length).toBe(1)); + connections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(getByTestId("status")).toHaveTextContent("live")); + + connections[0].close(); + + await waitFor(() => expect(getByTestId("status")).toHaveTextContent("reconnecting")); + + // Backoff is 1s base +/-25% jitter (max 1250ms) -- bounded wait for + // the retried connect() to land as a brand-new request. + await waitFor(() => expect(connections.length).toBe(2), { timeout: 3000 }); + + // The resync guarantee: invalidation fires as soon as the reconnect's + // fetch resolves OK, BEFORE that connection's own hello frame arrives. + await waitFor(() => expect(snapshotGetCount).toBe(2), { timeout: 1000 }); + + connections[1].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(getByTestId("status")).toHaveTextContent("live")); + }, + 8000, + ); + + it("aborts on unmount -- no further connect attempts", async () => { + const { Wrapper } = makeWrapper(); + const { result, unmount } = renderHook(() => useMonitorStream("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(connections.length).toBe(1)); + connections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(result.current.status).toBe("live")); + + unmount(); + + // Past one full backoff window (max 1250ms) -- if the abort were + // mistaken for a stream failure, a second connection would appear here. + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(connections.length).toBe(1); + }); + + it("closes the old stream and opens a new one when eventId changes, with a full status reset", async () => { + const { Wrapper } = makeWrapper(); + const { result, rerender } = renderHook(({ eventId }) => useMonitorStream(eventId), { + wrapper: Wrapper, + initialProps: { eventId: "evt-1" }, + }); + + await waitFor(() => expect(connections.length).toBe(1)); + expect(connections[0].url).toBe("http://api.test/api/events/evt-1/monitor/stream"); + connections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(result.current.status).toBe("live")); + + rerender({ eventId: "evt-2" }); + + // Full reset per the P4.1 round-3 lesson -- not a leftover "live". + expect(result.current.status).toBe("connecting"); + await waitFor(() => expect(connections.length).toBe(2)); + expect(connections[1].url).toBe("http://api.test/api/events/evt-2/monitor/stream"); + + connections[1].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(result.current.status).toBe("live")); + + // The old (evt-1) stream must no longer be driving state -- closing it + // now must not flip status back to "reconnecting". + connections[0].close(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(result.current.status).toBe("live"); + expect(connections.length).toBe(2); + }); +}); diff --git a/panel/src/features/monitor/useMonitorStream.ts b/panel/src/features/monitor/useMonitorStream.ts new file mode 100644 index 00000000..0933542b --- /dev/null +++ b/panel/src/features/monitor/useMonitorStream.ts @@ -0,0 +1,172 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getApiBaseUrl } from "../../shared/api/http"; +import { getToken } from "../../shared/api/session"; +import { MONITOR_SNAPSHOT_KEY } from "./hooks"; +import { createSseParser } from "./parseSse"; + +export type MonitorStreamStatus = "connecting" | "live" | "reconnecting"; + +// Thin-ping SSE (plan §4.1, Global Constraints): the backend's "update" +// frames carry no state, so every one of them just needs to trigger a +// re-read of the snapshot -- but a burst of frames (several check-ins +// landing within the same second) must not turn into a burst of refetches. +// This caps invalidation at 1/sec via a TRAILING-edge coalesce: the first +// update in a quiet window starts a timer; every update that lands before +// the timer fires is absorbed for free; the timer's firing is the ONE +// invalidation for the whole burst. +const COALESCE_MS = 1_000; + +// Exponential backoff for reconnects, per the plan verbatim: 1s base, x2 +// each attempt, capped at 30s, +/-25% jitter so many clients reconnecting +// after a shared outage don't all hammer the backend in lockstep. +const BACKOFF_BASE_MS = 1_000; +const BACKOFF_CAP_MS = 30_000; +const BACKOFF_JITTER_RATIO = 0.25; + +function backoffDelayMs(attempt: number): number { + const base = Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS); + const jitter = base * BACKOFF_JITTER_RATIO * (Math.random() * 2 - 1); // +/-25% + return Math.max(0, Math.round(base + jitter)); +} + +/** + * Live monitor SSE client (plan §4.1, P4.2 Task 6). Opens a fetch-streaming + * connection to `GET /api/events/{eventId}/monitor/stream` (documented in + * openapi.yaml by Task 4) and feeds decoded chunks into Task 5's + * `createSseParser`. This deliberately bypasses the generated `$api`/ + * `openapi-fetch` client (AGENTS.md's usual "never call fetch directly" + * rule) -- openapi-fetch has no streaming-body mode, and the plan explicitly + * designed this hook around raw `fetch` + `res.body.getReader()` (plan-time + * fact 6, which exports `getApiBaseUrl` from http.ts specifically "for the + * fetch-streaming client rather than re-deriving"). The endpoint itself is + * still fully documented; only the transport differs. + * + * Frame handling: + * - "hello" -- the very first frame the backend sends on every connection + * (`monitor_stream.go`) -- flips `status` to "live". + * - "update" -- a thin ping carrying no payload the client needs -- + * invalidates Task 5's `MONITOR_SNAPSHOT_KEY`, coalesced per + * `COALESCE_MS` above, so whatever's rendering `useMonitorSnapshot` + * re-reads the real state. + * - ": ping" keep-alive comments never reach here at all -- `createSseParser` + * swallows comment-only frames before they'd ever call back. + * + * On any disconnect (a clean close, a network error, or a non-OK response) + * `status` flips to "reconnecting" and this retries with `backoffDelayMs` + * above. A successful reconnect (the retried fetch resolves OK -- BEFORE + * that connection's own "hello" frame even arrives) immediately invalidates + * the snapshot too: the resync guarantee that whatever changed while + * disconnected must not wait for the next "update" ping to surface. + * + * No polling fallback (Global Constraints) -- a dead stream is surfaced via + * `status` for the UI to show a "reconnecting" badge over stale data, not + * papered over with a poller. + */ +export function useMonitorStream(eventId: string): { status: MonitorStreamStatus } { + const queryClient = useQueryClient(); + const [status, setStatus] = React.useState("connecting"); + + React.useEffect(() => { + // Full reset on every effect run -- both a genuine mount AND an eventId + // change (P4.1 round-3 Finding 5's lesson: a scope change must reset + // ALL local state, not layer a new fetch on top of stale flags). Every + // mutable variable below lives inside this effect's closure, so a fresh + // run gets fresh values for free; the cleanup at the bottom tears down + // the OLD scope's controller/timers before React commits this one. + setStatus("connecting"); + + const controller = new AbortController(); + let cancelled = false; + let attempt = 0; + let isReconnect = false; + let coalescePending = false; + let backoffTimer: ReturnType | undefined; + let coalesceTimer: ReturnType | undefined; + + function scheduleInvalidate() { + if (coalescePending) return; + coalescePending = true; + coalesceTimer = setTimeout(() => { + coalescePending = false; + void queryClient.invalidateQueries({ queryKey: MONITOR_SNAPSHOT_KEY(eventId) }); + }, COALESCE_MS); + } + + function scheduleReconnect() { + if (cancelled) return; + setStatus("reconnecting"); + isReconnect = true; + const delay = backoffDelayMs(attempt); + attempt += 1; + backoffTimer = setTimeout(() => { + if (!cancelled) void connect(); + }, delay); + } + + async function connect() { + const headers: Record = { Accept: "text/event-stream" }; + const token = getToken(); + if (token) headers.Authorization = `Bearer ${token}`; + + try { + const res = await fetch(`${getApiBaseUrl()}/api/events/${eventId}/monitor/stream`, { + headers, + signal: controller.signal, + }); + + if (!res.ok || !res.body) { + throw new Error(`monitor stream request failed with status ${res.status}`); + } + + // A successful (re)connect resets the backoff ladder, and -- if + // this was a reconnect -- fires the resync invalidation right away, + // before this connection's own "hello" frame even arrives. The + // "live" transition below is a separate signal (the parser is + // actually caught up), not just that the socket opened. + attempt = 0; + if (isReconnect) { + void queryClient.invalidateQueries({ queryKey: MONITOR_SNAPSHOT_KEY(eventId) }); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const feed = createSseParser((evt) => { + if (evt.event === "hello") { + setStatus("live"); + } else if (evt.event === "update") { + scheduleInvalidate(); + } + }); + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + feed(decoder.decode(value, { stream: true })); + } + + // The stream closed cleanly (server-side close, e.g. the request + // context ending) -- not an abort. Reconnect per the same policy as + // a network error below. + if (!cancelled) scheduleReconnect(); + } catch { + // An aborted read (unmount or an eventId change tore down + // `controller` mid-flight) must NOT be treated as a stream failure + // -- the scope is gone, there's nothing to reconnect for. + if (controller.signal.aborted) return; + if (!cancelled) scheduleReconnect(); + } + } + + void connect(); + + return () => { + cancelled = true; + controller.abort(); + if (backoffTimer !== undefined) clearTimeout(backoffTimer); + if (coalesceTimer !== undefined) clearTimeout(coalesceTimer); + }; + }, [eventId, queryClient]); + + return { status }; +} From 160ceebd9b7c1fbb384e10cb7392617697e413e9 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 02:06:29 +0300 Subject: [PATCH 10/23] feat(panel): monitor route, page shell, totals and zones cards --- panel/src/app/router.tsx | 19 ++ .../src/features/monitor/MonitorPage.test.tsx | 300 ++++++++++++++++++ panel/src/features/monitor/MonitorPage.tsx | 153 +++++++++ panel/src/features/monitor/TotalsCard.tsx | 75 +++++ panel/src/features/monitor/ZonesCard.tsx | 57 ++++ panel/src/shared/i18n/en.json | 12 +- panel/src/shared/i18n/ru.json | 12 +- 7 files changed, 626 insertions(+), 2 deletions(-) create mode 100644 panel/src/features/monitor/MonitorPage.test.tsx create mode 100644 panel/src/features/monitor/MonitorPage.tsx create mode 100644 panel/src/features/monitor/TotalsCard.tsx create mode 100644 panel/src/features/monitor/ZonesCard.tsx diff --git a/panel/src/app/router.tsx b/panel/src/app/router.tsx index cb277a5a..75e3e186 100644 --- a/panel/src/app/router.tsx +++ b/panel/src/app/router.tsx @@ -16,6 +16,7 @@ import { OrganizationPage } from "../features/organization/OrganizationPage"; import { StationPage } from "../features/checkin/StationPage"; import { checkinStationBeforeLoad, validateCheckinStationSearch } from "../features/checkin/searchParams"; import { LaunchCeremony } from "../features/checkin/LaunchCeremony"; +import { MonitorPage } from "../features/monitor/MonitorPage"; import { PlaceholderPage } from "../shared/ui/PlaceholderPage"; import { getInstance } from "../shared/api/client"; import { queryClient } from "./queryClient"; @@ -170,6 +171,23 @@ const eventCheckinLaunchRoute = createRoute({ component: LaunchCeremony, }); +// P4.2 Task 7 -- the live monitor (board 7e). Same TOP-LEVEL, sibling-of- +// eventWorkspaceRoute registration as eventCheckinRoute/ +// eventCheckinLaunchRoute above (plan-time fact 7: mirror that exact +// pattern, don't invent a new technique) -- registered directly under +// protectedLayoutRoute.addChildren, NOT nested inside +// eventWorkspaceRoute.addChildren, so `/events/$eventId/monitor` renders +// chrome-less/rail-less too: a glanceable, read-only tablet screen, not +// another workspace tab. MonitorPage.test.tsx's routing-proof harness +// mirrors StationPage.test.tsx's own (a real competing eventWorkspaceRoute +// sibling + a misregistration counter-example) to prove this registration +// discriminates correctly. +const eventMonitorRoute = createRoute({ + getParentRoute: () => protectedLayoutRoute, + path: "/events/$eventId/monitor", + component: MonitorPage, +}); + const routeTree = rootRoute.addChildren([ protectedLayoutRoute.addChildren([ indexRoute, @@ -181,6 +199,7 @@ const routeTree = rootRoute.addChildren([ ]), eventCheckinRoute, eventCheckinLaunchRoute, + eventMonitorRoute, ]), loginRoute, registerRoute, diff --git a/panel/src/features/monitor/MonitorPage.test.tsx b/panel/src/features/monitor/MonitorPage.test.tsx new file mode 100644 index 00000000..640a9a1f --- /dev/null +++ b/panel/src/features/monitor/MonitorPage.test.tsx @@ -0,0 +1,300 @@ +// P4.2 Task 7 -- MonitorPage tests. +// +// The FIRST describe block below is the highest-risk proof for this task +// (per the brief and plan-time fact 7): app/router.tsx registers +// `eventMonitorRoute` as a TOP-LEVEL protected route, a SIBLING of +// `eventWorkspaceRoute` (both children of `protectedLayoutRoute`), +// specifically so `/events/$eventId/monitor` renders MonitorPage WITHOUT +// the workspace rail shell (WorkspaceRail/EventWorkspaceLayout). Both +// registrations (sibling vs. "child of the workspace route with a relative +// path") resolve to the IDENTICAL final URL, so only the RENDERED OUTPUT +// (not the matched path string) can tell a correct sibling registration +// apart from an accidental nested one -- this file proves it two ways, +// mirroring StationPage.test.tsx's own harness EXACTLY (plan-time fact 7): +// (1) a routed harness shaped exactly like app/router.tsx's real +// registration renders MonitorPage's content with none of the workspace +// shell's nav markers present, and (2) a deliberately-misregistered harness +// (monitor route nested as a CHILD of the workspace route) demonstrates the +// SAME assertion would fail if the registration were wrong -- proof the +// technique actually discriminates, not a vacuously-passing check. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, +} from "@tanstack/react-router"; +import { render, screen } from "@testing-library/react"; +import { delay, http, HttpResponse } from "msw"; +import { MonitorPage } from "./MonitorPage"; +import { startMswServer } from "../../test/msw"; +import "../../shared/i18n"; + +// Distinguishing marker text for the workspace rail shell's own nav items +// (WorkspaceRail.tsx's real English copy) -- if the monitor route were +// wrongly nested under the workspace route, these would render alongside +// MonitorPage's own content. +function WorkspaceShellStub() { + return ( +
+ + +
+ ); +} + +// Mirrors app/router.tsx's REAL shape: an app-layout id route ("_app", +// standing in for protectedLayoutRoute) with the workspace route AND the +// monitor route registered as SIBLING children -- exactly the registration +// this task adds to the real router. +function buildCorrectRouter(initialPath: string) { + const rootRoute = createRootRoute(); + const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: "_app", component: () => }); + const workspaceRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId", + component: WorkspaceShellStub, + }); + const monitorRoute = createRoute({ + getParentRoute: () => appLayoutRoute, // sibling of workspaceRoute -- the shape under test. + path: "/events/$eventId/monitor", + component: MonitorPage, + }); + const routeTree = rootRoute.addChildren([appLayoutRoute.addChildren([workspaceRoute, monitorRoute])]); + return createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialPath] }) }); +} + +// Reproduces the bug the sibling registration above avoids: the monitor +// route nested as a CHILD of the workspace route (relative path "/monitor") +// resolves to the exact same final URL ("/events/$eventId/monitor") but +// renders wrapped inside the workspace shell's own . +function buildMisregisteredRouter(initialPath: string) { + const rootRoute = createRootRoute(); + const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: "_app", component: () => }); + const workspaceRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId", + component: WorkspaceShellStub, + }); + const nestedMonitorRoute = createRoute({ + getParentRoute: () => workspaceRoute, // the mistake: a CHILD, not a sibling. + path: "/monitor", + component: () =>
dummy
, + }); + const routeTree = rootRoute.addChildren([appLayoutRoute.addChildren([workspaceRoute.addChildren([nestedMonitorRoute])])]); + return createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialPath] }) }); +} + +function renderWithRouter(router: ReturnType | ReturnType) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {/* Cast, not @ts-expect-error: this test router's route shape differs + from the app's registered singleton -- same rationale as + StationPage.test.tsx / AttendeesPage.test.tsx. */} + + , + ); + return router; +} + +function renderCorrectAt(path: string) { + return renderWithRouter(buildCorrectRouter(path)); +} + +const EVENT = { + id: "evt-1", + tenant_id: "t1", + name: "Partner Day — Autumn", + start_date: "2026-09-03T00:00:00.000Z", + created_at: "", + updated_at: "", +}; + +function snapshotBody(overrides: Record = {}) { + return { + totals: { + checked_in: 1284, + total: 2410, + rate_per_min: 8.2, + peak: { rate: 14.6, at: "2026-07-18T09:40:00Z" }, + est_done_at: "2026-07-18T12:20:00Z", + }, + zones: [ + { zone_id: "z-1", name: "Main hall", checked_in: 1190 }, + { zone_id: "z-2", name: "VIP", checked_in: 62 }, + { zone_id: "z-3", name: "Backstage", checked_in: 32 }, + ], + unattributed: 0, + stations: [], + recent: [], + ...overrides, + }; +} + +let monitorSnapshot: ReturnType = snapshotBody(); + +// Task 6's useMonitorStream mounts unconditionally alongside the rest of +// this page (header LIVE pill) -- mocked with a stream that never closes +// and never pushes a frame (this task doesn't assert live-pill transitions; +// that liveness/reconnect-badge nuance is Task 8's), same "mock every +// endpoint this page hits" discipline StationPage.test.tsx's own top-of- +// block comment documents for its analogous useHeartbeat mount. +function monitorStreamHandler() { + return http.get("http://api.test/api/events/:eventId/monitor/stream", () => { + const stream = new ReadableStream({ start() {} }); + return new HttpResponse(stream, { headers: { "Content-Type": "text/event-stream" } }); + }); +} + +const server = startMswServer( + http.get("http://api.test/api/events/:id", () => HttpResponse.json(EVENT)), + http.get("http://api.test/api/events/:eventId/monitor", () => HttpResponse.json(monitorSnapshot)), + monitorStreamHandler(), +); +void server; + +describe("MonitorPage routing -- sibling registration proof", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + monitorSnapshot = snapshotBody(); + }); + + it("renders MonitorPage's own content with NONE of the workspace shell's nav markers, when registered as a top-level sibling of the workspace route (app/router.tsx's real shape)", async () => { + renderCorrectAt("/events/evt-1/monitor"); + + expect(await screen.findByTestId("monitor-page")).toBeInTheDocument(); + + // None of the workspace shell's own distinguishing nav text is + // present -- if the monitor route had been (incorrectly) nested as a + // CHILD of the workspace route instead of registered as its sibling, + // these would render too (see the misregistration reproduction below). + expect(screen.queryByText("Overview")).not.toBeInTheDocument(); + expect(screen.queryByText("Attendees")).not.toBeInTheDocument(); + expect(screen.queryByText("Zones")).not.toBeInTheDocument(); + expect(screen.queryByText("Staff")).not.toBeInTheDocument(); + expect(screen.queryByText("Badge")).not.toBeInTheDocument(); + }); + + it("sanity check: the SAME workspace-shell-marker assertion WOULD fail if the monitor route were (incorrectly) nested as a child of the workspace route -- proof the technique above actually discriminates", async () => { + const router = buildMisregisteredRouter("/events/evt-1/monitor"); + renderWithRouter(router); + + expect(await screen.findByTestId("dummy-monitor-page")).toBeInTheDocument(); + // The workspace shell's nav leaks through here -- this is the exact + // bug the sibling registration in app/router.tsx avoids. + expect(screen.getByText("Overview")).toBeInTheDocument(); + expect(screen.getByText("Badge")).toBeInTheDocument(); + }); +}); + +describe("MonitorPage", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + monitorSnapshot = snapshotBody(); + }); + + it("renders the header (LIVE pill, event name, Exit) and the totals/percent/rate line + by-zone breakdown from the seeded snapshot", async () => { + renderCorrectAt("/events/evt-1/monitor"); + + expect(await screen.findByRole("heading", { name: "Partner Day — Autumn" })).toBeInTheDocument(); + expect(screen.getByText("LIVE")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Exit/ })).toHaveAttribute("href", "/events/evt-1"); + + // Totals card -- board 7e: "1,284 / 2,410" + "53%", rate line "8.2 + // scans/min · peak 14.6 at 09:40 · est. done 12:20". + expect(screen.getByText("1,284 / 2,410")).toBeInTheDocument(); + expect(screen.getByText("53%")).toBeInTheDocument(); + expect(screen.getByText(/8\.2 scans\/min/)).toBeInTheDocument(); + expect(screen.getByText(/peak 14\.6 at 09:40/)).toBeInTheDocument(); + expect(screen.getByText(/est\. done 12:20/)).toBeInTheDocument(); + + // By-zone card -- Main hall / VIP / Backstage, per-zone counts. + expect(screen.getByText("Main hall")).toBeInTheDocument(); + expect(screen.getByText("1,190")).toBeInTheDocument(); + expect(screen.getByText("VIP")).toBeInTheDocument(); + expect(screen.getByText("62")).toBeInTheDocument(); + expect(screen.getByText("Backstage")).toBeInTheDocument(); + expect(screen.getByText("32")).toBeInTheDocument(); + + // Right column: Task 8's placeholders, present but empty. + expect(screen.getByTestId("monitor-stations-placeholder")).toBeInTheDocument(); + expect(screen.getByTestId("monitor-recent-placeholder")).toBeInTheDocument(); + }); + + it("omits the peak and est-done segments of the rate line when both are null, without fabricating times", async () => { + monitorSnapshot = snapshotBody({ + totals: { checked_in: 0, total: 100, rate_per_min: 0, peak: null, est_done_at: null }, + zones: [{ zone_id: "z-1", name: "Main hall", checked_in: 0 }], + }); + renderCorrectAt("/events/evt-1/monitor"); + await screen.findByText("0 / 100"); + + expect(screen.getByText(/0\.0 scans\/min/)).toBeInTheDocument(); + expect(screen.queryByText(/peak/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/est\. done/i)).not.toBeInTheDocument(); + }); + + it("hides the unattributed row when it is zero, and the visible zone counts sum to the totals card's checked-in count", async () => { + renderCorrectAt("/events/evt-1/monitor"); + await screen.findByText("Main hall"); + + expect(screen.queryByTestId("monitor-zone-unattributed")).not.toBeInTheDocument(); + // 1190 + 62 + 32 === 1284 (the totals card's checked_in count above). + expect(screen.getByText("1,190")).toBeInTheDocument(); + expect(screen.getByText("62")).toBeInTheDocument(); + expect(screen.getByText("32")).toBeInTheDocument(); + expect(screen.getByText("1,284 / 2,410")).toBeInTheDocument(); + }); + + it("shows the unattributed row (with a count) when it is greater than zero", async () => { + monitorSnapshot = snapshotBody({ + totals: { checked_in: 1291, total: 2410, rate_per_min: 8.2, peak: null, est_done_at: null }, + zones: [ + { zone_id: "z-1", name: "Main hall", checked_in: 1190 }, + { zone_id: "z-2", name: "VIP", checked_in: 62 }, + { zone_id: "z-3", name: "Backstage", checked_in: 32 }, + ], + unattributed: 7, + }); + renderCorrectAt("/events/evt-1/monitor"); + await screen.findByText("Main hall"); + + const row = screen.getByTestId("monitor-zone-unattributed"); + expect(row).toBeInTheDocument(); + expect(row).toHaveTextContent("7"); + }); + + it("shows loading skeletons for the snapshot cards (not fabricated zero totals) while the monitor snapshot is still loading", async () => { + server.use( + http.get("http://api.test/api/events/:eventId/monitor", async () => { + await delay(50); + return HttpResponse.json(monitorSnapshot); + }), + ); + renderCorrectAt("/events/evt-1/monitor"); + await screen.findByRole("heading", { name: "Partner Day — Autumn" }); + + expect(screen.queryByText(/0 \/ 0/)).not.toBeInTheDocument(); + expect(screen.queryByTestId("monitor-totals-card")).not.toBeInTheDocument(); + + expect(await screen.findByText("1,284 / 2,410")).toBeInTheDocument(); + }); + + it("shows an explicit error state (not fabricated zero totals) when the monitor snapshot fails to load", async () => { + server.use(http.get("http://api.test/api/events/:eventId/monitor", () => new HttpResponse(null, { status: 500 }))); + renderCorrectAt("/events/evt-1/monitor"); + await screen.findByRole("heading", { name: "Partner Day — Autumn" }); + + expect(await screen.findByTestId("monitor-snapshot-error")).toBeInTheDocument(); + expect(screen.queryByText(/0 \/ 0/)).not.toBeInTheDocument(); + expect(screen.queryByTestId("monitor-totals-card")).not.toBeInTheDocument(); + }); +}); diff --git a/panel/src/features/monitor/MonitorPage.tsx b/panel/src/features/monitor/MonitorPage.tsx new file mode 100644 index 00000000..4b787e5e --- /dev/null +++ b/panel/src/features/monitor/MonitorPage.tsx @@ -0,0 +1,153 @@ +// P4.2 Task 7 -- the live monitor itself (board 7e, tablet-landscape, +// "glanceable from across the room, read-only, no prep chrome"). Registered +// in app/router.tsx as `eventMonitorRoute`, a TOP-LEVEL protected route +// that is a SIBLING of `eventWorkspaceRoute` (not one of its children) -- +// so this page renders WITHOUT the workspace rail shell (WorkspaceRail / +// EventWorkspaceLayout), mirroring `eventCheckinRoute`'s exact pattern +// (plan-time fact 7). It is still wrapped by the outer AppShell/NavDrawer, +// same as every other protected route. +// +// Wires together Task 5/6's data layer (`useMonitorSnapshot` for the +// numbers, `useMonitorStream` for the header's LIVE pill + as the thing +// that keeps the snapshot fresh via invalidation elsewhere) with this +// task's own header chrome and left-column cards (TotalsCard, ZonesCard). +// The right column renders ONLY stable, content-free placeholders here -- +// Task 8 owns the Stations/Last-scans cards and the reconnecting badge. +import * as React from "react"; +import { Button, Card, CardContent, Skeleton } from "@idento/ui"; +import { Link, getRouteApi } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { $api } from "../../shared/api/query"; +import { TotalsCard } from "./TotalsCard"; +import { ZonesCard } from "./ZonesCard"; +import { useMonitorSnapshot } from "./hooks"; +import { useMonitorStream } from "./useMonitorStream"; + +// `getRouteApi` with the route's string id, not an import of the route +// object from app/router.tsx -- avoids a circular import (router.tsx +// imports THIS component for the route's `component:` field), same +// rationale as StationPage.tsx / EventWorkspaceLayout.tsx. +const routeApi = getRouteApi("/_app/events/$eventId/monitor"); + +export function MonitorPage() { + const { t } = useTranslation(); + const { eventId } = routeApi.useParams(); + + const eventQuery = $api.useQuery("get", "/api/events/{id}", { params: { path: { id: eventId } } }); + const snapshotQuery = useMonitorSnapshot(eventId); + const stream = useMonitorStream(eventId); + + // Local 1s ticker, purely to force a re-render every second so "Updated + // Ns ago" (derived from snapshotQuery's own `dataUpdatedAt` -- react- + // query's wall-clock timestamp of the last successful fetch) keeps + // counting up between snapshot refetches, matching the board's own + // "Updated 3 s ago" staleness label reading as a live-ticking clock, not + // a value frozen at fetch time. + const [now, setNow] = React.useState(() => Date.now()); + React.useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + if (eventQuery.isLoading) { + return ( +
+ + +
+ ); + } + + if (eventQuery.isError || !eventQuery.data) { + return ( +
+

{t("workspaceLoadError")}

+ +
+ ); + } + + const event = eventQuery.data; + const updatedSeconds = + snapshotQuery.dataUpdatedAt > 0 ? Math.max(0, Math.floor((now - snapshotQuery.dataUpdatedAt) / 1000)) : null; + const live = stream.status === "live"; + const snapshot = snapshotQuery.data; + + return ( +
+ {/* Header (56px per the board) -- LIVE pill · event name · "Updated + Ns ago" staleness label · Exit. */} +
+ + + {live ? ( + + ) : null} + + + {t("monitorLive")} + +

{event.name}

+ {updatedSeconds !== null ? ( + + {t("monitorUpdatedAgo", { seconds: updatedSeconds })} + + ) : null} +
+ +
+
+ + {/* Body -- #fafafa background (theme.css's --background token is + already that exact value), 2-column grid (1.15fr 1fr) per board + 7e. */} +
+
+ {snapshotQuery.isLoading ? ( + <> + + + + ) : snapshotQuery.isError || !snapshot ? ( + + +

+ {t("monitorSnapshotLoadError")} +

+
+
+ ) : ( + <> + + + + )} +
+ + {/* Right column -- Task 8 fills these with the Stations and + Last-scans cards (+ the reconnecting badge in the header + above). Stable, content-free placeholders only: no fake + station/feed rows to invent and then throw away next task. */} +
+
+
+
+
+
+ ); +} diff --git a/panel/src/features/monitor/TotalsCard.tsx b/panel/src/features/monitor/TotalsCard.tsx new file mode 100644 index 00000000..c21b44e3 --- /dev/null +++ b/panel/src/features/monitor/TotalsCard.tsx @@ -0,0 +1,75 @@ +// P4.2 Task 7 -- the live monitor's "Totals" card (board 7e, +// p4.2-board-7e-extract.md): the big `{checked_in} / {total}` count + +// percent, a green progress bar, then the rate line -- "8.2 scans/min · +// peak 14.6 at 09:40 · est. done 12:20". `peak`/`est_done_at` are both +// nullable (spec §3.1, backend's monitor_rates.go computeRates) -- their +// segments are simply omitted, never fabricated as a zero/blank +// placeholder. Loading/error states are the PAGE's concern (MonitorPage.tsx +// gates on snapshotQuery before this card ever mounts, same "explicit +// state, never fabricated zeros" discipline StationPage.tsx/ +// EventWorkspaceLayout.tsx already establish for their own event queries). +import { Card, CardContent, CardHeader, CardTitle, Progress } from "@idento/ui"; +import { useTranslation } from "react-i18next"; +import type { components } from "../../shared/api/schema"; + +type MonitorTotals = components["schemas"]["MonitorTotals"]; + +export interface TotalsCardProps { + totals: MonitorTotals; +} + +// `hourCycle: "h23"` + `timeZone: "UTC"` deliberately pinned, not left to +// the viewer's locale/timezone defaults: (1) VerdictCard.tsx's own +// formatUtcHHMM documents the codebase-wide convention that a viewer's +// local timezone must never shift a server-recorded check-in-domain +// moment -- `peak.at`/`est_done_at` are exactly that (derived from real +// `checkin_actions.created_at` rows), so the same UTC anchor applies here; +// (2) plain `Intl.DateTimeFormat(locale, {hour:"2-digit",minute:"2-digit"})` +// without an explicit hourCycle renders 12-hour "9:40 AM" for en-US (see +// LiveStrip.tsx's own identical-shaped formatter + LiveStrip.test.tsx's +// "12:00 AM" assertions) -- board 7e's copy is unambiguously 24-hour +// zero-padded ("09:40", "12:20"), which only `hourCycle: "h23"` guarantees +// across locales. `Intl` (rather than a fully hand-rolled formatter) is +// kept so non-Latin numbering systems still localize correctly. +function formatTime(iso: string, locale: string): string { + return new Intl.DateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZone: "UTC", + }).format(new Date(iso)); +} + +export function TotalsCard({ totals }: TotalsCardProps) { + const { t, i18n } = useTranslation(); + const numberFmt = new Intl.NumberFormat(i18n.language); + const percent = totals.total > 0 ? Math.round((totals.checked_in / totals.total) * 100) : 0; + + const rateParts = [t("monitorRate", { rate: totals.rate_per_min.toFixed(1) })]; + if (totals.peak) { + rateParts.push( + t("monitorPeakAt", { rate: totals.peak.rate.toFixed(1), time: formatTime(totals.peak.at, i18n.language) }), + ); + } + if (totals.est_done_at) { + rateParts.push(t("monitorEstDone", { time: formatTime(totals.est_done_at, i18n.language) })); + } + + return ( + + + {t("monitorTotalsTitle")} + + +
+ + {numberFmt.format(totals.checked_in)} / {numberFmt.format(totals.total)} + + {percent}% +
+ +

{rateParts.join(" · ")}

+
+
+ ); +} diff --git a/panel/src/features/monitor/ZonesCard.tsx b/panel/src/features/monitor/ZonesCard.tsx new file mode 100644 index 00000000..62ad776b --- /dev/null +++ b/panel/src/features/monitor/ZonesCard.tsx @@ -0,0 +1,57 @@ +// P4.2 Task 7 -- the live monitor's "By zone" card (board 7e, +// p4.2-board-7e-extract.md): per-zone label + mini progress bar + count +// (e.g. "Main hall 1,190 / VIP 62 / Backstage 32"), plus an "Unattributed" +// row for checked-in attendees the snapshot's zone aggregation couldn't +// attribute to a zone (backend store.GetMonitorZones' own invariant: +// sum(zones[].checked_in) + unattributed === totals.checked_in) -- shown +// ONLY when > 0, per this task's brief, so an event with perfect zone +// coverage never displays a permanent empty "Unattributed: 0" row. +import { Card, CardContent, CardHeader, CardTitle, Progress } from "@idento/ui"; +import { useTranslation } from "react-i18next"; +import type { components } from "../../shared/api/schema"; + +type MonitorZone = components["schemas"]["MonitorZone"]; + +export interface ZonesCardProps { + zones: MonitorZone[]; + unattributed: number; + // The mini progress bars are sized relative to the WHOLE currently- + // checked-in population (totals.checked_in), not to the largest zone -- + // so a zone's bar length is a direct, comparable read of "what share of + // everyone checked in is in this zone", matching the "glanceable from + // across the room" olabel the board extract calls out for this screen. + checkedInTotal: number; +} + +export function ZonesCard({ zones, unattributed, checkedInTotal }: ZonesCardProps) { + const { t, i18n } = useTranslation(); + const numberFmt = new Intl.NumberFormat(i18n.language); + + return ( + + + {t("monitorZonesTitle")} + + + {zones.map((zone) => ( +
+
+ {zone.name} + {numberFmt.format(zone.checked_in)} +
+ +
+ ))} + {unattributed > 0 ? ( +
+
+ {t("monitorUnattributed")} + {numberFmt.format(unattributed)} +
+ +
+ ) : null} +
+
+ ); +} diff --git a/panel/src/shared/i18n/en.json b/panel/src/shared/i18n/en.json index 02391201..9b12a7a5 100644 --- a/panel/src/shared/i18n/en.json +++ b/panel/src/shared/i18n/en.json @@ -621,5 +621,15 @@ "launchTestBadgeNoTemplate": "Design a badge template first to test print it.", "launchStartCheckin": "Start check-in", "launchUnsavedSettingsHint": "Save your check-in settings before starting check-in.", - "launchRegisterError": "Couldn't register the station. Try again." + "launchRegisterError": "Couldn't register the station. Try again.", + "monitorLive": "LIVE", + "monitorUpdatedAgo": "Updated {{seconds}} s ago", + "monitorExit": "Exit", + "monitorTotalsTitle": "Totals", + "monitorZonesTitle": "By zone", + "monitorRate": "{{rate}} scans/min", + "monitorPeakAt": "peak {{rate}} at {{time}}", + "monitorEstDone": "est. done {{time}}", + "monitorUnattributed": "Unattributed", + "monitorSnapshotLoadError": "Couldn't load the live monitor data." } diff --git a/panel/src/shared/i18n/ru.json b/panel/src/shared/i18n/ru.json index 76efa193..6fd4af4d 100644 --- a/panel/src/shared/i18n/ru.json +++ b/panel/src/shared/i18n/ru.json @@ -623,5 +623,15 @@ "launchTestBadgeNoTemplate": "Сначала создайте шаблон бейджа, чтобы протестировать печать.", "launchStartCheckin": "Начать регистрацию", "launchUnsavedSettingsHint": "Сохраните настройки регистрации перед запуском.", - "launchRegisterError": "Не удалось зарегистрировать станцию. Попробуйте ещё раз." + "launchRegisterError": "Не удалось зарегистрировать станцию. Попробуйте ещё раз.", + "monitorLive": "В ЭФИРЕ", + "monitorUpdatedAgo": "Обновлено {{seconds}} с назад", + "monitorExit": "Выход", + "monitorTotalsTitle": "Итоги", + "monitorZonesTitle": "По зонам", + "monitorRate": "{{rate}} сканов/мин", + "monitorPeakAt": "пик {{rate}} в {{time}}", + "monitorEstDone": "ожид. завершение {{time}}", + "monitorUnattributed": "Без зоны", + "monitorSnapshotLoadError": "Не удалось загрузить данные монитора." } From 1fefc238bba84f64c2e7fa6fb6e309eb6f1d3d25 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 02:25:24 +0300 Subject: [PATCH 11/23] feat(panel): monitor stations liveness and read-only recent feed Fills MonitorPage's right-column placeholders (Task 7) with the Stations card (dot + name + count, amber "stale Ns" text label alongside the dot per board 7e -- never color alone) and the read-only Last-scans card (verdictClasses.allowed for checkin rows only; neutral muted icons for undo/reprint; derived zone name; mono HH:MM:SS; no action buttons). Adds the header's amber reconnecting badge over stale snapshot data, plus a data-testid on the LIVE ring so its live-branch is finally exercised by a test (carried over from Task 7's review). Co-Authored-By: Claude Sonnet 5 --- .../src/features/monitor/MonitorPage.test.tsx | 264 +++++++++++++++++- panel/src/features/monitor/MonitorPage.tsx | 97 +++++-- panel/src/features/monitor/RecentFeedCard.tsx | 108 +++++++ panel/src/features/monitor/StationsCard.tsx | 72 +++++ panel/src/features/monitor/liveness.test.ts | 37 +++ panel/src/features/monitor/liveness.ts | 30 ++ panel/src/shared/i18n/en.json | 8 +- panel/src/shared/i18n/ru.json | 8 +- 8 files changed, 590 insertions(+), 34 deletions(-) create mode 100644 panel/src/features/monitor/RecentFeedCard.tsx create mode 100644 panel/src/features/monitor/StationsCard.tsx create mode 100644 panel/src/features/monitor/liveness.test.ts create mode 100644 panel/src/features/monitor/liveness.ts diff --git a/panel/src/features/monitor/MonitorPage.test.tsx b/panel/src/features/monitor/MonitorPage.test.tsx index 640a9a1f..cc4bf1a4 100644 --- a/panel/src/features/monitor/MonitorPage.test.tsx +++ b/panel/src/features/monitor/MonitorPage.test.tsx @@ -21,7 +21,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, } from "@tanstack/react-router"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import { delay, http, HttpResponse } from "msw"; import { MonitorPage } from "./MonitorPage"; import { startMswServer } from "../../test/msw"; @@ -150,6 +150,45 @@ function monitorStreamHandler() { }); } +// P4.2 Task 8's carried-over BINDING item from Task 7's review: at least +// one test where the stream mock emits a real "hello" frame and the +// header's live-state ring appears (Task 7 wired the ring but never +// exercised it). The "MonitorPage -- stream status" describe block below +// overrides the always-open, never-framed `monitorStreamHandler()` above +// with THIS hand-driven, controlled stream -- same deferred/controlled +// `ReadableStream` idiom as useMonitorStream.test.tsx's own +// `makeSseStream` (real timers throughout, no fake clock, per that file's +// own documented rationale: fake timers + MSW streaming don't mix here). +function makeSseStream() { + let controllerRef!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller; + }, + }); + const encoder = new TextEncoder(); + return { + stream, + push(frame: string) { + controllerRef.enqueue(encoder.encode(frame)); + }, + close() { + controllerRef.close(); + }, + }; +} + +type StreamConnection = ReturnType; +let streamConnections: StreamConnection[] = []; + +function controlledMonitorStreamHandler() { + return http.get("http://api.test/api/events/:eventId/monitor/stream", () => { + const conn = makeSseStream(); + streamConnections.push(conn); + return new HttpResponse(conn.stream, { headers: { "Content-Type": "text/event-stream" } }); + }); +} + const server = startMswServer( http.get("http://api.test/api/events/:id", () => HttpResponse.json(EVENT)), http.get("http://api.test/api/events/:eventId/monitor", () => HttpResponse.json(monitorSnapshot)), @@ -298,3 +337,226 @@ describe("MonitorPage", () => { expect(screen.queryByTestId("monitor-totals-card")).not.toBeInTheDocument(); }); }); + +// P4.2 Task 8 -- Stations card: board 7e's own answer to "how stale is +// stale" (p4.2-board-7e-extract.md) is a per-row amber dot PLUS a text +// duration label, never color alone. `last_seen_at` timestamps below are +// either "right now" (always fresh, however slowly this test itself runs) +// or a fixed far-past date (always stale by any realistic wall clock) -- +// deliberately far enough from the 45s threshold in either direction that +// this doesn't need to control MonitorPage's own `Date.now()`-seeded +// ticker state (liveness.test.ts already pins the exact 44.9s/45.1s +// boundary in isolation). +describe("MonitorPage -- Stations card (liveness)", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + }); + + it("shows a fresh station's green dot and count, with NO stale-duration label", async () => { + monitorSnapshot = snapshotBody({ + stations: [ + { id: "st-1", name: "Kiosk A", zone_id: null, last_seen_at: new Date().toISOString(), checkin_count: 12 }, + ], + }); + renderCorrectAt("/events/evt-1/monitor"); + + expect(await screen.findByText("Kiosk A")).toBeInTheDocument(); + expect(screen.getByTestId("monitor-station-dot-st-1")).toHaveClass("bg-success"); + expect(screen.getByTestId("monitor-station-dot-st-1")).not.toHaveClass("bg-warning"); + expect(screen.queryByTestId("monitor-station-stale-st-1")).not.toBeInTheDocument(); + expect(screen.queryByText(/stale/i)).not.toBeInTheDocument(); + expect(screen.getByText("12")).toBeInTheDocument(); + }); + + it("shows a stale station's amber dot AND a text 'stale Ns' duration label -- never color alone", async () => { + monitorSnapshot = snapshotBody({ + stations: [ + { id: "st-2", name: "Mobile 1", zone_id: null, last_seen_at: "2000-01-01T00:00:00.000Z", checkin_count: 3 }, + ], + }); + renderCorrectAt("/events/evt-1/monitor"); + + expect(await screen.findByText("Mobile 1")).toBeInTheDocument(); + expect(screen.getByTestId("monitor-station-dot-st-2")).toHaveClass("bg-warning"); + expect(screen.getByTestId("monitor-station-dot-st-2")).not.toHaveClass("bg-success"); + expect(screen.getByTestId("monitor-station-stale-st-2")).toHaveTextContent(/stale \d+ s/); + }); +}); + +// P4.2 Task 8 -- Last-scans (Recent feed) card: board 7e's own copy is +// explicit -- "compact rows: bare stroke icon (no circle badge) + name/ +// zone + mono timestamp, no action buttons (read-only)". CheckinActionRow's +// `action` is "checkin" | "undo" | "reprint" -- and checkin_actions only +// ever logs a 'checkin' row on outcome "checked_in" (backend +// pg_store_checkin_test.go's own comment: "never already_checked_in, never +// an [other outcome]"), so a checkin row is ALWAYS the `allowed` verdict -- +// the ONLY verdict this card ever renders. undo/reprint are explicitly NOT +// verdicts (Global Constraints) -- neutral muted icons, asserted below as +// carrying no `text-verdict-*` class at all, not just "a different one". +describe("MonitorPage -- Recent feed card (read-only)", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + }); + + it("renders a checkin row with the verdictClasses.allowed icon/color, a mono HH:MM:SS timestamp, and NO action buttons anywhere in the card", async () => { + monitorSnapshot = snapshotBody({ + recent: [ + { + id: "act-1", + action: "checkin", + station_id: null, + created_at: "2026-07-18T09:05:03.000Z", + attendee: { id: "att-1", first_name: "Ada", last_name: "Lovelace", code: "C1" }, + }, + ], + }); + renderCorrectAt("/events/evt-1/monitor"); + + const row = await screen.findByTestId("monitor-recent-row-act-1"); + expect(row).toHaveTextContent("Ada Lovelace"); + expect(row).toHaveTextContent("09:05:03"); + + const icon = row.querySelector("svg"); + expect(icon).toHaveClass("text-verdict-allowed"); + + expect(within(screen.getByTestId("monitor-recent-card")).queryAllByRole("button")).toHaveLength(0); + }); + + it("renders undo/reprint rows with a neutral muted icon -- asserted as carrying NO verdict color class at all", async () => { + monitorSnapshot = snapshotBody({ + recent: [ + { + id: "act-2", + action: "undo", + station_id: null, + created_at: "2026-07-18T09:06:00.000Z", + attendee: { id: "att-2", first_name: "Grace", last_name: "Hopper", code: "C2" }, + }, + { + id: "act-3", + action: "reprint", + station_id: null, + created_at: "2026-07-18T09:07:00.000Z", + attendee: { id: "att-3", first_name: "Alan", last_name: "Turing", code: "C3" }, + }, + ], + }); + renderCorrectAt("/events/evt-1/monitor"); + + const undoRow = await screen.findByTestId("monitor-recent-row-act-2"); + const undoIcon = undoRow.querySelector("svg"); + expect(undoIcon).toHaveClass("text-muted-foreground"); + expect(Array.from(undoIcon?.classList ?? []).some((c) => c.startsWith("text-verdict-"))).toBe(false); + + const reprintRow = screen.getByTestId("monitor-recent-row-act-3"); + const reprintIcon = reprintRow.querySelector("svg"); + expect(reprintIcon).toHaveClass("text-muted-foreground"); + expect(Array.from(reprintIcon?.classList ?? []).some((c) => c.startsWith("text-verdict-"))).toBe(false); + }); + + it("derives the zone name for a row via its station's zone when derivable, and omits it (no placeholder) when the chain is broken", async () => { + monitorSnapshot = snapshotBody({ + zones: [{ zone_id: "z-1", name: "Main hall", checked_in: 1 }], + stations: [ + { id: "st-1", name: "Kiosk A", zone_id: "z-1", last_seen_at: new Date().toISOString(), checkin_count: 1 }, + { id: "st-2", name: "Kiosk B", zone_id: null, last_seen_at: new Date().toISOString(), checkin_count: 0 }, + ], + recent: [ + { + id: "act-4", + action: "checkin", + station_id: "st-1", // has a zone -> derivable. + created_at: "2026-07-18T09:08:00.000Z", + attendee: { id: "att-4", first_name: "Rosalind", last_name: "Franklin", code: "C4" }, + }, + { + id: "act-5", + action: "checkin", + station_id: "st-2", // station has no zone -> not derivable. + created_at: "2026-07-18T09:09:00.000Z", + attendee: { id: "att-5", first_name: "Katherine", last_name: "Johnson", code: "C5" }, + }, + { + id: "act-6", + action: "checkin", + station_id: null, // station-less row -> not derivable. + created_at: "2026-07-18T09:10:00.000Z", + attendee: { id: "att-6", first_name: "Dorothy", last_name: "Vaughan", code: "C6" }, + }, + ], + }); + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByTestId("monitor-recent-row-act-4"); + expect(screen.getByTestId("monitor-recent-zone-act-4")).toHaveTextContent("Main hall"); + expect(screen.queryByTestId("monitor-recent-zone-act-5")).not.toBeInTheDocument(); + expect(screen.queryByTestId("monitor-recent-zone-act-6")).not.toBeInTheDocument(); + }); +}); + +// P4.2 Task 8 -- header stream-status coverage (connecting/live/ +// reconnecting), including the carried-over BINDING item from Task 7's own +// review: at least one test proving the header's live-state ring actually +// appears once a real "hello" frame arrives (Task 7 wired it but never +// exercised the `live` branch). Overrides the module-level +// `monitorStreamHandler()` (always-open, never-framed -- "connecting" +// forever, used by every OTHER describe block above) with the +// hand-driven `controlledMonitorStreamHandler()` so this block alone can +// drive hello/close frames and observe connecting -> live -> reconnecting +// -> live. +describe("MonitorPage -- stream status (connecting/live/reconnecting)", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + monitorSnapshot = snapshotBody(); + streamConnections = []; + server.use(controlledMonitorStreamHandler()); + }); + + it("shows no reconnecting badge and no live ring while still connecting, then shows the live ring once the hello frame arrives", async () => { + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("1,284 / 2,410"); + expect(screen.queryByTestId("monitor-live-ring")).not.toBeInTheDocument(); + expect(screen.queryByTestId("monitor-reconnecting-badge")).not.toBeInTheDocument(); + + await waitFor(() => expect(streamConnections.length).toBe(1)); + streamConnections[0].push("event: hello\ndata: {}\n\n"); + + await waitFor(() => expect(screen.getByTestId("monitor-live-ring")).toBeInTheDocument()); + expect(screen.queryByTestId("monitor-reconnecting-badge")).not.toBeInTheDocument(); + }); + + it( + "shows the amber reconnecting badge over the already-fetched (now stale) snapshot data once the stream disconnects, and hides it again after a successful reconnect", + async () => { + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("1,284 / 2,410"); + await waitFor(() => expect(streamConnections.length).toBe(1)); + streamConnections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(screen.getByTestId("monitor-live-ring")).toBeInTheDocument()); + + streamConnections[0].close(); + + await waitFor(() => expect(screen.getByTestId("monitor-reconnecting-badge")).toBeInTheDocument()); + // Global Constraints: "on stream failure show a reconnecting badge + // over stale data" -- the totals card's own numbers must still be + // on screen, not blanked out just because the stream is down. + expect(screen.getByText("1,284 / 2,410")).toBeInTheDocument(); + + // Backoff is 1s base +/-25% jitter (max 1250ms) -- bounded wait for + // the retried connect() to land as a brand-new request. + await waitFor(() => expect(streamConnections.length).toBe(2), { timeout: 3000 }); + streamConnections[1].push("event: hello\ndata: {}\n\n"); + + await waitFor(() => expect(screen.queryByTestId("monitor-reconnecting-badge")).not.toBeInTheDocument()); + }, + 8000, + ); +}); diff --git a/panel/src/features/monitor/MonitorPage.tsx b/panel/src/features/monitor/MonitorPage.tsx index 4b787e5e..eeadc9a2 100644 --- a/panel/src/features/monitor/MonitorPage.tsx +++ b/panel/src/features/monitor/MonitorPage.tsx @@ -9,16 +9,21 @@ // // Wires together Task 5/6's data layer (`useMonitorSnapshot` for the // numbers, `useMonitorStream` for the header's LIVE pill + as the thing -// that keeps the snapshot fresh via invalidation elsewhere) with this -// task's own header chrome and left-column cards (TotalsCard, ZonesCard). -// The right column renders ONLY stable, content-free placeholders here -- -// Task 8 owns the Stations/Last-scans cards and the reconnecting badge. +// that keeps the snapshot fresh via invalidation elsewhere) with the left- +// column cards (TotalsCard, ZonesCard, Task 7) and the right-column cards +// (StationsCard, RecentFeedCard, Task 8) plus the header's amber +// `monitorReconnecting` badge (also Task 8) -- shown whenever +// `stream.status === "reconnecting"`, with the already-fetched snapshot +// content staying rendered underneath it (a dead stream degrades the +// header, never blanks the body). import * as React from "react"; import { Button, Card, CardContent, Skeleton } from "@idento/ui"; import { Link, getRouteApi } from "@tanstack/react-router"; import { ArrowLeft } from "lucide-react"; import { useTranslation } from "react-i18next"; import { $api } from "../../shared/api/query"; +import { RecentFeedCard } from "./RecentFeedCard"; +import { StationsCard } from "./StationsCard"; import { TotalsCard } from "./TotalsCard"; import { ZonesCard } from "./ZonesCard"; import { useMonitorSnapshot } from "./hooks"; @@ -79,7 +84,8 @@ export function MonitorPage() { return (
{/* Header (56px per the board) -- LIVE pill · event name · "Updated - Ns ago" staleness label · Exit. */} + Ns ago" staleness label · (reconnecting badge, when the stream is + down) · Exit. */}
{live ? ( - + ) : null} @@ -99,6 +108,17 @@ export function MonitorPage() { {t("monitorUpdatedAgo", { seconds: updatedSeconds })} ) : null} + {/* Global Constraints: a dead stream shows a reconnecting badge + OVER stale data -- the body below keeps rendering whatever + snapshot was last fetched, unconditionally on stream.status. */} + {stream.status === "reconnecting" ? ( + + {t("monitorReconnecting")} + + ) : null}
+ {/* Board 1c/1d precedent (p4.2-board-7e-extract.md): "Open monitor" + sits beside the running card's existing CTA. */} +
+ + +
- {stats.isLoading ? ( + {snapshot.isLoading ? ( - ) : stats.isError ? ( + ) : snapshot.isError ? (

{t("homeStatsLoadError")}

) : (

@@ -85,22 +109,28 @@ function RunningCard({ event }: { event: ApiEvent }) {

)} - {!stats.isLoading && !stats.isError ? : null} - {/* zone_stats is a per-VERDICT breakdown (allowed/no_access/not_registered), - not a per-zone-name breakdown — there is no zone-name data in this - endpoint, so it is never rendered as such here. */} - {zoneStats ? ( -
- - {t("homeStatsAllowed")}: {zoneStats.allowed} - - - {t("homeStatsNoAccess")}: {zoneStats.no_access} - - - {t("homeStatsNotRegistered")}: {zoneStats.not_registered} - -
+ {!snapshot.isLoading && !snapshot.isError ? ( + <> + + {/* Compact per-zone mini-line (board 1c/1d): real zone-name + + count pairs from the monitor snapshot, unattributed shown + only when > 0 (an event with perfect zone coverage never + shows a permanent empty "Unattributed: 0"). */} + {zones.length > 0 || unattributed > 0 ? ( +
+ {zones.map((zone) => ( + + {zone.name}: {zone.checked_in} + + ))} + {unattributed > 0 ? ( + + {t("monitorUnattributed")}: {unattributed} + + ) : null} +
+ ) : null} + ) : null}
diff --git a/panel/src/shared/i18n/en.json b/panel/src/shared/i18n/en.json index 7c5447d7..b4aa6978 100644 --- a/panel/src/shared/i18n/en.json +++ b/panel/src/shared/i18n/en.json @@ -34,11 +34,9 @@ "homeLiveNow": "LIVE NOW", "homeCheckedIn": "checked in", "homeOpenEvent": "Open event →", + "homeOpenMonitor": "Open monitor →", "homeNextUp": "Next up", "homeReadyFraction": "{{done}} of {{total}} ready", - "homeStatsAllowed": "Allowed", - "homeStatsNoAccess": "No access", - "homeStatsNotRegistered": "Not registered", "homeStatsLoadError": "Couldn't load live stats.", "homeAllDay": "All day", "homeUpcoming": "Upcoming", diff --git a/panel/src/shared/i18n/ru.json b/panel/src/shared/i18n/ru.json index a2ded9f9..23cdd3ee 100644 --- a/panel/src/shared/i18n/ru.json +++ b/panel/src/shared/i18n/ru.json @@ -34,11 +34,9 @@ "homeLiveNow": "ИДЁТ СЕЙЧАС", "homeCheckedIn": "зарегистрировано", "homeOpenEvent": "Открыть мероприятие →", + "homeOpenMonitor": "Открыть монитор →", "homeNextUp": "Следующее", "homeReadyFraction": "{{done}} из {{total}} готово", - "homeStatsAllowed": "Разрешено", - "homeStatsNoAccess": "Нет доступа", - "homeStatsNotRegistered": "Не зарегистрированы", "homeStatsLoadError": "Не удалось загрузить статистику в реальном времени.", "homeAllDay": "Весь день", "homeUpcoming": "Предстоящие", From 5a231b83548e45f32403bbef02dfc142d607e02d Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 02:53:58 +0300 Subject: [PATCH 13/23] chore(panel): P4.2 final verification sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 10 (final): i18n sweep (16 monitor* keys, EN/RU parity, real Russian, no hardcoded strings), full gates (panel typecheck/test/lint/ build, packages/ui tests, generate:api zero drift, backend go test + golangci-lint + go test -race on broker/handler), cross-checks against main (router.tsx = only eventMonitorRoute added, backend diff scoped to broker+monitor+four publish sites+main.go+openapi.yaml, web/ untouched, zero new deps, zero migrations), and a spec walk mapping every section (§3.1-§3.3, §4.1-§4.3, §5, §6) to its implementing task and named test. No fixes were required. Marks all 27 plan checkboxes complete. --- .../2026-07-18-panel-p4.2-live-monitor.md | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md b/docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md index 62fc3476..19b96a55 100644 --- a/docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md +++ b/docs/superpowers/plans/2026-07-18-panel-p4.2-live-monitor.md @@ -1,6 +1,6 @@ # P4.2 — Live monitor + SSE — Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. **Goal:** Ship the read-only live monitor (board 7e) at `/events/$eventId/monitor` — one snapshot endpoint + the codebase's first SSE stream (thin pings over Postgres LISTEN/NOTIFY behind an `EventBroker` seam) — plus the Home LiveStrip upgrade (Open-monitor CTA, per-zone mini-breakdown, stream-driven refresh). @@ -48,9 +48,9 @@ - Produces: `NewMemBroker() *MemBroker` — pure in-process fanout (map[uuid][]chan + mutex). Used by ALL handler tests AND as the fanout core inside pgBroker. - Produces: `NewPGBroker(ctx context.Context, dbURL string) (*PGBroker, error)` — wraps a MemBroker for local fanout; `Publish` execs `SELECT pg_notify('checkin_events', $1::text)` via its own small pgxpool (MaxConns 2); a background goroutine holds ONE dedicated `pgx.Conn` in a `LISTEN checkin_events` / `WaitForNotification` loop, parses the uuid payload, and forwards into the MemBroker fanout; on connection error it logs, backs off (1s→30s cap), reconnects, and re-LISTENs. `Close()` stops the loop and closes connections. Malformed payloads are logged and skipped. -- [ ] **Step 1: Failing tests (MemBroker + fanout semantics).** Subscribe/Publish delivers to the right event's subscribers only; two subscribers both signaled; unsubscribe stops delivery and is idempotent; slow consumer (unread channel) does not block Publish (drop-if-full, buffered-1 coalescing proven: N publishes while unread → exactly 1 pending signal); Publish to an event with no subscribers is a no-op. pgBroker: payload-parse unit test on the exported/internal payload-handling func (valid uuid forwards to fanout; garbage logged-skipped) — the LISTEN loop itself is documented as not coverable without real Postgres (known repo gap, same posture as migration-000020's constraints). -- [ ] **Step 2:** Implement all three files. `go test ./internal/broker/... -count=1` green. -- [ ] **Step 3:** Full backend gates. **Commit:** `feat(backend): event broker — interface, in-memory fanout, pg LISTEN/NOTIFY transport` +- [x] **Step 1: Failing tests (MemBroker + fanout semantics).** Subscribe/Publish delivers to the right event's subscribers only; two subscribers both signaled; unsubscribe stops delivery and is idempotent; slow consumer (unread channel) does not block Publish (drop-if-full, buffered-1 coalescing proven: N publishes while unread → exactly 1 pending signal); Publish to an event with no subscribers is a no-op. pgBroker: payload-parse unit test on the exported/internal payload-handling func (valid uuid forwards to fanout; garbage logged-skipped) — the LISTEN loop itself is documented as not coverable without real Postgres (known repo gap, same posture as migration-000020's constraints). +- [x] **Step 2:** Implement all three files. `go test ./internal/broker/... -count=1` green. +- [x] **Step 3:** Full backend gates. **Commit:** `feat(backend): event broker — interface, in-memory fanout, pg LISTEN/NOTIFY transport` --- @@ -68,8 +68,8 @@ - `GetMonitorMinuteBuckets(ctx, eventID uuid.UUID, since time.Time) ([]MinuteBucket, error)` where `MinuteBucket{Minute time.Time; Count int}` — `date_trunc('minute', created_at)` GROUP BY over `checkin_actions WHERE action='checkin' AND created_at >= $2`, ascending. Caller passes UTC start-of-day (peak) — rate reuses the same buckets. - `GetMonitorStations(ctx, eventID uuid.UUID) ([]MonitorStation, error)` where `MonitorStation{ID uuid.UUID; Name string; ZoneID *uuid.UUID; LastSeenAt time.Time; CheckinCount int}` — stations LEFT JOIN a per-station `checkin`-action count, ordered by name. -- [ ] **Step 1: Failing pgxmock tests** asserting the REAL SQL text of all four (incl. the `DISTINCT ON` + FILTER clauses, the zones/unattributed single-statement shape, the bucket truncation) plus behavior rows: zero-attendee event → (0,0); zones include zero-count rows; unattributed picks up a checked-in row with no matching action. -- [ ] **Step 2:** Implement. **Step 3:** Gates. **Commit:** `feat(backend): monitor snapshot aggregations` +- [x] **Step 1: Failing pgxmock tests** asserting the REAL SQL text of all four (incl. the `DISTINCT ON` + FILTER clauses, the zones/unattributed single-statement shape, the bucket truncation) plus behavior rows: zero-attendee event → (0,0); zones include zero-count rows; unattributed picks up a checked-in row with no matching action. +- [x] **Step 2:** Implement. **Step 3:** Gates. **Commit:** `feat(backend): monitor snapshot aggregations` --- @@ -84,9 +84,9 @@ - Produces: `GetEventMonitor` handler → the spec §3.1 response verbatim (`totals{checked_in,total,rate_per_min,peak{rate,at}|null,est_done_at|null}`, `zones[]`, `unattributed`, `stations[]`, `recent[]`). `requireEventOwnership` first. - Produces (pure, in `monitor_rates.go`, unit-tested without HTTP): `computeRates(buckets []store.MinuteBucket, now time.Time, total, checkedIn int) (ratePerMin float64, peak *PeakRate, estDoneAt *time.Time)` — rate = sum of buckets within `[now-5m, now)` / 5 (one decimal); peak = max bucket today with its start time (nil when no buckets); estDoneAt = `now + remaining/rate` (nil when `rate < 0.1` or `checkedIn >= total`). -- [ ] **Step 1: openapi** — `MonitorSnapshot` + sub-schemas, all fields required (`peak`/`est_done_at` nullable), `additionalProperties: false`; house error shapes. -- [ ] **Step 2: Failing tests.** `monitor_rates_test.go`: empty buckets → (0, nil, nil); single bucket now → rate counts it; peak picks the max with timestamp; rate<0.1 → nil ETA; done event → nil ETA; window excludes buckets older than 5m. Contract: seeded fakeStore → 200 matching schema with the zones+unattributed invariant asserted; empty event → zeros + nulls; foreign event → 404. -- [ ] **Step 3:** Implement; regen `panel/src/shared/api/schema.d.ts` + commit; panel typecheck. **Step 4:** Gates. **Commit:** `feat(backend): monitor snapshot endpoint` +- [x] **Step 1: openapi** — `MonitorSnapshot` + sub-schemas, all fields required (`peak`/`est_done_at` nullable), `additionalProperties: false`; house error shapes. +- [x] **Step 2: Failing tests.** `monitor_rates_test.go`: empty buckets → (0, nil, nil); single bucket now → rate counts it; peak picks the max with timestamp; rate<0.1 → nil ETA; done event → nil ETA; window excludes buckets older than 5m. Contract: seeded fakeStore → 200 matching schema with the zones+unattributed invariant asserted; empty event → zeros + nulls; foreign event → 404. +- [x] **Step 3:** Implement; regen `panel/src/shared/api/schema.d.ts` + commit; panel typecheck. **Step 4:** Gates. **Commit:** `feat(backend): monitor snapshot endpoint` --- @@ -101,9 +101,9 @@ - Produces: `GET /api/events/{event_id}/monitor/stream` (`GetEventMonitorStream`): `requireEventOwnership` → headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`) → write `event: hello\ndata: {}\n\n` + flush → loop `select` on broker channel (→ `event: update\ndata: {"at":""}\n\n` + flush), 25s ticker (→ `: ping\n\n` + flush), and `c.Request().Context().Done()` (→ unsubscribe + return nil). Flush via `c.Response().Flush()`. - Produces: nil-safe publishes AFTER each successful store call in: `StationCheckin` (ONLY when outcome == `"checked_in"` — an `already_checked_in`/`blocked` response changes no monitor-visible state), `UndoCheckin` (on 200), `MarkAttendeePrinted` (only when a reprint feed row was actually logged), `HeartbeatCheckinStation` (on 204). Pattern: `if h.Broker != nil { if err := h.Broker.Publish(ctx, eventID); err != nil { log.Printf(...) } }` — log-don't-fail, never alters the HTTP response. -- [ ] **Step 1: openapi** — document the stream op (text/event-stream, prose describing hello/update/ping frames). -- [ ] **Step 2: Failing tests** (fake broker = `NewMemBroker()` on the Handler): httptest with a cancellable request context reading the live recorder body — hello frame arrives first; `Publish` → an `update` frame; context cancel → handler returns and the subscription is released (assert via a second Publish not panicking + goroutine-leak check with `runtime.NumGoroutine` delta or a done-channel); foreign event → 404 before any stream headers. Publish-site tests: each of the four handlers with a MemBroker asserts exactly-one/zero publishes per the rules above (e.g. `already_checked_in` → 0, heartbeat 204 → 1). Coverage: mark the stream route covered via the documented direct-map exception (plan-time fact 5). -- [ ] **Step 3:** Implement + wire main.go (construct after store, `defer broker.Close()`). **Step 4:** Gates. **Commit:** `feat(backend): SSE monitor stream + check-in event publishes` +- [x] **Step 1: openapi** — document the stream op (text/event-stream, prose describing hello/update/ping frames). +- [x] **Step 2: Failing tests** (fake broker = `NewMemBroker()` on the Handler): httptest with a cancellable request context reading the live recorder body — hello frame arrives first; `Publish` → an `update` frame; context cancel → handler returns and the subscription is released (assert via a second Publish not panicking + goroutine-leak check with `runtime.NumGoroutine` delta or a done-channel); foreign event → 404 before any stream headers. Publish-site tests: each of the four handlers with a MemBroker asserts exactly-one/zero publishes per the rules above (e.g. `already_checked_in` → 0, heartbeat 204 → 1). Coverage: mark the stream route covered via the documented direct-map exception (plan-time fact 5). +- [x] **Step 3:** Implement + wire main.go (construct after store, `defer broker.Close()`). **Step 4:** Gates. **Commit:** `feat(backend): SSE monitor stream + check-in event publishes` --- @@ -118,8 +118,8 @@ - Produces: `useMonitorSnapshot(eventId)` (`$api.useQuery` on `GET /api/events/{event_id}/monitor`, no refetchInterval) + `MONITOR_SNAPSHOT_KEY(eventId)` (house `[method, path, init]` shape, same discipline as `READINESS_KEY`). - Produces: `createSseParser(onEvent: (evt: {event: string; data: string}) => void): (chunk: string) => void` — incremental, buffer-carrying parser for `event:`/`data:` frames split on `\n\n`; ignores comment lines (`: ping`); tolerates frames split across chunks. -- [ ] **Step 1: Failing tests.** Parser: whole frame; frame split mid-line across two chunks; comment-only chunk → no events; two frames in one chunk → two events; data-only frame defaults event to `message`. Hooks: MSW URL/param capture; key helper prefix-invalidation. -- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor data layer + SSE frame parser` +- [x] **Step 1: Failing tests.** Parser: whole frame; frame split mid-line across two chunks; comment-only chunk → no events; two frames in one chunk → two events; data-only frame defaults event to `message`. Hooks: MSW URL/param capture; key helper prefix-invalidation. +- [x] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor data layer + SSE frame parser` --- @@ -132,8 +132,8 @@ - Consumes: `parseSse` + `MONITOR_SNAPSHOT_KEY` (Task 5), `getToken()` from `shared/api/session`, `getApiBaseUrl()`. - Produces: `useMonitorStream(eventId: string): {status: "connecting" | "live" | "reconnecting"}` — on mount: `fetch(`${base}/api/events/${eventId}/monitor/stream`, {headers: {Authorization: `Bearer ${token}`, Accept: "text/event-stream"}, signal})` → read `res.body` via `getReader()` + TextDecoder → feed `parseSse`. `hello`/first frame → status `live`. `update` → invalidate `MONITOR_SNAPSHOT_KEY(eventId)` COALESCED to ≤1/sec (trailing-edge: a burst schedules exactly one invalidation). Error/close → status `reconnecting`, retry with exponential backoff (1s base, ×2, 30s cap, ±25% jitter) — on successful reconnect, IMMEDIATE snapshot invalidation (resync guarantee) then `live`. AbortController on unmount + eventId change (full reset per scope change — P4.1 round-3 lesson). No polling fallback. -- [ ] **Step 1: Failing MSW streaming tests** (deferred-promise controlled `ReadableStream` bodies — the Task-13/P4.1 deferred-handler precedent): connect → hello → `live`; update frame → snapshot query invalidated (subscribed-observer refetch proof, the house idiom); 3 updates within 300ms → exactly 1 extra snapshot fetch (coalescing); stream close → `reconnecting` → next connect attempt observed + immediate snapshot refetch on success; unmount aborts (no further fetches); eventId change closes the old stream and opens the new URL. -- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor SSE stream hook with coalescing and backoff` +- [x] **Step 1: Failing MSW streaming tests** (deferred-promise controlled `ReadableStream` bodies — the Task-13/P4.1 deferred-handler precedent): connect → hello → `live`; update frame → snapshot query invalidated (subscribed-observer refetch proof, the house idiom); 3 updates within 300ms → exactly 1 extra snapshot fetch (coalescing); stream close → `reconnecting` → next connect attempt observed + immediate snapshot refetch on success; unmount aborts (no further fetches); eventId change closes the old stream and opens the new URL. +- [x] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor SSE stream hook with coalescing and backoff` --- @@ -147,8 +147,8 @@ - Consumes: `useMonitorSnapshot`, `useMonitorStream` (status for the header pill), `@idento/ui` `Progress`/`Card`/`Button`. - Produces: chrome-less page per board 7e — header (LIVE pill driven by stream status: green pulsing when `live`; event name via the existing event query; "Updated Ns ago" from the snapshot's `dataUpdatedAt` + a local 1s ticker; Exit → `/events/$eventId`), 2-col grid (`1.15fr 1fr`), left column: `TotalsCard` (`{checked_in} / {total}`, percent, `Progress`, rate line `X/min · peak Y at HH:MM · est. done HH:MM` — peak/ETA segments omitted when null), `ZonesCard` (per-zone label + mini `Progress` + count; unattributed row labeled via `monitorUnattributed` ONLY when > 0). Right column renders placeholder regions Task 8 fills. Loading → Skeletons; error → explicit error state (never fabricated zeros). -- [ ] **Step 1: Failing routed tests** (mirror `StationPage.test.tsx`'s harness incl. the REAL competing `eventWorkspaceRoute` sibling + misregistration counter-example): renders rail-less at `/events/evt-1/monitor`; totals/percent/rate line from seeded snapshot; null peak/ETA → segments absent; unattributed row hidden at 0, shown at >0; zones sum visibly equals total. Router guard untouched. -- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor route, page shell, totals and zones cards` +- [x] **Step 1: Failing routed tests** (mirror `StationPage.test.tsx`'s harness incl. the REAL competing `eventWorkspaceRoute` sibling + misregistration counter-example): renders rail-less at `/events/evt-1/monitor`; totals/percent/rate line from seeded snapshot; null peak/ETA → segments absent; unattributed row hidden at 0, shown at >0; zones sum visibly equals total. Router guard untouched. +- [x] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor route, page shell, totals and zones cards` --- @@ -162,8 +162,8 @@ - Consumes: snapshot `stations[]`/`recent[]`, `verdictClasses` (checkin rows → `allowed` icon/color ONLY), `useMonitorStream` status. - Produces: `liveness.ts`: `STATION_STALE_MS = 45_000`, `stationStaleness(lastSeenAt: string, now: number): {stale: boolean; seconds: number}`. `StationsCard`: dot (green = fresh, amber = stale) + name + count; stale rows ALSO show the text label `monitorStaleFor` ("stale {{s}} s") — never color-alone. `RecentFeedCard`: read-only rows (verdict/neutral icon per Global Constraints, name, zone name when derivable from the station's zone, mono `HH:MM:SS`); NO buttons. Header gains the amber `monitorReconnecting` badge whenever stream status is `reconnecting` (stale data stays rendered). The page's existing 1s ticker drives staleness labels + "Updated Ns ago". -- [ ] **Step 1: Failing tests.** `liveness.test.ts`: 44.9s → fresh, 45.1s → stale with seconds. Page: station beyond threshold shows amber + "stale Ns" text; fresh station has no label; checkin row uses `verdictClasses.allowed` classes, undo/reprint rows use muted neutral (assert NOT verdict classes); reconnecting status → badge shown, snapshot content still present. -- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor stations liveness and read-only recent feed` +- [x] **Step 1: Failing tests.** `liveness.test.ts`: 44.9s → fresh, 45.1s → stale with seconds. Page: station beyond threshold shows amber + "stale Ns" text; fresh station has no label; checkin row uses `verdictClasses.allowed` classes, undo/reprint rows use muted neutral (assert NOT verdict classes); reconnecting status → badge shown, snapshot content still present. +- [x] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): monitor stations liveness and read-only recent feed` --- @@ -176,18 +176,18 @@ - Consumes: `useMonitorSnapshot`, `useMonitorStream` (RunningCard only), monitor route path for the CTA. - Produces: `RunningCard` drops `useEventStats(poll)` + the dead `zone_stats` read; counters/progress come from `useMonitorSnapshot(event.id)` kept fresh by `useMonitorStream(event.id)`; adds the "Open monitor" `Button` (Link to `/events/$eventId/monitor`) beside the existing check-in CTA (board 1c/1d) and a compact per-zone line (name + count, unattributed only when >0). `UpcomingCard` untouched. `useEventStats` itself stays (other consumers may exist — verify with grep; if LiveStrip was the only `poll:true` consumer, leave the hook's poll option in place regardless, out of scope to remove). -- [ ] **Step 1: Failing tests.** RunningCard renders snapshot counts; "Open monitor" links to the monitor route; zone mini-line renders; no `/api/events/{event_id}/stats` request fires from RunningCard anymore (MSW negative assertion); UpcomingCard regression untouched. -- [ ] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): live-strip on monitor snapshot + open-monitor CTA + zone breakdown` +- [x] **Step 1: Failing tests.** RunningCard renders snapshot counts; "Open monitor" links to the monitor route; zone mini-line renders; no `/api/events/{event_id}/stats` request fires from RunningCard anymore (MSW negative assertion); UpcomingCard regression untouched. +- [x] **Step 2:** Implement. **Step 3:** Panel gates. **Commit:** `feat(panel): live-strip on monitor snapshot + open-monitor CTA + zone breakdown` --- ### Task 10: Final — i18n sweep + gates + cross-checks + spec walk -- [ ] **Step 1: i18n sweep.** Every `monitor*` key referenced, EN/RU parity (keyParity test), real Russian, no hardcoded strings in touched files. -- [ ] **Step 2: Full gates.** Panel typecheck/test/eslint/build; `npm test -w packages/ui` untouched-green; `npm run generate:api -w panel` zero drift; backend `OPENAPI_COVERAGE=1 go test ./... -count=1` + `golangci-lint run ./internal/...`. -- [ ] **Step 3: Cross-checks.** `git diff main -- panel/src/app/router.tsx` = ONLY `eventMonitorRoute` added; backend diff = broker package + monitor files + the four publish-site touches + main.go + openapi.yaml ONLY; `git diff main -- web/` EMPTY; zero new entries in either package.json/go.mod dependency lists; zero files under `backend/migrations/`. -- [ ] **Step 4: Spec walk.** §3.1 (snapshot + invariant + rate semantics) → Tasks 2-3; §3.2 (broker/NOTIFY/replica-correctness) → Tasks 1+4; §3.3 (stream frames/keep-alive/clean shutdown) → Task 4; §4.1 (fetch-streaming auth, coalescing, backoff, no polling fallback) → Task 6; §4.2 (7e layout, 45s staleness with text label, verdict discipline, read-only) → Tasks 7-8; §4.3 (LiveStrip) → Task 9; §5 invariants each pinned by a named test; §6 testing map holds. Mark plan checkboxes; controller appends the ledger entry. -- [ ] **Step 5: Commit.** `chore(panel): P4.2 final verification sweep` +- [x] **Step 1: i18n sweep.** Every `monitor*` key referenced, EN/RU parity (keyParity test), real Russian, no hardcoded strings in touched files. +- [x] **Step 2: Full gates.** Panel typecheck/test/eslint/build; `npm test -w packages/ui` untouched-green; `npm run generate:api -w panel` zero drift; backend `OPENAPI_COVERAGE=1 go test ./... -count=1` + `golangci-lint run ./internal/...`. +- [x] **Step 3: Cross-checks.** `git diff main -- panel/src/app/router.tsx` = ONLY `eventMonitorRoute` added; backend diff = broker package + monitor files + the four publish-site touches + main.go + openapi.yaml ONLY; `git diff main -- web/` EMPTY; zero new entries in either package.json/go.mod dependency lists; zero files under `backend/migrations/`. +- [x] **Step 4: Spec walk.** §3.1 (snapshot + invariant + rate semantics) → Tasks 2-3; §3.2 (broker/NOTIFY/replica-correctness) → Tasks 1+4; §3.3 (stream frames/keep-alive/clean shutdown) → Task 4; §4.1 (fetch-streaming auth, coalescing, backoff, no polling fallback) → Task 6; §4.2 (7e layout, 45s staleness with text label, verdict discipline, read-only) → Tasks 7-8; §4.3 (LiveStrip) → Task 9; §5 invariants each pinned by a named test; §6 testing map holds. Mark plan checkboxes; controller appends the ledger entry. +- [x] **Step 5: Commit.** `chore(panel): P4.2 final verification sweep` --- From 368b69f7f944fcc9330314d61c5a2698b17ac149 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 04:50:32 +0300 Subject: [PATCH 14/23] =?UTF-8?q?fix(backend):=20PR=20#81=20bot-review=20r?= =?UTF-8?q?ound=20=E2=80=94=20atomic=20monitor=20overview,=20undo-aware=20?= =?UTF-8?q?zone=20attribution,=20exact=20rate=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- backend/internal/handler/monitor.go | 32 +-- backend/internal/handler/monitor_rates.go | 46 ++-- .../internal/handler/monitor_rates_test.go | 145 ++++------- .../openapi_contract_monitor_p4_test.go | 32 +-- backend/internal/handler/testsupport_test.go | 14 +- backend/internal/store/interface.go | 62 +++-- backend/internal/store/pg_store_monitor.go | 195 +++++++++------ .../pg_store_monitor_integration_test.go | 95 +++++--- .../internal/store/pg_store_monitor_test.go | 229 ++++++++++-------- 9 files changed, 470 insertions(+), 380 deletions(-) diff --git a/backend/internal/handler/monitor.go b/backend/internal/handler/monitor.go index e158e6e1..98a6a72c 100644 --- a/backend/internal/handler/monitor.go +++ b/backend/internal/handler/monitor.go @@ -50,8 +50,11 @@ type MonitorStationRow struct { // /api/events/{event_id}/monitor (P4.2 Task 3, spec §3.1) — everything // screen 7e (the live monitor) renders in one request. Invariant: // sum(Zones[].CheckedIn) + Unattributed == Totals.CheckedIn, which holds by -// construction because both halves come from the SAME GetMonitorZones call -// (see store.GetMonitorZones' doc comment). +// construction because Total, CheckedIn, Zones, AND Unattributed all come +// from the SAME store.GetMonitorOverview call (see its doc comment; PR #81 +// bot-review round, Finding A1 — previously totals came from a separate +// GetMonitorCounts call that could transiently disagree with a concurrent +// GetMonitorZones call). type MonitorSnapshot struct { Totals MonitorTotals `json:"totals"` Zones []MonitorZone `json:"zones"` @@ -75,24 +78,25 @@ func (h *Handler) GetEventMonitor(c echo.Context) error { ctx := c.Request().Context() - total, checkedIn, err := h.Store.GetMonitorCounts(ctx, eventID) + total, checkedIn, zoneCounts, unattributed, err := h.Store.GetMonitorOverview(ctx, eventID) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor counts"}) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor overview"}) } - zoneCounts, unattributed, err := h.Store.GetMonitorZones(ctx, eventID) + // dayStart is UTC start-of-day: the domain for "today's" peak bucket + // (spec §3.1) — GetMonitorMinuteBuckets backs peak ONLY now (PR #81 + // bot-review round, Finding A3 moved rate_per_min off buckets onto the + // exact CountRecentCheckins query below). + now := time.Now().UTC() + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + buckets, err := h.Store.GetMonitorMinuteBuckets(ctx, eventID, dayStart) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor zones"}) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor rate buckets"}) } - // since is UTC start-of-day: the domain for "today's" peak bucket - // (spec §3.1); computeRates derives the 5-minute rate window from - // this SAME bucket set rather than issuing a second query. - now := time.Now().UTC() - since := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - buckets, err := h.Store.GetMonitorMinuteBuckets(ctx, eventID, since) + recentCount, err := h.Store.CountRecentCheckins(ctx, eventID, now.Add(-rateWindow)) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor rate buckets"}) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor recent check-in count"}) } stations, err := h.Store.GetMonitorStations(ctx, eventID) @@ -108,7 +112,7 @@ func (h *Handler) GetEventMonitor(c echo.Context) error { recent = []store.CheckinActionRow{} } - ratePerMin, peak, estDoneAt := computeRates(buckets, now, total, checkedIn) + ratePerMin, peak, estDoneAt := computeRates(recentCount, buckets, now, total, checkedIn) zones := make([]MonitorZone, 0, len(zoneCounts)) for _, z := range zoneCounts { diff --git a/backend/internal/handler/monitor_rates.go b/backend/internal/handler/monitor_rates.go index ff3e17f8..5631448f 100644 --- a/backend/internal/handler/monitor_rates.go +++ b/backend/internal/handler/monitor_rates.go @@ -16,8 +16,12 @@ type PeakRate struct { At time.Time `json:"at"` } -// rateWindow is the sliding window computeRates sums bucket counts over for -// totals.rate_per_min — the last 5 minutes, per spec §3.1. +// rateWindow is the exact window the caller passes to +// store.CountRecentCheckins for totals.rate_per_min — the last 5 minutes, +// per spec §3.1. PR #81 bot-review round, Finding A3: rate_per_min used to +// be derived from computeRates summing MinuteBucket rows (see below for why +// that was wrong); it's now an exact `created_at >= now-5m` COUNT the +// handler divides by 5.0, with no minute truncation or day clamp. const rateWindow = 5 * time.Minute // minRateForETA is the floor below which est_done_at is considered @@ -25,39 +29,39 @@ const rateWindow = 5 * time.Minute // rate would project an estimated-done time that's not a useful signal. const minRateForETA = 0.1 -// computeRates derives the monitor snapshot's rate/peak/ETA trio from ONE -// shared set of minute buckets (P4.2 Task 3, spec §3.1) — buckets is -// expected to already be scoped by the caller to "today" (UTC), the same -// set peak is computed over: +// computeRates derives the monitor snapshot's rate/peak/ETA trio (P4.2 Task +// 3, spec §3.1; reshaped by PR #81 bot-review round, Finding A3): // -// - ratePerMin: sum of bucket counts within the half-open window -// [now-5m, now) divided by 5, rounded to one decimal — "checkins in the -// last 5 minutes, per minute". -// - peak: the bucket with the highest count among ALL buckets passed in -// (not limited to the rate window — a spike from earlier today still -// wins), paired with that bucket's start time; nil when buckets is +// - ratePerMin: recentCount / 5.0, rounded to one decimal — recentCount is +// the caller-supplied EXACT count of 'checkin' actions in the last 5 +// minutes (store.CountRecentCheckins(ctx, eventID, now-5m)), not derived +// from buckets. The previous bucket-based approach had two compounding +// inaccuracies: (1) it summed MinuteBucket rows, whose 'Minute' is a +// minute-START timestamp — at 12:00:30 the window [11:55:30, 12:00:30) +// would exclude the ENTIRE 11:55 bucket even though its last 30s fall +// inside the window, a systematic undercount of up to ~20%; (2) buckets +// were clamped to UTC start-of-day, so for ~5 minutes after midnight the +// window reached into "yesterday" but got nothing. An exact COUNT with +// no truncation and no day clamp has neither problem. +// - peak: the bucket with the highest count among ALL buckets passed in — +// buckets is expected to already be scoped by the caller to "today" +// (UTC), UNCHANGED from before (GetMonitorMinuteBuckets backs peak +// alone now) — paired with that bucket's start time; nil when buckets is // empty. Ties keep the first (earliest) bucket, since the store returns // buckets in ascending time order. // - estDoneAt: now + (total-checkedIn)/ratePerMin minutes; nil when // ratePerMin is below minRateForETA or checkedIn >= total (the event is // stalled or already fully checked in — no meaningful projection). -func computeRates(buckets []store.MinuteBucket, now time.Time, total, checkedIn int) (ratePerMin float64, peak *PeakRate, estDoneAt *time.Time) { - windowStart := now.Add(-rateWindow) +func computeRates(recentCount int, buckets []store.MinuteBucket, now time.Time, total, checkedIn int) (ratePerMin float64, peak *PeakRate, estDoneAt *time.Time) { + ratePerMin = roundToOneDecimal(float64(recentCount) / 5.0) - var windowSum int var peakBucket *store.MinuteBucket for i := range buckets { b := &buckets[i] - if !b.Minute.Before(windowStart) && b.Minute.Before(now) { - windowSum += b.Count - } if peakBucket == nil || b.Count > peakBucket.Count { peakBucket = b } } - - ratePerMin = roundToOneDecimal(float64(windowSum) / 5.0) - if peakBucket != nil { peak = &PeakRate{Rate: float64(peakBucket.Count), At: peakBucket.Minute} } diff --git a/backend/internal/handler/monitor_rates_test.go b/backend/internal/handler/monitor_rates_test.go index 863940bd..df67c2dc 100644 --- a/backend/internal/handler/monitor_rates_test.go +++ b/backend/internal/handler/monitor_rates_test.go @@ -12,8 +12,24 @@ import ( // wall-clock time. var fixedNow = time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) -func TestComputeRates_EmptyBuckets(t *testing.T) { - rate, peak, estDoneAt := computeRates(nil, fixedNow, 100, 0) +// --- rate_per_min: PR #81 bot-review round, Finding A3 --- +// +// computeRates no longer derives rate_per_min by summing MinuteBucket rows +// within a 5-minute window — that had two compounding inaccuracies: (1) a +// bucket's 'Minute' is a minute-START timestamp, so a bucket whose last +// seconds fell inside the window but whose start didn't got excluded +// wholesale (undercount up to ~20%); (2) buckets were clamped to UTC +// start-of-day, losing the window's reach into "yesterday" for ~5 minutes +// after midnight. The exact boundary/truncation semantics now live entirely +// in store.CountRecentCheckins' SQL (`created_at >= $2`, no minute +// truncation, no day clamp) — proved by +// store/pg_store_monitor_test.go's TestCountRecentCheckins* pgxmock tests +// (which pin the exact `>= $2` predicate) and the real-Postgres integration +// test. computeRates' remaining job for rate_per_min is pure arithmetic: +// recentCount / 5.0, rounded to one decimal — exercised below. + +func TestComputeRates_EmptyBucketsAndNoRecentCheckins(t *testing.T) { + rate, peak, estDoneAt := computeRates(0, nil, fixedNow, 100, 0) if rate != 0 { t.Errorf("rate = %v, want 0", rate) @@ -26,32 +42,25 @@ func TestComputeRates_EmptyBuckets(t *testing.T) { } } -func TestComputeRates_RecentBucketCountsTowardRate(t *testing.T) { - bucketStart := fixedNow.Add(-1 * time.Minute) - buckets := []store.MinuteBucket{{Minute: bucketStart, Count: 5}} +func TestComputeRates_RecentCountDividedByFive(t *testing.T) { + // 5 recent check-ins / 5 minutes = 1.0/min — a pure pass-through of the + // caller-supplied exact count, no bucket involvement. + rate, _, estDoneAt := computeRates(5, nil, fixedNow, 1000, 10) - rate, peak, estDoneAt := computeRates(buckets, fixedNow, 1000, 10) - - // 5 check-ins somewhere in the last 5 minutes / 5 = 1.0/min. if rate != 1.0 { t.Errorf("rate = %v, want 1.0", rate) } - if peak == nil { - t.Fatalf("peak = nil, want a peak for the only bucket") - } - if peak.Rate != 5 || !peak.At.Equal(bucketStart) { - t.Errorf("peak = %+v, want {Rate:5 At:%v}", peak, bucketStart) - } if estDoneAt == nil { t.Fatalf("estDoneAt = nil, want non-nil (rate above floor, remaining > 0)") } } -func TestComputeRates_PeakPicksMaxBucketAcrossWholeDayNotJustWindow(t *testing.T) { - // An early-morning spike (09:40) far outside the last-5-minutes rate - // window should still win peak — peak considers every bucket the - // caller passed in (the store already scopes the query to "today"), - // not just the rate's sliding window. +func TestComputeRates_PeakPicksMaxBucketAcrossWholeDayIndependentOfRecentCount(t *testing.T) { + // peak considers every bucket the caller passed in (the store already + // scopes the query to "today"), completely independent of recentCount — + // an early-morning spike (09:40) still wins peak even though it + // contributes nothing to recentCount (which the caller derived from a + // SEPARATE exact 5-minute-window query). morningSpike := time.Date(2026, 7, 18, 9, 40, 0, 0, time.UTC) recentSmall := fixedNow.Add(-2 * time.Minute) buckets := []store.MinuteBucket{ @@ -59,7 +68,7 @@ func TestComputeRates_PeakPicksMaxBucketAcrossWholeDayNotJustWindow(t *testing.T {Minute: recentSmall, Count: 3}, } - rate, peak, _ := computeRates(buckets, fixedNow, 1000, 100) + rate, peak, _ := computeRates(3, buckets, fixedNow, 1000, 100) if rate != 0.6 { // 3/5 = 0.6 t.Errorf("rate = %v, want 0.6", rate) @@ -72,35 +81,11 @@ func TestComputeRates_PeakPicksMaxBucketAcrossWholeDayNotJustWindow(t *testing.T } } -func TestComputeRates_WindowExcludesBucketsOlderThanFiveMinutes(t *testing.T) { - // A bucket 6 minutes old (older than the 5-minute rate window) must not - // contribute to rate_per_min, even though it's the only bucket for the - // day (and therefore still becomes peak). - oldBucket := fixedNow.Add(-6 * time.Minute) - buckets := []store.MinuteBucket{{Minute: oldBucket, Count: 100}} - - rate, peak, estDoneAt := computeRates(buckets, fixedNow, 1000, 0) - - if rate != 0 { - t.Errorf("rate = %v, want 0 (bucket is outside the 5-minute window)", rate) - } - if peak == nil || peak.Rate != 100 { - t.Errorf("peak = %+v, want the old bucket still counted for peak", peak) - } - // rate is 0 (below the ETA floor), so estDoneAt must be nil even though - // remaining > 0. - if estDoneAt != nil { - t.Errorf("estDoneAt = %v, want nil (rate below floor)", *estDoneAt) - } -} - func TestComputeRates_RateBelowFloor_NilETA(t *testing.T) { - // No buckets in the rate window → rate 0, which is below the 0.1 - // minimum needed to project a meaningful est_done_at, even though - // remaining (total-checkedIn) is positive. - buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-30 * time.Minute), Count: 2}} - - rate, _, estDoneAt := computeRates(buckets, fixedNow, 500, 100) + // recentCount 0 -> rate 0, which is below the 0.1 minimum needed to + // project a meaningful est_done_at, even though remaining + // (total-checkedIn) is positive. + rate, _, estDoneAt := computeRates(0, nil, fixedNow, 500, 100) if rate != 0 { t.Errorf("rate = %v, want 0", rate) @@ -113,9 +98,7 @@ func TestComputeRates_RateBelowFloor_NilETA(t *testing.T) { func TestComputeRates_EventDone_NilETA(t *testing.T) { // checkedIn >= total: everyone is already in, even though the rate is // healthy — no meaningful "done at" projection remains. - buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-1 * time.Minute), Count: 10}} - - rate, _, estDoneAt := computeRates(buckets, fixedNow, 200, 200) + rate, _, estDoneAt := computeRates(10, nil, fixedNow, 200, 200) if rate != 2.0 { // 10/5 t.Errorf("rate = %v, want 2.0", rate) @@ -128,9 +111,7 @@ func TestComputeRates_EventDone_NilETA(t *testing.T) { func TestComputeRates_EventOverCheckedIn_NilETA(t *testing.T) { // checkedIn > total is a defensive edge (shouldn't happen, but a // negative "remaining" must never produce a bogus estDoneAt). - buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-1 * time.Minute), Count: 10}} - - _, _, estDoneAt := computeRates(buckets, fixedNow, 100, 105) + _, _, estDoneAt := computeRates(10, nil, fixedNow, 100, 105) if estDoneAt != nil { t.Errorf("estDoneAt = %v, want nil (checkedIn > total)", *estDoneAt) @@ -138,11 +119,9 @@ func TestComputeRates_EventOverCheckedIn_NilETA(t *testing.T) { } func TestComputeRates_EstDoneAtProjection(t *testing.T) { - // rate = 10 checkins over 5 minutes = 2.0/min; remaining = 100; ETA = - // now + 50 minutes. - buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-1 * time.Minute), Count: 10}} - - rate, _, estDoneAt := computeRates(buckets, fixedNow, 300, 200) + // rate = 10 recent check-ins over 5 minutes = 2.0/min; remaining = 100; + // ETA = now + 50 minutes. + rate, _, estDoneAt := computeRates(10, nil, fixedNow, 300, 200) if rate != 2.0 { t.Fatalf("rate = %v, want 2.0", rate) @@ -157,54 +136,16 @@ func TestComputeRates_EstDoneAtProjection(t *testing.T) { } func TestComputeRates_RateRoundedToOneDecimal(t *testing.T) { - // 7 check-ins / 5 minutes = 1.4/min exactly — proves the one-decimal - // contract holds for a non-trivial (non-multiple-of-5) count. - buckets := []store.MinuteBucket{{Minute: fixedNow.Add(-30 * time.Second), Count: 7}} - - rate, _, _ := computeRates(buckets, fixedNow, 1000, 0) + // 7 recent check-ins / 5 minutes = 1.4/min exactly — proves the + // one-decimal contract holds for a non-trivial (non-multiple-of-5) + // count. + rate, _, _ := computeRates(7, nil, fixedNow, 1000, 0) if rate != 1.4 { t.Errorf("rate = %v, want 1.4", rate) } } -func TestComputeRates_RateWindowBoundaryPins(t *testing.T) { - // The rate window is half-open [now-5m, now): a bucket at EXACTLY now-5m - // IS counted (inclusive boundary); a bucket at EXACTLY now is NOT counted - // (exclusive boundary). These tests pin the contract against future off-by-one refactors. - windowStart := fixedNow.Add(-5 * time.Minute) - - tests := []struct { - name string - bucketTime time.Time - bucketCount int - wantRate float64 - }{ - { - name: "bucket at exactly now-5m (windowStart) IS counted", - bucketTime: windowStart, - bucketCount: 5, - wantRate: 1.0, // 5/5 = 1.0 - }, - { - name: "bucket at exactly now is NOT counted", - bucketTime: fixedNow, - bucketCount: 5, - wantRate: 0.0, // excluded from window - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buckets := []store.MinuteBucket{{Minute: tt.bucketTime, Count: tt.bucketCount}} - rate, _, _ := computeRates(buckets, fixedNow, 1000, 0) - if rate != tt.wantRate { - t.Errorf("rate = %v, want %v", rate, tt.wantRate) - } - }) - } -} - func TestComputeRates_PeakTieBreakerPin(t *testing.T) { // When two buckets have equal counts, peak returns the earlier bucket's // timestamp (first-wins convention, since buckets are iterated in ascending @@ -217,7 +158,7 @@ func TestComputeRates_PeakTieBreakerPin(t *testing.T) { {Minute: laterBucket, Count: 10}, } - _, peak, _ := computeRates(buckets, fixedNow, 1000, 0) + _, peak, _ := computeRates(0, buckets, fixedNow, 1000, 0) if peak == nil { t.Fatalf("peak = nil, want non-nil") diff --git a/backend/internal/handler/openapi_contract_monitor_p4_test.go b/backend/internal/handler/openapi_contract_monitor_p4_test.go index ca1e50df..c4559f22 100644 --- a/backend/internal/handler/openapi_contract_monitor_p4_test.go +++ b/backend/internal/handler/openapi_contract_monitor_p4_test.go @@ -47,19 +47,13 @@ func TestOpenAPIContract_GetEventMonitor_SeededInvariantHolds(t *testing.T) { h := New(&fakeStore{ getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, - getMonitorCounts: func(eventID uuid.UUID) (int, int, error) { + getMonitorOverview: func(eventID uuid.UUID) (int, int, []store.MonitorZoneCount, int, error) { if eventID != event.ID { - t.Fatalf("GetMonitorCounts eventID = %s, want %s", eventID, event.ID) - } - return 100, 60, nil - }, - getMonitorZones: func(eventID uuid.UUID) ([]store.MonitorZoneCount, int, error) { - if eventID != event.ID { - t.Fatalf("GetMonitorZones eventID = %s, want %s", eventID, event.ID) + t.Fatalf("GetMonitorOverview eventID = %s, want %s", eventID, event.ID) } // 25 + 30 + 5 (unattributed) == 60 (checked_in) — the // invariant the response is expected to preserve verbatim. - return []store.MonitorZoneCount{ + return 100, 60, []store.MonitorZoneCount{ {ZoneID: zoneAID, Name: "Zone A", CheckedIn: 25}, {ZoneID: zoneBID, Name: "Zone B", CheckedIn: 30}, }, 5, nil @@ -70,6 +64,12 @@ func TestOpenAPIContract_GetEventMonitor_SeededInvariantHolds(t *testing.T) { } return []store.MinuteBucket{{Minute: now.Add(-1 * time.Minute), Count: 4}}, nil }, + countRecentCheckins: func(eventID uuid.UUID, since time.Time) (int, error) { + if eventID != event.ID { + t.Fatalf("CountRecentCheckins eventID = %s, want %s", eventID, event.ID) + } + return 4, nil + }, getMonitorStations: func(eventID uuid.UUID) ([]store.MonitorStation, error) { if eventID != event.ID { t.Fatalf("GetMonitorStations eventID = %s, want %s", eventID, event.ID) @@ -140,10 +140,12 @@ func TestOpenAPIContract_GetEventMonitor_EmptyEventZerosAndNulls(t *testing.T) { event := contractEvent(tenantID, "Fresh Event") h := New(&fakeStore{ - getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, - getMonitorCounts: func(uuid.UUID) (int, int, error) { return 0, 0, nil }, - getMonitorZones: func(uuid.UUID) ([]store.MonitorZoneCount, int, error) { return nil, 0, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getMonitorOverview: func(uuid.UUID) (int, int, []store.MonitorZoneCount, int, error) { + return 0, 0, nil, 0, nil + }, getMonitorMinuteBuckets: func(uuid.UUID, time.Time) ([]store.MinuteBucket, error) { return nil, nil }, + countRecentCheckins: func(uuid.UUID, time.Time) (int, error) { return 0, nil }, getMonitorStations: func(uuid.UUID) ([]store.MonitorStation, error) { return nil, nil }, getCheckinActions: func(uuid.UUID, int) ([]store.CheckinActionRow, error) { return nil, nil }, }) @@ -203,9 +205,9 @@ func TestOpenAPIContract_GetEventMonitor_ForeignEvent404(t *testing.T) { h := New(&fakeStore{ getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, - getMonitorCounts: func(uuid.UUID) (int, int, error) { - t.Fatalf("GetMonitorCounts should not be called for a foreign event") - return 0, 0, nil + getMonitorOverview: func(uuid.UUID) (int, int, []store.MonitorZoneCount, int, error) { + t.Fatalf("GetMonitorOverview should not be called for a foreign event") + return 0, 0, nil, 0, nil }, }) diff --git a/backend/internal/handler/testsupport_test.go b/backend/internal/handler/testsupport_test.go index 41a20136..ece44abe 100644 --- a/backend/internal/handler/testsupport_test.go +++ b/backend/internal/handler/testsupport_test.go @@ -74,9 +74,9 @@ type fakeStore struct { undoCheckin func(eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error) getCheckinActions func(eventID uuid.UUID, limit int) ([]store.CheckinActionRow, error) insertCheckinAction func(eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error - getMonitorCounts func(eventID uuid.UUID) (int, int, error) - getMonitorZones func(eventID uuid.UUID) ([]store.MonitorZoneCount, int, error) + getMonitorOverview func(eventID uuid.UUID) (int, int, []store.MonitorZoneCount, int, error) getMonitorMinuteBuckets func(eventID uuid.UUID, since time.Time) ([]store.MinuteBucket, error) + countRecentCheckins func(eventID uuid.UUID, since time.Time) (int, error) getMonitorStations func(eventID uuid.UUID) ([]store.MonitorStation, error) createTenantWithDefaultSubscription func(tenant *models.Tenant) error @@ -317,15 +317,15 @@ func (f *fakeStore) GetCheckinActions(_ context.Context, eventID uuid.UUID, limi func (f *fakeStore) InsertCheckinAction(_ context.Context, eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error { return f.insertCheckinAction(eventID, attendeeID, action, stationID, staffUserID) } -func (f *fakeStore) GetMonitorCounts(_ context.Context, eventID uuid.UUID) (int, int, error) { - return f.getMonitorCounts(eventID) -} -func (f *fakeStore) GetMonitorZones(_ context.Context, eventID uuid.UUID) ([]store.MonitorZoneCount, int, error) { - return f.getMonitorZones(eventID) +func (f *fakeStore) GetMonitorOverview(_ context.Context, eventID uuid.UUID) (int, int, []store.MonitorZoneCount, int, error) { + return f.getMonitorOverview(eventID) } func (f *fakeStore) GetMonitorMinuteBuckets(_ context.Context, eventID uuid.UUID, since time.Time) ([]store.MinuteBucket, error) { return f.getMonitorMinuteBuckets(eventID, since) } +func (f *fakeStore) CountRecentCheckins(_ context.Context, eventID uuid.UUID, since time.Time) (int, error) { + return f.countRecentCheckins(eventID, since) +} func (f *fakeStore) GetMonitorStations(_ context.Context, eventID uuid.UUID) ([]store.MonitorStation, error) { return f.getMonitorStations(eventID) } diff --git a/backend/internal/store/interface.go b/backend/internal/store/interface.go index 01371ae1..fae3cc94 100644 --- a/backend/internal/store/interface.go +++ b/backend/internal/store/interface.go @@ -243,43 +243,57 @@ type Store interface { // failure here as best-effort/non-fatal). InsertCheckinAction(ctx context.Context, eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error - // GetMonitorCounts returns the total non-deleted attendee count and the - // currently-checked-in count for eventID (P4.2 Task 2) from ONE query — - // COUNT(*) and COUNT(*) FILTER (WHERE checkin_status) over the same - // attendees row set — so the two numbers can never disagree the way two - // separate queries against a live, concurrently-changing event could (a - // check-in landing between them). Backs the monitor snapshot's - // totals.total/totals.checked_in. - GetMonitorCounts(ctx context.Context, eventID uuid.UUID) (total int, checkedIn int, err error) - - // GetMonitorZones returns every zone's currently-checked-in count, plus - // the count of checked-in attendees that can't be attributed to any zone - // (unattributed) — from ONE statement (P4.2 Task 2), so + // GetMonitorOverview returns the monitor snapshot's total attendee + // count, currently-checked-in count, every zone's currently-checked-in + // count, and the count of checked-in attendees that can't be + // attributed to any zone (unattributed) — ALL FOUR from ONE statement + // (PR #81 bot-review round, Finding A1; supersedes the former + // GetMonitorCounts + GetMonitorZones pair), so // sum(zones[].CheckedIn) + unattributed == checkedIn holds BY - // CONSTRUCTION: both numbers are aggregated from the SAME "attributed" - // CTE (one row per currently-checked-in attendee — checkin_status = true - // AND deleted_at IS NULL — carrying that attendee's zone, or NULL) - // rather than from two independently-issued queries that a future edit - // could let drift apart. An attendee's zone comes from their MOST RECENT - // 'checkin' action — DISTINCT ON (ca.attendee_id) ... ORDER BY - // ca.attendee_id, ca.created_at DESC, ca.id DESC, the same id + // CONSTRUCTION and total/checkedIn can never transiently disagree with + // zones/unattributed the way two independently-issued statements + // against a live, concurrently-changing event could (a check-in/undo + // landing between them). An attendee's zone comes from their MOST + // RECENT 'checkin' action, but only when no LATER 'undo' supersedes it + // (Finding A2: a 'reprint' action never participates in this + // state-changing lookup, since it doesn't change check-in state) — + // DISTINCT ON (ca.attendee_id) over ('checkin', 'undo') actions, ORDER + // BY ca.attendee_id, ca.created_at DESC, ca.id DESC, the same id // tie-breaker as GetCheckinActions (PR #77 bot-review round, Finding E) - // — joined through checkin_stations.zone_id to event_zones; a checked-in - // attendee with no 'checkin' action row, a station-less action, or a + // — joined through checkin_stations.zone_id to event_zones; a + // checked-in attendee with no 'checkin' action row, whose latest + // state-changing action is 'undo' (e.g. checked in, undone, then + // re-checked-in via a path — like the legacy PUT /api/attendees/{id} — + // that writes no new action row), a station-less action, or a // zone-less station all count into unattributed rather than any zone. // Zones are listed in event_zones.order_index order and INCLUDE // zero-count zones (LEFT JOIN FROM event_zones, not the other way // around, so an empty zone never silently disappears from the list). - GetMonitorZones(ctx context.Context, eventID uuid.UUID) (zones []MonitorZoneCount, unattributed int, err error) + GetMonitorOverview(ctx context.Context, eventID uuid.UUID) (total int, checkedIn int, zones []MonitorZoneCount, unattributed int, err error) // GetMonitorMinuteBuckets returns one row per minute (ascending) holding // the count of 'checkin' actions in that minute, for created_at >= since // (P4.2 Task 2) — a date_trunc('minute', created_at) GROUP BY. The // caller passes a UTC start-of-day for the monitor's today's-peak - // computation; the per-5-minute rate reuses these SAME buckets rather - // than issuing a second query. + // computation ONLY — totals.rate_per_min no longer reuses these buckets + // (PR #81 bot-review round, Finding A3 moved rate_per_min to the exact + // CountRecentCheckins query below); GetMonitorMinuteBuckets now backs + // peak alone. GetMonitorMinuteBuckets(ctx context.Context, eventID uuid.UUID, since time.Time) ([]MinuteBucket, error) + // CountRecentCheckins returns the exact count of 'checkin' actions for + // eventID at/after since — COUNT(*) FROM checkin_actions WHERE + // event_id=$1 AND action='checkin' AND created_at>=$2, no minute + // truncation and no day clamp (PR #81 bot-review round, Finding A3). + // Backs totals.rate_per_min: the caller passes since = + // now.Add(-5*time.Minute) and divides the result by 5.0. Replaces the + // previous bucket-window approach, which (a) excluded an entire + // minute-START bucket even when most of its seconds fell inside the + // window (systematic undercount up to ~20%), and (b) clamped to UTC + // start-of-day, losing the window's reach into "yesterday" for ~5 + // minutes after midnight. + CountRecentCheckins(ctx context.Context, eventID uuid.UUID, since time.Time) (int, error) + // GetMonitorStations returns every check-in station for eventID (P4.2 // Task 2) with its LEFT JOINed 'checkin'-action count — COUNT(...) // FILTER (WHERE ca.action = 'checkin'), so 'undo'/'reprint' rows sharing diff --git a/backend/internal/store/pg_store_monitor.go b/backend/internal/store/pg_store_monitor.go index 0438b1cb..81f16b81 100644 --- a/backend/internal/store/pg_store_monitor.go +++ b/backend/internal/store/pg_store_monitor.go @@ -7,61 +7,74 @@ import ( "github.com/google/uuid" ) -// GetMonitorCounts returns the total non-deleted attendee count and the -// currently-checked-in count for eventID (P4.2 Task 2) from ONE query — -// COUNT(*) and COUNT(*) FILTER (WHERE checkin_status) over the same -// attendees row set — so the two numbers can never disagree the way two -// separately-issued queries against a live, concurrently-changing event -// could (a check-in landing between them). -func (s *PGStore) GetMonitorCounts(ctx context.Context, eventID uuid.UUID) (total int, checkedIn int, err error) { - err = s.db.QueryRow(ctx, - `SELECT COUNT(*), COUNT(*) FILTER (WHERE checkin_status) FROM attendees WHERE event_id = $1 AND deleted_at IS NULL`, - eventID, - ).Scan(&total, &checkedIn) - if err != nil { - return 0, 0, err - } - return total, checkedIn, nil -} - -// monitorZonesSQL is GetMonitorZones' single statement. It has three -// parts: +// monitorOverviewSQL is GetMonitorOverview's single statement — it produces +// ALL FOUR of the monitor snapshot's totals/zones outputs (total, +// checked_in, per-zone counts, unattributed) from ONE statement, so they +// all read the SAME MVCC snapshot (PostgreSQL takes one snapshot per +// statement under READ COMMITTED, covering every CTE within it). Before +// this method existed, total/checked_in came from GetMonitorCounts and +// zones/unattributed came from GetMonitorZones — two independently-issued +// statements that could each see a different snapshot if a check-in/undo +// landed between them, transiently breaking the documented invariant +// sum(zones)+unattributed == checked_in (PR #81 bot-review round, Finding +// A1). It has four parts: // -// 1. latest_checkin: each attendee's MOST RECENT 'checkin' action — -// DISTINCT ON (ca.attendee_id) ... ORDER BY ca.attendee_id, -// ca.created_at DESC, ca.id DESC. The id tie-breaker matches -// GetCheckinActions' ordering (PR #77 bot-review round, Finding E): two -// 'checkin' actions for the same attendee sharing a created_at value -// would otherwise have no deterministic "most recent" pick. -// 2. attributed: one row per CURRENTLY checked-in attendee (checkin_status -// = true AND deleted_at IS NULL), LEFT JOINed to latest_checkin and then -// to checkin_stations, carrying that attendee's zone_id — or NULL when -// there's no 'checkin' action row, the action's station_id is NULL, or -// the station's zone_id is NULL. This is the row set BOTH halves of the -// final result are aggregated from. -// 3. The final SELECT/UNION ALL: one row per event_zones row (LEFT JOIN +// 1. counts: the event's total non-deleted attendee count and +// currently-checked-in count — COUNT(*) and COUNT(*) FILTER (WHERE +// checkin_status) over the same attendees row set. Always exactly one +// row (a bare aggregate with no GROUP BY). +// 2. latest_state: each attendee's MOST RECENT state-changing action — +// 'checkin' OR 'undo' (NOT 'reprint', which never changes check-in +// state and must not mask an undo) — DISTINCT ON (ca.attendee_id) ... +// ORDER BY ca.attendee_id, ca.created_at DESC, ca.id DESC. The id +// tie-breaker matches GetCheckinActions' ordering (PR #77 bot-review +// round, Finding E). Including 'undo' here (not just 'checkin') is PR +// #81 Finding A2's fix: an attendee who checked in at station A, was +// undone, then got re-checked-in through a path that writes NO action +// row (e.g. the legacy PUT /api/attendees/{id}, or a mobile batch +// write) is currently checked in but their latest ACTION is the +// 'undo' — they must fall to unattributed, not be attributed to +// station A's zone from the now-superseded 'checkin' row. +// 3. attributed: one row per CURRENTLY checked-in attendee (checkin_status +// = true AND deleted_at IS NULL), LEFT JOINed to latest_state and then +// to checkin_stations — but the checkin_stations join only fires when +// latest_state.action = 'checkin' (see the join's extra ON-clause +// predicate), so an attendee whose latest state-changing action is +// 'undo' carries zone_id = NULL (unattributed) even though a 'checkin' +// row still physically exists in their history. This is the row set +// the zones/unattributed halves are BOTH aggregated from. +// 4. The final SELECT/UNION ALL: one row per event_zones row (LEFT JOIN // FROM event_zones so a zone with zero currently-checked-in attendees -// still appears with checked_in = 0), UNION ALL with exactly one more -// row — COUNT(*) over attributed WHERE zone_id IS NULL — for the -// unattributed count. Because both halves aggregate the SAME attributed -// CTE, sum(zone rows) + unattributed always equals COUNT(attributed.*), -// which is exactly the checked-in population: the invariant holds by -// construction, not by two queries that happen to agree today. -const monitorZonesSQL = ` - WITH latest_checkin AS ( - SELECT DISTINCT ON (ca.attendee_id) ca.attendee_id, ca.station_id +// still appears with checked_in = 0), UNION ALL with one row for the +// unattributed count (COUNT(*) over attributed WHERE zone_id IS NULL), +// UNION ALL with one row for counts' total/checked_in. A row_kind +// discriminator column ('zone' | 'unattributed' | 'totals') tells the +// scanner which branch produced each row. Because the zone and +// unattributed rows both aggregate the SAME attributed CTE, and +// attributed/counts both read attendees within the SAME statement +// snapshot, sum(zone rows)+unattributed always equals checked_in: the +// invariant holds by construction, not by two queries that happen to +// agree today. +const monitorOverviewSQL = ` + WITH counts AS ( + SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE checkin_status) AS checked_in + FROM attendees + WHERE event_id = $1 AND deleted_at IS NULL + ), + latest_state AS ( + SELECT DISTINCT ON (ca.attendee_id) ca.attendee_id, ca.action, ca.station_id FROM checkin_actions ca - WHERE ca.event_id = $1 AND ca.action = 'checkin' + WHERE ca.event_id = $1 AND ca.action IN ('checkin', 'undo') ORDER BY ca.attendee_id, ca.created_at DESC, ca.id DESC ), attributed AS ( SELECT a.id AS attendee_id, cs.zone_id AS zone_id FROM attendees a - LEFT JOIN latest_checkin lc ON lc.attendee_id = a.id - LEFT JOIN checkin_stations cs ON cs.id = lc.station_id + LEFT JOIN latest_state ls ON ls.attendee_id = a.id + LEFT JOIN checkin_stations cs ON cs.id = ls.station_id AND ls.action = 'checkin' WHERE a.event_id = $1 AND a.checkin_status = true AND a.deleted_at IS NULL ) - SELECT ez.id AS zone_id, ez.name, COUNT(attributed.attendee_id) AS checked_in, ez.order_index AS sort_key + SELECT 'zone' AS row_kind, ez.id AS zone_id, ez.name, COUNT(attributed.attendee_id) AS count, ez.order_index AS sort_key, NULL::int AS total FROM event_zones ez LEFT JOIN attributed ON attributed.zone_id = ez.id WHERE ez.event_id = $1 @@ -69,47 +82,93 @@ const monitorZonesSQL = ` UNION ALL - SELECT NULL, NULL, COUNT(*), NULL + SELECT 'unattributed', NULL, NULL, COUNT(*), NULL, NULL FROM attributed WHERE attributed.zone_id IS NULL + UNION ALL + + SELECT 'totals', NULL, NULL, counts.checked_in, NULL, counts.total + FROM counts + ORDER BY sort_key NULLS LAST` -// GetMonitorZones returns every zone's currently-checked-in count, plus the -// count of checked-in attendees that can't be attributed to any zone -// (unattributed) — see monitorZonesSQL for how the single statement makes -// sum(zones)+unattributed == checkedIn hold by construction. Each result -// row's zone_id is NULL for exactly one row (the unattributed total, -// always present — COUNT(*) with no GROUP BY returns one row even when it -// counts zero); every other row is a zone, in event_zones.order_index -// order, zero-count zones included. -func (s *PGStore) GetMonitorZones(ctx context.Context, eventID uuid.UUID) ([]MonitorZoneCount, int, error) { - rows, err := s.db.Query(ctx, monitorZonesSQL, eventID) +// GetMonitorOverview returns the monitor snapshot's total attendee count, +// currently-checked-in count, every zone's currently-checked-in count, and +// the count of checked-in attendees that can't be attributed to any zone — +// all from ONE statement (see monitorOverviewSQL) so sum(zones)+unattributed +// == checkedIn holds BY CONSTRUCTION and can never transiently disagree +// with total/checkedIn (PR #81 bot-review round, Finding A1). An attendee's +// zone comes from their MOST RECENT 'checkin' action, but only when no +// LATER 'undo' supersedes it (Finding A2) — DISTINCT ON (ca.attendee_id) +// over ('checkin', 'undo') actions, ORDER BY ca.attendee_id, ca.created_at +// DESC, ca.id DESC (the same id tie-breaker as GetCheckinActions, PR #77 +// bot-review round Finding E) — joined through checkin_stations.zone_id to +// event_zones; a checked-in attendee with no 'checkin' action row, whose +// latest state-changing action is 'undo', a station-less action, or a +// zone-less station all count into unattributed rather than any zone. +// Zones are listed in event_zones.order_index order and INCLUDE zero-count +// zones (LEFT JOIN FROM event_zones, not the other way around, so an empty +// zone never silently disappears from the list). +func (s *PGStore) GetMonitorOverview(ctx context.Context, eventID uuid.UUID) (total int, checkedIn int, zones []MonitorZoneCount, unattributed int, err error) { + rows, err := s.db.Query(ctx, monitorOverviewSQL, eventID) if err != nil { - return nil, 0, err + return 0, 0, nil, 0, err } defer rows.Close() - var zones []MonitorZoneCount - unattributed := 0 for rows.Next() { + var rowKind string var zoneID *uuid.UUID var name *string - var checkedIn int + var count int var sortKey *int - if err := rows.Scan(&zoneID, &name, &checkedIn, &sortKey); err != nil { - return nil, 0, err + var totalCol *int + if err := rows.Scan(&rowKind, &zoneID, &name, &count, &sortKey, &totalCol); err != nil { + return 0, 0, nil, 0, err } - if zoneID == nil { - unattributed = checkedIn - continue + switch rowKind { + case "zone": + zones = append(zones, MonitorZoneCount{ZoneID: *zoneID, Name: *name, CheckedIn: count}) + case "unattributed": + unattributed = count + case "totals": + checkedIn = count + if totalCol != nil { + total = *totalCol + } } - zones = append(zones, MonitorZoneCount{ZoneID: *zoneID, Name: *name, CheckedIn: checkedIn}) } if err := rows.Err(); err != nil { - return nil, 0, err + return 0, 0, nil, 0, err + } + return total, checkedIn, zones, unattributed, nil +} + +// CountRecentCheckins returns the exact count of 'checkin' actions for +// eventID at/after since — no minute truncation, no day clamp (PR #81 +// bot-review round, Finding A3). It replaces the previous rate_per_min +// computation, which derived its 5-minute window from +// GetMonitorMinuteBuckets' minute-START buckets (systematically +// undercounting up to ~20% — a bucket whose start is just before the +// window boundary was excluded wholesale even though most of its seconds +// fall inside the window) additionally clamped to UTC start-of-day (for +// ~5 minutes after midnight the window reached into "yesterday" and got +// nothing). The caller passes since = now.Add(-5*time.Minute) for +// totals.rate_per_min; GetMonitorMinuteBuckets is unrelated and stays +// bucket-based — it backs ONLY totals.peak ("today's" highest one-minute +// bucket), which is legitimately day-scoped and unaffected by this +// finding. +func (s *PGStore) CountRecentCheckins(ctx context.Context, eventID uuid.UUID, since time.Time) (int, error) { + var count int + err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM checkin_actions WHERE event_id = $1 AND action = 'checkin' AND created_at >= $2`, + eventID, since, + ).Scan(&count) + if err != nil { + return 0, err } - return zones, unattributed, nil + return count, nil } // GetMonitorMinuteBuckets returns one row per minute (ascending) holding diff --git a/backend/internal/store/pg_store_monitor_integration_test.go b/backend/internal/store/pg_store_monitor_integration_test.go index a4bb8d13..5ab06c26 100644 --- a/backend/internal/store/pg_store_monitor_integration_test.go +++ b/backend/internal/store/pg_store_monitor_integration_test.go @@ -10,12 +10,15 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction proves, +// TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction proves, // against a REAL Postgres database, the load-bearing correctness property -// of GetMonitorZones: sum(zones[].CheckedIn) + unattributed == checkedIn. -// pgxmock (used by every other test in this file) only echoes back rows -// it's told to return — it can prove the SQL's exact text and this -// package's row-scanning logic, but it cannot execute the DISTINCT ON +// of GetMonitorOverview: sum(zones[].CheckedIn) + unattributed == checkedIn +// (PR #81 bot-review round, Finding A1 — this now also covers total and +// checkedIn, since GetMonitorCounts and GetMonitorZones were merged into +// this one statement so all four numbers share one snapshot). pgxmock +// (used by every other test in this file) only echoes back rows it's told +// to return — it can prove the SQL's exact text and this package's +// row-scanning logic, but it cannot execute the DISTINCT ON // most-recent-action tie-breaking or the UNION ALL aggregation for real, so // it cannot prove the query is even syntactically valid, let alone that it // picks the right zone per attendee. This is the only test in the repo that @@ -24,16 +27,19 @@ import ( // // The fixture also proves the DISTINCT ON tie-breaker matters: attendee A1 // has an OLDER 'checkin' action pointing at Station 2 / Zone Two, then a -// NEWER one pointing at Station 1 / Zone One — GetMonitorZones must -// attribute A1 to Zone One (the most recent), not Zone Two. +// NEWER one pointing at Station 1 / Zone One — GetMonitorOverview must +// attribute A1 to Zone One (the most recent), not Zone Two. Attendee A7 +// proves Finding A2: a LATER 'undo' must supersede an EARLIER 'checkin' for +// attribution purposes even when the attendee is (via an out-of-band +// UPDATE) currently checked in again — see A7's fixture comment below. // // Gated behind TEST_DATABASE_URL (this codebase has no real-database CI // harness — see pg_store_attendees_page_integration_test.go) and SKIPS, not // fails, when it's unset. To run it locally against the docker-compose db: // // TEST_DATABASE_URL="postgres://idento:idento_password@localhost:5438/idento_db?sslmode=disable" \ -// go test ./internal/store/ -run TestGetMonitorZones_RealPostgres -v -func TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction(t *testing.T) { +// go test ./internal/store/ -run TestGetMonitorOverview_RealPostgres -v +func TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction(t *testing.T) { dbURL := os.Getenv("TEST_DATABASE_URL") if dbURL == "" { t.Skip("TEST_DATABASE_URL not set; skipping real-Postgres monitor-aggregation test (see doc comment for how to run it)") @@ -129,6 +135,14 @@ func TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction(t *testing.T) // A4: checked in, only an 'undo' action (no 'checkin' row at all) -> unattributed. // A5: NOT checked in -> excluded entirely from every count. // A6: soft-deleted -> excluded entirely from every count. + // A7: checked in, undo-supersedes-checkin (PR #81 bot-review round, + // Finding A2) — checked in at station1/zoneOne, undone, THEN + // re-checked-in via a direct UPDATE that writes NO new checkin_actions + // row (simulating a legacy path like PUT /api/attendees/{id} or a + // mobile batch write that bypasses CheckInAttendee). checkin_status is + // currently true, but the latest STATE-CHANGING action is the 'undo' — + // attribution must fall to unattributed, NOT to station1/zoneOne from + // the now-superseded 'checkin' row. attendees := []struct { id uuid.UUID checkedIn bool @@ -140,6 +154,7 @@ func TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction(t *testing.T) {uuid.New(), true, false}, // A4 {uuid.New(), false, false}, // A5 {uuid.New(), true, true}, // A6 + {uuid.New(), false, false}, // A7 (flipped to checked-in below via a direct UPDATE) } for i, a := range attendees { var deletedAt *time.Time @@ -154,7 +169,7 @@ func TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction(t *testing.T) t.Fatalf("insert attendee[%d]: %v", i, err) } } - a1, a2, a3, a4 := attendees[0].id, attendees[1].id, attendees[2].id, attendees[3].id + a1, a2, a3, a4, a7 := attendees[0].id, attendees[1].id, attendees[2].id, attendees[3].id, attendees[6].id insertAction := func(attendeeID uuid.UUID, stationID *uuid.UUID, action string, createdAt time.Time) { t.Helper() @@ -169,23 +184,30 @@ func TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction(t *testing.T) insertAction(a1, &station1, "checkin", now.Add(-1*time.Minute)) // wins: most recent insertAction(a2, &station2, "checkin", now.Add(-5*time.Minute)) insertAction(a3, &stationless, "checkin", now.Add(-3*time.Minute)) - insertAction(a4, &station1, "undo", now.Add(-2*time.Minute)) // no 'checkin' row for A4 at all + insertAction(a4, &station1, "undo", now.Add(-2*time.Minute)) // no 'checkin' row for A4 at all + insertAction(a7, &station1, "checkin", now.Add(-8*time.Minute)) // superseded by the undo below + insertAction(a7, &station1, "undo", now.Add(-6*time.Minute)) // latest state-changing action for A7 - total, checkedIn, err := s.GetMonitorCounts(ctx, eventID) + // Legacy re-checkin: flips checkin_status back to true WITHOUT writing a + // new checkin_actions row — the scenario Finding A2 targets. + if _, err := pool.Exec(ctx, + `UPDATE attendees SET checkin_status = true, checked_in_at = $2 WHERE id = $1`, + a7, now, + ); err != nil { + t.Fatalf("legacy re-checkin UPDATE for A7: %v", err) + } + + total, checkedIn, zones, unattributed, err := s.GetMonitorOverview(ctx, eventID) if err != nil { - t.Fatalf("GetMonitorCounts: %v", err) + t.Fatalf("GetMonitorOverview: %v", err) } - if total != 5 { - t.Errorf("total = %d, want 5 (excludes the soft-deleted A6)", total) + if total != 6 { + t.Errorf("total = %d, want 6 (excludes the soft-deleted A6)", total) } - if checkedIn != 4 { - t.Errorf("checkedIn = %d, want 4 (A1-A4; A5 not checked in, A6 soft-deleted)", checkedIn) + if checkedIn != 5 { + t.Errorf("checkedIn = %d, want 5 (A1-A4, A7; A5 not checked in, A6 soft-deleted)", checkedIn) } - zones, unattributed, err := s.GetMonitorZones(ctx, eventID) - if err != nil { - t.Fatalf("GetMonitorZones: %v", err) - } if len(zones) != 3 { t.Fatalf("len(zones) = %d, want 3 (all event_zones, including the empty one)", len(zones)) } @@ -194,13 +216,13 @@ func TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction(t *testing.T) t.Errorf("zones[0] = %+v, want Zone Two with CheckedIn=1 (A2)", zones[0]) } if zones[1].ZoneID != zoneOne || zones[1].CheckedIn != 1 { - t.Errorf("zones[1] = %+v, want Zone One with CheckedIn=1 (A1, via its MOST RECENT action)", zones[1]) + t.Errorf("zones[1] = %+v, want Zone One with CheckedIn=1 (A1, via its MOST RECENT action — A7's OLDER checkin at the same station/zone must NOT also land here)", zones[1]) } if zones[2].ZoneID != zoneEmpty || zones[2].CheckedIn != 0 { t.Errorf("zones[2] = %+v, want the empty zone with CheckedIn=0 (zero-count zones must still be listed)", zones[2]) } - if unattributed != 2 { - t.Errorf("unattributed = %d, want 2 (A3: station-less action; A4: no 'checkin' action row at all)", unattributed) + if unattributed != 3 { + t.Errorf("unattributed = %d, want 3 (A3: station-less action; A4: no 'checkin' action row at all; A7: latest state-changing action is 'undo')", unattributed) } sum := 0 @@ -222,8 +244,20 @@ func TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction(t *testing.T) t.Errorf("buckets not strictly ascending at index %d: %v then %v", i, buckets[i-1].Minute, b.Minute) } } - if bucketTotal != 4 { - t.Errorf("sum of bucket counts = %d, want 4 (A1 has 2 'checkin' rows, A2 and A3 have 1 each; A4's only action is 'undo', excluded)", bucketTotal) + if bucketTotal != 5 { + t.Errorf("sum of bucket counts = %d, want 5 (A1 has 2 'checkin' rows, A2/A3/A7 have 1 each; A4's only action and A7's second action are 'undo', excluded)", bucketTotal) + } + + // CountRecentCheckins (PR #81 bot-review round, Finding A3) must agree + // with the same 'checkin'-action population GetMonitorMinuteBuckets + // summed above — an exact COUNT over a wide-enough window is just an + // unbucketed version of the same query. + recentCount, err := s.CountRecentCheckins(ctx, eventID, now.Add(-24*time.Hour)) + if err != nil { + t.Fatalf("CountRecentCheckins: %v", err) + } + if recentCount != bucketTotal { + t.Errorf("CountRecentCheckins = %d, want %d (must match the bucketed 'checkin'-action total)", recentCount, bucketTotal) } stations, err := s.GetMonitorStations(ctx, eventID) @@ -237,10 +271,11 @@ func TestGetMonitorZones_RealPostgres_InvariantHoldsByConstruction(t *testing.T) for _, st := range stations { byID[st.ID] = st } - // Station 1 received A1's newer 'checkin' AND A4's 'undo' — the FILTER - // must count only the 'checkin' row. - if got := byID[station1].CheckinCount; got != 1 { - t.Errorf("station1.CheckinCount = %d, want 1 (the 'undo' row must not inflate it)", got) + // Station 1 received A1's newer 'checkin', A4's 'undo', and A7's + // 'checkin'+'undo' pair — the FILTER must count only the two 'checkin' + // rows (A1's and A7's). + if got := byID[station1].CheckinCount; got != 2 { + t.Errorf("station1.CheckinCount = %d, want 2 (the 'undo' rows must not inflate it)", got) } // Station 2 received A1's older 'checkin' AND A2's 'checkin'. if got := byID[station2].CheckinCount; got != 2 { diff --git a/backend/internal/store/pg_store_monitor_test.go b/backend/internal/store/pg_store_monitor_test.go index 21bcc84f..7009e145 100644 --- a/backend/internal/store/pg_store_monitor_test.go +++ b/backend/internal/store/pg_store_monitor_test.go @@ -9,88 +9,42 @@ import ( pgxmock "github.com/pashagolub/pgxmock/v4" ) -// --- P4.2 Task 2: monitor snapshot aggregations --- - -// getMonitorCountsSQL matches GetMonitorCounts' exact SELECT — ONE query -// for both total and checked-in counts, so the two numbers can never -// disagree the way two separately-issued queries against a live event -// could (a check-in landing between them). -const getMonitorCountsSQL = `SELECT COUNT\(\*\), COUNT\(\*\) FILTER \(WHERE checkin_status\) FROM attendees WHERE event_id = \$1 AND deleted_at IS NULL` - -func TestGetMonitorCountsReturnsTotalsFromOneQuery(t *testing.T) { - mock, err := pgxmock.NewPool() - if err != nil { - t.Fatalf("pgxmock.NewPool: %v", err) - } - defer mock.Close() - - eventID := uuid.New() - mock.ExpectQuery(getMonitorCountsSQL). - WithArgs(eventID). - WillReturnRows(pgxmock.NewRows([]string{"count", "count"}).AddRow(42, 17)) - - s := &PGStore{db: mock} - total, checkedIn, err := s.GetMonitorCounts(context.Background(), eventID) - if err != nil { - t.Fatalf("GetMonitorCounts: %v", err) - } - if total != 42 || checkedIn != 17 { - t.Errorf("total=%d checkedIn=%d, want 42, 17", total, checkedIn) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unmet expectations: %v", err) - } -} - -// TestGetMonitorCountsZeroAttendeeEventReturnsZeroZero proves a -// freshly-created event with no attendees at all reports (0, 0), not an -// error — COUNT(*) over an empty row set is 0, never NULL. -func TestGetMonitorCountsZeroAttendeeEventReturnsZeroZero(t *testing.T) { - mock, err := pgxmock.NewPool() - if err != nil { - t.Fatalf("pgxmock.NewPool: %v", err) - } - defer mock.Close() - - eventID := uuid.New() - mock.ExpectQuery(getMonitorCountsSQL). - WithArgs(eventID). - WillReturnRows(pgxmock.NewRows([]string{"count", "count"}).AddRow(0, 0)) - - s := &PGStore{db: mock} - total, checkedIn, err := s.GetMonitorCounts(context.Background(), eventID) - if err != nil { - t.Fatalf("GetMonitorCounts: %v", err) - } - if total != 0 || checkedIn != 0 { - t.Errorf("total=%d checkedIn=%d, want 0, 0", total, checkedIn) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unmet expectations: %v", err) - } +// --- P4.2 Task 2 / PR #81 bot-review round Findings A1+A2: monitor +// snapshot aggregations --- + +// getMonitorOverviewSQL matches GetMonitorOverview's exact single +// statement (PR #81 bot-review round, Finding A1 merges the former +// GetMonitorCounts + GetMonitorZones into one query so total/checked_in +// can never transiently disagree with zones/unattributed): a counts CTE +// (total + checked_in from one attendees scan), a latest_state CTE +// (DISTINCT ON (ca.attendee_id) over 'checkin'/'undo' actions — Finding +// A2: including 'undo' so a later undo supersedes an earlier checkin's +// attribution — ORDER BY ca.attendee_id, ca.created_at DESC, ca.id DESC, +// the same id tie-breaker as GetCheckinActions, PR #77 bot-review round +// Finding E), an attributed CTE (one row per currently-checked-in +// attendee, joined to checkin_stations ONLY when the latest state-changing +// action is 'checkin'), and a final 3-branch UNION ALL (zone rows, +// the unattributed row, the totals row) discriminated by a leading +// row_kind column — so sum(zone rows)+unattributed == checked_in holds by +// construction: all four numbers come out of the SAME statement's +// snapshot. +const getMonitorOverviewSQL = `WITH counts AS \(\s+SELECT COUNT\(\*\) AS total, COUNT\(\*\) FILTER \(WHERE checkin_status\) AS checked_in\s+FROM attendees\s+WHERE event_id = \$1 AND deleted_at IS NULL\s+\),\s+latest_state AS \(\s+SELECT DISTINCT ON \(ca\.attendee_id\) ca\.attendee_id, ca\.action, ca\.station_id\s+FROM checkin_actions ca\s+WHERE ca\.event_id = \$1 AND ca\.action IN \('checkin', 'undo'\)\s+ORDER BY ca\.attendee_id, ca\.created_at DESC, ca\.id DESC\s+\),\s+attributed AS \(\s+SELECT a\.id AS attendee_id, cs\.zone_id AS zone_id\s+FROM attendees a\s+LEFT JOIN latest_state ls ON ls\.attendee_id = a\.id\s+LEFT JOIN checkin_stations cs ON cs\.id = ls\.station_id AND ls\.action = 'checkin'\s+WHERE a\.event_id = \$1 AND a\.checkin_status = true AND a\.deleted_at IS NULL\s+\)\s+SELECT 'zone' AS row_kind, ez\.id AS zone_id, ez\.name, COUNT\(attributed\.attendee_id\) AS count, ez\.order_index AS sort_key, NULL::int AS total\s+FROM event_zones ez\s+LEFT JOIN attributed ON attributed\.zone_id = ez\.id\s+WHERE ez\.event_id = \$1\s+GROUP BY ez\.id, ez\.name, ez\.order_index\s+UNION ALL\s+SELECT 'unattributed', NULL, NULL, COUNT\(\*\), NULL, NULL\s+FROM attributed\s+WHERE attributed\.zone_id IS NULL\s+UNION ALL\s+SELECT 'totals', NULL, NULL, counts\.checked_in, NULL, counts\.total\s+FROM counts\s+ORDER BY sort_key NULLS LAST` + +// monitorOverviewRows builds a pgxmock row set with the 6 columns +// GetMonitorOverview scans: row_kind, zone_id, name, count, sort_key, total. +func monitorOverviewRows() *pgxmock.Rows { + return pgxmock.NewRows([]string{"row_kind", "zone_id", "name", "count", "sort_key", "total"}) } -// getMonitorZonesSQL matches GetMonitorZones' exact single statement: a -// latest_checkin CTE (DISTINCT ON (ca.attendee_id) ... ORDER BY -// ca.attendee_id, ca.created_at DESC, ca.id DESC — the same id tie-breaker -// as GetCheckinActions, PR #77 bot-review round Finding E) feeding an -// attributed CTE (one row per currently-checked-in attendee, carrying its -// zone or NULL), which the final SELECT/UNION ALL turns into the -// zones-with-zero-count list (LEFT JOIN FROM event_zones) plus a single -// unattributed-count row (zone_id IS NULL) — so sum(zones)+unattributed == -// checkedIn holds by construction: both come out of the SAME attributed CTE -// in the SAME statement. -const getMonitorZonesSQL = `WITH latest_checkin AS \(\s+SELECT DISTINCT ON \(ca\.attendee_id\) ca\.attendee_id, ca\.station_id\s+FROM checkin_actions ca\s+WHERE ca\.event_id = \$1 AND ca\.action = 'checkin'\s+ORDER BY ca\.attendee_id, ca\.created_at DESC, ca\.id DESC\s+\),\s+attributed AS \(\s+SELECT a\.id AS attendee_id, cs\.zone_id AS zone_id\s+FROM attendees a\s+LEFT JOIN latest_checkin lc ON lc\.attendee_id = a\.id\s+LEFT JOIN checkin_stations cs ON cs\.id = lc\.station_id\s+WHERE a\.event_id = \$1 AND a\.checkin_status = true AND a\.deleted_at IS NULL\s+\)\s+SELECT ez\.id AS zone_id, ez\.name, COUNT\(attributed\.attendee_id\) AS checked_in, ez\.order_index AS sort_key\s+FROM event_zones ez\s+LEFT JOIN attributed ON attributed\.zone_id = ez\.id\s+WHERE ez\.event_id = \$1\s+GROUP BY ez\.id, ez\.name, ez\.order_index\s+UNION ALL\s+SELECT NULL, NULL, COUNT\(\*\), NULL\s+FROM attributed\s+WHERE attributed\.zone_id IS NULL\s+ORDER BY sort_key NULLS LAST` - -// TestGetMonitorZonesIncludesZeroCountZonesAndUnattributed proves the exact -// SQL and the scanning discriminator: a row with a non-nil zone_id is a -// MonitorZoneCount (including a zero-count zone, which must still appear in -// the slice — LEFT JOIN FROM event_zones, not the other way around), and -// the single row with a nil zone_id/name is the unattributed count — the -// row a checked-in attendee with no matching 'checkin' action row falls -// into. Also proves the load-bearing invariant sum(zones)+unattributed == -// checkedIn on this fixture. -func TestGetMonitorZonesIncludesZeroCountZonesAndUnattributed(t *testing.T) { +// TestGetMonitorOverviewReturnsTotalsZonesAndUnattributedFromOneQuery +// proves the exact SQL and the row_kind scanning discriminator: 'zone' rows +// (including a zero-count zone, which must still appear — LEFT JOIN FROM +// event_zones, not the other way around) become MonitorZoneCount entries, +// the 'unattributed' row becomes unattributed, and the 'totals' row becomes +// total/checkedIn. Also proves the load-bearing invariant +// sum(zones)+unattributed == checkedIn on this fixture — all from the SAME +// mocked statement. +func TestGetMonitorOverviewReturnsTotalsZonesAndUnattributedFromOneQuery(t *testing.T) { mock, err := pgxmock.NewPool() if err != nil { t.Fatalf("pgxmock.NewPool: %v", err) @@ -102,18 +56,25 @@ func TestGetMonitorZonesIncludesZeroCountZonesAndUnattributed(t *testing.T) { zoneB := uuid.New() zoneEmpty := uuid.New() - mock.ExpectQuery(getMonitorZonesSQL). + mock.ExpectQuery(getMonitorOverviewSQL). WithArgs(eventID). - WillReturnRows(pgxmock.NewRows([]string{"zone_id", "name", "checked_in", "sort_key"}). - AddRow(&zoneB, strPtr("Zone B"), 1, intPtr(1)). - AddRow(&zoneA, strPtr("Zone A"), 2, intPtr(2)). - AddRow(&zoneEmpty, strPtr("Zone Empty"), 0, intPtr(3)). - AddRow((*uuid.UUID)(nil), (*string)(nil), 1, (*int)(nil))) + WillReturnRows(monitorOverviewRows(). + AddRow("zone", &zoneB, strPtr("Zone B"), 1, intPtr(1), (*int)(nil)). + AddRow("zone", &zoneA, strPtr("Zone A"), 2, intPtr(2), (*int)(nil)). + AddRow("zone", &zoneEmpty, strPtr("Zone Empty"), 0, intPtr(3), (*int)(nil)). + AddRow("unattributed", (*uuid.UUID)(nil), (*string)(nil), 1, (*int)(nil), (*int)(nil)). + AddRow("totals", (*uuid.UUID)(nil), (*string)(nil), 4, (*int)(nil), intPtr(10))) s := &PGStore{db: mock} - zones, unattributed, err := s.GetMonitorZones(context.Background(), eventID) + total, checkedIn, zones, unattributed, err := s.GetMonitorOverview(context.Background(), eventID) if err != nil { - t.Fatalf("GetMonitorZones: %v", err) + t.Fatalf("GetMonitorOverview: %v", err) + } + if total != 10 { + t.Errorf("total = %d, want 10", total) + } + if checkedIn != 4 { + t.Errorf("checkedIn = %d, want 4", checkedIn) } if len(zones) != 3 { t.Fatalf("len(zones) = %d, want 3", len(zones)) @@ -132,7 +93,6 @@ func TestGetMonitorZonesIncludesZeroCountZonesAndUnattributed(t *testing.T) { for _, z := range zones { sum += z.CheckedIn } - const checkedIn = 4 // must match this fixture's total checked-in population if sum+unattributed != checkedIn { t.Errorf("sum(zones)+unattributed = %d, want %d (invariant broken)", sum+unattributed, checkedIn) } @@ -141,11 +101,12 @@ func TestGetMonitorZonesIncludesZeroCountZonesAndUnattributed(t *testing.T) { } } -// TestGetMonitorZonesNoZonesOnlyUnattributedRow proves an event with zero -// zones still gets exactly the unattributed row back (COUNT(*) over an -// aggregate with no GROUP BY always returns one row, even 0) rather than an -// empty/error result. -func TestGetMonitorZonesNoZonesOnlyUnattributedRow(t *testing.T) { +// TestGetMonitorOverviewZeroAttendeeEventReturnsZeros proves a +// freshly-created event with no attendees, no zones, and no check-ins +// reports zeroed total/checkedIn/unattributed and a nil zones slice, not an +// error — COUNT(*) over an empty row set is 0, never NULL, and the +// unattributed/totals branches always return exactly one row each. +func TestGetMonitorOverviewZeroAttendeeEventReturnsZeros(t *testing.T) { mock, err := pgxmock.NewPool() if err != nil { t.Fatalf("pgxmock.NewPool: %v", err) @@ -153,15 +114,19 @@ func TestGetMonitorZonesNoZonesOnlyUnattributedRow(t *testing.T) { defer mock.Close() eventID := uuid.New() - mock.ExpectQuery(getMonitorZonesSQL). + mock.ExpectQuery(getMonitorOverviewSQL). WithArgs(eventID). - WillReturnRows(pgxmock.NewRows([]string{"zone_id", "name", "checked_in", "sort_key"}). - AddRow((*uuid.UUID)(nil), (*string)(nil), 0, (*int)(nil))) + WillReturnRows(monitorOverviewRows(). + AddRow("unattributed", (*uuid.UUID)(nil), (*string)(nil), 0, (*int)(nil), (*int)(nil)). + AddRow("totals", (*uuid.UUID)(nil), (*string)(nil), 0, (*int)(nil), intPtr(0))) s := &PGStore{db: mock} - zones, unattributed, err := s.GetMonitorZones(context.Background(), eventID) + total, checkedIn, zones, unattributed, err := s.GetMonitorOverview(context.Background(), eventID) if err != nil { - t.Fatalf("GetMonitorZones: %v", err) + t.Fatalf("GetMonitorOverview: %v", err) + } + if total != 0 || checkedIn != 0 { + t.Errorf("total=%d checkedIn=%d, want 0, 0", total, checkedIn) } if len(zones) != 0 { t.Errorf("len(zones) = %d, want 0", len(zones)) @@ -244,6 +209,72 @@ func TestGetMonitorMinuteBucketsNoneReturnsEmpty(t *testing.T) { } } +// --- PR #81 bot-review round Finding A3: exact rate window --- + +// getCountRecentCheckinsSQL matches CountRecentCheckins' exact SELECT — an +// exact COUNT(*) with a >= cutoff, no minute truncation and no day clamp +// (replaces the former bucket-summing approach in computeRates, which +// undercounted by excluding a bucket's minute-START timestamp from the +// window even when most of the bucket's seconds fell inside it, and which +// separately clamped to UTC start-of-day). +const getCountRecentCheckinsSQL = `SELECT COUNT\(\*\) FROM checkin_actions WHERE event_id = \$1 AND action = 'checkin' AND created_at >= \$2` + +func TestCountRecentCheckinsReturnsExactCount(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + since := time.Date(2026, 7, 18, 11, 55, 30, 0, time.UTC) + + mock.ExpectQuery(getCountRecentCheckinsSQL). + WithArgs(eventID, since). + WillReturnRows(pgxmock.NewRows([]string{"count"}).AddRow(7)) + + s := &PGStore{db: mock} + got, err := s.CountRecentCheckins(context.Background(), eventID, since) + if err != nil { + t.Fatalf("CountRecentCheckins: %v", err) + } + if got != 7 { + t.Errorf("got = %d, want 7", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestCountRecentCheckinsNoneReturnsZero proves an event with no 'checkin' +// actions since the cutoff reports 0, not an error. +func TestCountRecentCheckinsNoneReturnsZero(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + since := time.Date(2026, 7, 18, 11, 55, 30, 0, time.UTC) + + mock.ExpectQuery(getCountRecentCheckinsSQL). + WithArgs(eventID, since). + WillReturnRows(pgxmock.NewRows([]string{"count"}).AddRow(0)) + + s := &PGStore{db: mock} + got, err := s.CountRecentCheckins(context.Background(), eventID, since) + if err != nil { + t.Fatalf("CountRecentCheckins: %v", err) + } + if got != 0 { + t.Errorf("got = %d, want 0", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + // getMonitorStationsSQL matches GetMonitorStations' exact SELECT — a // per-station 'checkin'-action count via FILTER (so 'undo'/'reprint' rows // sharing the same station_id don't inflate it), ordered by name. From 09cb230ce6c929c9e0656eae108daeaec5d646a3 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 05:15:50 +0300 Subject: [PATCH 15/23] =?UTF-8?q?fix(backend):=20PR=20#81=20bot=20round=20?= =?UTF-8?q?=E2=80=94=20reconnect=20broadcast,=20detached=20bounded=20publi?= =?UTF-8?q?shes,=20legacy-path=20publishes,=20fail-closed=20stream,=20hear?= =?UTF-8?q?tbeat=20throttle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave B (broker/stream/publish layer) of the PR #81 bot-review round: - B1: PGBroker fans out MemBroker.BroadcastAll() to every local subscriber after a successful LISTEN reconnect, so NOTIFYs lost during the connection gap no longer leave monitors stale indefinitely. - B2: publishCheckinEvent is now the one shared, nil-safe publish helper — detaches from the caller's request context (context.WithoutCancel) and bounds the result with a 2s timeout, closing both the client-disconnect and stalled-Postgres failure modes at once; main.go's broker startup connect is now bounded by a 10s timeout too. - B3: the three legacy check-in write paths (PUT /api/attendees/{id}, the mobile batch endpoint, offline sync push) now publish on success, which they never did before. - B4: GetEventMonitorStream fails closed with a 503 when no Broker is configured, instead of silently serving a stream with no updates ever. - B5: heartbeat-sourced publishes are throttled to at most one per 15s per event; check-in/undo/reprint publishes stay unthrottled. Co-Authored-By: Claude Sonnet 5 --- backend/internal/broker/broker_test.go | 155 +++++++ backend/internal/broker/mem_broker.go | 25 + backend/internal/broker/pg_broker.go | 26 ++ backend/internal/handler/attendee_printed.go | 11 +- backend/internal/handler/attendees.go | 16 + backend/internal/handler/checkin.go | 24 +- backend/internal/handler/checkin_stations.go | 61 ++- .../handler/checkin_stations_publish_test.go | 152 ++++++ backend/internal/handler/checkins_batch.go | 17 + backend/internal/handler/event_publish.go | 60 +++ .../internal/handler/event_publish_test.go | 135 ++++++ backend/internal/handler/handler.go | 11 + .../internal/handler/legacy_publish_test.go | 439 ++++++++++++++++++ backend/internal/handler/monitor_stream.go | 30 +- .../internal/handler/monitor_stream_test.go | 53 ++- backend/internal/handler/sync.go | 23 + backend/main.go | 12 +- backend/openapi.yaml | 15 + panel/src/shared/api/schema.d.ts | 9 + 19 files changed, 1210 insertions(+), 64 deletions(-) create mode 100644 backend/internal/handler/event_publish.go create mode 100644 backend/internal/handler/event_publish_test.go create mode 100644 backend/internal/handler/legacy_publish_test.go diff --git a/backend/internal/broker/broker_test.go b/backend/internal/broker/broker_test.go index c32f764e..3490e5c3 100644 --- a/backend/internal/broker/broker_test.go +++ b/backend/internal/broker/broker_test.go @@ -230,6 +230,127 @@ func TestMemBroker_ConcurrentSubscribePublishUnsubscribe(t *testing.T) { wg.Wait() } +// --- MemBroker: BroadcastAll (Finding B1 — reconnect-gap resync) --------- + +// TestMemBroker_BroadcastAllReachesSubscribersAcrossDifferentEvents proves +// BroadcastAll fans a signal out to EVERY current subscriber regardless of +// which event they're subscribed to — unlike Publish, which is scoped to +// one eventID. This backs PGBroker's post-reconnect resync (Finding B1): a +// LISTEN connection drop can lose NOTIFYs for ANY event, so recovery must +// nudge ALL local subscribers to re-fetch, not just one. +func TestMemBroker_BroadcastAllReachesSubscribersAcrossDifferentEvents(t *testing.T) { + b := NewMemBroker() + eventA := uuid.New() + eventB := uuid.New() + + chA, unsubA := b.Subscribe(eventA) + defer unsubA() + chB, unsubB := b.Subscribe(eventB) + defer unsubB() + + b.BroadcastAll() + + select { + case <-chA: + default: + t.Fatal("expected eventA subscriber to receive a broadcast signal") + } + select { + case <-chB: + default: + t.Fatal("expected eventB subscriber to receive a broadcast signal") + } +} + +// TestMemBroker_BroadcastAllSignalsAllSubscribersOfSameEvent proves multiple +// subscribers of the SAME event all get a signal too (not just one per +// event). +func TestMemBroker_BroadcastAllSignalsAllSubscribersOfSameEvent(t *testing.T) { + b := NewMemBroker() + eventID := uuid.New() + + ch1, unsub1 := b.Subscribe(eventID) + defer unsub1() + ch2, unsub2 := b.Subscribe(eventID) + defer unsub2() + + b.BroadcastAll() + + for i, ch := range []<-chan struct{}{ch1, ch2} { + select { + case <-ch: + default: + t.Fatalf("subscriber %d did not receive a broadcast signal", i) + } + } +} + +// TestMemBroker_BroadcastAllWithNoSubscribersIsNoop proves calling +// BroadcastAll on an empty broker (nothing subscribed yet, e.g. right after +// process boot) neither panics nor blocks. +func TestMemBroker_BroadcastAllWithNoSubscribersIsNoop(t *testing.T) { + b := NewMemBroker() + b.BroadcastAll() +} + +// TestMemBroker_BroadcastAllCoalescesWithPendingSignal proves the same +// drop-if-full, never-blocks contract Publish has: a subscriber that +// already has an unread pending signal (from an earlier Publish) just +// coalesces on BroadcastAll rather than blocking or double-buffering. +func TestMemBroker_BroadcastAllCoalescesWithPendingSignal(t *testing.T) { + b := NewMemBroker() + eventID := uuid.New() + + ch, unsubscribe := b.Subscribe(eventID) + defer unsubscribe() + + if err := b.Publish(context.Background(), eventID); err != nil { + t.Fatalf("Publish: %v", err) + } + + done := make(chan struct{}) + go func() { + defer close(done) + b.BroadcastAll() + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("BroadcastAll blocked on an unread, full subscriber channel") + } + + select { + case <-ch: + default: + t.Fatal("expected exactly one pending signal after Publish+BroadcastAll while unread") + } + select { + case <-ch: + t.Fatal("expected the channel to be empty after draining the single coalesced signal") + default: + } +} + +// TestMemBroker_BroadcastAllDoesNotDeliverToUnsubscribed proves an +// unsubscribed channel is not touched by BroadcastAll (same bookkeeping +// Publish already respects). +func TestMemBroker_BroadcastAllDoesNotDeliverToUnsubscribed(t *testing.T) { + b := NewMemBroker() + eventID := uuid.New() + + ch, unsubscribe := b.Subscribe(eventID) + unsubscribe() + + b.BroadcastAll() + + select { + case <-ch: + t.Fatal("unsubscribed channel should not receive a broadcast signal") + default: + } +} + // --- Broker interface compliance ----------------------------------------- func TestMemBroker_SatisfiesBrokerInterface(t *testing.T) { @@ -287,3 +408,37 @@ func TestHandleNotification_EmptyPayloadIsLoggedAndSkipped(t *testing.T) { default: } } + +// --- pgBroker reconnect→broadcast wiring (Finding B1, unit-testable part) - +// +// listenLoop's reconnect() call itself requires a live Postgres connection +// (pgx.Connect) and is documented-uncoverable (see listenLoop's doc +// comment). What IS unit-testable without a live DB is the wiring right +// after a successful reconnect: handleReconnectSuccess is the exact call +// listenLoop makes there, factored out for the same reason handleNotification +// is — so this one line of "what happens on reconnect success" has a direct +// unit test independent of the un-testable network code around it. + +func TestHandleReconnectSuccess_BroadcastsToAllCurrentSubscribers(t *testing.T) { + mem := NewMemBroker() + eventA := uuid.New() + eventB := uuid.New() + + chA, unsubA := mem.Subscribe(eventA) + defer unsubA() + chB, unsubB := mem.Subscribe(eventB) + defer unsubB() + + handleReconnectSuccess(mem) + + select { + case <-chA: + default: + t.Fatal("expected eventA subscriber to receive a signal after a successful reconnect") + } + select { + case <-chB: + default: + t.Fatal("expected eventB subscriber to receive a signal after a successful reconnect") + } +} diff --git a/backend/internal/broker/mem_broker.go b/backend/internal/broker/mem_broker.go index 70535941..0844855b 100644 --- a/backend/internal/broker/mem_broker.go +++ b/backend/internal/broker/mem_broker.go @@ -48,6 +48,31 @@ func (b *MemBroker) Publish(_ context.Context, eventID uuid.UUID) error { return nil } +// BroadcastAll signals EVERY current subscriber across ALL events — unlike +// Publish, which is scoped to one eventID. It exists for PGBroker's +// post-reconnect resync (Finding B1, PR #81 bot-review round): NOTIFYs sent +// while the LISTEN connection was down are permanently lost (Postgres does +// not replay them), so once a fresh LISTEN is established there is no way +// to know which specific event(s) changed during the gap — the only correct +// recovery is to nudge every current subscriber to re-fetch via its normal +// update path, regardless of event. Same drop-if-full, never-blocks +// semantics as Publish (see its doc comment), just applied across the whole +// fanout map in one pass instead of one event's subscriber set. +func (b *MemBroker) BroadcastAll() { + b.mu.Lock() + defer b.mu.Unlock() + + for _, subs := range b.subs { + for ch := range subs { + select { + case ch <- struct{}{}: + default: + // Already has a pending, unread signal — coalesce. + } + } + } +} + // Subscribe registers a new 1-buffered channel for eventID. See // Broker.Subscribe for the coalescing and idempotent-unsubscribe contract. func (b *MemBroker) Subscribe(eventID uuid.UUID) (<-chan struct{}, func()) { diff --git a/backend/internal/broker/pg_broker.go b/backend/internal/broker/pg_broker.go index d53bd42a..fa926a81 100644 --- a/backend/internal/broker/pg_broker.go +++ b/backend/internal/broker/pg_broker.go @@ -164,6 +164,16 @@ func (b *PGBroker) listenLoop(ctx context.Context, conn *pgx.Conn, dbURL string) // ctx was cancelled while reconnecting. return } + // Finding B1 (PR #81 bot-review round): any NOTIFY sent during + // the gap between the old connection dying and this fresh + // LISTEN taking over is permanently lost — Postgres does not + // replay them. Broadcasting to every current local subscriber + // (not just the initial connect — see NewPGBroker, which never + // calls this) is the only correct recovery: every attached SSE + // client gets nudged to re-fetch a snapshot via its normal + // update path, so it can never stay silently stale for an + // unbounded time waiting on some later, unrelated publish. + handleReconnectSuccess(b.mem) continue } @@ -217,6 +227,22 @@ func closeConn(conn *pgx.Conn) { } } +// handleReconnectSuccess runs the exact recovery step listenLoop performs +// immediately after re-establishing LISTEN (Finding B1, PR #81 bot-review +// round): it fans out one coalesced signal to every current local +// subscriber via mem.BroadcastAll, so a subscriber that missed NOTIFYs +// during the connection gap still gets nudged onto its normal +// re-fetch-on-update path instead of staying silently stale until some +// later, unrelated publish happens to land. Factored out of listenLoop for +// the same reason handleNotification is: this one step is unit-testable +// without a live Postgres connection, even though the reconnect() call +// around it is not (see listenLoop's doc comment). Deliberately NOT called +// from NewPGBroker's initial connect — there is no "gap" to recover from on +// first boot, only on a LATER reconnect. +func handleReconnectSuccess(mem *MemBroker) { + mem.BroadcastAll() +} + // handleNotification parses payload as a uuid.UUID and forwards it into // mem's fanout. It is factored out of listenLoop specifically so it's // unit-testable without a live Postgres connection — see listenLoop's doc diff --git a/backend/internal/handler/attendee_printed.go b/backend/internal/handler/attendee_printed.go index 5f347ed3..3e54a9bf 100644 --- a/backend/internal/handler/attendee_printed.go +++ b/backend/internal/handler/attendee_printed.go @@ -166,16 +166,15 @@ func (h *Handler) MarkAttendeePrinted(c echo.Context) error { log.Printf("mark attendee printed: skip reprint log, invalid staff user id: %v", err) } else if err := h.Store.InsertCheckinAction(c.Request().Context(), *eventID, attendeeID, "reprint", stationID, staffUserID); err != nil { log.Printf("mark attendee printed: failed to log reprint checkin_actions row: %v", err) - } else if h.Broker != nil { + } else { // Publish ONLY reached when the reprint feed row was actually // logged (P4.2 Task 4) — a skipped log (no claims, bad staff // id, or a store failure above) means the monitor's recent-feed // wouldn't show anything new anyway, so signaling it to - // re-fetch would be a pointless round trip. Nil-safe, - // best-effort, after the row already committed. - if pubErr := h.Broker.Publish(c.Request().Context(), *eventID); pubErr != nil { - log.Printf("mark attendee printed: broker publish failed: %v", pubErr) - } + // re-fetch would be a pointless round trip. publishCheckinEvent + // (Finding B2) is nil-safe, best-effort, detached, + // timeout-bounded — after the row already committed. + h.publishCheckinEvent(c.Request().Context(), *eventID) } } diff --git a/backend/internal/handler/attendees.go b/backend/internal/handler/attendees.go index ad9b691e..a9c53393 100644 --- a/backend/internal/handler/attendees.go +++ b/backend/internal/handler/attendees.go @@ -278,6 +278,16 @@ func (h *Handler) UpdateAttendeeHandler(c echo.Context) error { return writeErr(c, err) } + // Captured BEFORE any field is mutated below (Finding B3, PR #81 + // bot-review round): this legacy check-in-status write path never + // published to the monitor broker before — a mobile-kiosk-only event + // (zero panel check-in stations, so zero heartbeats either) would leave + // attached monitors stale indefinitely. existingAttendee is already + // loaded here, so an exact before/after compare on CheckinStatus is + // cheap and avoids a noisy publish on a no-op PUT (e.g. a client + // re-sending the same status). + beforeCheckinStatus := existingAttendee.CheckinStatus + // Bind update request var req struct { CheckinStatus bool `json:"checkin_status"` @@ -332,6 +342,12 @@ func (h *Handler) UpdateAttendeeHandler(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update attendee"}) } + // Finding B3: publish only when checkin_status actually flipped — see + // beforeCheckinStatus's doc comment above. + if existingAttendee.CheckinStatus != beforeCheckinStatus { + h.publishCheckinEvent(c.Request().Context(), existingAttendee.EventID) + } + return c.JSON(http.StatusOK, existingAttendee) } diff --git a/backend/internal/handler/checkin.go b/backend/internal/handler/checkin.go index e0dd5374..21bcd2fe 100644 --- a/backend/internal/handler/checkin.go +++ b/backend/internal/handler/checkin.go @@ -2,7 +2,6 @@ package handler import ( "errors" - "log" "net/http" "strconv" "time" @@ -175,13 +174,13 @@ func (h *Handler) StationCheckin(c echo.Context) error { // self-review notes): "already_checked_in" and "blocked" both leave the // monitor-visible state completely unchanged, so signaling the monitor // to re-fetch a snapshot that will come back identical would just be - // wasted work on every subscriber. Nil-safe, best-effort, AFTER the - // store call already committed — a publish failure never turns a - // successful check-in into an error response. - if outcome == "checked_in" && h.Broker != nil { - if pubErr := h.Broker.Publish(c.Request().Context(), eventID); pubErr != nil { - log.Printf("station checkin: broker publish failed: %v", pubErr) - } + // wasted work on every subscriber. publishCheckinEvent (Finding B2) is + // nil-safe, best-effort, detached from this request's own + // cancellation, and timeout-bounded — AFTER the store call already + // committed, so nothing here can turn a successful check-in into an + // error response. + if outcome == "checked_in" { + h.publishCheckinEvent(c.Request().Context(), eventID) } var checkin *CheckinInfo @@ -249,12 +248,9 @@ func (h *Handler) UndoCheckin(c echo.Context) error { // already-clear case: a redundant signal just costs subscribers one // harmless snapshot re-fetch that comes back unchanged, which is // strictly cheaper than trying to detect "did this undo actually - // change anything" here. Nil-safe, best-effort, AFTER the store call. - if h.Broker != nil { - if pubErr := h.Broker.Publish(c.Request().Context(), eventID); pubErr != nil { - log.Printf("undo checkin: broker publish failed: %v", pubErr) - } - } + // change anything" here. publishCheckinEvent (Finding B2) is nil-safe, + // best-effort, detached, timeout-bounded — AFTER the store call. + h.publishCheckinEvent(c.Request().Context(), eventID) return c.JSON(http.StatusOK, UndoCheckinResponse{Attendee: updated}) } diff --git a/backend/internal/handler/checkin_stations.go b/backend/internal/handler/checkin_stations.go index 336c4af1..75175c05 100644 --- a/backend/internal/handler/checkin_stations.go +++ b/backend/internal/handler/checkin_stations.go @@ -2,9 +2,9 @@ package handler import ( "errors" - "log" "net/http" "strings" + "time" "idento/backend/internal/models" "idento/backend/internal/store" @@ -121,17 +121,64 @@ func (h *Handler) HeartbeatCheckinStation(c echo.Context) error { // notes): unlike check-in/undo/reprint, a heartbeat's ONLY effect is // bumping last_seen_at — but the monitor's stations card renders that // exact field (liveness dot + staleness), so it IS monitor-visible - // state and every 204 here is worth a signal. Nil-safe, best-effort, - // after the store call already committed. - if h.Broker != nil { - if pubErr := h.Broker.Publish(c.Request().Context(), eventID); pubErr != nil { - log.Printf("heartbeat checkin station: broker publish failed: %v", pubErr) - } + // state and every 204 here is worth a signal. publishCheckinEvent + // (Finding B2) is nil-safe, best-effort, detached, timeout-bounded — + // after the store call already committed. shouldPublishHeartbeat + // (Finding B5) additionally throttles heartbeat-SOURCED publishes per + // event — unlike check-in/undo/reprint, which stay unthrottled. + if h.shouldPublishHeartbeat(eventID) { + h.publishCheckinEvent(c.Request().Context(), eventID) } return c.NoContent(http.StatusNoContent) } +// heartbeatPublishThrottle bounds how often a heartbeat-SOURCED publish can +// fire per event (Finding B5, CodeRabbit, PR #81 bot-review round): every +// station's heartbeat cadence is 20s (P4.1 Task 12 precedent), so N +// stations on a larger event would otherwise produce a near-continuous +// stream of update-pings — up to roughly 1/s on a busy event, each one +// costing every attached monitor a full snapshot re-fetch. 15s keeps +// heartbeat-driven staleness comfortably under the monitor's own 45s +// liveness threshold (P4.2 spec §3.2) while cutting the worst-case publish +// rate by roughly an order of magnitude. Check-in/undo/reprint publishes +// are user-visible state changes and stay completely unthrottled — this +// throttle gates ONLY HeartbeatCheckinStation's own publish, never touches +// broker.Broker itself (kept in the handler layer on purpose, per the +// finding). A package var, not a const, so +// checkin_stations_publish_test.go can shrink it to exercise the throttle +// window without a real 15-second wait — same idiom as monitor_stream.go's +// monitorStreamPingInterval. +var heartbeatPublishThrottle = 15 * time.Second + +// shouldPublishHeartbeat reports whether a heartbeat-sourced publish for +// eventID is allowed right now, and if allowed atomically records this +// moment as eventID's new "last published" time in h.heartbeatLastPublish. +// The check-and-set is a single LoadOrStore/CompareAndSwap loop so two +// concurrent heartbeats for the same event (e.g. two different stations at +// the same event, both landing in the same instant) can't both slip through +// right at the window boundary — at most one of them ever wins the race and +// returns true. +func (h *Handler) shouldPublishHeartbeat(eventID uuid.UUID) bool { + now := time.Now() + for { + v, loaded := h.heartbeatLastPublish.LoadOrStore(eventID, now) + if !loaded { + return true // first heartbeat ever observed for this event + } + last := v.(time.Time) + if now.Sub(last) < heartbeatPublishThrottle { + return false + } + if h.heartbeatLastPublish.CompareAndSwap(eventID, last, now) { + return true + } + // Lost the race to another concurrent heartbeat that updated + // eventID's timestamp in between our Load and this CompareAndSwap; + // retry against the now-current value. + } +} + // ListCheckinStations returns every check-in station registered for an // event (P4.1 Task 2). func (h *Handler) ListCheckinStations(c echo.Context) error { diff --git a/backend/internal/handler/checkin_stations_publish_test.go b/backend/internal/handler/checkin_stations_publish_test.go index 0739b50a..4e081bd5 100644 --- a/backend/internal/handler/checkin_stations_publish_test.go +++ b/backend/internal/handler/checkin_stations_publish_test.go @@ -3,8 +3,10 @@ package handler import ( "net/http" "testing" + "time" "idento/backend/internal/broker" + "idento/backend/internal/models" "idento/backend/internal/store" "github.com/google/uuid" @@ -79,6 +81,156 @@ func TestHeartbeatCheckinStation_Unknown404DoesNotPublish(t *testing.T) { } } +// --- Finding B5 (CodeRabbit, PR #81 bot-review round): heartbeat publish +// throttling --------------------------------------------------------------- +// +// Every station heartbeat lands here (20s cadence, checkin_stations.go +// doc); N stations on a larger event would otherwise fire a near-continuous +// stream of publishes, each one costing every attached monitor a full +// snapshot re-fetch. shouldPublishHeartbeat (checkin_stations.go) throttles +// heartbeat-SOURCED publishes to at most one per heartbeatPublishThrottle +// window, per event — independent of any other event's own window. +// Check-in/undo/reprint publishes (checkin_publish_test.go, +// attendee_printed_publish_test.go) are never throttled; this is the ONLY +// site that is. + +// TestHeartbeatCheckinStation_ThrottlesRepeatedPublishesWithinWindow proves +// two heartbeats for the SAME event within the throttle window produce +// exactly one publish, not two. +func TestHeartbeatCheckinStation_ThrottlesRepeatedPublishesWithinWindow(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + + h := newCheckinStationHandler(event, nil, nil, + func(uuid.UUID, uuid.UUID) error { return nil }, + nil, + ) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinStationHeartbeatPath(event.ID, stationID) + + c1, rec1 := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c1, event.ID, stationID) + if err := h.HeartbeatCheckinStation(c1); err != nil { + t.Fatalf("HeartbeatCheckinStation (1st): %v", err) + } + if rec1.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d, body=%s", rec1.Code, rec1.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on the first heartbeat") + } + + c2, rec2 := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c2, event.ID, stationID) + if err := h.HeartbeatCheckinStation(c2); err != nil { + t.Fatalf("HeartbeatCheckinStation (2nd): %v", err) + } + if rec2.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d, body=%s", rec2.Code, rec2.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false — second heartbeat within the throttle window must not publish again") + } +} + +// TestHeartbeatCheckinStation_PublishesAgainAfterThrottleWindowElapses +// proves a heartbeat AFTER the window has elapsed publishes again. Uses the +// package var (shrunk here, restored via t.Cleanup) rather than a real +// 15-second wait — same idiom as monitor_stream.go's +// monitorStreamPingInterval and event_publish.go's publishCheckinTimeout. +func TestHeartbeatCheckinStation_PublishesAgainAfterThrottleWindowElapses(t *testing.T) { + orig := heartbeatPublishThrottle + heartbeatPublishThrottle = 20 * time.Millisecond + t.Cleanup(func() { heartbeatPublishThrottle = orig }) + + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + + h := newCheckinStationHandler(event, nil, nil, + func(uuid.UUID, uuid.UUID) error { return nil }, + nil, + ) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinStationHeartbeatPath(event.ID, stationID) + + c1, _ := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c1, event.ID, stationID) + if err := h.HeartbeatCheckinStation(c1); err != nil { + t.Fatalf("HeartbeatCheckinStation (1st): %v", err) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on the first heartbeat") + } + + time.Sleep(heartbeatPublishThrottle * 3) + + c2, _ := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c2, event.ID, stationID) + if err := h.HeartbeatCheckinStation(c2); err != nil { + t.Fatalf("HeartbeatCheckinStation (2nd): %v", err) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true — a heartbeat after the throttle window elapsed must publish again") + } +} + +// TestHeartbeatCheckinStation_ThrottlesIndependentlyPerEvent proves eventA's +// throttle window has no effect on eventB: a heartbeat for eventB +// immediately after one for eventA still publishes. +func TestHeartbeatCheckinStation_ThrottlesIndependentlyPerEvent(t *testing.T) { + tenantID := uuid.New() + eventA := contractEvent(tenantID, "Tech Summit A") + eventB := contractEvent(tenantID, "Tech Summit B") + stationA := uuid.New() + stationB := uuid.New() + + events := map[uuid.UUID]*models.Event{eventA.ID: eventA, eventB.ID: eventB} + h := New(&fakeStore{ + getEventByID: func(id uuid.UUID) (*models.Event, error) { return events[id], nil }, + heartbeatCheckinStation: func(uuid.UUID, uuid.UUID) error { return nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + chA, unsubA := mem.Subscribe(eventA.ID) + defer unsubA() + chB, unsubB := mem.Subscribe(eventB.ID) + defer unsubB() + + e := echo.New() + + pathA := checkinStationHeartbeatPath(eventA.ID, stationA) + cA, _ := newAuthedContext(e, http.MethodPost, pathA, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(cA, eventA.ID, stationA) + if err := h.HeartbeatCheckinStation(cA); err != nil { + t.Fatalf("HeartbeatCheckinStation (eventA): %v", err) + } + if !pendingSignal(chA) { + t.Fatal("eventA publish signal = false, want true on its first heartbeat") + } + + pathB := checkinStationHeartbeatPath(eventB.ID, stationB) + cB, _ := newAuthedContext(e, http.MethodPost, pathB, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(cB, eventB.ID, stationB) + if err := h.HeartbeatCheckinStation(cB); err != nil { + t.Fatalf("HeartbeatCheckinStation (eventB): %v", err) + } + if !pendingSignal(chB) { + t.Fatal("eventB publish signal = false, want true — eventA's throttle window must not affect eventB") + } +} + // TestHeartbeatCheckinStation_NilBrokerDoesNotPanic proves the nil-safe // guard for the heartbeat publish site. func TestHeartbeatCheckinStation_NilBrokerDoesNotPanic(t *testing.T) { diff --git a/backend/internal/handler/checkins_batch.go b/backend/internal/handler/checkins_batch.go index 10c6ebad..bb56aceb 100644 --- a/backend/internal/handler/checkins_batch.go +++ b/backend/internal/handler/checkins_batch.go @@ -37,6 +37,14 @@ func (h *Handler) BatchCheckin(c echo.Context) error { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"}) } + // anyCreated tracks whether ANY item in this batch actually created a + // new check-in (Finding B3, PR #81 bot-review round): this endpoint is + // scoped to a single event_id from the path, so every item belongs to + // the same event — one publish for the whole batch once, not one per + // item, and only when the batch produced a real monitor-visible change + // (an "already_exists"/"error"-only batch changed nothing). + anyCreated := false + results := make([]models.BatchCheckinResult, 0, len(items)) for i := range items { item := items[i] @@ -69,6 +77,7 @@ func (h *Handler) BatchCheckin(c echo.Context) error { } switch outcome { case store.BatchCheckinCreated: + anyCreated = true results = append(results, models.BatchCheckinResult{ClientUUID: item.ClientUUID, Status: "created"}) case store.BatchCheckinAlreadyCheckedIn, store.BatchCheckinDuplicateClientUUID: // Both mean "no new check-in was created by this specific @@ -82,5 +91,13 @@ func (h *Handler) BatchCheckin(c echo.Context) error { results = append(results, models.BatchCheckinResult{ClientUUID: item.ClientUUID, Status: "error", Error: "unknown outcome"}) } } + + // Finding B3: publish once for the batch's event, after the whole loop + // — never per item — and only when something in the batch actually + // changed monitor-visible state. + if anyCreated { + h.publishCheckinEvent(c.Request().Context(), eventID) + } + return c.JSON(http.StatusOK, results) } diff --git a/backend/internal/handler/event_publish.go b/backend/internal/handler/event_publish.go new file mode 100644 index 00000000..dec5629a --- /dev/null +++ b/backend/internal/handler/event_publish.go @@ -0,0 +1,60 @@ +package handler + +import ( + "context" + "log" + "time" + + "github.com/google/uuid" +) + +// publishCheckinTimeout bounds every detached Broker.Publish call +// publishCheckinEvent issues (PR #81 bot-review round, Finding B2b): a +// stalled Postgres must not hang the caller indefinitely. A package var, +// not a const, so event_publish_test.go can shrink it to exercise the +// timeout branch without a real 2-second wait — same idiom as +// monitor_stream.go's monitorStreamPingInterval. +var publishCheckinTimeout = 2 * time.Second + +// publishCheckinEvent is the ONE shared call site every check-in-visible +// mutation funnels its monitor broker publish through — the four original +// P4.2 Task 4 sites (StationCheckin, UndoCheckin, MarkAttendeePrinted's +// reprint log, HeartbeatCheckinStation) and, per Finding B3, the three +// legacy write paths (UpdateAttendeeHandler, BatchCheckin, SyncPush) that +// never published before. It closes PR #81's Finding B2 (a)+(b) together: +// +// - (a) riding the caller's ctx (the HTTP request's context, in every +// current call site) would let a client disconnecting the INSTANT after +// the store write already committed silently cancel the publish, +// dropping a durable write's monitor signal for no reason tied to the +// write itself. context.WithoutCancel derives a context that carries +// over ctx's values but is never canceled by ctx's own +// cancellation/deadline — exactly the "detach from the request, keep +// nothing but a bounded lifetime of our own" shape this needs. +// - (b) Publish runs synchronously, pre-response (every call site is +// AFTER its store write already succeeded) — an unbounded call here +// could hang the mutation response on a stalled broker (e.g. wedged +// Postgres). Re-bounding the detached context with publishCheckinTimeout +// caps the worst case to a small, fixed delay instead of forever. +// +// Nil-safe (h.Broker == nil is a silent no-op, matching every pre-existing +// call site's own `if h.Broker != nil` guard) and log-don't-fail (a Publish +// error — including the timeout firing — is logged and never surfaces to +// the caller, who has already committed and responded/is about to respond +// by the time this runs). Callers keep their own outcome-gating logic (e.g. +// StationCheckin only calls this on outcome=="checked_in") — this helper +// only owns the ctx/timeout/nil-safety/logging mechanics, never whether to +// publish at all. +func (h *Handler) publishCheckinEvent(ctx context.Context, eventID uuid.UUID) { + if h == nil || h.Broker == nil { + return + } + + detached := context.WithoutCancel(ctx) + pubCtx, cancel := context.WithTimeout(detached, publishCheckinTimeout) + defer cancel() + + if err := h.Broker.Publish(pubCtx, eventID); err != nil { + log.Printf("publish checkin event: broker publish failed: %v", err) + } +} diff --git a/backend/internal/handler/event_publish_test.go b/backend/internal/handler/event_publish_test.go new file mode 100644 index 00000000..bbf68796 --- /dev/null +++ b/backend/internal/handler/event_publish_test.go @@ -0,0 +1,135 @@ +package handler + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" +) + +// --- PR #81 bot-review round, Finding B2: detached, bounded publish helper - +// +// (a) All four P4.2 publish sites used to pass the HTTP request's own +// context straight into Broker.Publish — a client disconnecting right after +// the store commit cancels that context, silently dropping the monitor +// signal for a write that already durably happened. (b) Publish ran +// synchronously pre-response with no deadline of its own, so a stalled +// Postgres could hang a mutation response indefinitely. publishCheckinEvent +// closes both at once: it detaches from the caller's cancellation +// (context.WithoutCancel) and re-bounds the result with a short timeout, so +// every call site gets "fire, wait a little, log-don't-fail" instead of +// either of those failure modes. + +// capturingBroker records the eventID and, more importantly, whether the +// context.Context it was handed was ALREADY canceled/errored at the moment +// Publish ran — the detachment proof for +// TestPublishCheckinEvent_CanceledRequestContextStillPublishes below. +// ctxErrAtCallTime is snapshotted synchronously inside Publish itself +// (never read from the outside after the call returns): publishCheckinEvent +// defers its own cancel() on the timeout-bounded context it constructs, so +// by the time the outer call returns that context is ALWAYS canceled +// (ordinary, correct resource cleanup) — the only meaningful moment to +// observe "was this ctx already dead when Publish began" is during the call. +type capturingBroker struct { + published bool + ctxErrAtCallTime error + publishedID uuid.UUID +} + +func (b *capturingBroker) Publish(ctx context.Context, eventID uuid.UUID) error { + b.published = true + b.ctxErrAtCallTime = ctx.Err() + b.publishedID = eventID + return nil +} + +func (b *capturingBroker) Subscribe(uuid.UUID) (<-chan struct{}, func()) { + return nil, func() {} +} + +// TestPublishCheckinEvent_NilBrokerDoesNotPanic proves the helper is +// nil-safe exactly like every existing per-site `if h.Broker != nil` guard +// it replaces. +func TestPublishCheckinEvent_NilBrokerDoesNotPanic(t *testing.T) { + h := &Handler{} + h.publishCheckinEvent(context.Background(), uuid.New()) +} + +// TestPublishCheckinEvent_CanceledRequestContextStillPublishes is the +// detachment proof for Finding B2(a): a request context canceled BEFORE +// publishCheckinEvent is even called (simulating a client disconnecting the +// instant after the store write committed) must not stop the publish, and +// the context.Context Broker.Publish actually receives must not itself be +// the canceled one — context.WithoutCancel strips cancellation propagation +// while still deriving from ctx. +func TestPublishCheckinEvent_CanceledRequestContextStillPublishes(t *testing.T) { + fb := &capturingBroker{} + h := &Handler{Broker: fb} + + reqCtx, cancel := context.WithCancel(context.Background()) + cancel() // client already gone by the time we publish + + eventID := uuid.New() + h.publishCheckinEvent(reqCtx, eventID) + + if !fb.published { + t.Fatal("expected Broker.Publish to be called even though the request context was already canceled") + } + if fb.publishedID != eventID { + t.Fatalf("published eventID = %s, want %s", fb.publishedID, eventID) + } + if fb.ctxErrAtCallTime != nil { + t.Fatalf("ctx handed to Broker.Publish must not be canceled at call time, got Err() = %v", fb.ctxErrAtCallTime) + } +} + +// wedgedBroker's Publish never returns on its own — it only unblocks when +// its ctx is done (timeout/cancel) or the test explicitly releases it via +// unblock. This is how TestPublishCheckinEvent_BoundsAWedgedBrokerByTimeout +// simulates Finding B2(b)'s "stalled Postgres" scenario without a real +// database. +type wedgedBroker struct { + unblock <-chan struct{} +} + +func (b *wedgedBroker) Publish(ctx context.Context, _ uuid.UUID) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-b.unblock: + return nil + } +} + +func (b *wedgedBroker) Subscribe(uuid.UUID) (<-chan struct{}, func()) { + return nil, func() {} +} + +// TestPublishCheckinEvent_BoundsAWedgedBrokerByTimeout proves Finding +// B2(b): a Broker.Publish that never returns on its own must not hang +// publishCheckinEvent forever — the detached context is itself +// timeout-bounded, so the call returns once that timeout fires. Uses the +// package var (shrunk here, restored via t.Cleanup) rather than a real 2s +// sleep — same idiom as monitor_stream.go's monitorStreamPingInterval. +func TestPublishCheckinEvent_BoundsAWedgedBrokerByTimeout(t *testing.T) { + orig := publishCheckinTimeout + publishCheckinTimeout = 20 * time.Millisecond + t.Cleanup(func() { publishCheckinTimeout = orig }) + + unblock := make(chan struct{}) + t.Cleanup(func() { close(unblock) }) + h := &Handler{Broker: &wedgedBroker{unblock: unblock}} + + done := make(chan struct{}) + go func() { + defer close(done) + h.publishCheckinEvent(context.Background(), uuid.New()) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("publishCheckinEvent did not return once its bounded timeout elapsed — Publish hung it") + } +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 4f650d96..b87e34fe 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -4,6 +4,7 @@ package handler import ( + "sync" "time" "idento/backend/internal/broker" @@ -29,6 +30,16 @@ import ( type Handler struct { Store store.Store Broker broker.Broker + + // heartbeatLastPublish tracks, per event, the last time a + // heartbeat-SOURCED broker publish fired (Finding B5, PR #81 + // bot-review round) — see shouldPublishHeartbeat + // (checkin_stations.go) for the throttle this backs. Deliberately kept + // in the handler layer, not the broker package: it's purely about + // rate-limiting one specific publish SITE, not a broker-level concern. + // sync.Map's zero value is ready to use, so the ~70 existing + // `&Handler{Store: fs}` test literals stay valid untouched. + heartbeatLastPublish sync.Map } // New returns a new Handler with the given store. diff --git a/backend/internal/handler/legacy_publish_test.go b/backend/internal/handler/legacy_publish_test.go new file mode 100644 index 00000000..ea942774 --- /dev/null +++ b/backend/internal/handler/legacy_publish_test.go @@ -0,0 +1,439 @@ +package handler + +import ( + "errors" + "net/http" + "testing" + + "idento/backend/internal/broker" + "idento/backend/internal/models" + "idento/backend/internal/store" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// --- PR #81 bot-review round, Finding B3: legacy check-in write paths ----- +// +// PUT /api/attendees/{id} (UpdateAttendeeHandler), the mobile batch endpoint +// (BatchCheckin), and the offline sync push path (SyncPush) all mutate +// attendees.checkin_status but — unlike the four P4.2 Task 4 sites — never +// published to the monitor broker. A mobile-kiosk-only event (zero panel +// check-in stations, so zero heartbeats either) would leave attached +// monitors stale indefinitely. These tests prove the additive publish these +// three handlers now perform, using publishCheckinEvent (event_publish.go, +// Finding B2) exactly like the original four sites. + +// --- UpdateAttendeeHandler ------------------------------------------------ + +// TestUpdateAttendeeHandler_PublishesWhenCheckinStatusChanges proves the +// before/after compare: existingAttendee is already loaded via +// requireAttendeeOwnership, so an exact diff is cheap and avoids a noisy +// publish on a no-op PUT. +func TestUpdateAttendeeHandler_PublishesWhenCheckinStatusChanges(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + attendee.CheckinStatus = false + staffUser := contractUser("staff@org.io") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getUserByID: func(uuid.UUID) (*models.User, error) { return staffUser, nil }, + updateAttendee: func(*models.Attendee) error { return nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/attendees/" + attendee.ID.String() + c, rec := newAuthedContext(e, http.MethodPut, path, `{"checkin_status":true}`, tenantID.String(), "staff") + c.SetPath("/api/attendees/:id") + c.SetParamNames("id") + c.SetParamValues(attendee.ID.String()) + + if err := h.UpdateAttendeeHandler(c); err != nil { + t.Fatalf("UpdateAttendeeHandler: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true when checkin_status flips false -> true") + } +} + +// TestUpdateAttendeeHandler_DoesNotPublishWhenCheckinStatusUnchanged proves +// a PUT that leaves checkin_status exactly as it was (e.g. a client +// re-sending the same status, or PATCHing an unrelated field via this same +// endpoint's checkin_status:false default) does not signal the monitor. +func TestUpdateAttendeeHandler_DoesNotPublishWhenCheckinStatusUnchanged(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + attendee.CheckinStatus = true + now := attendee.UpdatedAt + attendee.CheckedInAt = &now + staffUser := contractUser("staff@org.io") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getUserByID: func(uuid.UUID) (*models.User, error) { return staffUser, nil }, + updateAttendee: func(*models.Attendee) error { return nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/attendees/" + attendee.ID.String() + c, rec := newAuthedContext(e, http.MethodPut, path, `{"checkin_status":true}`, tenantID.String(), "staff") + c.SetPath("/api/attendees/:id") + c.SetParamNames("id") + c.SetParamValues(attendee.ID.String()) + + if err := h.UpdateAttendeeHandler(c); err != nil { + t.Fatalf("UpdateAttendeeHandler: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false when checkin_status did not change") + } +} + +// TestUpdateAttendeeHandler_DoesNotPublishOnStoreFailure proves a failed +// store write never signals the monitor. +func TestUpdateAttendeeHandler_DoesNotPublishOnStoreFailure(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + attendee.CheckinStatus = false + staffUser := contractUser("staff@org.io") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getUserByID: func(uuid.UUID) (*models.User, error) { return staffUser, nil }, + updateAttendee: func(*models.Attendee) error { return errors.New("boom") }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/attendees/" + attendee.ID.String() + c, rec := newAuthedContext(e, http.MethodPut, path, `{"checkin_status":true}`, tenantID.String(), "staff") + c.SetPath("/api/attendees/:id") + c.SetParamNames("id") + c.SetParamValues(attendee.ID.String()) + + if err := h.UpdateAttendeeHandler(c); err != nil { + t.Fatalf("UpdateAttendeeHandler: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false on a store failure") + } +} + +// TestUpdateAttendeeHandler_NilBrokerDoesNotPanic proves the nil-safe guard +// (via publishCheckinEvent) for this newly-added publish site. +func TestUpdateAttendeeHandler_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + attendee.CheckinStatus = false + staffUser := contractUser("staff@org.io") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getUserByID: func(uuid.UUID) (*models.User, error) { return staffUser, nil }, + updateAttendee: func(*models.Attendee) error { return nil }, + }) + // h.Broker intentionally left nil. + + e := echo.New() + path := "/api/attendees/" + attendee.ID.String() + c, rec := newAuthedContext(e, http.MethodPut, path, `{"checkin_status":true}`, tenantID.String(), "staff") + c.SetPath("/api/attendees/:id") + c.SetParamNames("id") + c.SetParamValues(attendee.ID.String()) + + if err := h.UpdateAttendeeHandler(c); err != nil { + t.Fatalf("UpdateAttendeeHandler: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +// --- BatchCheckin ---------------------------------------------------------- + +// TestBatchCheckin_PublishesOnceWhenAnyItemCreated proves the count +// semantics: N items in the batch that each result in +// store.BatchCheckinCreated still produce exactly ONE publish for the +// batch's single event (the endpoint is scoped to one event_id from the +// path — every item in the batch belongs to it), not one per item. +func TestBatchCheckin_PublishesOnceWhenAnyItemCreated(t *testing.T) { + eventID := uuid.New() + tenantID := uuid.New() + attendeeID1 := uuid.New() + attendeeID2 := uuid.New() + + fs := &fakeStore{ + getEventByID: func(id uuid.UUID) (*models.Event, error) { + return &models.Event{ID: id, TenantID: tenantID}, nil + }, + getAttendeeByID: func(id uuid.UUID) (*models.Attendee, error) { + return &models.Attendee{ID: id, EventID: eventID}, nil + }, + applyBatchCheckin: func(_, _ uuid.UUID, _ *models.BatchCheckinItem) (store.BatchCheckinOutcome, error) { + return store.BatchCheckinCreated, nil + }, + } + h := &Handler{Store: fs} + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(eventID) + defer unsubscribe() + + e := echo.New() + body := `[` + + `{"client_uuid":"` + uuid.New().String() + `","attendee_id":"` + attendeeID1.String() + `","at":"2026-07-10T10:00:00Z","device_number":1,"kind":"checkin"},` + + `{"client_uuid":"` + uuid.New().String() + `","attendee_id":"` + attendeeID2.String() + `","at":"2026-07-10T10:00:01Z","device_number":1,"kind":"checkin"}` + + `]` + c, rec := newAuthedContext(e, http.MethodPost, "/api/events/"+eventID.String()+"/checkins/batch", body, tenantID.String(), "staff") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) + + if err := h.BatchCheckin(c); err != nil { + t.Fatalf("BatchCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true when at least one item created a check-in") + } + // Coalescing (1-buffered channel) already makes a second signal + // undetectable here, which is exactly the point: the assertion above is + // the "at least one, never N distinguishable pings" proof this package's + // other publish tests rely on (see pendingSignal's doc comment). +} + +// TestBatchCheckin_NoPublishWhenNoItemCreated proves a batch where every +// item is a no-op from the monitor's point of view (already checked in, +// duplicate client_uuid, or an outright error) does not signal the monitor. +func TestBatchCheckin_NoPublishWhenNoItemCreated(t *testing.T) { + eventID := uuid.New() + tenantID := uuid.New() + attendeeID := uuid.New() + + fs := &fakeStore{ + getEventByID: func(id uuid.UUID) (*models.Event, error) { + return &models.Event{ID: id, TenantID: tenantID}, nil + }, + getAttendeeByID: func(id uuid.UUID) (*models.Attendee, error) { + return &models.Attendee{ID: id, EventID: eventID}, nil + }, + applyBatchCheckin: func(_, _ uuid.UUID, _ *models.BatchCheckinItem) (store.BatchCheckinOutcome, error) { + return store.BatchCheckinAlreadyCheckedIn, nil + }, + } + h := &Handler{Store: fs} + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(eventID) + defer unsubscribe() + + e := echo.New() + body := `[{"client_uuid":"` + uuid.New().String() + `","attendee_id":"` + attendeeID.String() + `","at":"2026-07-10T10:00:00Z","device_number":1,"kind":"checkin"}]` + c, rec := newAuthedContext(e, http.MethodPost, "/api/events/"+eventID.String()+"/checkins/batch", body, tenantID.String(), "staff") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) + + if err := h.BatchCheckin(c); err != nil { + t.Fatalf("BatchCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false when no item actually created a check-in") + } +} + +// TestBatchCheckin_NilBrokerDoesNotPanic proves the nil-safe guard for the +// batch publish site. +func TestBatchCheckin_NilBrokerDoesNotPanic(t *testing.T) { + eventID := uuid.New() + tenantID := uuid.New() + attendeeID := uuid.New() + + fs := &fakeStore{ + getEventByID: func(id uuid.UUID) (*models.Event, error) { + return &models.Event{ID: id, TenantID: tenantID}, nil + }, + getAttendeeByID: func(id uuid.UUID) (*models.Attendee, error) { + return &models.Attendee{ID: id, EventID: eventID}, nil + }, + applyBatchCheckin: func(_, _ uuid.UUID, _ *models.BatchCheckinItem) (store.BatchCheckinOutcome, error) { + return store.BatchCheckinCreated, nil + }, + } + h := &Handler{Store: fs} + // h.Broker intentionally left nil. + + e := echo.New() + body := `[{"client_uuid":"` + uuid.New().String() + `","attendee_id":"` + attendeeID.String() + `","at":"2026-07-10T10:00:00Z","device_number":1,"kind":"checkin"}]` + c, rec := newAuthedContext(e, http.MethodPost, "/api/events/"+eventID.String()+"/checkins/batch", body, tenantID.String(), "staff") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) + + if err := h.BatchCheckin(c); err != nil { + t.Fatalf("BatchCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +// --- SyncPush --------------------------------------------------------------- + +// TestSyncPush_PublishesOncePerAffectedEvent proves sync's per-attendee, +// potentially-cross-event write pattern: two successfully-updated attendees +// in the SAME event still produce exactly one publish for that event (not +// two), and an attendee in a SECOND event produces its own, separate +// publish — "collect distinct event ids, publish once each" rather than +// once per attendee. +func TestSyncPush_PublishesOncePerAffectedEvent(t *testing.T) { + tenant := uuid.New() + eventA := uuid.New() + eventB := uuid.New() + attendee1 := uuid.New() // eventA + attendee2 := uuid.New() // eventA (same event as attendee1) + attendee3 := uuid.New() // eventB + + existingByID := map[uuid.UUID]*models.Attendee{ + attendee1: {ID: attendee1, EventID: eventA}, + attendee2: {ID: attendee2, EventID: eventA}, + attendee3: {ID: attendee3, EventID: eventB}, + } + fs := &fakeStore{ + getAttendeeByID: func(id uuid.UUID) (*models.Attendee, error) { + return existingByID[id], nil + }, + getEventByID: func(id uuid.UUID) (*models.Event, error) { + return &models.Event{ID: id, TenantID: tenant}, nil + }, + updateAttendee: func(*models.Attendee) error { return nil }, + } + h := &Handler{Store: fs} + mem := broker.NewMemBroker() + h.Broker = mem + chA, unsubA := mem.Subscribe(eventA) + defer unsubA() + chB, unsubB := mem.Subscribe(eventB) + defer unsubB() + + e := echo.New() + body := `{"changes":{"attendees":{"updated":[` + + `{"id":"` + attendee1.String() + `","event_id":"` + eventA.String() + `","first_name":"a","last_name":"b","email":"a@x.com","checkin_status":true},` + + `{"id":"` + attendee2.String() + `","event_id":"` + eventA.String() + `","first_name":"c","last_name":"d","email":"c@x.com","checkin_status":true},` + + `{"id":"` + attendee3.String() + `","event_id":"` + eventB.String() + `","first_name":"e","last_name":"f","email":"e@x.com","checkin_status":true}` + + `]}},"lastPulledAt":0}` + c, rec := newAuthedContext(e, http.MethodPost, "/api/sync", body, tenant.String(), "staff") + + if err := h.SyncPush(c); err != nil { + t.Fatalf("SyncPush: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(chA) { + t.Fatal("publish signal = false for eventA, want true (two successfully-updated attendees)") + } + if !pendingSignal(chB) { + t.Fatal("publish signal = false for eventB, want true (one successfully-updated attendee)") + } +} + +// TestSyncPush_SkipsPublishForUnknownOrForeignAttendee proves an attendee +// that's skipped (not found, or belongs to a different tenant's event) +// never contributes to a publish — SyncPush's existing silent-skip +// semantics (continue, not error) are unaffected. +func TestSyncPush_SkipsPublishForUnknownOrForeignAttendee(t *testing.T) { + tenant := uuid.New() + unknownAttendeeID := uuid.New() + + fs := &fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return nil, nil }, + getEventByID: func(id uuid.UUID) (*models.Event, error) { + return &models.Event{ID: id, TenantID: tenant}, nil + }, + updateAttendee: func(*models.Attendee) error { + t.Fatal("UpdateAttendee must not be called for an unknown attendee") + return nil + }, + } + h := &Handler{Store: fs} + mem := broker.NewMemBroker() + h.Broker = mem + + e := echo.New() + body := `{"changes":{"attendees":{"updated":[` + + `{"id":"` + unknownAttendeeID.String() + `","event_id":"` + uuid.New().String() + `","first_name":"a","last_name":"b","email":"a@x.com"}` + + `]}},"lastPulledAt":0}` + c, rec := newAuthedContext(e, http.MethodPost, "/api/sync", body, tenant.String(), "staff") + + if err := h.SyncPush(c); err != nil { + t.Fatalf("SyncPush: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestSyncPush_NilBrokerDoesNotPanic proves the nil-safe guard for the sync +// publish site. +func TestSyncPush_NilBrokerDoesNotPanic(t *testing.T) { + tenant := uuid.New() + eventID := uuid.New() + attendeeID := uuid.New() + + fs := &fakeStore{ + getAttendeeByID: func(id uuid.UUID) (*models.Attendee, error) { + return &models.Attendee{ID: id, EventID: eventID}, nil + }, + getEventByID: func(id uuid.UUID) (*models.Event, error) { + return &models.Event{ID: id, TenantID: tenant}, nil + }, + updateAttendee: func(*models.Attendee) error { return nil }, + } + h := &Handler{Store: fs} + // h.Broker intentionally left nil. + + e := echo.New() + body := `{"changes":{"attendees":{"updated":[` + + `{"id":"` + attendeeID.String() + `","event_id":"` + eventID.String() + `","first_name":"a","last_name":"b","email":"a@x.com","checkin_status":true}` + + `]}},"lastPulledAt":0}` + c, rec := newAuthedContext(e, http.MethodPost, "/api/sync", body, tenant.String(), "staff") + + if err := h.SyncPush(c); err != nil { + t.Fatalf("SyncPush: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/internal/handler/monitor_stream.go b/backend/internal/handler/monitor_stream.go index 12370e79..ba442f19 100644 --- a/backend/internal/handler/monitor_stream.go +++ b/backend/internal/handler/monitor_stream.go @@ -22,9 +22,11 @@ var monitorStreamPingInterval = 25 * time.Second // endpoint. It is a deliberately "thin-ping" stream: frames carry no // monitor state themselves, only a signal telling the client to re-fetch // Task 3's GET .../monitor snapshot. Order matters: requireEventOwnership -// runs BEFORE any stream header is written, so a foreign/missing event -// still gets a plain 404 JSON body (never a half-open event-stream -// response the client would have to notice and abandon). +// AND the nil-Broker check both run BEFORE any stream header is written, +// so a foreign/missing event still gets a plain 404 JSON body, and a +// misconfigured deployment (no Broker wired) gets a plain 503 — never a +// half-open event-stream response the client would have to notice and +// abandon. // // The handler blocks for the lifetime of the connection — that's the // correct shape for a streaming handler, not a goroutine leak: it returns @@ -39,6 +41,17 @@ func (h *Handler) GetEventMonitorStream(c echo.Context) error { return writeErr(c, err) } + // Fail closed (Finding B4, CodeRabbit, PR #81 bot-review round): a nil + // Broker used to still serve hello+ping frames forever with no "update" + // ever possible — a broken deployment would look healthy while every + // attached monitor silently staled. Checked BEFORE any stream header is + // written, same ordering discipline as requireEventOwnership above, so + // the caller gets a normal, complete 503 JSON response instead of a + // half-open stream. + if h.Broker == nil { + return writeErr(c, newHTTPError(http.StatusServiceUnavailable, "Live monitor stream unavailable: no event broker configured")) + } + res := c.Response() res.Header().Set(echo.HeaderContentType, "text/event-stream") res.Header().Set("Cache-Control", "no-cache") @@ -51,15 +64,8 @@ func (h *Handler) GetEventMonitorStream(c echo.Context) error { // be missed — see broker.Broker.Subscribe's coalescing contract // (1-buffered, drop-if-full) for why a signal delivered before the // select loop below starts running is still safely picked up once it - // does. Nil-safe (Handler.Broker doc comment, handler.go): a Handler - // built without a Broker still serves a valid stream — ch stays a - // permanently-nil channel, so its select case simply never fires; the - // client still gets hello + keep-alive pings, just no "update" frames. - var ch <-chan struct{} - unsubscribe := func() {} - if h.Broker != nil { - ch, unsubscribe = h.Broker.Subscribe(eventID) - } + // does. h.Broker is guaranteed non-nil past the check above. + ch, unsubscribe := h.Broker.Subscribe(eventID) defer unsubscribe() if !writeSSEFrame(res, "event: hello\ndata: {}\n\n") { diff --git a/backend/internal/handler/monitor_stream_test.go b/backend/internal/handler/monitor_stream_test.go index 8a843d6c..6fce2bfc 100644 --- a/backend/internal/handler/monitor_stream_test.go +++ b/backend/internal/handler/monitor_stream_test.go @@ -207,39 +207,44 @@ func TestGetEventMonitorStream_PingKeepAlive(t *testing.T) { } } -// TestGetEventMonitorStream_NilBrokerServesHelloAndPing proves the -// nil-safe design: a Handler built without a Broker (an older -// &Handler{Store: fs} test literal, or a genuine misconfiguration) still -// serves a valid stream — hello and pings keep flowing, there is just -// never an "update" frame (nothing to prove a negative on within a bounded -// test, so this only asserts the two frames that DO still arrive). -func TestGetEventMonitorStream_NilBrokerServesHelloAndPing(t *testing.T) { - orig := monitorStreamPingInterval - monitorStreamPingInterval = 20 * time.Millisecond - t.Cleanup(func() { monitorStreamPingInterval = orig }) - +// TestGetEventMonitorStream_NilBrokerReturns503BeforeAnyStreamHeader proves +// the fail-closed design (Finding B4, CodeRabbit, PR #81 bot-review round): +// a Handler built without a Broker (an older &Handler{Store: fs} test +// literal, or a genuine misconfiguration) used to still serve hello+ping +// frames with no updates ever — a broken deployment would look healthy +// while monitors silently staled. It must now fail closed: a plain 503 +// JSON body, in the house {"error": msg} shape, written BEFORE any +// text/event-stream header — never a half-open stream the client would +// have to notice and abandon (mirrors the 404 foreign-event precedent +// below in this same file). +func TestGetEventMonitorStream_NilBrokerReturns503BeforeAnyStreamHeader(t *testing.T) { tenantID := uuid.New() event := contractEvent(tenantID, "Tech Summit") h := New(&fakeStore{getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }}) // h.Broker intentionally left nil. - srv, _ := newMonitorStreamTestServer(t, h, event.ID, tenantID) + e := echo.New() + path := monitorStreamPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", tenantID.String(), "staff") + setMonitorStreamPathParams(c, event.ID) - resp, err := http.Get(srv.URL) - if err != nil { - t.Fatalf("GET stream: %v", err) + if err := h.GetEventMonitorStream(c); err != nil { + t.Fatalf("GetEventMonitorStream: %v", err) } - defer resp.Body.Close() - - r := bufio.NewReader(resp.Body) - hello := readSSEFrame(t, r, 2*time.Second) - if hello != "event: hello\ndata: {}\n\n" { - t.Fatalf("hello frame = %q", hello) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("want 503, got %d, body=%s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get(echo.HeaderContentType); strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("Content-Type = %q — stream headers must never be set when Broker is nil", ct) } - ping := readSSEFrame(t, r, 2*time.Second) - if ping != ": ping\n\n" { - t.Fatalf("ping frame = %q", ping) + var body map[string]string + if err := jsonUnmarshalBody(rec, &body); err != nil { + t.Fatalf("unmarshal body: %v", err) } + if body["error"] == "" { + t.Fatalf("body = %v, want the house {\"error\": msg} shape", body) + } + validateResponse(t, http.MethodGet, path, rec) } // TestGetEventMonitorStream_ClientDisconnectUnsubscribesCleanly proves the diff --git a/backend/internal/handler/sync.go b/backend/internal/handler/sync.go index 9168f15a..aaa5787b 100644 --- a/backend/internal/handler/sync.go +++ b/backend/internal/handler/sync.go @@ -116,6 +116,19 @@ func (h *Handler) SyncPush(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) } + // affectedEvents collects the distinct events touched by a successful + // attendee update below (Finding B3, PR #81 bot-review round): unlike + // checkins_batch.go, sync pushes are per-attendee and can span MULTIPLE + // events in one request, so a single publish keyed on some fixed + // event_id would be wrong — every distinct event that had at least one + // successfully-updated attendee gets exactly one publish, issued once + // the whole push finishes (not once per attendee). This legacy write + // path mutates attendees.checkin_status via UpdateAttendee but never + // published to the monitor broker before — a mobile-kiosk-only event + // (zero panel check-in stations, so zero heartbeats either) would leave + // attached monitors stale indefinitely. + affectedEvents := make(map[uuid.UUID]struct{}) + // Process attendee updates (most common use case: checking in) for _, attendee := range req.Changes.Attendees.Updated { // Verify attendee belongs to tenant's events @@ -140,6 +153,10 @@ func (h *Handler) SyncPush(c echo.Context) error { // Log error but continue with other updates continue } + + // existingAttendee.EventID (not the client-supplied attendee.EventID) + // is the trusted, already-tenant-verified event this write belongs to. + affectedEvents[existingAttendee.EventID] = struct{}{} } // Process created attendees (if mobile app allows creating new attendees) @@ -195,6 +212,12 @@ func (h *Handler) SyncPush(c echo.Context) error { // Process deletions (soft delete) // Not implemented in MVP + // Finding B3: one publish per distinct affected event, after the whole + // push finishes — see affectedEvents' doc comment above. + for eventID := range affectedEvents { + h.publishCheckinEvent(c.Request().Context(), eventID) + } + return c.JSON(http.StatusOK, map[string]interface{}{ "status": "ok", "timestamp": time.Now().UnixMilli(), diff --git a/backend/main.go b/backend/main.go index 88237494..00e51206 100644 --- a/backend/main.go +++ b/backend/main.go @@ -54,7 +54,17 @@ func main() { // transport) — constructed right after the store, closed the same way // pgStore.Close() is (plan-time fact 4): a dedicated LISTEN connection // plus a small notify pool, both against the same DatabaseURL. - eventBroker, err := broker.NewPGBroker(context.Background(), cfg.DatabaseURL) + // + // Bounded with a 10s timeout (PR #81 bot-review round, Finding B2c): + // NewPGBroker's initial connect used to run against an unbounded + // context.Background(), so a wedged/unreachable Postgres at boot could + // hang startup forever. store.NewPGStore's own Ping already refuses to + // boot without Postgres reachable at all — this timeout gives the + // broker's connect the same fail-fast-at-boot posture instead of + // hanging indefinitely on a reachable-but-stalled connection. + brokerCtx, brokerCancel := context.WithTimeout(context.Background(), 10*time.Second) + eventBroker, err := broker.NewPGBroker(brokerCtx, cfg.DatabaseURL) + brokerCancel() if err != nil { log.Fatalf("Unable to start event broker: %v\n", err) } diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 27a05f3b..6dfcf334 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -2355,6 +2355,21 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Error" } + "503": + description: > + Fail-closed nil-Broker guard (PR #81 bot-review round, Finding + B4): the server has no event broker configured (a + misconfigured/degraded deployment). Checked AFTER + requireEventOwnership but BEFORE any stream header is written, + so this is a plain, complete JSON response — never a half-open + event-stream connection the client would have to notice and + abandon. A nil Broker used to still serve hello/ping frames + forever with no "update" ever possible, silently masking the + misconfiguration; failing closed here surfaces it immediately + instead. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } /api/events/{id}/readiness: get: operationId: getEventReadiness diff --git a/panel/src/shared/api/schema.d.ts b/panel/src/shared/api/schema.d.ts index 8962dd2e..b21655b2 100644 --- a/panel/src/shared/api/schema.d.ts +++ b/panel/src/shared/api/schema.d.ts @@ -3407,6 +3407,15 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Fail-closed nil-Broker guard (PR #81 bot-review round, Finding B4): the server has no event broker configured (a misconfigured/degraded deployment). Checked AFTER requireEventOwnership but BEFORE any stream header is written, so this is a plain, complete JSON response — never a half-open event-stream connection the client would have to notice and abandon. A nil Broker used to still serve hello/ping frames forever with no "update" ever possible, silently masking the misconfiguration; failing closed here surfaces it immediately instead. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; getEventReadiness: { From 8d25db2d5f117cd4e98c90777376933f5fb867cf Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 05:19:39 +0300 Subject: [PATCH 16/23] fix(backend): count sync-created attendees toward monitor publish events Created attendees change monitor-visible state (total count, possibly checked_in if an offline kiosk created-and-checked-in in one push), so their event IDs should be added to affectedEvents and trigger publishes just like Updated attendees. Sync push with ONLY creates now publishes once per distinct affected event. Co-Authored-By: Claude Sonnet 5 --- .../internal/handler/legacy_publish_test.go | 42 +++++++++++++++++++ backend/internal/handler/sync.go | 5 +++ 2 files changed, 47 insertions(+) diff --git a/backend/internal/handler/legacy_publish_test.go b/backend/internal/handler/legacy_publish_test.go index ea942774..8d11c3c3 100644 --- a/backend/internal/handler/legacy_publish_test.go +++ b/backend/internal/handler/legacy_publish_test.go @@ -405,6 +405,48 @@ func TestSyncPush_SkipsPublishForUnknownOrForeignAttendee(t *testing.T) { } } +// TestSyncPush_PublishesOnCreatedAttendees proves created attendees (not just +// updated ones) trigger publishes. A sync push with ONLY attendee creations +// should still publish once per distinct affected event, since created +// attendees also change monitor-visible state (total count, possibly +// checked_in if an offline kiosk created-and-checked-in in one push). +func TestSyncPush_PublishesOnCreatedAttendees(t *testing.T) { + tenant := uuid.New() + eventID := uuid.New() + createdAttendeeID := uuid.New() + + fs := &fakeStore{ + getEventByID: func(id uuid.UUID) (*models.Event, error) { + return &models.Event{ID: id, TenantID: tenant}, nil + }, + checkAttendeeLimit: func(tenantID, eventID uuid.UUID, adding int) (bool, int, int, error) { + return true, 0, 50, nil // under limit + }, + createAttendee: func(attendee *models.Attendee) error { return nil }, + } + h := &Handler{Store: fs} + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsub := mem.Subscribe(eventID) + defer unsub() + + e := echo.New() + body := `{"changes":{"attendees":{"created":[` + + `{"id":"` + createdAttendeeID.String() + `","event_id":"` + eventID.String() + `","first_name":"a","last_name":"b","email":"a@x.com"}` + + `]}},"lastPulledAt":0}` + c, rec := newAuthedContext(e, http.MethodPost, "/api/sync", body, tenant.String(), "staff") + + if err := h.SyncPush(c); err != nil { + t.Fatalf("SyncPush: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true when attendee is created") + } +} + // TestSyncPush_NilBrokerDoesNotPanic proves the nil-safe guard for the sync // publish site. func TestSyncPush_NilBrokerDoesNotPanic(t *testing.T) { diff --git a/backend/internal/handler/sync.go b/backend/internal/handler/sync.go index aaa5787b..d9722948 100644 --- a/backend/internal/handler/sync.go +++ b/backend/internal/handler/sync.go @@ -207,6 +207,11 @@ func (h *Handler) SyncPush(c echo.Context) error { c.Logger().Errorf("sync: create attendee failed (tenant %s, event %s, attendee %s): %v", tenantID, p.eventID, p.attendee.ID, err) continue } + // Track successfully-created attendees' events for monitor publish, + // same as Updated attendees above: created attendees also change + // monitor-visible state (total count, and possibly checked_in if an + // offline kiosk created-and-checked-in in one push). + affectedEvents[p.eventID] = struct{}{} } // Process deletions (soft delete) From 48e580e56a0cc8a8c2def2ae63c8a38a7486320c Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 05:54:54 +0300 Subject: [PATCH 17/23] =?UTF-8?q?fix(panel):=20PR=20#81=20bot=20round=20?= =?UTF-8?q?=E2=80=94=20StatusPill=20primitives,=20stream=20URL/auth/backof?= =?UTF-8?q?f/resync=20hardening,=20retain-stale-data,=20surface-owned=20i1?= =?UTF-8?q?8n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings C1-C7 from the CodeRabbit + Codex bot-review round on PR #81 (panel P4.2 live-monitor wave, last of three sequential waves): rebuild the monitor header's LIVE/reconnecting badges (and LiveStrip's LIVE NOW badge) on @idento/ui's StatusPill (extended with an additive dot/pulse indicator); strip a trailing slash off getApiBaseUrl so useMonitorStream's raw fetch never double-slashes; give useMonitorStream a terminal "error" status for non-OK 4xx responses (routed through the app's global auth/tenant-suspension handling via a newly-extracted shared/api/handleApiError.ts), reset its backoff attempt counter only once a hello frame actually arrives, and resync the snapshot on every hello (not just a reconnect's); gate MonitorPage/LiveStrip's error cards on missing data rather than isError so a failed background refetch no longer blanks already-good content; and give the monitor page and LiveStrip their own i18n keys instead of borrowing another surface's. Co-Authored-By: Claude Sonnet 5 --- .../ui/src/components/status-pill.test.tsx | 36 +++ packages/ui/src/components/status-pill.tsx | 50 +++- panel/src/app/queryClient.ts | 36 +-- panel/src/features/home/LiveStrip.test.tsx | 47 +++- panel/src/features/home/LiveStrip.tsx | 40 +++- .../src/features/monitor/MonitorPage.test.tsx | 102 +++++++- panel/src/features/monitor/MonitorPage.tsx | 91 ++++--- .../monitor/useMonitorStream.test.tsx | 225 +++++++++++++++++- .../src/features/monitor/useMonitorStream.ts | 102 ++++++-- panel/src/shared/api/handleApiError.ts | 48 ++++ panel/src/shared/api/http.test.ts | 39 ++- panel/src/shared/api/http.ts | 34 ++- panel/src/shared/i18n/en.json | 4 + panel/src/shared/i18n/ru.json | 4 + 14 files changed, 734 insertions(+), 124 deletions(-) create mode 100644 panel/src/shared/api/handleApiError.ts diff --git a/packages/ui/src/components/status-pill.test.tsx b/packages/ui/src/components/status-pill.test.tsx index fb8923c4..a5161ea8 100644 --- a/packages/ui/src/components/status-pill.test.tsx +++ b/packages/ui/src/components/status-pill.test.tsx @@ -26,4 +26,40 @@ describe("StatusPill", () => { const readyIcon = readyContainer.querySelector("svg"); expect(readyIcon).not.toHaveClass("animate-spin"); }); + + // PR #81 bot round Finding C1: panel's live-monitor header hand-rolled a + // pulsing-dot LIVE pill (SaveStatePill's own precedent -- Fix 5, an + // earlier bot round -- shows the house convention: rebuild ON TOP of this + // primitive rather than leave local markup). The dot variant didn't exist + // here yet, so it's added as a small additive API rather than the panel + // reimplementing it a second time. + describe("indicator=\"dot\"", () => { + it("renders a status-colored dot instead of the icon, with no svg present", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeNull(); + expect(container.querySelector(".rounded-full.bg-success")).not.toBeNull(); + }); + + it("omits the animated ping ring when pulse is false (the default)", () => { + const { container } = render(); + expect(container.querySelector(".animate-ping")).toBeNull(); + }); + + it("adds an animated ping ring, colored to match the status, when pulse is true", () => { + const { container } = render(); + const ring = container.querySelector(".animate-ping"); + expect(ring).not.toBeNull(); + expect(ring).toHaveClass("bg-success"); + }); + + it("still renders the label text alongside the dot (WCAG 1.4.1 -- never color alone)", () => { + render(); + expect(screen.getByText("Connection lost")).toBeInTheDocument(); + }); + + it("colors the dot to match a non-success status (e.g. error -> destructive)", () => { + const { container } = render(); + expect(container.querySelector(".rounded-full.bg-destructive")).not.toBeNull(); + }); + }); }); diff --git a/packages/ui/src/components/status-pill.tsx b/packages/ui/src/components/status-pill.tsx index 757fa044..d7216888 100644 --- a/packages/ui/src/components/status-pill.tsx +++ b/packages/ui/src/components/status-pill.tsx @@ -6,13 +6,17 @@ import { cn } from "../lib/cn"; export const STATUS_PILL_STATUSES = ["ready", "in_progress", "empty", "optional", "live", "error"] as const; export type StatusPillStatus = (typeof STATUS_PILL_STATUSES)[number]; -const config: Record = { - ready: { icon: CheckCircle2, className: "border-transparent bg-success/10 text-success" }, - in_progress: { icon: Loader2, className: "border-transparent bg-warning/10 text-warning" }, - empty: { icon: Circle, className: "border-transparent bg-muted text-muted-foreground" }, - optional: { icon: CircleDashed, className: "border-dashed border-border text-muted-foreground" }, - live: { icon: Radio, className: "border-transparent bg-success text-success-foreground" }, - error: { icon: AlertCircle, className: "border-transparent bg-destructive/10 text-destructive" }, +const config: Record = { + ready: { icon: CheckCircle2, className: "border-transparent bg-success/10 text-success", dotClassName: "bg-success" }, + in_progress: { icon: Loader2, className: "border-transparent bg-warning/10 text-warning", dotClassName: "bg-warning" }, + empty: { icon: Circle, className: "border-transparent bg-muted text-muted-foreground", dotClassName: "bg-muted-foreground" }, + optional: { + icon: CircleDashed, + className: "border-dashed border-border text-muted-foreground", + dotClassName: "bg-muted-foreground", + }, + live: { icon: Radio, className: "border-transparent bg-success text-success-foreground", dotClassName: "bg-success-foreground" }, + error: { icon: AlertCircle, className: "border-transparent bg-destructive/10 text-destructive", dotClassName: "bg-destructive" }, }; export interface StatusPillProps { @@ -20,9 +24,23 @@ export interface StatusPillProps { label: string; icon?: LucideIcon; className?: string; + // PR #81 bot round Finding C1: a "live connection" indicator (panel's + // monitor header LIVE pill, LiveStrip's LIVE NOW badge) reads as a + // colored pulsing dot, not an icon+label pair -- board 7e/1c's own + // vocabulary, distinct from every other StatusPill consumer so far. + // Additive, defaulted-off: every existing call site is unaffected. + /** Renders a status-colored dot instead of the icon. Defaults to the icon. */ + indicator?: "icon" | "dot"; + /** + * Only meaningful when `indicator="dot"` -- adds an animated "ping" ring + * around the dot. The dot itself always renders under `indicator="dot"`; + * only the ring is gated on this flag, so a caller can show a static dot + * while e.g. "connecting" and switch on the ring only once truly live. + */ + pulse?: boolean; } -export function StatusPill({ status, label, icon, className }: StatusPillProps) { +export function StatusPill({ status, label, icon, className, indicator = "icon", pulse = false }: StatusPillProps) { const Icon = icon ?? config[status].icon; return ( - + {indicator === "dot" ? ( + + {pulse ? ( + + ) : null} + + + ) : ( + + )} {label} ); diff --git a/panel/src/app/queryClient.ts b/panel/src/app/queryClient.ts index 37967c24..cc5f54ad 100644 --- a/panel/src/app/queryClient.ts +++ b/panel/src/app/queryClient.ts @@ -1,33 +1,11 @@ -import { MutationCache, QueryCache, QueryClient, type Mutation } from "@tanstack/react-query"; -import { ApiError } from "../shared/api/ApiError"; -import { clearSession } from "../shared/api/session"; -import { tenantStatusStore } from "../shared/tenant-status/tenantStatusStore"; - -// Login/register/QR-login legitimately reject with 401 on wrong -// credentials — that's the screen's own inline error (see LoginScreen.tsx -// etc.), not a dead session. Skip the global 401 handler for exactly -// these, identified by mutationKey. -const AUTH_MUTATION_KEYS = new Set(["login", "register", "loginWithQr"]); - -function isAuthMutation(mutation?: Mutation): boolean { - const key = mutation?.options.mutationKey?.[0]; - return typeof key === "string" && AUTH_MUTATION_KEYS.has(key); -} - -function handleApiError(error: unknown, mutation?: Mutation) { - if (!(error instanceof ApiError)) return; - if (error.code === "tenant_suspended") { - tenantStatusStore.setSuspended(true); - return; - } - if (error.status === 401 && !isAuthMutation(mutation)) { - clearSession(); - if (!window.location.pathname.startsWith("/login")) { - window.location.assign("/login"); - } - } -} +import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; +import { handleApiError } from "../shared/api/handleApiError"; +// handleApiError lives in shared/api/ (PR #81 bot round Finding C3) so +// useMonitorStream.ts -- a features/ module that must not import from +// app/ -- can route its own SSE connection failures through the exact +// same tenant-suspension/dead-session handling this QueryClient wires up +// below for every ordinary query/mutation failure. export const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => handleApiError(error), diff --git a/panel/src/features/home/LiveStrip.test.tsx b/panel/src/features/home/LiveStrip.test.tsx index 8d640e89..bf041441 100644 --- a/panel/src/features/home/LiveStrip.test.tsx +++ b/panel/src/features/home/LiveStrip.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterContextProvider, createRootRoute, createRouter } from "@tanstack/react-router"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import type { ReactNode } from "react"; import { LiveStrip } from "./LiveStrip"; @@ -26,11 +26,19 @@ const testRouter = createRouter({ routeTree: createRootRoute({ component: () => function renderWithProviders(ui: ReactNode) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( + const result = render( {ui} , ); + // `queryClient` returned alongside the RTL render result (PR #81 bot + // round Finding C6) so a test can trigger an explicit background refetch + // via `queryClient.invalidateQueries()` -- same exposed-QueryClient + + // `server.use` MSW-override idiom as MonitorPage.test.tsx's own C6 tests + // (and BadgeEditorPage.test.tsx's background-refetch precedent) -- rather + // than a fresh remount, which would prove nothing about retaining + // ALREADY-rendered stale data. + return { ...result, queryClient }; } function monitorSnapshotBody(overrides: Partial = {}): MonitorSnapshot { @@ -207,6 +215,41 @@ describe("LiveStrip", () => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); }); + // PR #81 bot round Finding C6: retain-last-known-good. A single failed + // BACKGROUND refetch (isError=true, data still retained per react-query) + // must not blank an already-successfully-rendered card into the error + // message above -- exercised via the exposed-`queryClient` + + // `server.use` MSW-override + explicit `invalidateQueries()` idiom (not + // a fresh remount, which would prove nothing about retaining ALREADY- + // rendered content). + it("keeps rendering the counters/progress/zone line after a background snapshot refetch fails", async () => { + const running = apiEvent({ + id: "evt-stale-refetch", + name: "Still Running Event", + start_date: "2026-07-14T09:00:00Z", + end_date: "2026-07-14T18:00:00Z", + }); + const { queryClient } = renderWithProviders(); + + expect(await screen.findByText("120")).toBeInTheDocument(); + expect(screen.getByTestId("home-zone-z-main")).toHaveTextContent("Main hall: 100"); + + server.use( + http.get("http://api.test/api/events/:eventId/monitor", () => new HttpResponse(null, { status: 500 })), + ); + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ["get", "/api/events/{event_id}/monitor"] }); + }); + + // `getBy` (not `findBy`) proves this is the SAME still-mounted content, + // not a fresh success re-render -- the failed refetch must not have + // replaced it with "Couldn't load live stats." + expect(screen.getByText("120")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + expect(screen.getByTestId("home-zone-z-main")).toHaveTextContent("Main hall: 100"); + expect(screen.queryByText("Couldn't load live stats.")).not.toBeInTheDocument(); + }); + it("renders the upcoming-fallback hero when nothing is running", async () => { const upcoming = apiEvent({ id: "evt-upcoming", diff --git a/panel/src/features/home/LiveStrip.tsx b/panel/src/features/home/LiveStrip.tsx index b088fb1c..27ecbead 100644 --- a/panel/src/features/home/LiveStrip.tsx +++ b/panel/src/features/home/LiveStrip.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Progress, Skeleton } from "@idento/ui"; +import { Button, Card, Progress, Skeleton, StatusPill } from "@idento/ui"; import { Link } from "@tanstack/react-router"; import { useTranslation } from "react-i18next"; import { formatDateRange } from "../events/eventDates"; @@ -55,7 +55,13 @@ function formatRunningWindow(event: ApiEvent, locale: string, allDayLabel: strin // kept fresh by `useMonitorStream`'s SSE-driven invalidation instead of a // poller -- the stream's own `status` isn't surfaced here (no reconnecting // badge on the home strip; that's the monitor page's own concern), it's -// mounted purely for its invalidation side effect. +// mounted purely for its invalidation side effect. That includes +// `status === "error"` (PR #81 bot round Finding C3 -- a terminal stream +// failure): this card still doesn't render a dedicated indicator for it, +// matching its pre-existing non-treatment of stream status -- the global +// handling that status triggers (session redirect / suspension takeover, +// useMonitorStream.ts's own `handleApiError` call) runs regardless of who +// mounted the hook, so there's nothing else for THIS card to do. function RunningCard({ event }: { event: ApiEvent }) { const { t, i18n } = useTranslation(); const snapshot = useMonitorSnapshot(event.id); @@ -70,13 +76,14 @@ function RunningCard({ event }: { event: ApiEvent }) {
- - - - - - {t("homeLiveNow")} - + {/* PR #81 bot round Finding C1: composed from @idento/ui's + StatusPill (`indicator="dot" pulse`) instead of hand-rolled + markup -- panel/AGENTS.md's "UI primitives come only from + @idento/ui". Always pulsing (unlike the monitor page's own + LIVE pill, whose ring is gated on the stream's `live` status): + this badge means "the EVENT is currently running", not "the + SSE connection is up". */} + {event.name} {timing ? {timing} : null}
@@ -96,9 +103,14 @@ function RunningCard({ event }: { event: ApiEvent }) {
+ {/* PR #81 bot round Finding C6: gated on `!snapshot.data` alone, not + `snapshot.isError` -- once the snapshot has loaded once, a + single failed background refetch (isError=true, data still + retained per react-query) must not blank the counters back into + this error message. */} {snapshot.isLoading ? ( - ) : snapshot.isError ? ( + ) : !snapshot.data ? (

{t("homeStatsLoadError")}

) : (

@@ -109,7 +121,7 @@ function RunningCard({ event }: { event: ApiEvent }) {

)} - {!snapshot.isLoading && !snapshot.isError ? ( + {!snapshot.isLoading && snapshot.data ? ( <> {/* Compact per-zone mini-line (board 1c/1d): real zone-name + @@ -125,7 +137,11 @@ function RunningCard({ event }: { event: ApiEvent }) { ))} {unattributed > 0 ? ( - {t("monitorUnattributed")}: {unattributed} + {/* PR #81 bot round Finding C7: home-owned copy -- this + card no longer borrows the monitor page's + `monitorUnattributed` key (panel/AGENTS.md's + cross-surface i18n convention). */} + {t("homeZoneUnattributed")}: {unattributed} ) : null}
diff --git a/panel/src/features/monitor/MonitorPage.test.tsx b/panel/src/features/monitor/MonitorPage.test.tsx index cc4bf1a4..5762d22f 100644 --- a/panel/src/features/monitor/MonitorPage.test.tsx +++ b/panel/src/features/monitor/MonitorPage.test.tsx @@ -21,7 +21,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, } from "@tanstack/react-router"; -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import { delay, http, HttpResponse } from "msw"; import { MonitorPage } from "./MonitorPage"; import { startMswServer } from "../../test/msw"; @@ -98,7 +98,13 @@ function renderWithRouter(router: ReturnType | Return , ); - return router; + // `queryClient` returned alongside `router` (PR #81 bot round Finding C6) + // so tests can trigger an explicit background refetch via + // `queryClient.invalidateQueries()` -- the same exposed-QueryClient + + // `server.use` MSW-override idiom BadgeEditorPage.test.tsx's own + // background-refetch test uses -- rather than a fresh remount, which + // would prove nothing about retaining ALREADY-rendered stale data. + return { router, queryClient }; } function renderCorrectAt(path: string) { @@ -508,7 +514,17 @@ describe("MonitorPage -- Recent feed card (read-only)", () => { // hand-driven `controlledMonitorStreamHandler()` so this block alone can // drive hello/close frames and observe connecting -> live -> reconnecting // -> live. -describe("MonitorPage -- stream status (connecting/live/reconnecting)", () => { +// PR #81 bot round Finding C1: the live-state ring is now StatusPill's own +// `indicator="dot" pulse` rendering (packages/ui/src/components/status- +// pill.tsx), not a dedicated `monitor-live-ring` testid -- queried here via +// its stable `.animate-ping` class, scoped inside the `monitor-live-pill` +// wrapper (the same "assert via class, not a sub-element testid" idiom +// packages/ui's own agent-status.test.tsx uses for its dot indicator). +function liveRing(): Element | null { + return screen.getByTestId("monitor-live-pill").querySelector(".animate-ping"); +} + +describe("MonitorPage -- stream status (connecting/live/reconnecting/error)", () => { beforeEach(() => { window.__ENV__ = { API_URL: "http://api.test" }; localStorage.clear(); @@ -522,13 +538,13 @@ describe("MonitorPage -- stream status (connecting/live/reconnecting)", () => { renderCorrectAt("/events/evt-1/monitor"); await screen.findByText("1,284 / 2,410"); - expect(screen.queryByTestId("monitor-live-ring")).not.toBeInTheDocument(); + expect(liveRing()).not.toBeInTheDocument(); expect(screen.queryByTestId("monitor-reconnecting-badge")).not.toBeInTheDocument(); await waitFor(() => expect(streamConnections.length).toBe(1)); streamConnections[0].push("event: hello\ndata: {}\n\n"); - await waitFor(() => expect(screen.getByTestId("monitor-live-ring")).toBeInTheDocument()); + await waitFor(() => expect(liveRing()).toBeInTheDocument()); expect(screen.queryByTestId("monitor-reconnecting-badge")).not.toBeInTheDocument(); }); @@ -540,7 +556,7 @@ describe("MonitorPage -- stream status (connecting/live/reconnecting)", () => { await screen.findByText("1,284 / 2,410"); await waitFor(() => expect(streamConnections.length).toBe(1)); streamConnections[0].push("event: hello\ndata: {}\n\n"); - await waitFor(() => expect(screen.getByTestId("monitor-live-ring")).toBeInTheDocument()); + await waitFor(() => expect(liveRing()).toBeInTheDocument()); streamConnections[0].close(); @@ -559,4 +575,78 @@ describe("MonitorPage -- stream status (connecting/live/reconnecting)", () => { }, 8000, ); + + // PR #81 bot round Finding C3: a terminal stream failure (this test uses a + // documented 404 -- no global tenant/session side effect to also assert, + // that's useMonitorStream.test.tsx's own concern) replaces the LIVE pill + // entirely with a destructive error badge instead of looping the + // "reconnecting" badge forever, while the already-fetched snapshot stays + // rendered underneath it (Finding C6 -- retain-last-known-good). + it("replaces the LIVE pill with a destructive stream-error badge on a terminal 4xx, keeping the already-fetched snapshot rendered", async () => { + server.use( + http.get("http://api.test/api/events/:eventId/monitor/stream", () => new HttpResponse(null, { status: 404 })), + ); + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("1,284 / 2,410"); + await waitFor(() => expect(screen.getByTestId("monitor-stream-error-badge")).toBeInTheDocument()); + expect(screen.queryByTestId("monitor-live-pill")).not.toBeInTheDocument(); + expect(screen.queryByTestId("monitor-reconnecting-badge")).not.toBeInTheDocument(); + expect(screen.getByText("1,284 / 2,410")).toBeInTheDocument(); + + // Terminal means terminal -- no reconnect attempt ever lands. + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(streamConnections.length).toBe(0); + expect(screen.getByTestId("monitor-stream-error-badge")).toBeInTheDocument(); + }, 5000); +}); + +// PR #81 bot round Finding C6: retain-last-known-good. A single failed +// BACKGROUND refetch (isError=true, data still retained per react-query) +// must not blank an already-successfully-rendered page into an error card -- +// exercised here via the exposed-`queryClient` + `server.use` MSW-override + +// explicit `invalidateQueries()` idiom (not a fresh remount, which would +// prove nothing about retaining ALREADY-rendered content). +describe("MonitorPage -- retains stale data across a failed background refetch (C6)", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + monitorSnapshot = snapshotBody(); + }); + + it("keeps rendering the snapshot content after a background refetch fails", async () => { + const { queryClient } = renderCorrectAt("/events/evt-1/monitor"); + + expect(await screen.findByText("1,284 / 2,410")).toBeInTheDocument(); + expect(screen.getByText("Main hall")).toBeInTheDocument(); + + server.use( + http.get("http://api.test/api/events/:eventId/monitor", () => new HttpResponse(null, { status: 500 })), + ); + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ["get", "/api/events/{event_id}/monitor"] }); + }); + + // The failed refetch must not have replaced the content with the + // snapshot-error card -- `getBy` (not `findBy`) proves it's the SAME + // still-mounted content, not a fresh success re-render. + expect(screen.getByText("1,284 / 2,410")).toBeInTheDocument(); + expect(screen.getByText("Main hall")).toBeInTheDocument(); + expect(screen.queryByTestId("monitor-snapshot-error")).not.toBeInTheDocument(); + }); + + it("keeps rendering the event header after a background event refetch fails", async () => { + const { queryClient } = renderCorrectAt("/events/evt-1/monitor"); + + expect(await screen.findByRole("heading", { name: "Partner Day — Autumn" })).toBeInTheDocument(); + + server.use(http.get("http://api.test/api/events/:id", () => new HttpResponse(null, { status: 500 }))); + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ["get", "/api/events/{id}"] }); + }); + + expect(screen.getByRole("heading", { name: "Partner Day — Autumn" })).toBeInTheDocument(); + expect(screen.getByTestId("monitor-page")).toBeInTheDocument(); + }); }); diff --git a/panel/src/features/monitor/MonitorPage.tsx b/panel/src/features/monitor/MonitorPage.tsx index eeadc9a2..c4167d48 100644 --- a/panel/src/features/monitor/MonitorPage.tsx +++ b/panel/src/features/monitor/MonitorPage.tsx @@ -16,8 +16,21 @@ // `stream.status === "reconnecting"`, with the already-fetched snapshot // content staying rendered underneath it (a dead stream degrades the // header, never blanks the body). +// +// PR #81 bot round: the header's LIVE pill/reconnecting badge/stream-error +// badge are composed from `@idento/ui`'s `StatusPill` (Finding C1 -- +// panel/AGENTS.md's "UI primitives come only from @idento/ui"; the +// pulsing-dot variant this needed was genuinely missing, so it was added to +// the primitive itself rather than hand-rolled here again). `stream.status +// === "error"` (Finding C3 -- a terminal 4xx on the SSE connection, e.g. an +// expired session or a suspended tenant) replaces the LIVE pill entirely +// with a destructive-colored badge -- reconnecting has already permanently +// stopped by that point, so a "reconnecting" badge would be a lie; the body +// below still keeps rendering the last good snapshot underneath it (Finding +// C6 -- retain-last-known-good, gated on `!snapshot` rather than +// `isError`, so a single failed background refetch never blanks the page). import * as React from "react"; -import { Button, Card, CardContent, Skeleton } from "@idento/ui"; +import { Button, Card, CardContent, Skeleton, StatusPill } from "@idento/ui"; import { Link, getRouteApi } from "@tanstack/react-router"; import { ArrowLeft } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -64,12 +77,19 @@ export function MonitorPage() { ); } - if (eventQuery.isError || !eventQuery.data) { + // PR #81 bot round Finding C6: gated on `!eventQuery.data` alone, not + // `isError || !data` -- once the event has loaded once, a single failed + // background refetch (isError=true, data still retained per react-query) + // must not blank the whole page into this error card. + if (!eventQuery.data) { return (
-

{t("workspaceLoadError")}

+ {/* PR #81 bot round Finding C7: monitor-owned copy -- this page no + longer borrows workspace's `workspaceLoadError`/`workspaceBackHome` + keys (panel/AGENTS.md's cross-surface i18n convention). */} +

{t("monitorLoadError")}

); @@ -87,38 +107,44 @@ export function MonitorPage() { Ns ago" staleness label · (reconnecting badge, when the stream is down) · Exit. */}
- - - {live ? ( - + + + ) : ( + <> + + + + {/* Global Constraints: a dead-but-retryable stream shows a + reconnecting badge OVER stale data -- the body below keeps + rendering whatever snapshot was last fetched, unconditionally + on stream.status. */} + {stream.status === "reconnecting" ? ( + + + ) : null} - - - {t("monitorLive")} - + + )}

{event.name}

{updatedSeconds !== null ? ( {t("monitorUpdatedAgo", { seconds: updatedSeconds })} ) : null} - {/* Global Constraints: a dead stream shows a reconnecting badge - OVER stale data -- the body below keeps rendering whatever - snapshot was last fetched, unconditionally on stream.status. */} - {stream.status === "reconnecting" ? ( - - {t("monitorReconnecting")} - - ) : null}
- ) : snapshotQuery.isError || !snapshot ? ( + ) : !snapshot ? ( + // PR #81 bot round Finding C6: gated on `!snapshot` alone, not + // `snapshotQuery.isError || !snapshot` -- once the snapshot has + // loaded once, a single failed background refetch (isError=true, + // data still retained per react-query) must not blank the body + // into this error card. The "reconnecting"/"error" header badges + // above already cover a degraded live connection; this card is + // reserved for genuinely having nothing to show yet. <>
diff --git a/panel/src/features/monitor/useMonitorStream.test.tsx b/panel/src/features/monitor/useMonitorStream.test.tsx index 069c1514..f04b82bf 100644 --- a/panel/src/features/monitor/useMonitorStream.test.tsx +++ b/panel/src/features/monitor/useMonitorStream.test.tsx @@ -6,6 +6,20 @@ // refetch on success; unmount aborts (no further fetches); eventId change // closes the old stream and opens the new URL. // +// PR #81 bot round: extended for Findings C3/C4/C5 (see this file's sibling +// useMonitorStream.ts for the full state-machine rationale) -- +// - C3: a non-OK stream response (401/403 tenant_suspended/other 4xx) is +// terminal -- status flips to "error", no further reconnect, and the +// failure is routed through the app's global handling (handleApiError.ts) +// the exact same way every other API failure is. 5xx and network errors +// keep the pre-existing backoff loop. +// - C4: the backoff `attempt` counter now resets only once a "hello" frame +// actually arrives, not as soon as the connect's fetch resolves OK -- an +// endpoint that 200s and then immediately closes without ever sending +// hello must keep climbing the ladder. +// - C5: every "hello" -- not just a reconnect's -- resyncs the snapshot, +// routed through the SAME trailing coalescer as "update" frames. +// // Real timers throughout (never fake) -- this feature's repeatedly- // documented convention, since fake timers + MSW streaming is exactly the // interaction the codebase avoids (see this file's sibling task briefs and @@ -20,9 +34,36 @@ import { render, renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import type { ReactNode } from "react"; import { startMswServer } from "../../test/msw"; +import { getToken, saveSession } from "../../shared/api/session"; +import { tenantStatusStore } from "../../shared/tenant-status/tenantStatusStore"; import { useMonitorSnapshot } from "./hooks"; import { useMonitorStream } from "./useMonitorStream"; +const AUTH = { + token: "jwt-test", + user: { id: "u1", tenant_id: "t1", email: "a@b.com", role: "admin", created_at: "", updated_at: "" }, + tenants: [{ id: "t1", name: "Acme" }], + current_tenant: { id: "t1", name: "Acme" }, +}; + +// Same "delete + replace the whole `location` object" idiom +// queryClient.test.ts uses -- jsdom's Location is a special exotic object +// whose own properties aren't configurable, so `vi.spyOn` on `.assign` +// throws "Cannot redefine property". +const realLocation = window.location; + +function mockLocationAssign(): ReturnType { + const assign = vi.fn(); + // @ts-expect-error -- intentionally deleting a non-optional global for the mock swap + delete window.location; + window.location = { ...realLocation, assign } as Location; + return assign; +} + +function restoreLocation(): void { + window.location = realLocation; +} + // --------------------------------------------------------------------------- // Deferred/controlled SSE stream helper. Each `monitor/stream` GET the hook // makes (the initial connect AND every reconnect) is a SEPARATE HTTP request @@ -144,6 +185,28 @@ describe("useMonitorStream", () => { await waitFor(() => expect(snapshotGetCount).toBe(2), { timeout: 2000 }); }); + it( + "resyncs the snapshot on the INITIAL hello alone -- C5: not just a reconnect's, and with no update frame needed", + async () => { + const { Wrapper } = makeWrapper(); + const { getByTestId } = render(, { wrapper: Wrapper }); + + await waitFor(() => expect(snapshotGetCount).toBe(1)); // the page's own initial GET /monitor + await waitFor(() => expect(connections.length).toBe(1)); + connections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(getByTestId("status")).toHaveTextContent("live")); + + // A mutation landing between that initial GET and this connection's + // subscribe registration would have no "update" subscriber to notify + // it -- the hello itself must resync, closing that race even when NO + // update frame ever arrives. Routed through the same 1s trailing + // coalescer as "update" frames (COALESCE_MS in useMonitorStream.ts), + // so this lands up to ~1s after hello, not instantly. + await waitFor(() => expect(snapshotGetCount).toBe(2), { timeout: 2000 }); + }, + 5000, + ); + it("coalesces a burst of update frames within the same window into exactly one extra snapshot fetch", async () => { const { Wrapper } = makeWrapper(); render(, { wrapper: Wrapper }); @@ -151,7 +214,9 @@ describe("useMonitorStream", () => { await waitFor(() => expect(snapshotGetCount).toBe(1)); await waitFor(() => expect(connections.length).toBe(1)); connections[0].push("event: hello\ndata: {}\n\n"); - await waitFor(() => expect(snapshotGetCount).toBe(1)); // hello alone triggers no fetch + // C5: hello itself now schedules a coalesced resync too (asserted in + // its own test above) -- the burst below lands well inside that SAME + // 1s window, so it's still exactly ONE extra fetch overall, not two. connections[0].push('event: update\ndata: {"at":"t1"}\n\n'); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -169,7 +234,7 @@ describe("useMonitorStream", () => { }); it( - "reconnects with backoff after a clean stream close, and immediately re-invalidates the snapshot on successful reconnect", + "reconnects with backoff after a clean stream close, and resyncs on the RECONNECT's own hello -- not on the bare reconnect fetch resolving OK", async () => { const { Wrapper } = makeWrapper(); const { getByTestId } = render(, { wrapper: Wrapper }); @@ -178,6 +243,8 @@ describe("useMonitorStream", () => { await waitFor(() => expect(connections.length).toBe(1)); connections[0].push("event: hello\ndata: {}\n\n"); await waitFor(() => expect(getByTestId("status")).toHaveTextContent("live")); + // The initial hello's own coalesced resync (C5) lands here. + await waitFor(() => expect(snapshotGetCount).toBe(2), { timeout: 2000 }); connections[0].close(); @@ -187,12 +254,13 @@ describe("useMonitorStream", () => { // the retried connect() to land as a brand-new request. await waitFor(() => expect(connections.length).toBe(2), { timeout: 3000 }); - // The resync guarantee: invalidation fires as soon as the reconnect's - // fetch resolves OK, BEFORE that connection's own hello frame arrives. - await waitFor(() => expect(snapshotGetCount).toBe(2), { timeout: 1000 }); + // C4/C5: resync is gated on THIS connection's own hello, not on the + // reconnect's fetch merely resolving OK -- no new invalidation yet. + expect(snapshotGetCount).toBe(2); connections[1].push("event: hello\ndata: {}\n\n"); await waitFor(() => expect(getByTestId("status")).toHaveTextContent("live")); + await waitFor(() => expect(snapshotGetCount).toBe(3), { timeout: 2000 }); }, 8000, ); @@ -243,3 +311,150 @@ describe("useMonitorStream", () => { expect(connections.length).toBe(2); }); }); + +// PR #81 bot round Finding C4: `attempt` used to reset to 0 as soon as the +// connect's fetch resolved OK -- BEFORE the stream proved itself live via +// an actual "hello" frame. An endpoint that accepts the connection and then +// immediately closes it (no hello ever sent) got hammered at the 1s backoff +// base forever instead of climbing the ladder. +describe("useMonitorStream -- backoff attempt reset (C4)", () => { + beforeEach(() => { + connections = []; + snapshotGetCount = 0; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + it( + "keeps the backoff attempt counter climbing across repeated OK-but-no-hello closes, instead of resetting on every bare 200", + async () => { + const { Wrapper } = makeWrapper(); + renderHook(() => useMonitorStream("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(connections.length).toBe(1)); + const t0 = Date.now(); + connections[0].close(); // 200 OK, closes immediately, no hello -- attempt must NOT reset. + + await waitFor(() => expect(connections.length).toBe(2), { timeout: 3000 }); + const t1 = Date.now(); + const firstGapMs = t1 - t0; + + connections[1].close(); // second OK-but-no-hello close -- attempt should now be 1, not reset back to 0. + + await waitFor(() => expect(connections.length).toBe(3), { timeout: 5000 }); + const t2 = Date.now(); + const secondGapMs = t2 - t1; + + // backoffDelayMs(0) draws from [750, 1250]ms and backoffDelayMs(1) + // from [1500, 2500]ms (base*2^attempt +/-25% jitter, non-overlapping + // ranges) -- so this lower bound is a robust, non-flaky discriminator + // between "reset every time" (the bug -- second gap would also fall + // in [750, 1250]) and "reset only on hello" (the fix). Also captures + // the earlier review's own missing lower-bound assertion (the burst + // test above only ever asserted an UPPER bound via `waitFor` timeouts). + expect(secondGapMs).toBeGreaterThan(1300); + expect(secondGapMs).toBeGreaterThan(firstGapMs); + }, + 10000, + ); +}); + +// PR #81 bot round Findings C3 (+ CodeRabbit): a non-OK stream response used +// to be treated identically to a network error -- infinite reconnect behind +// a "reconnecting" badge, even for a 401 (expired session) or 403 +// tenant_suspended, which should instead trigger the app's global handling +// (queryClient.ts's handleApiError, now shared via handleApiError.ts) the +// same way every other API failure does. 5xx and genuine network errors +// keep the pre-existing backoff loop -- those ARE expected to eventually +// succeed on retry. +describe("useMonitorStream -- terminal vs retryable stream failures (C3)", () => { + beforeEach(() => { + connections = []; + snapshotGetCount = 0; + localStorage.clear(); + tenantStatusStore.setSuspended(false); + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + afterEach(() => { + restoreLocation(); + tenantStatusStore.setSuspended(false); + }); + + it("stops retrying and surfaces status 'error' on a 401, clearing the session and redirecting to /login", async () => { + saveSession(AUTH); + const assign = mockLocationAssign(); + server.use( + http.get("http://api.test/api/events/:eventId/monitor/stream", () => + HttpResponse.json({ error: "Session expired" }, { status: 401 }), + ), + ); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useMonitorStream("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.status).toBe("error")); + + expect(getToken()).toBeNull(); + expect(assign).toHaveBeenCalledWith("/login"); + + // Past one full backoff window -- a terminal 4xx must never retry. + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(connections.length).toBe(0); // handled before any streaming `connections` entry would be pushed + expect(result.current.status).toBe("error"); + }, 5000); + + it("stops retrying and surfaces status 'error' on a 403 tenant_suspended, marking the tenant suspended", async () => { + server.use( + http.get("http://api.test/api/events/:eventId/monitor/stream", () => + HttpResponse.json({ code: "tenant_suspended", error: "Tenant is suspended" }, { status: 403 }), + ), + ); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useMonitorStream("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.status).toBe("error")); + expect(tenantStatusStore.isSuspended()).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(result.current.status).toBe("error"); + }, 5000); + + it("stops retrying and surfaces status 'error' on a documented 404 with no matching global handler", async () => { + server.use( + http.get("http://api.test/api/events/:eventId/monitor/stream", () => new HttpResponse(null, { status: 404 })), + ); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useMonitorStream("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.status).toBe("error")); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(result.current.status).toBe("error"); + }, 5000); + + it("keeps retrying (never surfaces 'error') on a 500", async () => { + server.use( + http.get("http://api.test/api/events/:eventId/monitor/stream", () => new HttpResponse(null, { status: 500 })), + ); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useMonitorStream("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.status).toBe("reconnecting"), { timeout: 3000 }); + expect(result.current.status).not.toBe("error"); + }, 5000); + + it("keeps retrying (never surfaces 'error') on a plain network error", async () => { + server.use(http.get("http://api.test/api/events/:eventId/monitor/stream", () => HttpResponse.error())); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useMonitorStream("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.status).toBe("reconnecting"), { timeout: 3000 }); + expect(result.current.status).not.toBe("error"); + }, 5000); +}); diff --git a/panel/src/features/monitor/useMonitorStream.ts b/panel/src/features/monitor/useMonitorStream.ts index 0933542b..a27f2a48 100644 --- a/panel/src/features/monitor/useMonitorStream.ts +++ b/panel/src/features/monitor/useMonitorStream.ts @@ -1,11 +1,12 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { getApiBaseUrl } from "../../shared/api/http"; +import { apiErrorFromResponse, getApiBaseUrl } from "../../shared/api/http"; +import { handleApiError } from "../../shared/api/handleApiError"; import { getToken } from "../../shared/api/session"; import { MONITOR_SNAPSHOT_KEY } from "./hooks"; import { createSseParser } from "./parseSse"; -export type MonitorStreamStatus = "connecting" | "live" | "reconnecting"; +export type MonitorStreamStatus = "connecting" | "live" | "reconnecting" | "error"; // Thin-ping SSE (plan §4.1, Global Constraints): the backend's "update" // frames carry no state, so every one of them just needs to trigger a @@ -44,7 +45,14 @@ function backoffDelayMs(attempt: number): number { * * Frame handling: * - "hello" -- the very first frame the backend sends on every connection - * (`monitor_stream.go`) -- flips `status` to "live". + * (`monitor_stream.go`) -- flips `status` to "live", resets the backoff + * `attempt` ladder (PR #81 bot round Finding C4 -- NOT a bare 200; an + * endpoint that accepts the connection and then immediately closes it + * without ever sending hello must keep climbing the ladder, not get + * hammered at the 1s base forever), and schedules a coalesced snapshot + * resync (Finding C5 -- on EVERY hello, not just a reconnect's: a + * mutation landing between the page's initial GET and THIS connection's + * subscribe registration has no "update" subscriber to notify it). * - "update" -- a thin ping carrying no payload the client needs -- * invalidates Task 5's `MONITOR_SNAPSHOT_KEY`, coalesced per * `COALESCE_MS` above, so whatever's rendering `useMonitorSnapshot` @@ -52,16 +60,25 @@ function backoffDelayMs(attempt: number): number { * - ": ping" keep-alive comments never reach here at all -- `createSseParser` * swallows comment-only frames before they'd ever call back. * - * On any disconnect (a clean close, a network error, or a non-OK response) - * `status` flips to "reconnecting" and this retries with `backoffDelayMs` - * above. A successful reconnect (the retried fetch resolves OK -- BEFORE - * that connection's own "hello" frame even arrives) immediately invalidates - * the snapshot too: the resync guarantee that whatever changed while - * disconnected must not wait for the next "update" ping to surface. + * On a clean close, a network error, or a RETRYABLE non-OK response (5xx -- + * an overloaded/restarting backend, the same class of failure a network + * error is), `status` flips to "reconnecting" and this retries with + * `backoffDelayMs` above. * - * No polling fallback (Global Constraints) -- a dead stream is surfaced via - * `status` for the UI to show a "reconnecting" badge over stale data, not - * papered over with a poller. + * A non-OK response in the 4xx range is instead TERMINAL (PR #81 bot round + + * CodeRabbit Finding C3): it's normalized into the same `ApiError` shape + * http.ts's `errors` middleware builds for the `api` client + * (`apiErrorFromResponse`) and routed through the app's global handling + * (`handleApiError` -- tenant_suspended suspension takeover, 401 dead-session + * redirect) exactly like every other API failure, `status` flips to "error", + * and reconnecting stops -- an expired session or a suspended tenant will + * never succeed on a bare retry, and looping behind a "reconnecting" badge + * forever would hide a failure that's either already been surfaced + * elsewhere or never will resolve itself. + * + * No polling fallback (Global Constraints) -- a dead-but-retryable stream is + * surfaced via `status` for the UI to show a "reconnecting" badge over + * stale data, not papered over with a poller. */ export function useMonitorStream(eventId: string): { status: MonitorStreamStatus } { const queryClient = useQueryClient(); @@ -79,7 +96,6 @@ export function useMonitorStream(eventId: string): { status: MonitorStreamStatus const controller = new AbortController(); let cancelled = false; let attempt = 0; - let isReconnect = false; let coalescePending = false; let backoffTimer: ReturnType | undefined; let coalesceTimer: ReturnType | undefined; @@ -96,7 +112,6 @@ export function useMonitorStream(eventId: string): { status: MonitorStreamStatus function scheduleReconnect() { if (cancelled) return; setStatus("reconnecting"); - isReconnect = true; const delay = backoffDelayMs(attempt); attempt += 1; backoffTimer = setTimeout(() => { @@ -115,24 +130,58 @@ export function useMonitorStream(eventId: string): { status: MonitorStreamStatus signal: controller.signal, }); - if (!res.ok || !res.body) { - throw new Error(`monitor stream request failed with status ${res.status}`); + if (!res.ok) { + // PR #81 bot round + CodeRabbit Finding C3: normalize into the + // SAME `ApiError` shape http.ts's `errors` middleware builds for + // the `api` client -- this hook deliberately bypasses `api`/ + // openapi-fetch for its streaming transport (see this file's own + // top-of-file comment), but that must not also mean bypassing the + // app's global auth/suspension handling every other API failure + // gets. + const apiError = await apiErrorFromResponse(res); + handleApiError(apiError); + if (res.status >= 400 && res.status < 500) { + // Terminal: an expired session (401), a suspended tenant (403 + // tenant_suspended -- already actioned above), or any other + // documented 4xx (400/404/...) will never succeed on a bare + // retry. Stop climbing the backoff ladder and surface a dead + // stream instead of looping behind a "reconnecting" badge + // forever. + if (!cancelled) setStatus("error"); + return; + } + // 5xx: transient (an overloaded/restarting backend) -- same retry + // policy as a network error below. + throw apiError; } - // A successful (re)connect resets the backoff ladder, and -- if - // this was a reconnect -- fires the resync invalidation right away, - // before this connection's own "hello" frame even arrives. The - // "live" transition below is a separate signal (the parser is - // actually caught up), not just that the socket opened. - attempt = 0; - if (isReconnect) { - void queryClient.invalidateQueries({ queryKey: MONITOR_SNAPSHOT_KEY(eventId) }); + if (!res.body) { + throw new Error("monitor stream response has no body"); } const reader = res.body.getReader(); const decoder = new TextDecoder(); const feed = createSseParser((evt) => { if (evt.event === "hello") { + // C4: only a "hello" -- proof the stream is actually live, not + // just that the TCP/HTTP handshake succeeded -- resets the + // backoff ladder. Resetting on a bare 200 let an endpoint that + // accepts the connection and then immediately closes it get + // hammered at the 1s base forever. + attempt = 0; + // C5: every hello resyncs the snapshot -- not just a + // reconnect's. A mutation landing between the page's initial + // GET /monitor and THIS connection's subscribe registration has + // no "update" subscriber to notify it, so relying on "update" + // pings alone can silently lose it. Routed through the SAME + // trailing coalescer as "update" frames (not fired immediately) + // so the initial hello -- which lands milliseconds after the + // page's own initial fetch -- doesn't turn into an instant, + // redundant second GET; a genuinely racing mutation still + // surfaces within one coalesce window, and a burst of real + // "update" pings landing in that same window is absorbed into + // this same single refetch. + scheduleInvalidate(); setStatus("live"); } else if (evt.event === "update") { scheduleInvalidate(); @@ -152,7 +201,10 @@ export function useMonitorStream(eventId: string): { status: MonitorStreamStatus } catch { // An aborted read (unmount or an eventId change tore down // `controller` mid-flight) must NOT be treated as a stream failure - // -- the scope is gone, there's nothing to reconnect for. + // -- the scope is gone, there's nothing to reconnect for. Every + // other failure here is retryable (a network error, or the 5xx + // rethrown above) -- a terminal 4xx already returned before + // reaching this catch. if (controller.signal.aborted) return; if (!cancelled) scheduleReconnect(); } diff --git a/panel/src/shared/api/handleApiError.ts b/panel/src/shared/api/handleApiError.ts new file mode 100644 index 00000000..3cd5921a --- /dev/null +++ b/panel/src/shared/api/handleApiError.ts @@ -0,0 +1,48 @@ +import type { Mutation } from "@tanstack/react-query"; +import { ApiError } from "./ApiError"; +import { clearSession } from "./session"; +import { tenantStatusStore } from "../tenant-status/tenantStatusStore"; + +// Extracted from app/queryClient.ts (PR #81 bot round Finding C3) into +// shared/api/ so useMonitorStream.ts's raw-`fetch` SSE client -- a +// features/ module that must not import from app/ (panel/AGENTS.md's +// feature-sliced layering; app/ assembles features/, not the reverse, +// avoiding an import cycle since app/router.tsx pulls in feature route +// components) -- can route ITS non-OK stream responses through the exact +// same global handling (tenant suspension, dead-session redirect) every +// other API failure gets via the `api` client's query/mutation caches, +// instead of a second, drifting copy of this logic. + +// Login/register/QR-login legitimately reject with 401 on wrong +// credentials — that's the screen's own inline error (see LoginScreen.tsx +// etc.), not a dead session. Skip the global 401 handler for exactly +// these, identified by mutationKey. +const AUTH_MUTATION_KEYS = new Set(["login", "register", "loginWithQr"]); + +function isAuthMutation(mutation?: Mutation): boolean { + const key = mutation?.options.mutationKey?.[0]; + return typeof key === "string" && AUTH_MUTATION_KEYS.has(key); +} + +/** + * Routes an `ApiError` through the app's global failure handling: + * `tenant_suspended` flips the suspension takeover on; a bare 401 (outside + * the auth screens' own login/register/QR-login mutations, which handle + * their own 401s inline) clears the session and redirects to /login. + * Non-`ApiError` failures and every other status are no-ops here — callers + * (queryClient.ts's query/mutation caches, useMonitorStream.ts's SSE + * client) keep their own retry/rethrow behavior for those. + */ +export function handleApiError(error: unknown, mutation?: Mutation): void { + if (!(error instanceof ApiError)) return; + if (error.code === "tenant_suspended") { + tenantStatusStore.setSuspended(true); + return; + } + if (error.status === 401 && !isAuthMutation(mutation)) { + clearSession(); + if (!window.location.pathname.startsWith("/login")) { + window.location.assign("/login"); + } + } +} diff --git a/panel/src/shared/api/http.test.ts b/panel/src/shared/api/http.test.ts index 9d3005c0..38b9b126 100644 --- a/panel/src/shared/api/http.test.ts +++ b/panel/src/shared/api/http.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "./ApiError"; -import { api, getAgentBaseUrl } from "./http"; +import { api, getAgentBaseUrl, getApiBaseUrl } from "./http"; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -84,3 +84,40 @@ describe("getAgentBaseUrl", () => { expect(getAgentBaseUrl()).toBe("http://localhost:12345"); }); }); + +// PR #81 bot round Finding C2: useMonitorStream.ts builds its SSE request +// URL by plain string concatenation (`${getApiBaseUrl()}/api/events/...`, +// the exact bug class Fix 5 above fixed for AGENT_URL) since it bypasses +// the `api` openapi-fetch client for its streaming transport (see that +// file's own top-of-file comment). openapi-fetch's `baseUrl` handling +// already tolerates a trailing slash internally (`removeTrailingSlash` in +// openapi-fetch/dist -- verified against the installed 0.17.0), and +// `dynamicBaseUrl` in this file only ever copies protocol/hostname/port +// from `getApiBaseUrl()`, never the pathname, so normalizing HERE is safe +// for every existing consumer and fixes the one raw-`fetch` caller that +// isn't otherwise protected. +describe("getApiBaseUrl", () => { + afterEach(() => { + window.__ENV__ = undefined; + }); + + it("strips a trailing slash from a configured API_URL", () => { + window.__ENV__ = { API_URL: "http://api.test/" }; + expect(getApiBaseUrl()).toBe("http://api.test"); + }); + + it("strips multiple trailing slashes", () => { + window.__ENV__ = { API_URL: "http://api.test///" }; + expect(getApiBaseUrl()).toBe("http://api.test"); + }); + + it("leaves an already-clean API_URL untouched", () => { + window.__ENV__ = { API_URL: "http://api.test" }; + expect(getApiBaseUrl()).toBe("http://api.test"); + }); + + it("falls back to the dev-machine default when unset", () => { + window.__ENV__ = undefined; + expect(getApiBaseUrl()).toBe("http://localhost:8008"); + }); +}); diff --git a/panel/src/shared/api/http.ts b/panel/src/shared/api/http.ts index ad047c95..6e13793d 100644 --- a/panel/src/shared/api/http.ts +++ b/panel/src/shared/api/http.ts @@ -9,8 +9,19 @@ declare global { } } +// PR #81 bot round Finding C2: mirrors getAgentBaseUrl's trailing-slash +// strip below -- a configured API_URL with a trailing slash (an operator +// typo, or a value copy-pasted straight from a browser address bar) is +// otherwise safe for every consumer THROUGH this file (openapi-fetch's own +// `baseUrl` handling already tolerates it, and `dynamicBaseUrl` below only +// ever copies protocol/hostname/port off this value, never the pathname), +// but useMonitorStream.ts's SSE client bypasses `api`/openapi-fetch +// entirely (raw `fetch` + string concatenation, the exact bug class Fix 5 +// fixed for AGENT_URL) and would otherwise build "http://api.test//api/...". +// Normalizing once, here, covers that caller without it needing to know. export function getApiBaseUrl(): string { - return window.__ENV__?.API_URL || import.meta.env.VITE_API_URL || "http://localhost:8008"; + const configured = window.__ENV__?.API_URL || import.meta.env.VITE_API_URL || "http://localhost:8008"; + return configured.replace(/\/+$/, ""); } // The local print agent is a separate origin from the backend API — it's not @@ -91,14 +102,25 @@ const auth: Middleware = { }, }; +// Extracted (PR #81 bot round Finding C3) so useMonitorStream.ts's raw +// `fetch`-based SSE client -- which deliberately bypasses this `api` client +// for its streaming transport (see that file's own top-of-file comment) -- +// can turn ITS non-OK responses into the exact same `ApiError` shape and +// route them through the app's global handling (handleApiError.ts), +// instead of silently reinventing (and inevitably drifting from) this +// parsing. +export async function apiErrorFromResponse(response: Response): Promise { + const body = (await response + .clone() + .json() + .catch(() => ({}))) as { code?: string; error?: string; message?: string }; + return new ApiError(response.status, body.code, body.error || body.message || response.statusText); +} + const errors: Middleware = { async onResponse({ response }) { if (!response.ok) { - const body = (await response - .clone() - .json() - .catch(() => ({}))) as { code?: string; error?: string; message?: string }; - throw new ApiError(response.status, body.code, body.error || body.message || response.statusText); + throw await apiErrorFromResponse(response); } return response; }, diff --git a/panel/src/shared/i18n/en.json b/panel/src/shared/i18n/en.json index b4aa6978..8e16ef3a 100644 --- a/panel/src/shared/i18n/en.json +++ b/panel/src/shared/i18n/en.json @@ -35,6 +35,7 @@ "homeCheckedIn": "checked in", "homeOpenEvent": "Open event →", "homeOpenMonitor": "Open monitor →", + "homeZoneUnattributed": "Unattributed", "homeNextUp": "Next up", "homeReadyFraction": "{{done}} of {{total}} ready", "homeStatsLoadError": "Couldn't load live stats.", @@ -623,6 +624,9 @@ "monitorLive": "LIVE", "monitorUpdatedAgo": "Updated {{seconds}} s ago", "monitorExit": "Exit", + "monitorLoadError": "Couldn't load this event.", + "monitorBackHome": "← Back to Home", + "monitorStreamError": "Live updates unavailable", "monitorTotalsTitle": "Totals", "monitorZonesTitle": "By zone", "monitorRate": "{{rate}} scans/min", diff --git a/panel/src/shared/i18n/ru.json b/panel/src/shared/i18n/ru.json index 23cdd3ee..4bd4cb3e 100644 --- a/panel/src/shared/i18n/ru.json +++ b/panel/src/shared/i18n/ru.json @@ -35,6 +35,7 @@ "homeCheckedIn": "зарегистрировано", "homeOpenEvent": "Открыть мероприятие →", "homeOpenMonitor": "Открыть монитор →", + "homeZoneUnattributed": "Без зоны", "homeNextUp": "Следующее", "homeReadyFraction": "{{done}} из {{total}} готово", "homeStatsLoadError": "Не удалось загрузить статистику в реальном времени.", @@ -625,6 +626,9 @@ "monitorLive": "В ЭФИРЕ", "monitorUpdatedAgo": "Обновлено {{seconds}} с назад", "monitorExit": "Выход", + "monitorLoadError": "Не удалось загрузить это мероприятие.", + "monitorBackHome": "← На главную", + "monitorStreamError": "Обновления в реальном времени недоступны", "monitorTotalsTitle": "Итоги", "monitorZonesTitle": "По зонам", "monitorRate": "{{rate}} сканов/мин", From c75943982993807996549d38f404f281ba85341d Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 06:53:35 +0300 Subject: [PATCH 18/23] fix(backend): gate batch publishes to registration check-ins, event-scope station count join PR #81 round-2 convergence, Findings 1-2: - BatchCheckin only signals the monitor broker when a kind=checkin item was genuinely created; ApplyBatchCheckin deliberately reports BatchCheckinCreated for zone_entry items too (even pre-existing ones), but zone entries write zone_checkins, which the monitor snapshot never reads, so a zone-entry-only access-control sync no longer spuriously publishes and forces every attached monitor to refetch unchanged data. - GetMonitorStations' checkin_actions join now carries an `AND ca.event_id = cs.event_id` predicate so Postgres can use idx_checkin_actions_event_created instead of scanning/hashing the global table for tenants with many actions in other events. Co-Authored-By: Claude Sonnet 5 --- backend/internal/handler/checkins_batch.go | 21 +++- .../internal/handler/legacy_publish_test.go | 105 ++++++++++++++++++ backend/internal/store/pg_store_monitor.go | 12 +- .../internal/store/pg_store_monitor_test.go | 9 +- 4 files changed, 137 insertions(+), 10 deletions(-) diff --git a/backend/internal/handler/checkins_batch.go b/backend/internal/handler/checkins_batch.go index bb56aceb..143bb33c 100644 --- a/backend/internal/handler/checkins_batch.go +++ b/backend/internal/handler/checkins_batch.go @@ -38,11 +38,18 @@ func (h *Handler) BatchCheckin(c echo.Context) error { } // anyCreated tracks whether ANY item in this batch actually created a - // new check-in (Finding B3, PR #81 bot-review round): this endpoint is - // scoped to a single event_id from the path, so every item belongs to - // the same event — one publish for the whole batch once, not one per - // item, and only when the batch produced a real monitor-visible change - // (an "already_exists"/"error"-only batch changed nothing). + // monitor-visible check-in (Finding B3, PR #81 bot-review round; refined + // by PR #81 round-2 convergence Finding 1): this endpoint is scoped to a + // single event_id from the path, so every item belongs to the same event + // — one publish for the whole batch once, not one per item, and only + // when the batch produced a real monitor-visible change. ApplyBatchCheckin + // deliberately reports BatchCheckinCreated for kind=zone_entry items too + // (even pre-existing ones — see pg_store_batch.go's doc comment), but + // zone entries write zone_checkins, a table the monitor snapshot never + // reads. So this must additionally gate on item.Kind == "checkin" — the + // registration check-in kind — or a zone-entry-only access-control sync + // would spuriously publish and force every attached monitor to refetch + // unchanged data. anyCreated := false results := make([]models.BatchCheckinResult, 0, len(items)) @@ -77,7 +84,9 @@ func (h *Handler) BatchCheckin(c echo.Context) error { } switch outcome { case store.BatchCheckinCreated: - anyCreated = true + if item.Kind == "checkin" { + anyCreated = true + } results = append(results, models.BatchCheckinResult{ClientUUID: item.ClientUUID, Status: "created"}) case store.BatchCheckinAlreadyCheckedIn, store.BatchCheckinDuplicateClientUUID: // Both mean "no new check-in was created by this specific diff --git a/backend/internal/handler/legacy_publish_test.go b/backend/internal/handler/legacy_publish_test.go index 8d11c3c3..e1389ad6 100644 --- a/backend/internal/handler/legacy_publish_test.go +++ b/backend/internal/handler/legacy_publish_test.go @@ -274,6 +274,111 @@ func TestBatchCheckin_NoPublishWhenNoItemCreated(t *testing.T) { } } +// TestBatchCheckin_NoPublishForZoneEntryOnlyBatch is the PR #81 round-2 +// convergence fix (Finding 1): ApplyBatchCheckin deliberately reports +// BatchCheckinCreated for kind=zone_entry items too — even a pre-existing +// zone entry (see pg_store_batch.go's ApplyBatchCheckin doc comment) — but +// zone entries write zone_checkins, a table the monitor snapshot never +// reads. A batch made up entirely of zone_entry items must not publish: +// doing so would force every attached monitor to refetch unchanged data for +// a plain access-control sync. +func TestBatchCheckin_NoPublishForZoneEntryOnlyBatch(t *testing.T) { + eventID := uuid.New() + tenantID := uuid.New() + attendeeID := uuid.New() + zoneID := uuid.New() + + fs := &fakeStore{ + getEventByID: func(id uuid.UUID) (*models.Event, error) { + return &models.Event{ID: id, TenantID: tenantID}, nil + }, + getAttendeeByID: func(id uuid.UUID) (*models.Attendee, error) { + return &models.Attendee{ID: id, EventID: eventID}, nil + }, + getEventZoneByID: func(id uuid.UUID) (*models.EventZone, error) { + return &models.EventZone{ID: id, EventID: eventID}, nil + }, + applyBatchCheckin: func(_, _ uuid.UUID, _ *models.BatchCheckinItem) (store.BatchCheckinOutcome, error) { + // zone_entry items always report Created per ApplyBatchCheckin's + // documented semantics, even though nothing monitor-visible changed. + return store.BatchCheckinCreated, nil + }, + } + h := &Handler{Store: fs} + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(eventID) + defer unsubscribe() + + e := echo.New() + body := `[{"client_uuid":"` + uuid.New().String() + `","attendee_id":"` + attendeeID.String() + `","zone_id":"` + zoneID.String() + `","at":"2026-07-10T10:00:00Z","device_number":1,"kind":"zone_entry"}]` + c, rec := newAuthedContext(e, http.MethodPost, "/api/events/"+eventID.String()+"/checkins/batch", body, tenantID.String(), "staff") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) + + if err := h.BatchCheckin(c); err != nil { + t.Fatalf("BatchCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false for a zone-entry-only batch (monitor snapshot never reads zone_checkins)") + } +} + +// TestBatchCheckin_PublishesOnceForMixedBatch proves a batch containing both +// a zone_entry item (reported Created, monitor-invisible) and a genuine +// kind=checkin item (reported Created, monitor-visible) still publishes +// exactly once — the checkin item alone is enough to trigger the batch's one +// publish, regardless of the zone_entry item's outcome. +func TestBatchCheckin_PublishesOnceForMixedBatch(t *testing.T) { + eventID := uuid.New() + tenantID := uuid.New() + checkinAttendeeID := uuid.New() + zoneAttendeeID := uuid.New() + zoneID := uuid.New() + + fs := &fakeStore{ + getEventByID: func(id uuid.UUID) (*models.Event, error) { + return &models.Event{ID: id, TenantID: tenantID}, nil + }, + getAttendeeByID: func(id uuid.UUID) (*models.Attendee, error) { + return &models.Attendee{ID: id, EventID: eventID}, nil + }, + getEventZoneByID: func(id uuid.UUID) (*models.EventZone, error) { + return &models.EventZone{ID: id, EventID: eventID}, nil + }, + applyBatchCheckin: func(_, _ uuid.UUID, item *models.BatchCheckinItem) (store.BatchCheckinOutcome, error) { + return store.BatchCheckinCreated, nil + }, + } + h := &Handler{Store: fs} + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(eventID) + defer unsubscribe() + + e := echo.New() + body := `[` + + `{"client_uuid":"` + uuid.New().String() + `","attendee_id":"` + zoneAttendeeID.String() + `","zone_id":"` + zoneID.String() + `","at":"2026-07-10T10:00:00Z","device_number":1,"kind":"zone_entry"},` + + `{"client_uuid":"` + uuid.New().String() + `","attendee_id":"` + checkinAttendeeID.String() + `","at":"2026-07-10T10:00:01Z","device_number":1,"kind":"checkin"}` + + `]` + c, rec := newAuthedContext(e, http.MethodPost, "/api/events/"+eventID.String()+"/checkins/batch", body, tenantID.String(), "staff") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) + + if err := h.BatchCheckin(c); err != nil { + t.Fatalf("BatchCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true when the batch's checkin item created a monitor-visible check-in") + } +} + // TestBatchCheckin_NilBrokerDoesNotPanic proves the nil-safe guard for the // batch publish site. func TestBatchCheckin_NilBrokerDoesNotPanic(t *testing.T) { diff --git a/backend/internal/store/pg_store_monitor.go b/backend/internal/store/pg_store_monitor.go index 81f16b81..6eff60f7 100644 --- a/backend/internal/store/pg_store_monitor.go +++ b/backend/internal/store/pg_store_monitor.go @@ -207,12 +207,20 @@ func (s *PGStore) GetMonitorMinuteBuckets(ctx context.Context, eventID uuid.UUID // 'checkin'), so 'undo'/'reprint' rows sharing the same station_id don't // inflate the count — ordered by name, the same deterministic-listing // convention as ListCheckinStations, with the running count attached for -// the monitor's stations card. +// the monitor's stations card. The join also carries an `ca.event_id = +// cs.event_id` predicate (PR #81 round-2 convergence Finding 2): station_id +// alone uniquely identifies the event already (stations don't move between +// events), but without the event predicate in the join condition itself, +// Postgres plans this as a join against the ENTIRE checkin_actions table +// before filtering — for tenants with many actions in other events, that +// can't use idx_checkin_actions_event_created and forces a scan/hash of the +// global table on every snapshot refetch. cs.event_id needs no new query +// param since the stations are already event-filtered by the WHERE clause. func (s *PGStore) GetMonitorStations(ctx context.Context, eventID uuid.UUID) ([]MonitorStation, error) { rows, err := s.db.Query(ctx, ` SELECT cs.id, cs.name, cs.zone_id, cs.last_seen_at, COUNT(ca.id) FILTER (WHERE ca.action = 'checkin') FROM checkin_stations cs - LEFT JOIN checkin_actions ca ON ca.station_id = cs.id + LEFT JOIN checkin_actions ca ON ca.station_id = cs.id AND ca.event_id = cs.event_id WHERE cs.event_id = $1 GROUP BY cs.id, cs.name, cs.zone_id, cs.last_seen_at ORDER BY cs.name`, eventID) diff --git a/backend/internal/store/pg_store_monitor_test.go b/backend/internal/store/pg_store_monitor_test.go index 7009e145..0a37c62e 100644 --- a/backend/internal/store/pg_store_monitor_test.go +++ b/backend/internal/store/pg_store_monitor_test.go @@ -277,8 +277,13 @@ func TestCountRecentCheckinsNoneReturnsZero(t *testing.T) { // getMonitorStationsSQL matches GetMonitorStations' exact SELECT — a // per-station 'checkin'-action count via FILTER (so 'undo'/'reprint' rows -// sharing the same station_id don't inflate it), ordered by name. -const getMonitorStationsSQL = `SELECT cs\.id, cs\.name, cs\.zone_id, cs\.last_seen_at, COUNT\(ca\.id\) FILTER \(WHERE ca\.action = 'checkin'\)\s+FROM checkin_stations cs\s+LEFT JOIN checkin_actions ca ON ca\.station_id = cs\.id\s+WHERE cs\.event_id = \$1\s+GROUP BY cs\.id, cs\.name, cs\.zone_id, cs\.last_seen_at\s+ORDER BY cs\.name` +// sharing the same station_id don't inflate it), ordered by name. The join +// condition also scopes to the station's own event (PR #81 round-2 +// convergence Finding 2): without it, Postgres can't use +// idx_checkin_actions_event_created and may scan/hash the global +// checkin_actions table per snapshot refetch for tenants with many actions +// in OTHER events. +const getMonitorStationsSQL = `SELECT cs\.id, cs\.name, cs\.zone_id, cs\.last_seen_at, COUNT\(ca\.id\) FILTER \(WHERE ca\.action = 'checkin'\)\s+FROM checkin_stations cs\s+LEFT JOIN checkin_actions ca ON ca\.station_id = cs\.id AND ca\.event_id = cs\.event_id\s+WHERE cs\.event_id = \$1\s+GROUP BY cs\.id, cs\.name, cs\.zone_id, cs\.last_seen_at\s+ORDER BY cs\.name` func TestGetMonitorStationsReturnsNameOrderedWithCounts(t *testing.T) { mock, err := pgxmock.NewPool() From d489766fc6ff3e2136c60eab68340024ee6ba604 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 07:10:31 +0300 Subject: [PATCH 19/23] fix(panel): shared SSE transport seam, accessible feed action labels, StatusPill station liveness PR #81 round-2 convergence, Findings 3-5: - useMonitorStream.ts's raw `fetch` call is extracted into a new shared/api/sseStream.ts (`openSseStream`), which owns URL construction, auth/Accept headers, non-OK -> ApiError normalization, and the reader/decoder/parseSse consume loop -- the hook keeps its full state-machine (statuses, backoff, coalescing, resync-on-hello, terminal-4xx stop) but now has zero direct `fetch` references, per panel/AGENTS.md's data-fetching rule. parseSse.ts (pure, no monitor knowledge) moves to shared/api/ alongside it, since shared/ must not depend on features/. - RecentFeedCard.tsx's undo/reprint/checkin rows now carry a localized, visually-hidden (sr-only) action label alongside the existing aria-hidden icon, so a screen reader can tell the three action types apart instead of hearing identical name/zone/time for all of them. - @idento/ui's StatusPill gains an additive `variant="bare"` (a chrome-less, label-as-aria-label status dot) so StationsCard.tsx's per-station liveness dot composes from the shared primitive instead of hand-rolling its own colored circle; visuals (green fresh / amber stale + the mandatory "stale Ns" text) are unchanged. Co-Authored-By: Claude Sonnet 5 --- .../ui/src/components/status-pill.test.tsx | 48 +++++ packages/ui/src/components/status-pill.tsx | 37 +++- .../src/features/monitor/MonitorPage.test.tsx | 96 +++++++++- panel/src/features/monitor/RecentFeedCard.tsx | 15 ++ panel/src/features/monitor/StationsCard.tsx | 30 +++- .../src/features/monitor/useMonitorStream.ts | 152 ++++++++-------- .../monitor => shared/api}/parseSse.test.ts | 0 .../monitor => shared/api}/parseSse.ts | 12 +- panel/src/shared/api/sseStream.test.ts | 166 ++++++++++++++++++ panel/src/shared/api/sseStream.ts | 66 +++++++ panel/src/shared/i18n/en.json | 4 + panel/src/shared/i18n/ru.json | 4 + 12 files changed, 535 insertions(+), 95 deletions(-) rename panel/src/{features/monitor => shared/api}/parseSse.test.ts (100%) rename panel/src/{features/monitor => shared/api}/parseSse.ts (73%) create mode 100644 panel/src/shared/api/sseStream.test.ts create mode 100644 panel/src/shared/api/sseStream.ts diff --git a/packages/ui/src/components/status-pill.test.tsx b/packages/ui/src/components/status-pill.test.tsx index a5161ea8..555a11aa 100644 --- a/packages/ui/src/components/status-pill.test.tsx +++ b/packages/ui/src/components/status-pill.test.tsx @@ -62,4 +62,52 @@ describe("StatusPill", () => { expect(container.querySelector(".rounded-full.bg-destructive")).not.toBeNull(); }); }); + + // PR #81 round-2 convergence Finding 5: StationsCard.tsx's per-station + // liveness dot needs the SAME status-colored dot as indicator="dot" above, + // but with NO pill chrome (border/background/padding) and NO always- + // visible label -- the row around it already supplies its own name and a + // SEPARATE, conditional text label (e.g. "stale 40 s", rendered only while + // stale; nothing renders at all while fresh). Added as `variant="bare"` + // rather than the panel hand-rolling a dot a second time. + describe("variant=\"bare\"", () => { + it("renders only the status-colored dot -- no pill chrome, no visible label text, no icon", () => { + const { container } = render(); + expect(screen.queryByText("Fresh")).not.toBeInTheDocument(); + expect(container.querySelector("svg")).toBeNull(); + expect(container.querySelector(".rounded-full.bg-success")).not.toBeNull(); + // No pill chrome (border/background/padding) anywhere in the tree. + expect(container.querySelector(".border")).toBeNull(); + expect(container.querySelector(".px-2\\.5")).toBeNull(); + }); + + it("exposes the label via aria-label, since no visible text is rendered", () => { + render(); + expect(screen.getByLabelText("Stale 40 s")).toBeInTheDocument(); + }); + + it("colors the dot to match the given status (e.g. in_progress -> warning)", () => { + const { container } = render(); + expect(container.querySelector(".rounded-full.bg-warning")).not.toBeNull(); + }); + + it("omits the animated ping ring when pulse is false (the default)", () => { + const { container } = render(); + expect(container.querySelector(".animate-ping")).toBeNull(); + }); + + it("adds an animated ping ring, colored to match the status, when pulse is true", () => { + const { container } = render(); + const ring = container.querySelector(".animate-ping"); + expect(ring).not.toBeNull(); + expect(ring).toHaveClass("bg-success"); + }); + + it("applies a caller className on the root element (e.g. sizing overrides)", () => { + const { container } = render( + , + ); + expect(container.querySelector(".my-marker-class")).not.toBeNull(); + }); + }); }); diff --git a/packages/ui/src/components/status-pill.tsx b/packages/ui/src/components/status-pill.tsx index d7216888..9a845d42 100644 --- a/packages/ui/src/components/status-pill.tsx +++ b/packages/ui/src/components/status-pill.tsx @@ -38,10 +38,45 @@ export interface StatusPillProps { * while e.g. "connecting" and switch on the ring only once truly live. */ pulse?: boolean; + /** + * PR #81 round-2 convergence Finding 5: "pill" (default) is the original + * API -- full badge chrome (border/background/padding) with `label` + * always rendered as visible text next to the icon/dot. "bare" renders + * ONLY a status-colored dot -- no chrome, no icon, no inline label -- for + * a caller embedding a compact liveness dot in its OWN row layout + * alongside a name and a SEPARATE, conditional text label (e.g. + * StationsCard.tsx's per-station dot: green/fresh renders no text at all, + * amber/stale renders its own "stale Ns" span elsewhere in the row). + * `indicator` is ignored in this variant (bare is always a dot). `label` + * stays required and is applied as `aria-label` on the root element, so + * assistive tech still gets a description even though nothing is drawn. + * `className` merges onto the root element, same as "pill". + */ + variant?: "pill" | "bare"; } -export function StatusPill({ status, label, icon, className, indicator = "icon", pulse = false }: StatusPillProps) { +export function StatusPill({ + status, label, icon, className, indicator = "icon", pulse = false, variant = "pill", +}: StatusPillProps) { const Icon = icon ?? config[status].icon; + + if (variant === "bare") { + return ( + + {pulse ? ( + + ) : null} + + + ); + } + return ( { renderCorrectAt("/events/evt-1/monitor"); expect(await screen.findByText("Kiosk A")).toBeInTheDocument(); - expect(screen.getByTestId("monitor-station-dot-st-1")).toHaveClass("bg-success"); - expect(screen.getByTestId("monitor-station-dot-st-1")).not.toHaveClass("bg-warning"); + // PR #81 round-2 convergence Finding 5: the dot is now composed from + // @idento/ui's StatusPill (variant="bare") -- the testid'd element is + // StationsCard's own wrapper span (same "wrap StatusPill in a testid'd + // span" idiom as the header's monitor-live-pill/monitor-reconnecting- + // badge below), and the color class lives on StatusPill's own inner dot + // node, found via querySelector -- mirrors monitor-live-pill's own + // `.querySelector(".animate-ping")` idiom for reaching into the + // primitive's internals. + const dot1 = screen.getByTestId("monitor-station-dot-st-1").querySelector(".rounded-full"); + expect(dot1).toHaveClass("bg-success"); + expect(dot1).not.toHaveClass("bg-warning"); expect(screen.queryByTestId("monitor-station-stale-st-1")).not.toBeInTheDocument(); expect(screen.queryByText(/stale/i)).not.toBeInTheDocument(); expect(screen.getByText("12")).toBeInTheDocument(); @@ -385,10 +394,33 @@ describe("MonitorPage -- Stations card (liveness)", () => { renderCorrectAt("/events/evt-1/monitor"); expect(await screen.findByText("Mobile 1")).toBeInTheDocument(); - expect(screen.getByTestId("monitor-station-dot-st-2")).toHaveClass("bg-warning"); - expect(screen.getByTestId("monitor-station-dot-st-2")).not.toHaveClass("bg-success"); + const dot2 = screen.getByTestId("monitor-station-dot-st-2").querySelector(".rounded-full"); + expect(dot2).toHaveClass("bg-warning"); + expect(dot2).not.toHaveClass("bg-success"); expect(screen.getByTestId("monitor-station-stale-st-2")).toHaveTextContent(/stale \d+ s/); }); + + // PR #81 round-2 convergence Finding 5: the primitive's `label` (applied + // as `aria-label` by StatusPill's `variant="bare"`) still gives assistive + // tech a description of the dot even for a fresh station, which renders NO + // visible text at all next to it. + it("exposes an accessible label on the dot even when fresh (no visible text rendered)", async () => { + monitorSnapshot = snapshotBody({ + stations: [ + { id: "st-3", name: "Kiosk C", zone_id: null, last_seen_at: new Date().toISOString(), checkin_count: 0 }, + ], + }); + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("Kiosk C"); + // The `aria-label` lives on StatusPill's own bare-variant root node, a + // child of StationsCard's testid'd wrapper span -- same "reach into the + // primitive via querySelector" idiom as the color-class assertions + // above. + const labelled = screen.getByTestId("monitor-station-dot-st-3").querySelector("[aria-label]"); + expect(labelled).not.toBeNull(); + expect(labelled?.getAttribute("aria-label")).not.toBe(""); + }); }); // P4.2 Task 8 -- Last-scans (Recent feed) card: board 7e's own copy is @@ -430,6 +462,11 @@ describe("MonitorPage -- Recent feed card (read-only)", () => { expect(icon).toHaveClass("text-verdict-allowed"); expect(within(screen.getByTestId("monitor-recent-card")).queryAllByRole("button")).toHaveLength(0); + + // PR #81 round-2 convergence Finding 4: the icon alone is `aria-hidden` + // -- a screen reader must still be able to tell this row apart from an + // undo/reprint row via a visually-hidden text label. + expect(screen.getByTestId("monitor-recent-action-act-1")).toHaveTextContent("Checked in"); }); it("renders undo/reprint rows with a neutral muted icon -- asserted as carrying NO verdict color class at all", async () => { @@ -464,6 +501,57 @@ describe("MonitorPage -- Recent feed card (read-only)", () => { expect(Array.from(reprintIcon?.classList ?? []).some((c) => c.startsWith("text-verdict-"))).toBe(false); }); + // PR #81 round-2 convergence Finding 4: undo/reprint rows used to be + // distinguishable from a checkin row ONLY by an `aria-hidden` icon -- a + // screen reader heard just name/zone/time, indistinguishable from a + // check-in. Every one of the three action types now carries its own + // visually-hidden, localized accessible text. + it("gives each of the three action types its own distinguishable accessible text (screen-reader-only)", async () => { + monitorSnapshot = snapshotBody({ + recent: [ + { + id: "act-7", + action: "checkin", + station_id: null, + created_at: "2026-07-18T09:11:00.000Z", + attendee: { id: "att-7", first_name: "Marie", last_name: "Curie", code: "C7" }, + }, + { + id: "act-8", + action: "undo", + station_id: null, + created_at: "2026-07-18T09:12:00.000Z", + attendee: { id: "att-8", first_name: "Niels", last_name: "Bohr", code: "C8" }, + }, + { + id: "act-9", + action: "reprint", + station_id: null, + created_at: "2026-07-18T09:13:00.000Z", + attendee: { id: "att-9", first_name: "Rosalind", last_name: "Yalow", code: "C9" }, + }, + ], + }); + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByTestId("monitor-recent-row-act-7"); + const checkinText = screen.getByTestId("monitor-recent-action-act-7").textContent; + const undoText = screen.getByTestId("monitor-recent-action-act-8").textContent; + const reprintText = screen.getByTestId("monitor-recent-action-act-9").textContent; + + expect(checkinText).toBeTruthy(); + expect(undoText).toBeTruthy(); + expect(reprintText).toBeTruthy(); + // All three distinguishable from each other -- the actual bug (icon-only + // differentiation) let all three read identically to assistive tech. + expect(new Set([checkinText, undoText, reprintText]).size).toBe(3); + + // The label lives in an `sr-only` node, not visible body copy. + expect(screen.getByTestId("monitor-recent-action-act-7")).toHaveClass("sr-only"); + expect(screen.getByTestId("monitor-recent-action-act-8")).toHaveClass("sr-only"); + expect(screen.getByTestId("monitor-recent-action-act-9")).toHaveClass("sr-only"); + }); + it("derives the zone name for a row via its station's zone when derivable, and omits it (no placeholder) when the chain is broken", async () => { monitorSnapshot = snapshotBody({ zones: [{ zone_id: "z-1", name: "Main hall", checked_in: 1 }], diff --git a/panel/src/features/monitor/RecentFeedCard.tsx b/panel/src/features/monitor/RecentFeedCard.tsx index dce18830..4b53dfa0 100644 --- a/panel/src/features/monitor/RecentFeedCard.tsx +++ b/panel/src/features/monitor/RecentFeedCard.tsx @@ -31,6 +31,18 @@ type CheckinActionRow = components["schemas"]["CheckinActionRow"]; type MonitorStationRow = components["schemas"]["MonitorStationRow"]; type MonitorZone = components["schemas"]["MonitorZone"]; +// PR #81 round-2 convergence Finding 4: undo/reprint rows used to be +// distinguishable from a checkin row ONLY by an `aria-hidden` icon -- a +// screen reader heard just name/zone/time, indistinguishable from a +// check-in. Mirrors WorkspaceRail.tsx's own "icon + color alone can't +// convey status to assistive tech (WCAG 1.4.1)" `sr-only` idiom: every row +// gets a real, localized, visually-hidden text label naming its action. +const ACTION_LABEL_KEY: Record = { + checkin: "monitorRecentActionCheckin", + undo: "monitorRecentActionUndo", + reprint: "monitorRecentActionReprint", +}; + export interface RecentFeedCardProps { recent: CheckinActionRow[]; stations: MonitorStationRow[]; @@ -82,6 +94,9 @@ export function RecentFeedCard({ recent, stations, zones }: RecentFeedCardProps) data-testid={`monitor-recent-row-${row.id}`} > + + {t(ACTION_LABEL_KEY[row.action])} + {row.attendee.first_name} {row.attendee.last_name} diff --git a/panel/src/features/monitor/StationsCard.tsx b/panel/src/features/monitor/StationsCard.tsx index 8ba75330..341a370e 100644 --- a/panel/src/features/monitor/StationsCard.tsx +++ b/panel/src/features/monitor/StationsCard.tsx @@ -12,7 +12,18 @@ // MonitorPage's own existing 1s ticker state, passed down so every // stale-duration label in the card advances in lockstep with the header's // "Updated Ns ago" label, off a single shared clock. -import { Card, CardContent, CardHeader, CardTitle } from "@idento/ui"; +// +// PR #81 round-2 convergence Finding 5: the liveness dot itself is composed +// from `@idento/ui`'s `StatusPill` (`variant="bare"`) instead of being +// hand-rolled here -- panel/AGENTS.md's "UI primitives come only from +// @idento/ui" rule, the same discipline the header's own LIVE pill already +// follows (MonitorPage.tsx). The round-1 `indicator="dot"` API always +// renders a visible label next to the dot (its own WCAG 1.4.1 invariant), +// which doesn't fit this compact row (green/fresh shows no text at all, +// amber/stale shows its own separate "stale Ns" span below) -- `variant= +// "bare"` was added to the primitive itself for exactly this shape rather +// than re-hand-rolling a dot a second time here. +import { Card, CardContent, CardHeader, CardTitle, StatusPill } from "@idento/ui"; import { useTranslation } from "react-i18next"; import type { components } from "../../shared/api/schema"; import { stationStaleness } from "./liveness"; @@ -39,17 +50,24 @@ export function StationsCard({ stations, now }: StationsCardProps) { ) : ( stations.map((station) => { const staleness = stationStaleness(station.last_seen_at, now); + // The dot's own accessible label -- exposed via StatusPill's + // `variant="bare"` `aria-label`, since a fresh row renders no + // visible text at all next to the dot. A stale row reuses the + // EXACT same string as the separately-rendered visible + // "stale Ns" span below (not a second, potentially drifting + // copy of the same fact). + const dotLabel = staleness.stale + ? t("monitorStaleFor", { s: staleness.seconds }) + : t("monitorStationFresh"); return (
- + + + {station.name} {staleness.stale ? ( = { Accept: "text/event-stream" }; - const token = getToken(); - if (token) headers.Authorization = `Bearer ${token}`; - try { - const res = await fetch(`${getApiBaseUrl()}/api/events/${eventId}/monitor/stream`, { - headers, + // openSseStream (shared/api/sseStream.ts) owns the fetch, headers, + // and reader/decoder/parse loop; it resolves on a clean stream close + // and throws for everything else (a non-OK response's `ApiError`, a + // network error, a stream read error, or an abort). This hook reacts + // to the decoded frames via `onEvent` and owns every state-machine + // decision below. + await openSseStream(`/api/events/${eventId}/monitor/stream`, { signal: controller.signal, + onEvent: (evt) => { + if (evt.event === "hello") { + // C4: only a "hello" -- proof the stream is actually live, not + // just that the TCP/HTTP handshake succeeded -- resets the + // backoff ladder. Resetting on a bare 200 let an endpoint that + // accepts the connection and then immediately closes it get + // hammered at the 1s base forever. + attempt = 0; + // C5: every hello resyncs the snapshot -- not just a + // reconnect's. A mutation landing between the page's initial + // GET /monitor and THIS connection's subscribe registration + // has no "update" subscriber to notify it, so relying on + // "update" pings alone can silently lose it. Routed through + // the SAME trailing coalescer as "update" frames (not fired + // immediately) so the initial hello -- which lands + // milliseconds after the page's own initial fetch -- doesn't + // turn into an instant, redundant second GET; a genuinely + // racing mutation still surfaces within one coalesce window, + // and a burst of real "update" pings landing in that same + // window is absorbed into this same single refetch. + scheduleInvalidate(); + setStatus("live"); + } else if (evt.event === "update") { + scheduleInvalidate(); + } + }, }); - if (!res.ok) { - // PR #81 bot round + CodeRabbit Finding C3: normalize into the - // SAME `ApiError` shape http.ts's `errors` middleware builds for - // the `api` client -- this hook deliberately bypasses `api`/ - // openapi-fetch for its streaming transport (see this file's own - // top-of-file comment), but that must not also mean bypassing the - // app's global auth/suspension handling every other API failure - // gets. - const apiError = await apiErrorFromResponse(res); - handleApiError(apiError); - if (res.status >= 400 && res.status < 500) { + // The stream closed cleanly (server-side close, e.g. the request + // context ending) -- not an abort. Reconnect per the same policy as + // a network error below. + if (!cancelled) scheduleReconnect(); + } catch (err) { + // An aborted read (unmount or an eventId change tore down + // `controller` mid-flight) must NOT be treated as a stream failure + // -- the scope is gone, there's nothing to reconnect for. + if (controller.signal.aborted) return; + + if (err instanceof ApiError) { + // PR #81 bot round + CodeRabbit Finding C3: route through the app's + // global auth/suspension handling (tenant_suspended takeover, 401 + // dead-session redirect) exactly like every other API failure -- + // openSseStream's transport bypass must not also mean bypassing + // this. + handleApiError(err); + if (err.status >= 400 && err.status < 500) { // Terminal: an expired session (401), a suspended tenant (403 // tenant_suspended -- already actioned above), or any other // documented 4xx (400/404/...) will never succeed on a bare @@ -152,60 +191,9 @@ export function useMonitorStream(eventId: string): { status: MonitorStreamStatus } // 5xx: transient (an overloaded/restarting backend) -- same retry // policy as a network error below. - throw apiError; - } - - if (!res.body) { - throw new Error("monitor stream response has no body"); - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - const feed = createSseParser((evt) => { - if (evt.event === "hello") { - // C4: only a "hello" -- proof the stream is actually live, not - // just that the TCP/HTTP handshake succeeded -- resets the - // backoff ladder. Resetting on a bare 200 let an endpoint that - // accepts the connection and then immediately closes it get - // hammered at the 1s base forever. - attempt = 0; - // C5: every hello resyncs the snapshot -- not just a - // reconnect's. A mutation landing between the page's initial - // GET /monitor and THIS connection's subscribe registration has - // no "update" subscriber to notify it, so relying on "update" - // pings alone can silently lose it. Routed through the SAME - // trailing coalescer as "update" frames (not fired immediately) - // so the initial hello -- which lands milliseconds after the - // page's own initial fetch -- doesn't turn into an instant, - // redundant second GET; a genuinely racing mutation still - // surfaces within one coalesce window, and a burst of real - // "update" pings landing in that same window is absorbed into - // this same single refetch. - scheduleInvalidate(); - setStatus("live"); - } else if (evt.event === "update") { - scheduleInvalidate(); - } - }); - - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - feed(decoder.decode(value, { stream: true })); } - - // The stream closed cleanly (server-side close, e.g. the request - // context ending) -- not an abort. Reconnect per the same policy as - // a network error below. - if (!cancelled) scheduleReconnect(); - } catch { - // An aborted read (unmount or an eventId change tore down - // `controller` mid-flight) must NOT be treated as a stream failure - // -- the scope is gone, there's nothing to reconnect for. Every - // other failure here is retryable (a network error, or the 5xx - // rethrown above) -- a terminal 4xx already returned before - // reaching this catch. - if (controller.signal.aborted) return; + // Every other failure here is retryable (a network error, a stream + // read error, or a 5xx ApiError above). if (!cancelled) scheduleReconnect(); } } diff --git a/panel/src/features/monitor/parseSse.test.ts b/panel/src/shared/api/parseSse.test.ts similarity index 100% rename from panel/src/features/monitor/parseSse.test.ts rename to panel/src/shared/api/parseSse.test.ts diff --git a/panel/src/features/monitor/parseSse.ts b/panel/src/shared/api/parseSse.ts similarity index 73% rename from panel/src/features/monitor/parseSse.ts rename to panel/src/shared/api/parseSse.ts index 86689575..1aadff3a 100644 --- a/panel/src/features/monitor/parseSse.ts +++ b/panel/src/shared/api/parseSse.ts @@ -1,6 +1,14 @@ // Pure, incremental Server-Sent Events frame parser. No fetch, no React — -// Task 6's useMonitorStream owns the fetch/ReadableStream plumbing and just -// feeds decoded text chunks into the function this returns. +// this file's sibling `sseStream.ts` owns the fetch/ReadableStream plumbing +// and just feeds decoded text chunks into the function this returns. +// +// Lives in shared/api/ (moved here from features/monitor/ by the PR #81 +// round-2 convergence, Finding 3) because sseStream.ts's `openSseStream` — +// the shared transport seam every SSE consumer calls instead of `fetch` +// directly — needs it internally, and shared/ must not depend on features/ +// (panel/AGENTS.md's feature-sliced layering). It has no monitor-specific +// knowledge (frame/event/data are the generic SSE wire format), so this move +// is a pure relocation, not a scope change. // // SSE frames are separated by a blank line ("\n\n"); a chunk boundary can // land anywhere (mid-line, mid-frame, mid-separator), so this buffers diff --git a/panel/src/shared/api/sseStream.test.ts b/panel/src/shared/api/sseStream.test.ts new file mode 100644 index 00000000..5dc7f480 --- /dev/null +++ b/panel/src/shared/api/sseStream.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "./ApiError"; +import { openSseStream } from "./sseStream"; + +// PR #81 round-2 convergence, Finding 3 -- transport-level tests for the +// shared SSE seam, moved out of useMonitorStream.test.tsx (which keeps its +// own state-machine-level MSW tests unchanged; the helper still hits the +// same URL). Same "vi.spyOn(globalThis, 'fetch')" idiom as http.test.ts, +// rather than MSW, since this exercises the raw-fetch transport itself, not +// a consumer of it. + +// Same controlled-ReadableStream idiom as useMonitorStream.test.tsx's own +// makeSseStream helper (see that file's comment for the streaming-body +// rationale), duplicated locally rather than shared/imported across a +// features/ <-> shared/api/ boundary. +function makeStream() { + let controllerRef!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller; + }, + }); + const encoder = new TextEncoder(); + return { + stream, + push(frame: string) { + controllerRef.enqueue(encoder.encode(frame)); + }, + close() { + controllerRef.close(); + }, + error(err: unknown = new Error("stream error")) { + controllerRef.error(err); + }, + }; +} + +describe("openSseStream", () => { + beforeEach(() => { + localStorage.clear(); + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.__ENV__ = undefined; + }); + + it("requests the base-URL-qualified path with Accept: text/event-stream and no Authorization when unauthenticated", async () => { + const { stream, close } = makeStream(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(stream, { status: 200 })); + close(); + + const controller = new AbortController(); + await openSseStream("/api/events/evt-1/monitor/stream", { signal: controller.signal, onEvent: () => {} }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("http://api.test/api/events/evt-1/monitor/stream"); + const headers = new Headers(init.headers); + expect(headers.get("Accept")).toBe("text/event-stream"); + expect(headers.get("Authorization")).toBeNull(); + expect(init.signal).toBe(controller.signal); + }); + + it("attaches the Authorization header when a token exists", async () => { + localStorage.setItem("token", "jwt-abc"); + const { stream, close } = makeStream(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(stream, { status: 200 })); + close(); + + const controller = new AbortController(); + await openSseStream("/api/events/evt-1/monitor/stream", { signal: controller.signal, onEvent: () => {} }); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBe("Bearer jwt-abc"); + }); + + it("throws an ApiError built from a non-ok JSON response, without ever reading a body", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ code: "tenant_suspended", error: "Tenant is suspended" }), { status: 403 }), + ); + + const controller = new AbortController(); + await expect( + openSseStream("/api/events/evt-1/monitor/stream", { signal: controller.signal, onEvent: () => {} }), + ).rejects.toMatchObject(new ApiError(403, "tenant_suspended", "Tenant is suspended")); + }); + + it("throws an ApiError with statusText when the error body is not JSON", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("gateway timeout", { status: 502, statusText: "Bad Gateway" }), + ); + + const controller = new AbortController(); + await expect( + openSseStream("/api/events/evt-1/monitor/stream", { signal: controller.signal, onEvent: () => {} }), + ).rejects.toBeInstanceOf(ApiError); + }); + + it("throws when the ok response has no body", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); + + const controller = new AbortController(); + await expect( + openSseStream("/api/events/evt-1/monitor/stream", { signal: controller.signal, onEvent: () => {} }), + ).rejects.toThrow(/no body/); + }); + + it("parses SSE frames delivered on the stream body and dispatches decoded events in order", async () => { + const { stream, push, close } = makeStream(); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(stream, { status: 200 })); + + const events: { event: string; data: string }[] = []; + const controller = new AbortController(); + const promise = openSseStream("/api/events/evt-1/monitor/stream", { + signal: controller.signal, + onEvent: (evt) => events.push(evt), + }); + + push("event: hello\ndata: {}\n\n"); + push('event: update\ndata: {"at":"t1"}\n\n'); + close(); + await promise; + + expect(events).toEqual([ + { event: "hello", data: "{}" }, + { event: "update", data: '{"at":"t1"}' }, + ]); + }); + + it("resolves once the stream closes cleanly", async () => { + const { stream, close } = makeStream(); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(stream, { status: 200 })); + close(); + + const controller = new AbortController(); + await expect( + openSseStream("/api/events/evt-1/monitor/stream", { signal: controller.signal, onEvent: () => {} }), + ).resolves.toBeUndefined(); + }); + + it("propagates a network error thrown by fetch itself", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch")); + + const controller = new AbortController(); + await expect( + openSseStream("/api/events/evt-1/monitor/stream", { signal: controller.signal, onEvent: () => {} }), + ).rejects.toThrow("Failed to fetch"); + }); + + it("propagates an error raised mid-stream by the reader", async () => { + const { stream, error } = makeStream(); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(stream, { status: 200 })); + + const controller = new AbortController(); + const promise = openSseStream("/api/events/evt-1/monitor/stream", { + signal: controller.signal, + onEvent: () => {}, + }); + error(new Error("boom")); + + await expect(promise).rejects.toThrow("boom"); + }); +}); diff --git a/panel/src/shared/api/sseStream.ts b/panel/src/shared/api/sseStream.ts new file mode 100644 index 00000000..1753b7ae --- /dev/null +++ b/panel/src/shared/api/sseStream.ts @@ -0,0 +1,66 @@ +import { apiErrorFromResponse, getApiBaseUrl } from "./http"; +import { createSseParser } from "./parseSse"; +import { getToken } from "./session"; + +export interface SseEvent { + event: string; + data: string; +} + +export interface OpenSseStreamOptions { + signal: AbortSignal; + onEvent: (evt: SseEvent) => void; +} + +// Shared SSE transport seam (PR #81 round-2 convergence, Finding 3). Round 1 +// centralized base-URL/auth/error pieces in shared/api/ but left the raw +// `fetch(...)` call itself inside the feature hook (useMonitorStream.ts), +// which violates panel/AGENTS.md's "never call fetch directly from a +// feature/component" rule. `openapi-fetch` (the `api` client in http.ts) has +// no streaming-body mode, so it can't drive a stream-consume loop -- this +// hand-rolled transport is the one sanctioned exception, but it now lives +// behind this single thin helper instead of being inlined in feature code. +// +// Owns: URL construction off the shared base URL (`getApiBaseUrl`), the +// Authorization header via `getToken()`, the `Accept: text/event-stream` +// header, non-OK -> `ApiError` construction (`apiErrorFromResponse`, the +// exact shape the `api` client itself throws), and the +// reader/decoder/`createSseParser` consume loop. +// +// Resolves once the stream closes cleanly (e.g. the server ends the request +// context) -- an ordinary, expected outcome the caller decides how to react +// to (useMonitorStream.ts treats it as "reconnect"). Rejects with: +// - the constructed `ApiError` for a non-OK response, +// - a plain `Error` if an OK response has no body, +// - whatever `fetch`/the reader itself throws for a network error, a +// stream error, or an aborted request (the caller's own `AbortSignal`). +// Every retry/backoff/terminal-vs-transient DECISION based on any of the +// above belongs to the caller's state machine -- this helper only performs +// the transport and reports what happened, exactly like a "regular" `fetch` +// call would. +export async function openSseStream(path: string, opts: OpenSseStreamOptions): Promise { + const { signal, onEvent } = opts; + + const headers: Record = { Accept: "text/event-stream" }; + const token = getToken(); + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`${getApiBaseUrl()}${path}`, { headers, signal }); + + if (!res.ok) { + throw await apiErrorFromResponse(res); + } + if (!res.body) { + throw new Error("SSE stream response has no body"); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const feed = createSseParser(onEvent); + + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + feed(decoder.decode(value, { stream: true })); + } +} diff --git a/panel/src/shared/i18n/en.json b/panel/src/shared/i18n/en.json index 8e16ef3a..80f8a7b0 100644 --- a/panel/src/shared/i18n/en.json +++ b/panel/src/shared/i18n/en.json @@ -639,5 +639,9 @@ "monitorStaleFor": "stale {{s}} s", "monitorRecentTitle": "Last scans", "monitorRecentEmpty": "No scans yet.", + "monitorStationFresh": "Online", + "monitorRecentActionCheckin": "Checked in", + "monitorRecentActionUndo": "Undone", + "monitorRecentActionReprint": "Reprinted", "monitorReconnecting": "Reconnecting" } diff --git a/panel/src/shared/i18n/ru.json b/panel/src/shared/i18n/ru.json index 4bd4cb3e..e3f94835 100644 --- a/panel/src/shared/i18n/ru.json +++ b/panel/src/shared/i18n/ru.json @@ -641,5 +641,9 @@ "monitorStaleFor": "устарело {{s}} с", "monitorRecentTitle": "Последние сканирования", "monitorRecentEmpty": "Пока нет сканирований.", + "monitorStationFresh": "В сети", + "monitorRecentActionCheckin": "Зарегистрирован", + "monitorRecentActionUndo": "Отменено", + "monitorRecentActionReprint": "Перепечатано", "monitorReconnecting": "Переподключение" } From f0e89050f6a55482e8f3ed4b62e9bd1e0af5e957 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 10:43:17 +0300 Subject: [PATCH 20/23] fix(backend): sanitized LISTEN conn config, current-period zone attribution, attendee-CRUD publishes PR #81 round-3 convergence, three backend findings: - Finding 1 (Codex P1): NewPGBroker's LISTEN connection (initial + reconnect) used a raw pgx.Connect(ctx, dbURL), re-parsing dbURL via pgx.ParseConfig -- a different parser than pgxpool.ParseConfig that leaves pool-only options (pool_max_conns, pool_min_conns) in RuntimeParams, which Postgres rejects as unrecognized startup parameters. Now reuses the pool's own sanitized ConnConfig via pgx.ConnectConfig (Copy()'d per connect, matching pgxpool's own pattern). - Finding 2 (Codex): zone attribution reused a stale 'checkin' action across a legacy clear (no undo row) + legacy re-checkin (no new action row), since the latest state-changing action was still the old 'checkin'. Scoped attribution to the attendee's current check-in period via ls.created_at >= a.checked_in_at, exploiting CheckInAttendee's same-tx now() for checked_in_at and the action row's created_at. - Finding 3 (Codex): CreateAttendee, DeleteAttendee, and BulkCreateAttendees never published to the monitor broker, leaving totals stale on a running event with no station heartbeat. Added publishCheckinEvent calls to all three (bulk: exactly once per batch, gated on createdCount > 0). Co-Authored-By: Claude Sonnet 5 --- backend/internal/broker/broker_test.go | 65 +++ backend/internal/broker/pg_broker.go | 75 +++- .../handler/attendee_crud_publish_test.go | 369 ++++++++++++++++++ backend/internal/handler/attendees.go | 16 + backend/internal/handler/bulk_import.go | 10 + backend/internal/store/pg_store_monitor.go | 52 ++- .../pg_store_monitor_integration_test.go | 96 +++-- .../internal/store/pg_store_monitor_test.go | 53 ++- 8 files changed, 683 insertions(+), 53 deletions(-) create mode 100644 backend/internal/handler/attendee_crud_publish_test.go diff --git a/backend/internal/broker/broker_test.go b/backend/internal/broker/broker_test.go index 3490e5c3..b858473b 100644 --- a/backend/internal/broker/broker_test.go +++ b/backend/internal/broker/broker_test.go @@ -7,6 +7,8 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) // --- MemBroker: routing ------------------------------------------------ @@ -419,6 +421,69 @@ func TestHandleNotification_EmptyPayloadIsLoggedAndSkipped(t *testing.T) { // is — so this one line of "what happens on reconnect success" has a direct // unit test independent of the un-testable network code around it. +// --- prepareListenConnConfig (PR #81 round-3 convergence, Backend Finding +// 1): pool-only URL options must not reach the raw LISTEN connection ------- +// +// Regression coverage for a real deployment-breaking bug: NewPGBroker's +// LISTEN connection used to be established via pgx.Connect(ctx, dbURL) — a +// SECOND, raw parse of dbURL through pgx.ParseConfig, which (unlike +// pgxpool.ParseConfig) has no notion of pgxpool-only options such as +// pool_max_conns/pool_min_conns and leaves them sitting in +// ConnConfig.RuntimeParams. pgx then sends RuntimeParams to Postgres as +// connection startup parameters, and every real Postgres server rejects an +// unrecognized one outright — so a DATABASE_URL tuned with a pool size +// (entirely valid, pgxpool-documented syntax) let the notify POOL connect +// fine while the LISTEN connection was refused, failing process startup. +// This is unit-testable without a live DB because pgxpool.ParseConfig is a +// pure string-parsing operation — no network I/O. +func TestPrepareListenConnConfig_StripsPoolOnlyURLParams(t *testing.T) { + dbURL := "postgres://user:pass@localhost:5432/db?pool_max_conns=5&pool_min_conns=1&sslmode=disable" + + poolCfg, err := pgxpool.ParseConfig(dbURL) + if err != nil { + t.Fatalf("pgxpool.ParseConfig: %v", err) + } + + connConfig := prepareListenConnConfig(poolCfg) + + if _, ok := connConfig.RuntimeParams["pool_max_conns"]; ok { + t.Error("connConfig.RuntimeParams carries pool_max_conns — Postgres would reject this as an unrecognized startup parameter") + } + if _, ok := connConfig.RuntimeParams["pool_min_conns"]; ok { + t.Error("connConfig.RuntimeParams carries pool_min_conns — Postgres would reject this as an unrecognized startup parameter") + } + + // sslmode is a genuine libpq/wire-protocol-recognized option (not a + // pgxpool-only one) — prepareListenConnConfig must not have stripped + // it too. pgx parses sslmode into ConnConfig.TLSConfig, not + // RuntimeParams, so its absence from RuntimeParams doesn't mean it was + // dropped; this just guards against a naive "clear RuntimeParams + // entirely" implementation silently discarding real params should this + // function's implementation ever change. + if connConfig.Host != "localhost" || connConfig.Port != 5432 || connConfig.Database != "db" { + t.Errorf("connConfig host/port/database = %s:%d/%s, want localhost:5432/db (real connection fields must survive)", connConfig.Host, connConfig.Port, connConfig.Database) + } +} + +// TestPrepareListenConnConfig_MatchesRawParseIsBrokenDemonstratesTheBug +// proves the bug this fix closes actually exists in the raw parser +// NewPGBroker used to call directly (pgx.Connect -> pgx.ParseConfig): the +// SAME dbURL, parsed the OLD way, retains the pool-only params in +// RuntimeParams. This is the "before" half of the regression; the "after" +// half is TestPrepareListenConnConfig_StripsPoolOnlyURLParams above. +func TestPrepareListenConnConfig_MatchesRawParseIsBrokenDemonstratesTheBug(t *testing.T) { + dbURL := "postgres://user:pass@localhost:5432/db?pool_max_conns=5&pool_min_conns=1&sslmode=disable" + + rawCfg, err := pgx.ParseConfig(dbURL) + if err != nil { + t.Fatalf("pgx.ParseConfig: %v", err) + } + + if _, ok := rawCfg.RuntimeParams["pool_max_conns"]; !ok { + t.Fatal("expected the raw pgx.ParseConfig path to retain pool_max_conns in RuntimeParams (this is the bug being fixed) — if this fails, pgx's behavior changed and this test's premise needs revisiting") + } +} + func TestHandleReconnectSuccess_BroadcastsToAllCurrentSubscribers(t *testing.T) { mem := NewMemBroker() eventA := uuid.New() diff --git a/backend/internal/broker/pg_broker.go b/backend/internal/broker/pg_broker.go index fa926a81..b2c289a5 100644 --- a/backend/internal/broker/pg_broker.go +++ b/backend/internal/broker/pg_broker.go @@ -49,6 +49,14 @@ type PGBroker struct { pool *pgxpool.Pool cancel context.CancelFunc done chan struct{} + + // connConfig is the sanitized *pgx.ConnConfig the dedicated LISTEN + // connection is (re)established from — see prepareListenConnConfig's + // doc comment (PR #81 round-3 convergence, Backend Finding 1). Stored + // on the broker so listenLoop's reconnect path reuses the exact same + // sanitized config the initial connect used, rather than re-deriving + // it (or, as before this fix, falling back to a raw dbURL re-parse). + connConfig *pgx.ConnConfig } // NewPGBroker connects a dedicated LISTEN connection and a small (MaxConns @@ -73,7 +81,28 @@ func NewPGBroker(ctx context.Context, dbURL string) (*PGBroker, error) { return nil, err } - conn, err := pgx.Connect(ctx, dbURL) + // Finding 1 (PR #81 round-3 convergence): the dedicated LISTEN + // connection must be established from the SAME sanitized ConnConfig + // pgxpool already parsed for the notify pool above — not a second, raw + // pgx.Connect(ctx, dbURL) call. pgx.Connect re-parses dbURL via + // pgx.ParseConfig, a DIFFERENT parser that has no knowledge of + // pgxpool-only URL options (pool_max_conns, pool_min_conns, ...) and + // leaves them sitting in ConnConfig.RuntimeParams, which pgx then sends + // to Postgres as startup parameters — every real server rejects an + // unrecognized one outright. A dbURL tuned with those options would + // make the pool connect fine (ParseConfig above consumes and strips + // them) while this raw connection was refused, breaking process + // startup. prepareListenConnConfig reuses poolCfg's own + // already-sanitized ConnConfig instead of re-deriving a second one. + connConfig := prepareListenConnConfig(poolCfg) + + // pgx.ConnectConfig documents that ConnConfig must come from ParseConfig + // and may be mutated by the connect call itself — Copy() before every + // use (initial connect here, and every reconnect in reconnect() below) + // is the same defensive pattern pgxpool.Pool's own connResource + // constructor uses when it hands the SAME shared ConnConfig to + // pgx.ConnectConfig for each new pooled connection. + conn, err := pgx.ConnectConfig(ctx, connConfig.Copy()) if err != nil { pool.Close() return nil, err @@ -89,17 +118,35 @@ func NewPGBroker(ctx context.Context, dbURL string) (*PGBroker, error) { // long before the broker should stop). loopCtx, cancel := context.WithCancel(context.Background()) b := &PGBroker{ - mem: NewMemBroker(), - pool: pool, - cancel: cancel, - done: make(chan struct{}), + mem: NewMemBroker(), + pool: pool, + connConfig: connConfig, + cancel: cancel, + done: make(chan struct{}), } - go b.listenLoop(loopCtx, conn, dbURL) + go b.listenLoop(loopCtx, conn) return b, nil } +// prepareListenConnConfig derives the *pgx.ConnConfig the dedicated LISTEN +// connection connects with, from an already-parsed *pgxpool.Config (Finding +// 1, PR #81 round-3 convergence). It is factored out of NewPGBroker +// specifically so the sanitization property is unit-testable at this exact +// seam without a live Postgres connection: pgxpool.ParseConfig recognizes +// and strips pool-only URL options (pool_max_conns, pool_min_conns, ...) +// out of poolCfg.ConnConfig.RuntimeParams before they'd otherwise be +// forwarded as unrecognized PostgreSQL startup parameters — see this +// file's pg_broker_test.go-adjacent coverage for the exact before/after +// proof. Trivial today (poolCfg.ConnConfig already IS the sanitized +// config), but named and tested as its own function so the sanitization +// guarantee has a permanent regression test independent of NewPGBroker's +// live-DB-only Ping/Connect calls. +func prepareListenConnConfig(poolCfg *pgxpool.Config) *pgx.ConnConfig { + return poolCfg.ConnConfig +} + // Publish executes `SELECT pg_notify('checkin_events', $1::text)` through // the notify pool. It never touches the LISTEN connection (which is // permanently blocked inside WaitForNotification and cannot also send a @@ -143,7 +190,7 @@ func (b *PGBroker) Close() { // pg_store_checkin_composite_fk_integration_test.go's TEST_DATABASE_URL- // gated (skip-not-fail) test. handleNotification carries everything here // that IS unit-tested. -func (b *PGBroker) listenLoop(ctx context.Context, conn *pgx.Conn, dbURL string) { +func (b *PGBroker) listenLoop(ctx context.Context, conn *pgx.Conn) { defer close(b.done) defer func() { closeConn(conn) }() @@ -159,7 +206,7 @@ func (b *PGBroker) listenLoop(ctx context.Context, conn *pgx.Conn, dbURL string) closeConn(conn) var ok bool - conn, ok = b.reconnect(ctx, dbURL) + conn, ok = b.reconnect(ctx) if !ok { // ctx was cancelled while reconnecting. return @@ -181,10 +228,12 @@ func (b *PGBroker) listenLoop(ctx context.Context, conn *pgx.Conn, dbURL string) } } -// reconnect retries pgx.Connect + LISTEN against dbURL with exponential -// backoff (1s doubling to a 30s cap) until it succeeds or ctx is -// cancelled. The bool return is false only when ctx was cancelled first. -func (b *PGBroker) reconnect(ctx context.Context, dbURL string) (*pgx.Conn, bool) { +// reconnect retries pgx.ConnectConfig + LISTEN against b.connConfig (the +// same sanitized config the initial connect used — Finding 1, PR #81 +// round-3 convergence) with exponential backoff (1s doubling to a 30s cap) +// until it succeeds or ctx is cancelled. The bool return is false only when +// ctx was cancelled first. +func (b *PGBroker) reconnect(ctx context.Context) (*pgx.Conn, bool) { backoff := reconnectBackoffInitial for { select { @@ -193,7 +242,7 @@ func (b *PGBroker) reconnect(ctx context.Context, dbURL string) (*pgx.Conn, bool case <-time.After(backoff): } - conn, err := pgx.Connect(ctx, dbURL) + conn, err := pgx.ConnectConfig(ctx, b.connConfig.Copy()) if err != nil { log.Printf("broker: reconnect failed: %v", err) backoff = nextBackoff(backoff) diff --git a/backend/internal/handler/attendee_crud_publish_test.go b/backend/internal/handler/attendee_crud_publish_test.go new file mode 100644 index 00000000..93ecd4b1 --- /dev/null +++ b/backend/internal/handler/attendee_crud_publish_test.go @@ -0,0 +1,369 @@ +package handler + +import ( + "errors" + "net/http" + "testing" + + "idento/backend/internal/broker" + "idento/backend/internal/models" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// --- PR #81 round-3 convergence, Backend Finding 3: attendee CRUD never +// publishes; totals go stale ------------------------------------------------ +// +// CreateAttendee, DeleteAttendee, and BulkCreateAttendees all change the +// monitor snapshot's `total` (DeleteAttendee can also change `checked_in`, +// for an attendee who was currently checked in) without ever publishing to +// the monitor broker — UpdateAttendee already publishes (round-1 fix, +// legacy_publish_test.go), but a running event with no station heartbeat +// would otherwise leave every attached monitor stale indefinitely after a +// plain add/delete. These tests prove the additive publish these three +// handlers now perform, using publishCheckinEvent (event_publish.go) exactly +// like every other publish site in this package. + +// --- CreateAttendee --------------------------------------------------------- + +func TestCreateAttendee_PublishesOnSuccess(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + createAttendee: func(*models.Attendee) error { return nil }, + logUsage: func(*models.UsageLog) error { return nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/attendees" + c, rec := newAuthedContext(e, http.MethodPost, path, + `{"first_name":"Ada","last_name":"Lovelace","email":"ada@example.com"}`, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/attendees") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.CreateAttendee(c); err != nil { + t.Fatalf("CreateAttendee: %v", err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on a successful create (monitor `total` changed)") + } +} + +func TestCreateAttendee_DoesNotPublishOnStoreFailure(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + createAttendee: func(*models.Attendee) error { return errors.New("boom") }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/attendees" + c, rec := newAuthedContext(e, http.MethodPost, path, + `{"first_name":"Ada","last_name":"Lovelace","email":"ada@example.com"}`, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/attendees") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.CreateAttendee(c); err != nil { + t.Fatalf("CreateAttendee: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false on a store failure") + } +} + +func TestCreateAttendee_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + createAttendee: func(*models.Attendee) error { return nil }, + logUsage: func(*models.UsageLog) error { return nil }, + }) + // h.Broker intentionally left nil. + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/attendees" + c, rec := newAuthedContext(e, http.MethodPost, path, + `{"first_name":"Ada","last_name":"Lovelace","email":"ada@example.com"}`, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/attendees") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.CreateAttendee(c); err != nil { + t.Fatalf("CreateAttendee: %v", err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +// --- DeleteAttendee ---------------------------------------------------------- + +func TestDeleteAttendee_PublishesOnSuccess(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + updateAttendee: func(*models.Attendee) error { return nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/attendees/" + attendee.ID.String() + c, rec := newAuthedContext(e, http.MethodDelete, path, "", tenantID.String(), "admin") + c.SetPath("/api/attendees/:id") + c.SetParamNames("id") + c.SetParamValues(attendee.ID.String()) + + if err := h.DeleteAttendee(c); err != nil { + t.Fatalf("DeleteAttendee: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on a successful delete (monitor `total`/`checked_in` changed)") + } +} + +func TestDeleteAttendee_DoesNotPublishOnStoreFailure(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + updateAttendee: func(*models.Attendee) error { return errors.New("boom") }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/attendees/" + attendee.ID.String() + c, rec := newAuthedContext(e, http.MethodDelete, path, "", tenantID.String(), "admin") + c.SetPath("/api/attendees/:id") + c.SetParamNames("id") + c.SetParamValues(attendee.ID.String()) + + if err := h.DeleteAttendee(c); err != nil { + t.Fatalf("DeleteAttendee: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false on a store failure") + } +} + +func TestDeleteAttendee_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + updateAttendee: func(*models.Attendee) error { return nil }, + }) + // h.Broker intentionally left nil. + + e := echo.New() + path := "/api/attendees/" + attendee.ID.String() + c, rec := newAuthedContext(e, http.MethodDelete, path, "", tenantID.String(), "admin") + c.SetPath("/api/attendees/:id") + c.SetParamNames("id") + c.SetParamValues(attendee.ID.String()) + + if err := h.DeleteAttendee(c); err != nil { + t.Fatalf("DeleteAttendee: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +// --- BulkCreateAttendees ----------------------------------------------------- + +// TestBulkCreateAttendees_PublishesOnceWhenAtLeastOneCreated proves the +// count semantics: a batch of N rows that ALL create successfully still +// produces exactly ONE publish for the batch's single event_id (the endpoint +// is scoped to one event_id from the path), not one per row. +func TestBulkCreateAttendees_PublishesOnceWhenAtLeastOneCreated(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + body := `{"attendees":[` + + `{"first_name":"Ada","last_name":"Lovelace","email":"ada@example.com"},` + + `{"first_name":"Grace","last_name":"Hopper","email":"grace@example.com"}` + + `]}` + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + checkAttendeeLimit: func(uuid.UUID, uuid.UUID, int) (bool, int, int, error) { return true, 0, 100, nil }, + getAttendeesByEventID: func(uuid.UUID, string, string) ([]*models.Attendee, error) { return nil, nil }, + createAttendee: func(*models.Attendee) error { return nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/attendees/bulk" + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/attendees/bulk") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.BulkCreateAttendees(c); err != nil { + t.Fatalf("BulkCreateAttendees: %v", err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true when at least one row was created") + } + // Coalescing (1-buffered channel) already makes a second signal + // undetectable here — same "at least one, never N distinguishable + // pings" proof this package's other batch publish tests rely on (see + // pendingSignal's doc comment / TestBatchCheckin_PublishesOnceWhenAnyItemCreated). +} + +// TestBulkCreateAttendees_DoesNotPublishWhenAllDuplicates proves a batch +// where every row is skipped as a duplicate (createAttendee never called) +// does not signal the monitor — nothing monitor-visible changed. +func TestBulkCreateAttendees_DoesNotPublishWhenAllDuplicates(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + existing := contractAttendee(event.ID) + existing.Email = "ada@example.com" + body := `{"attendees":[{"first_name":"Ada","last_name":"Lovelace","email":"ada@example.com"}]}` + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + checkAttendeeLimit: func(uuid.UUID, uuid.UUID, int) (bool, int, int, error) { return true, 0, 100, nil }, + getAttendeesByEventID: func(uuid.UUID, string, string) ([]*models.Attendee, error) { + return []*models.Attendee{existing}, nil + }, + createAttendee: func(*models.Attendee) error { + t.Fatal("CreateAttendee must not be called for an all-duplicate batch") + return nil + }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/attendees/bulk" + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/attendees/bulk") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.BulkCreateAttendees(c); err != nil { + t.Fatalf("BulkCreateAttendees: %v", err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false when every row was skipped as a duplicate") + } +} + +// TestBulkCreateAttendees_DoesNotPublishOnLimitExceeded proves a +// request-level failure (the whole batch rejected before any row is +// touched) never signals the monitor. +func TestBulkCreateAttendees_DoesNotPublishOnLimitExceeded(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + body := `{"attendees":[{"first_name":"Ada","last_name":"Lovelace","email":"ada@example.com"}]}` + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + checkAttendeeLimit: func(uuid.UUID, uuid.UUID, int) (bool, int, int, error) { return false, 100, 100, nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/attendees/bulk" + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/attendees/bulk") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.BulkCreateAttendees(c); err != nil { + t.Fatalf("BulkCreateAttendees: %v", err) + } + if rec.Code != http.StatusForbidden { + t.Fatalf("want 403, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false when the batch is rejected before any row is created") + } +} + +func TestBulkCreateAttendees_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + body := `{"attendees":[{"first_name":"Ada","last_name":"Lovelace","email":"ada@example.com"}]}` + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + checkAttendeeLimit: func(uuid.UUID, uuid.UUID, int) (bool, int, int, error) { return true, 0, 100, nil }, + getAttendeesByEventID: func(uuid.UUID, string, string) ([]*models.Attendee, error) { return nil, nil }, + createAttendee: func(*models.Attendee) error { return nil }, + }) + // h.Broker intentionally left nil. + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/attendees/bulk" + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/attendees/bulk") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.BulkCreateAttendees(c); err != nil { + t.Fatalf("BulkCreateAttendees: %v", err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d, body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/internal/handler/attendees.go b/backend/internal/handler/attendees.go index a9c53393..86920a21 100644 --- a/backend/internal/handler/attendees.go +++ b/backend/internal/handler/attendees.go @@ -74,6 +74,13 @@ func (h *Handler) CreateAttendee(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create attendee"}) } + // PR #81 round-3 convergence, Backend Finding 3: a new attendee changes + // the monitor's `total` even though checkin_status never touches + // checked_in — the monitor snapshot must still refetch, or an + // in-flight event with no station heartbeat would show a stale total + // indefinitely. + h.publishCheckinEvent(c.Request().Context(), eventID) + // Log usage (best-effort, do not fail request) if err := h.Store.LogUsage(c.Request().Context(), &models.UsageLog{ TenantID: tenantID, @@ -426,5 +433,14 @@ func (h *Handler) DeleteAttendee(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to delete attendee"}) } + // PR #81 round-3 convergence, Backend Finding 3: a deleted attendee + // changes the monitor's `total` (and `checked_in` too, if they were + // currently checked in) — see CreateAttendee's matching publish for the + // same rationale. The panel's "bulk delete" is this SAME single-item + // endpoint called once per selected attendee (BulkBar.tsx's sequential + // loop, not a dedicated batch endpoint), so this one publish site also + // covers that case — one publish per request, N requests for N deletes. + h.publishCheckinEvent(c.Request().Context(), attendee.EventID) + return c.JSON(http.StatusOK, map[string]string{"message": "Attendee deleted successfully"}) } diff --git a/backend/internal/handler/bulk_import.go b/backend/internal/handler/bulk_import.go index c42a9718..61ff9582 100644 --- a/backend/internal/handler/bulk_import.go +++ b/backend/internal/handler/bulk_import.go @@ -243,6 +243,16 @@ func (h *Handler) BulkCreateAttendees(c echo.Context) error { createdCount++ } + // PR #81 round-3 convergence, Backend Finding 3: a bulk import changes + // the monitor's `total` exactly like a single CreateAttendee does, but + // as ONE request creating N attendees — publish exactly ONCE for the + // whole batch (never once per row), and only when at least one row + // actually got created (an all-duplicate/all-error batch changed + // nothing monitor-visible). + if createdCount > 0 { + h.publishCheckinEvent(c.Request().Context(), eventID) + } + response := BulkImportResponse{ Message: "Bulk import completed", Created: createdCount, diff --git a/backend/internal/store/pg_store_monitor.go b/backend/internal/store/pg_store_monitor.go index 6eff60f7..67c8f8d1 100644 --- a/backend/internal/store/pg_store_monitor.go +++ b/backend/internal/store/pg_store_monitor.go @@ -34,15 +34,36 @@ import ( // row (e.g. the legacy PUT /api/attendees/{id}, or a mobile batch // write) is currently checked in but their latest ACTION is the // 'undo' — they must fall to unattributed, not be attributed to -// station A's zone from the now-superseded 'checkin' row. +// station A's zone from the now-superseded 'checkin' row. ca.created_at +// is also carried through here (not just action/station_id) to support +// part 3's current-period guard below. // 3. attributed: one row per CURRENTLY checked-in attendee (checkin_status // = true AND deleted_at IS NULL), LEFT JOINed to latest_state and then // to checkin_stations — but the checkin_stations join only fires when -// latest_state.action = 'checkin' (see the join's extra ON-clause -// predicate), so an attendee whose latest state-changing action is -// 'undo' carries zone_id = NULL (unattributed) even though a 'checkin' -// row still physically exists in their history. This is the row set -// the zones/unattributed halves are BOTH aggregated from. +// latest_state.action = 'checkin' AND that action belongs to the +// attendee's CURRENT check-in period (ls.created_at >= a.checked_in_at +// — PR #81 round-3 convergence, Backend Finding 2), so an attendee +// whose latest state-changing action is 'undo' carries zone_id = NULL +// (unattributed) even though a 'checkin' row still physically exists in +// their history. The current-period guard closes a narrower gap A2 +// alone doesn't: attendee checked in at station A (writes a 'checkin' +// action), cleared via a LEGACY path that writes NO 'undo' row (e.g. +// attendee PUT, or a raw sync write), then re-checked-in via a path +// that ALSO writes no action row — the latest state-changing action is +// still that OLD 'checkin' row, so without the guard it would be +// wrongly attributed to station A's zone for a check-in it never +// actually observed. It works because CheckInAttendee's guarded UPDATE +// (pg_store.go's checkInAttendeeGuardedUpdateSQL) sets checked_in_at = +// now() and checkinActionInsertSQL's created_at DEFAULT now() run in +// the SAME transaction — Postgres's now() is transaction-stable, so +// they're EXACTLY equal for every legitimate station attribution, while +// any action predating a legacy re-checkin's fresh checked_in_at falls +// outside the >= comparison and is excluded. a.checked_in_at IS NOT +// NULL is required defensively too: a checked-in row with a null +// checked_in_at (should not happen, but the column has always been +// nullable — see migration 000001) reads as unattributed rather than +// comparing NULL >= NULL (which SQL never treats as true). This is the +// row set the zones/unattributed halves are BOTH aggregated from. // 4. The final SELECT/UNION ALL: one row per event_zones row (LEFT JOIN // FROM event_zones so a zone with zero currently-checked-in attendees // still appears with checked_in = 0), UNION ALL with one row for the @@ -62,7 +83,7 @@ const monitorOverviewSQL = ` WHERE event_id = $1 AND deleted_at IS NULL ), latest_state AS ( - SELECT DISTINCT ON (ca.attendee_id) ca.attendee_id, ca.action, ca.station_id + SELECT DISTINCT ON (ca.attendee_id) ca.attendee_id, ca.action, ca.station_id, ca.created_at FROM checkin_actions ca WHERE ca.event_id = $1 AND ca.action IN ('checkin', 'undo') ORDER BY ca.attendee_id, ca.created_at DESC, ca.id DESC @@ -71,7 +92,10 @@ const monitorOverviewSQL = ` SELECT a.id AS attendee_id, cs.zone_id AS zone_id FROM attendees a LEFT JOIN latest_state ls ON ls.attendee_id = a.id - LEFT JOIN checkin_stations cs ON cs.id = ls.station_id AND ls.action = 'checkin' + LEFT JOIN checkin_stations cs ON cs.id = ls.station_id + AND ls.action = 'checkin' + AND a.checked_in_at IS NOT NULL + AND ls.created_at >= a.checked_in_at WHERE a.event_id = $1 AND a.checkin_status = true AND a.deleted_at IS NULL ) SELECT 'zone' AS row_kind, ez.id AS zone_id, ez.name, COUNT(attributed.attendee_id) AS count, ez.order_index AS sort_key, NULL::int AS total @@ -103,9 +127,15 @@ const monitorOverviewSQL = ` // LATER 'undo' supersedes it (Finding A2) — DISTINCT ON (ca.attendee_id) // over ('checkin', 'undo') actions, ORDER BY ca.attendee_id, ca.created_at // DESC, ca.id DESC (the same id tie-breaker as GetCheckinActions, PR #77 -// bot-review round Finding E) — joined through checkin_stations.zone_id to -// event_zones; a checked-in attendee with no 'checkin' action row, whose -// latest state-changing action is 'undo', a station-less action, or a +// bot-review round Finding E) — AND only when that action falls within the +// attendee's CURRENT check-in period, i.e. ca.created_at >= +// attendees.checked_in_at (PR #81 round-3 convergence, Backend Finding 2: +// a legacy clear + legacy re-checkin, neither of which writes an action +// row, must not inherit attribution from a now-stale 'checkin' action that +// predates the CURRENT check-in) — joined through checkin_stations.zone_id +// to event_zones; a checked-in attendee with no 'checkin' action row, whose +// latest state-changing action is 'undo', whose only 'checkin' action +// predates their current check-in period, a station-less action, or a // zone-less station all count into unattributed rather than any zone. // Zones are listed in event_zones.order_index order and INCLUDE zero-count // zones (LEFT JOIN FROM event_zones, not the other way around, so an empty diff --git a/backend/internal/store/pg_store_monitor_integration_test.go b/backend/internal/store/pg_store_monitor_integration_test.go index 5ab06c26..c3a3e2c5 100644 --- a/backend/internal/store/pg_store_monitor_integration_test.go +++ b/backend/internal/store/pg_store_monitor_integration_test.go @@ -143,18 +143,34 @@ func TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction(t *testing // currently true, but the latest STATE-CHANGING action is the 'undo' — // attribution must fall to unattributed, NOT to station1/zoneOne from // the now-superseded 'checkin' row. + // A8: checked in, legacy-clear-then-legacy-re-checkin (PR #81 round-3 + // convergence, Backend Finding 2) — checked in at station1/zoneOne + // (writes a 'checkin' action), cleared via a LEGACY path that writes NO + // 'undo' row (e.g. attendee PUT, or a raw sync write — simulated here by + // a direct UPDATE), then re-checked-in via ANOTHER legacy path that also + // writes NO new checkin_actions row, with a FRESH checked_in_at. The + // latest STATE-CHANGING action is still the OLD 'checkin' row (unlike + // A7, there's no 'undo' to flip latest_state.action away from + // 'checkin'), so Finding A2's undo-supersedes guard alone does not catch + // this — only the current-period guard (ls.created_at >= + // a.checked_in_at) does: A8's one checkin_actions row predates A8's + // fresh checked_in_at, so attribution must fall to unattributed rather + // than reattributing to station1/zoneOne for a check-in it never + // actually observed. attendees := []struct { - id uuid.UUID - checkedIn bool - deleted bool + id uuid.UUID + checkedIn bool + deleted bool + checkedInAt time.Time }{ - {uuid.New(), true, false}, // A1 - {uuid.New(), true, false}, // A2 - {uuid.New(), true, false}, // A3 - {uuid.New(), true, false}, // A4 - {uuid.New(), false, false}, // A5 - {uuid.New(), true, true}, // A6 - {uuid.New(), false, false}, // A7 (flipped to checked-in below via a direct UPDATE) + {uuid.New(), true, false, now.Add(-1 * time.Minute)}, // A1 (matches its winning, most-recent checkin action below) + {uuid.New(), true, false, now.Add(-5 * time.Minute)}, // A2 (matches its single checkin action below) + {uuid.New(), true, false, now.Add(-3 * time.Minute)}, // A3 (matches its single checkin action below) + {uuid.New(), true, false, now}, // A4 (irrelevant: latest action is 'undo', join never fires) + {uuid.New(), false, false, now}, // A5 (irrelevant: not checked in) + {uuid.New(), true, true, now}, // A6 (irrelevant: soft-deleted) + {uuid.New(), false, false, now}, // A7 (flipped to checked-in below via a direct UPDATE) + {uuid.New(), false, false, now}, // A8 (flipped to checked-in below via direct UPDATEs) } for i, a := range attendees { var deletedAt *time.Time @@ -164,12 +180,12 @@ func TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction(t *testing if _, err := pool.Exec(ctx, `INSERT INTO attendees (id, event_id, first_name, last_name, code, checkin_status, checked_in_at, deleted_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)`, - a.id, eventID, "A", uuid.New().String()[:8], "CODE-"+uuid.New().String()[:8], a.checkedIn, now, deletedAt, now, + a.id, eventID, "A", uuid.New().String()[:8], "CODE-"+uuid.New().String()[:8], a.checkedIn, a.checkedInAt, deletedAt, now, ); err != nil { t.Fatalf("insert attendee[%d]: %v", i, err) } } - a1, a2, a3, a4, a7 := attendees[0].id, attendees[1].id, attendees[2].id, attendees[3].id, attendees[6].id + a1, a2, a3, a4, a7, a8 := attendees[0].id, attendees[1].id, attendees[2].id, attendees[3].id, attendees[6].id, attendees[7].id insertAction := func(attendeeID uuid.UUID, stationID *uuid.UUID, action string, createdAt time.Time) { t.Helper() @@ -184,9 +200,10 @@ func TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction(t *testing insertAction(a1, &station1, "checkin", now.Add(-1*time.Minute)) // wins: most recent insertAction(a2, &station2, "checkin", now.Add(-5*time.Minute)) insertAction(a3, &stationless, "checkin", now.Add(-3*time.Minute)) - insertAction(a4, &station1, "undo", now.Add(-2*time.Minute)) // no 'checkin' row for A4 at all - insertAction(a7, &station1, "checkin", now.Add(-8*time.Minute)) // superseded by the undo below - insertAction(a7, &station1, "undo", now.Add(-6*time.Minute)) // latest state-changing action for A7 + insertAction(a4, &station1, "undo", now.Add(-2*time.Minute)) // no 'checkin' row for A4 at all + insertAction(a7, &station1, "checkin", now.Add(-8*time.Minute)) // superseded by the undo below + insertAction(a7, &station1, "undo", now.Add(-6*time.Minute)) // latest state-changing action for A7 + insertAction(a8, &station1, "checkin", now.Add(-20*time.Minute)) // A8's only action — predates its fresh re-checkin below // Legacy re-checkin: flips checkin_status back to true WITHOUT writing a // new checkin_actions row — the scenario Finding A2 targets. @@ -197,15 +214,36 @@ func TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction(t *testing t.Fatalf("legacy re-checkin UPDATE for A7: %v", err) } + // A8: legacy clear (checkin_status -> false, checked_in_at -> NULL, + // WITHOUT writing an 'undo' row) followed by a legacy re-checkin + // (checkin_status -> true with a FRESH checked_in_at, WITHOUT writing a + // new 'checkin' row) — the exact scenario Backend Finding 2 targets. + // Unlike A7, A8's latest_state.action is STILL 'checkin' (there's no + // 'undo' row at all), so only the current-period guard (ls.created_at + // >= a.checked_in_at) — not Finding A2's undo-supersedes guard — can + // catch this. + if _, err := pool.Exec(ctx, + `UPDATE attendees SET checkin_status = false, checked_in_at = NULL WHERE id = $1`, + a8, + ); err != nil { + t.Fatalf("legacy clear UPDATE for A8: %v", err) + } + if _, err := pool.Exec(ctx, + `UPDATE attendees SET checkin_status = true, checked_in_at = $2 WHERE id = $1`, + a8, now, + ); err != nil { + t.Fatalf("legacy re-checkin UPDATE for A8: %v", err) + } + total, checkedIn, zones, unattributed, err := s.GetMonitorOverview(ctx, eventID) if err != nil { t.Fatalf("GetMonitorOverview: %v", err) } - if total != 6 { - t.Errorf("total = %d, want 6 (excludes the soft-deleted A6)", total) + if total != 7 { + t.Errorf("total = %d, want 7 (excludes the soft-deleted A6)", total) } - if checkedIn != 5 { - t.Errorf("checkedIn = %d, want 5 (A1-A4, A7; A5 not checked in, A6 soft-deleted)", checkedIn) + if checkedIn != 6 { + t.Errorf("checkedIn = %d, want 6 (A1-A4, A7, A8; A5 not checked in, A6 soft-deleted)", checkedIn) } if len(zones) != 3 { @@ -221,8 +259,8 @@ func TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction(t *testing if zones[2].ZoneID != zoneEmpty || zones[2].CheckedIn != 0 { t.Errorf("zones[2] = %+v, want the empty zone with CheckedIn=0 (zero-count zones must still be listed)", zones[2]) } - if unattributed != 3 { - t.Errorf("unattributed = %d, want 3 (A3: station-less action; A4: no 'checkin' action row at all; A7: latest state-changing action is 'undo')", unattributed) + if unattributed != 4 { + t.Errorf("unattributed = %d, want 4 (A3: station-less action; A4: no 'checkin' action row at all; A7: latest state-changing action is 'undo'; A8: latest 'checkin' action predates its fresh checked_in_at)", unattributed) } sum := 0 @@ -244,8 +282,8 @@ func TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction(t *testing t.Errorf("buckets not strictly ascending at index %d: %v then %v", i, buckets[i-1].Minute, b.Minute) } } - if bucketTotal != 5 { - t.Errorf("sum of bucket counts = %d, want 5 (A1 has 2 'checkin' rows, A2/A3/A7 have 1 each; A4's only action and A7's second action are 'undo', excluded)", bucketTotal) + if bucketTotal != 6 { + t.Errorf("sum of bucket counts = %d, want 6 (A1 has 2 'checkin' rows, A2/A3/A7/A8 have 1 each; A4's only action and A7's second action are 'undo', excluded)", bucketTotal) } // CountRecentCheckins (PR #81 bot-review round, Finding A3) must agree @@ -271,11 +309,13 @@ func TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction(t *testing for _, st := range stations { byID[st.ID] = st } - // Station 1 received A1's newer 'checkin', A4's 'undo', and A7's - // 'checkin'+'undo' pair — the FILTER must count only the two 'checkin' - // rows (A1's and A7's). - if got := byID[station1].CheckinCount; got != 2 { - t.Errorf("station1.CheckinCount = %d, want 2 (the 'undo' rows must not inflate it)", got) + // Station 1 received A1's newer 'checkin', A4's 'undo', A7's + // 'checkin'+'undo' pair, and A8's 'checkin' — the FILTER must count only + // the three 'checkin' rows (A1's, A7's, A8's); station attribution + // (which the monitor overview computes separately) is irrelevant to + // this raw per-station action count. + if got := byID[station1].CheckinCount; got != 3 { + t.Errorf("station1.CheckinCount = %d, want 3 (the 'undo' rows must not inflate it)", got) } // Station 2 received A1's older 'checkin' AND A2's 'checkin'. if got := byID[station2].CheckinCount; got != 2 { diff --git a/backend/internal/store/pg_store_monitor_test.go b/backend/internal/store/pg_store_monitor_test.go index 0a37c62e..24cf4ad9 100644 --- a/backend/internal/store/pg_store_monitor_test.go +++ b/backend/internal/store/pg_store_monitor_test.go @@ -28,7 +28,7 @@ import ( // row_kind column — so sum(zone rows)+unattributed == checked_in holds by // construction: all four numbers come out of the SAME statement's // snapshot. -const getMonitorOverviewSQL = `WITH counts AS \(\s+SELECT COUNT\(\*\) AS total, COUNT\(\*\) FILTER \(WHERE checkin_status\) AS checked_in\s+FROM attendees\s+WHERE event_id = \$1 AND deleted_at IS NULL\s+\),\s+latest_state AS \(\s+SELECT DISTINCT ON \(ca\.attendee_id\) ca\.attendee_id, ca\.action, ca\.station_id\s+FROM checkin_actions ca\s+WHERE ca\.event_id = \$1 AND ca\.action IN \('checkin', 'undo'\)\s+ORDER BY ca\.attendee_id, ca\.created_at DESC, ca\.id DESC\s+\),\s+attributed AS \(\s+SELECT a\.id AS attendee_id, cs\.zone_id AS zone_id\s+FROM attendees a\s+LEFT JOIN latest_state ls ON ls\.attendee_id = a\.id\s+LEFT JOIN checkin_stations cs ON cs\.id = ls\.station_id AND ls\.action = 'checkin'\s+WHERE a\.event_id = \$1 AND a\.checkin_status = true AND a\.deleted_at IS NULL\s+\)\s+SELECT 'zone' AS row_kind, ez\.id AS zone_id, ez\.name, COUNT\(attributed\.attendee_id\) AS count, ez\.order_index AS sort_key, NULL::int AS total\s+FROM event_zones ez\s+LEFT JOIN attributed ON attributed\.zone_id = ez\.id\s+WHERE ez\.event_id = \$1\s+GROUP BY ez\.id, ez\.name, ez\.order_index\s+UNION ALL\s+SELECT 'unattributed', NULL, NULL, COUNT\(\*\), NULL, NULL\s+FROM attributed\s+WHERE attributed\.zone_id IS NULL\s+UNION ALL\s+SELECT 'totals', NULL, NULL, counts\.checked_in, NULL, counts\.total\s+FROM counts\s+ORDER BY sort_key NULLS LAST` +const getMonitorOverviewSQL = `WITH counts AS \(\s+SELECT COUNT\(\*\) AS total, COUNT\(\*\) FILTER \(WHERE checkin_status\) AS checked_in\s+FROM attendees\s+WHERE event_id = \$1 AND deleted_at IS NULL\s+\),\s+latest_state AS \(\s+SELECT DISTINCT ON \(ca\.attendee_id\) ca\.attendee_id, ca\.action, ca\.station_id, ca\.created_at\s+FROM checkin_actions ca\s+WHERE ca\.event_id = \$1 AND ca\.action IN \('checkin', 'undo'\)\s+ORDER BY ca\.attendee_id, ca\.created_at DESC, ca\.id DESC\s+\),\s+attributed AS \(\s+SELECT a\.id AS attendee_id, cs\.zone_id AS zone_id\s+FROM attendees a\s+LEFT JOIN latest_state ls ON ls\.attendee_id = a\.id\s+LEFT JOIN checkin_stations cs ON cs\.id = ls\.station_id\s+AND ls\.action = 'checkin'\s+AND a\.checked_in_at IS NOT NULL\s+AND ls\.created_at >= a\.checked_in_at\s+WHERE a\.event_id = \$1 AND a\.checkin_status = true AND a\.deleted_at IS NULL\s+\)\s+SELECT 'zone' AS row_kind, ez\.id AS zone_id, ez\.name, COUNT\(attributed\.attendee_id\) AS count, ez\.order_index AS sort_key, NULL::int AS total\s+FROM event_zones ez\s+LEFT JOIN attributed ON attributed\.zone_id = ez\.id\s+WHERE ez\.event_id = \$1\s+GROUP BY ez\.id, ez\.name, ez\.order_index\s+UNION ALL\s+SELECT 'unattributed', NULL, NULL, COUNT\(\*\), NULL, NULL\s+FROM attributed\s+WHERE attributed\.zone_id IS NULL\s+UNION ALL\s+SELECT 'totals', NULL, NULL, counts\.checked_in, NULL, counts\.total\s+FROM counts\s+ORDER BY sort_key NULLS LAST` // monitorOverviewRows builds a pgxmock row set with the 6 columns // GetMonitorOverview scans: row_kind, zone_id, name, count, sort_key, total. @@ -139,6 +139,57 @@ func TestGetMonitorOverviewZeroAttendeeEventReturnsZeros(t *testing.T) { } } +// TestGetMonitorOverviewLegacyReCheckinScenarioReadsUnattributedRow proves +// the SQL/scanning half of PR #81 round-3 convergence Backend Finding 2 at +// the mock layer: a legacy clear (no 'undo' row written) followed by a +// legacy re-checkin (no new 'checkin' row written, fresh checked_in_at) is +// the scenario the current-period guard (ls.created_at >= a.checked_in_at) +// targets — the attendee's ONLY checkin_actions row now predates their +// current check-in period, so Postgres's real join (proved end-to-end by +// TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction, which +// pgxmock cannot execute — it only echoes back rows it's told to return) +// would leave that attendee unattributed rather than misattributing them to +// their stale station. This test proves GetMonitorOverview issues the +// UPDATED statement text (getMonitorOverviewSQL, which now includes the +// checked_in_at guard) and correctly scans a row set matching that +// corrected outcome: one checked-in attendee, zero zone attribution, one +// unattributed. +func TestGetMonitorOverviewLegacyReCheckinScenarioReadsUnattributedRow(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + zoneA := uuid.New() + + mock.ExpectQuery(getMonitorOverviewSQL). + WithArgs(eventID). + WillReturnRows(monitorOverviewRows(). + AddRow("zone", &zoneA, strPtr("Zone A"), 0, intPtr(1), (*int)(nil)). + AddRow("unattributed", (*uuid.UUID)(nil), (*string)(nil), 1, (*int)(nil), (*int)(nil)). + AddRow("totals", (*uuid.UUID)(nil), (*string)(nil), 1, (*int)(nil), intPtr(1))) + + s := &PGStore{db: mock} + total, checkedIn, zones, unattributed, err := s.GetMonitorOverview(context.Background(), eventID) + if err != nil { + t.Fatalf("GetMonitorOverview: %v", err) + } + if total != 1 || checkedIn != 1 { + t.Errorf("total=%d checkedIn=%d, want 1, 1", total, checkedIn) + } + if len(zones) != 1 || zones[0].CheckedIn != 0 { + t.Fatalf("zones = %+v, want Zone A with CheckedIn=0 (the stale checkin must NOT attribute here)", zones) + } + if unattributed != 1 { + t.Errorf("unattributed = %d, want 1 (legacy re-checkin whose only 'checkin' action predates checked_in_at)", unattributed) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations (SQL text must include the checked_in_at guard): %v", err) + } +} + // getMonitorMinuteBucketsSQL matches GetMonitorMinuteBuckets' exact SELECT // — date_trunc('minute', created_at) GROUP BY, restricted to 'checkin' // actions at/after $2, ascending. From 1881059cabdca8c96d7496b0b32191cccd2d92e3 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 10:43:34 +0300 Subject: [PATCH 21/23] fix(ui): bare StatusPill real sr-only text + visible online label for station liveness PR #81 round-3 convergence, UI Finding 4 (CodeRabbit + Codex, two facets of the round-2 variant="bare" StatusPill): - Codex facet: the dot's aria-label sat on a generic, non-focusable , which many assistive-tech paths don't reliably announce. bare now renders the label as real sr-only DOM text on a nested span instead, matching the sr-only idiom already used by RecentFeedCard.tsx/WorkspaceRail.tsx. - CodeRabbit facet: a bare colored dot with no visible text violates "never color alone" for sighted colorblind users -- the fresh station row rendered no text at all. StationsCard.tsx now also renders its own visible, muted status word next to a fresh row's dot (new monitorStationOnline i18n key, EN+RU), mirroring the stale row's pre-existing visible "stale Ns" span. Deliberately deviates from board 7e's text-free fresh-row spec -- the codified never-color-alone rule governs. Co-Authored-By: Claude Sonnet 5 --- .../ui/src/components/status-pill.test.tsx | 44 ++++++++++++++---- packages/ui/src/components/status-pill.tsx | 30 +++++++++---- .../src/features/monitor/MonitorPage.test.tsx | 43 ++++++++++++++---- panel/src/features/monitor/StationsCard.tsx | 45 ++++++++++++++----- panel/src/shared/i18n/en.json | 1 + panel/src/shared/i18n/ru.json | 1 + 6 files changed, 127 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/components/status-pill.test.tsx b/packages/ui/src/components/status-pill.test.tsx index 555a11aa..cc8b5d6d 100644 --- a/packages/ui/src/components/status-pill.test.tsx +++ b/packages/ui/src/components/status-pill.test.tsx @@ -66,14 +66,31 @@ describe("StatusPill", () => { // PR #81 round-2 convergence Finding 5: StationsCard.tsx's per-station // liveness dot needs the SAME status-colored dot as indicator="dot" above, // but with NO pill chrome (border/background/padding) and NO always- - // visible label -- the row around it already supplies its own name and a - // SEPARATE, conditional text label (e.g. "stale 40 s", rendered only while - // stale; nothing renders at all while fresh). Added as `variant="bare"` - // rather than the panel hand-rolling a dot a second time. + // VISIBLE label -- the row around it already supplies its own name and a + // SEPARATE, conditional visible text label (e.g. "stale 40 s", rendered + // only while stale). Added as `variant="bare"` rather than the panel + // hand-rolling a dot a second time. + // + // PR #81 round-3 convergence, UI Finding 4 (CodeRabbit + Codex): the round-2 + // shape above shipped with two accessibility gaps, both closed here: + // (a, CodeRabbit) a bare colored dot with NO text anywhere (visible or + // not) violates "never color alone" for sighted colorblind users -- the + // FRESH station row rendered no text at all. + // (b, Codex) the label was exposed via `aria-label` on a generic, + // non-focusable `` -- many assistive-tech paths don't reliably + // announce aria-label on a plain span with no ARIA role, so fresh + // stations could announce nothing. + // The fix: `bare` now renders the label as REAL, visually-hidden DOM text + // (an `sr-only` span -- the same idiom as WorkspaceRail.tsx/ + // RecentFeedCard.tsx elsewhere in this codebase), not an aria-label on the + // root. That closes (b) at the primitive level; (a) is closed one layer up + // by StationsCard.tsx additionally rendering its own VISIBLE muted status + // word next to a fresh row (see StationsCard.test.tsx) -- the primitive + // itself intentionally stays label-optional/caller-composed for (a), since + // "always show visible text" isn't true for every bare consumer. describe("variant=\"bare\"", () => { - it("renders only the status-colored dot -- no pill chrome, no visible label text, no icon", () => { + it("renders the status-colored dot -- no pill chrome, no icon", () => { const { container } = render(); - expect(screen.queryByText("Fresh")).not.toBeInTheDocument(); expect(container.querySelector("svg")).toBeNull(); expect(container.querySelector(".rounded-full.bg-success")).not.toBeNull(); // No pill chrome (border/background/padding) anywhere in the tree. @@ -81,9 +98,18 @@ describe("StatusPill", () => { expect(container.querySelector(".px-2\\.5")).toBeNull(); }); - it("exposes the label via aria-label, since no visible text is rendered", () => { - render(); - expect(screen.getByLabelText("Stale 40 s")).toBeInTheDocument(); + it("exposes the label as real, visually-hidden (sr-only) DOM text -- not aria-label on a generic span", () => { + const { container } = render(); + // The text is genuinely present in the DOM (findable by screen-reader + // AND by a plain DOM/testing-library text query), inside an sr-only + // element -- not merely attached as an aria-label attribute that a + // generic, non-focusable span can leave unannounced. + const labelNode = screen.getByText("Stale 40 s"); + expect(labelNode).toBeInTheDocument(); + expect(labelNode).toHaveClass("sr-only"); + // The unreliable aria-label-on-generic-span pattern must be gone + // entirely -- nothing in the bare tree carries an aria-label anymore. + expect(container.querySelector("[aria-label]")).toBeNull(); }); it("colors the dot to match the given status (e.g. in_progress -> warning)", () => { diff --git a/packages/ui/src/components/status-pill.tsx b/packages/ui/src/components/status-pill.tsx index 9a845d42..25364869 100644 --- a/packages/ui/src/components/status-pill.tsx +++ b/packages/ui/src/components/status-pill.tsx @@ -42,14 +42,22 @@ export interface StatusPillProps { * PR #81 round-2 convergence Finding 5: "pill" (default) is the original * API -- full badge chrome (border/background/padding) with `label` * always rendered as visible text next to the icon/dot. "bare" renders - * ONLY a status-colored dot -- no chrome, no icon, no inline label -- for - * a caller embedding a compact liveness dot in its OWN row layout - * alongside a name and a SEPARATE, conditional text label (e.g. - * StationsCard.tsx's per-station dot: green/fresh renders no text at all, - * amber/stale renders its own "stale Ns" span elsewhere in the row). - * `indicator` is ignored in this variant (bare is always a dot). `label` - * stays required and is applied as `aria-label` on the root element, so - * assistive tech still gets a description even though nothing is drawn. + * ONLY a status-colored dot plus its accessible label -- no chrome, no + * icon -- for a caller embedding a compact liveness dot in its OWN row + * layout alongside a name and (per PR #81 round-3 convergence, UI + * Finding 4) its OWN separately-composed visible status text. + * `indicator` is ignored in this variant (bare is always a dot). + * + * `label` stays required, but (Finding 4, Codex facet) is rendered as + * REAL visually-hidden (`sr-only`) DOM text on a nested span -- not as an + * `aria-label` attribute on the generic, non-focusable root ``, + * which many assistive-tech paths don't reliably announce. The dot itself + * carries no visible text (Finding 4, CodeRabbit facet: a colorblind + * sighted user still gets nothing from the dot alone) -- callers that + * need a color-independent VISIBLE cue must render their own text next to + * `bare` (see StationsCard.tsx, which does exactly this for its fresh/ + * stale station rows). + * * `className` merges onto the root element, same as "pill". */ variant?: "pill" | "bare"; @@ -62,7 +70,7 @@ export function StatusPill({ if (variant === "bare") { return ( - + {pulse ? ( ) : null} + {/* Finding 4 (Codex facet): real DOM text, not aria-label on this + generic span -- matches the sr-only idiom already used by + RecentFeedCard.tsx/WorkspaceRail.tsx elsewhere in this codebase. */} + {label} ); } diff --git a/panel/src/features/monitor/MonitorPage.test.tsx b/panel/src/features/monitor/MonitorPage.test.tsx index f9efd972..0e43b300 100644 --- a/panel/src/features/monitor/MonitorPage.test.tsx +++ b/panel/src/features/monitor/MonitorPage.test.tsx @@ -382,6 +382,11 @@ describe("MonitorPage -- Stations card (liveness)", () => { expect(dot1).not.toHaveClass("bg-warning"); expect(screen.queryByTestId("monitor-station-stale-st-1")).not.toBeInTheDocument(); expect(screen.queryByText(/stale/i)).not.toBeInTheDocument(); + // PR #81 round-3 convergence, UI Finding 4 (CodeRabbit facet): a fresh + // row now ALSO renders its own visible muted "Online" status word next + // to the dot -- the green dot is never the sole channel conveying + // liveness (never color alone). + expect(screen.getByTestId("monitor-station-online-st-1")).toHaveTextContent("Online"); expect(screen.getByText("12")).toBeInTheDocument(); }); @@ -400,11 +405,13 @@ describe("MonitorPage -- Stations card (liveness)", () => { expect(screen.getByTestId("monitor-station-stale-st-2")).toHaveTextContent(/stale \d+ s/); }); - // PR #81 round-2 convergence Finding 5: the primitive's `label` (applied - // as `aria-label` by StatusPill's `variant="bare"`) still gives assistive - // tech a description of the dot even for a fresh station, which renders NO - // visible text at all next to it. - it("exposes an accessible label on the dot even when fresh (no visible text rendered)", async () => { + // PR #81 round-3 convergence, UI Finding 4 (Codex facet): the primitive's + // `label` is now rendered as REAL, visually-hidden (sr-only) DOM text + // inside StatusPill's bare-variant root -- not an `aria-label` attribute + // on a generic, non-focusable span, which many assistive-tech paths don't + // reliably announce. This still gives assistive tech a description of the + // dot even for a fresh station. + it("exposes an sr-only accessible label on the dot even when fresh, as real DOM text (not aria-label)", async () => { monitorSnapshot = snapshotBody({ stations: [ { id: "st-3", name: "Kiosk C", zone_id: null, last_seen_at: new Date().toISOString(), checkin_count: 0 }, @@ -413,13 +420,31 @@ describe("MonitorPage -- Stations card (liveness)", () => { renderCorrectAt("/events/evt-1/monitor"); await screen.findByText("Kiosk C"); - // The `aria-label` lives on StatusPill's own bare-variant root node, a + // The sr-only label lives on StatusPill's own bare-variant root node, a // child of StationsCard's testid'd wrapper span -- same "reach into the // primitive via querySelector" idiom as the color-class assertions // above. - const labelled = screen.getByTestId("monitor-station-dot-st-3").querySelector("[aria-label]"); - expect(labelled).not.toBeNull(); - expect(labelled?.getAttribute("aria-label")).not.toBe(""); + const dotRoot = screen.getByTestId("monitor-station-dot-st-3"); + expect(dotRoot.querySelector("[aria-label]")).toBeNull(); + const srOnlyLabel = dotRoot.querySelector(".sr-only"); + expect(srOnlyLabel).not.toBeNull(); + expect(srOnlyLabel?.textContent).not.toBe(""); + }); + + // PR #81 round-3 convergence, UI Finding 4 (CodeRabbit facet): a fresh + // row's dot alone must never be the ONLY channel conveying "online" -- + // this station's row also carries its own separate, VISIBLE muted status + // word (distinct from the dot's own sr-only label above). + it("shows a fresh station's own visible muted 'Online' status word, not just a colored dot", async () => { + monitorSnapshot = snapshotBody({ + stations: [ + { id: "st-4", name: "Kiosk D", zone_id: null, last_seen_at: new Date().toISOString(), checkin_count: 5 }, + ], + }); + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("Kiosk D"); + expect(screen.getByTestId("monitor-station-online-st-4")).toHaveTextContent("Online"); }); }); diff --git a/panel/src/features/monitor/StationsCard.tsx b/panel/src/features/monitor/StationsCard.tsx index 341a370e..a371f1d1 100644 --- a/panel/src/features/monitor/StationsCard.tsx +++ b/panel/src/features/monitor/StationsCard.tsx @@ -19,10 +19,24 @@ // @idento/ui" rule, the same discipline the header's own LIVE pill already // follows (MonitorPage.tsx). The round-1 `indicator="dot"` API always // renders a visible label next to the dot (its own WCAG 1.4.1 invariant), -// which doesn't fit this compact row (green/fresh shows no text at all, -// amber/stale shows its own separate "stale Ns" span below) -- `variant= -// "bare"` was added to the primitive itself for exactly this shape rather -// than re-hand-rolling a dot a second time here. +// which doesn't fit this compact row -- `variant="bare"` was added to the +// primitive itself for exactly this shape rather than re-hand-rolling a dot +// a second time here. +// +// PR #81 round-3 convergence, UI Finding 4 (CodeRabbit): the round-2 shape +// above shipped with a fresh row rendering NO text at all next to its green +// dot -- a bare colored dot with no icon/text violates "never color alone" +// for a sighted colorblind user. This DELIBERATELY DEVIATES from board 7e's +// original "green/fresh shows no text at all" spec (p4.2-board-7e-extract.md +// itself flags amber as an overloaded color token elsewhere on this same +// card, so the board extract is not the final word on this card's color +// discipline) -- the codebase's codified never-color-alone rule governs. A +// fresh row now ALSO renders its own small VISIBLE muted status word (a NEW +// `monitorStationOnline` i18n key, kept separate from `monitorStationFresh` +// -- the dot's own accessible sr-only label -- so the two can diverge later +// without forcing a shared string), mirroring the stale row's pre-existing +// visible "stale Ns" span exactly: every row now conveys its liveness by +// TEXT, with color as a secondary reinforcing cue, never the sole channel. import { Card, CardContent, CardHeader, CardTitle, StatusPill } from "@idento/ui"; import { useTranslation } from "react-i18next"; import type { components } from "../../shared/api/schema"; @@ -51,11 +65,15 @@ export function StationsCard({ stations, now }: StationsCardProps) { stations.map((station) => { const staleness = stationStaleness(station.last_seen_at, now); // The dot's own accessible label -- exposed via StatusPill's - // `variant="bare"` `aria-label`, since a fresh row renders no - // visible text at all next to the dot. A stale row reuses the - // EXACT same string as the separately-rendered visible - // "stale Ns" span below (not a second, potentially drifting - // copy of the same fact). + // `variant="bare"` as real sr-only DOM text (PR #81 round-3 + // convergence, UI Finding 4 -- Codex facet: previously an + // aria-label on a generic, non-focusable span). A stale row's + // dot label reuses the EXACT same string as the + // separately-rendered visible "stale Ns" span below (not a + // second, potentially drifting copy of the same fact); a fresh + // row's dot label is `monitorStationFresh`, distinct from the + // NEW visible `monitorStationOnline` word below (Finding 4 -- + // CodeRabbit facet). const dotLabel = staleness.stale ? t("monitorStaleFor", { s: staleness.seconds }) : t("monitorStationFresh"); @@ -76,7 +94,14 @@ export function StationsCard({ stations, now }: StationsCardProps) { > {t("monitorStaleFor", { s: staleness.seconds })} - ) : null} + ) : ( + + {t("monitorStationOnline")} + + )} {numberFmt.format(station.checkin_count)} diff --git a/panel/src/shared/i18n/en.json b/panel/src/shared/i18n/en.json index 80f8a7b0..193d495c 100644 --- a/panel/src/shared/i18n/en.json +++ b/panel/src/shared/i18n/en.json @@ -640,6 +640,7 @@ "monitorRecentTitle": "Last scans", "monitorRecentEmpty": "No scans yet.", "monitorStationFresh": "Online", + "monitorStationOnline": "Online", "monitorRecentActionCheckin": "Checked in", "monitorRecentActionUndo": "Undone", "monitorRecentActionReprint": "Reprinted", diff --git a/panel/src/shared/i18n/ru.json b/panel/src/shared/i18n/ru.json index e3f94835..3ff72a9f 100644 --- a/panel/src/shared/i18n/ru.json +++ b/panel/src/shared/i18n/ru.json @@ -642,6 +642,7 @@ "monitorRecentTitle": "Последние сканирования", "monitorRecentEmpty": "Пока нет сканирований.", "monitorStationFresh": "В сети", + "monitorStationOnline": "В сети", "monitorRecentActionCheckin": "Зарегистрирован", "monitorRecentActionUndo": "Отменено", "monitorRecentActionReprint": "Перепечатано", From 76a16c19b0a31e1f1d2dc636eeb4770a2cd83ee5 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 11:29:16 +0300 Subject: [PATCH 22/23] =?UTF-8?q?fix(backend):=20PR=20#81=20round=204=20?= =?UTF-8?q?=E2=80=94=20broker=20pool=20minima=20clamp,=20attribution=20ind?= =?UTF-8?q?ex,=20station/zone=20publish=20sites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes four convergence-round findings on PR #81 (panel P4.2 live monitor + SSE): a DATABASE_URL carrying pool_min_conns would leave PGBroker's 2-conn notify pool perpetually churning to satisfy an unreachable minimum; the monitor's per-attendee latest-action attribution query had no supporting index and forced a full sort on every snapshot re-fetch; RegisterCheckinStation and the three zone CRUD handlers changed monitor-visible state without publishing, leaving dormant re-registrations and zone list changes stale indefinitely. Co-Authored-By: Claude Sonnet 5 --- backend/internal/broker/broker_test.go | 61 ++++ backend/internal/broker/pg_broker.go | 35 +- backend/internal/handler/checkin_stations.go | 15 + .../handler/checkin_stations_publish_test.go | 158 +++++++++ backend/internal/handler/zones.go | 35 +- .../internal/handler/zones_publish_test.go | 308 ++++++++++++++++++ .../pg_store_monitor_integration_test.go | 71 ++++ ...0022_checkin_actions_attendee_idx.down.sql | 1 + ...000022_checkin_actions_attendee_idx.up.sql | 24 ++ 9 files changed, 704 insertions(+), 4 deletions(-) create mode 100644 backend/internal/handler/zones_publish_test.go create mode 100644 backend/migrations/000022_checkin_actions_attendee_idx.down.sql create mode 100644 backend/migrations/000022_checkin_actions_attendee_idx.up.sql diff --git a/backend/internal/broker/broker_test.go b/backend/internal/broker/broker_test.go index b858473b..7fcd27cb 100644 --- a/backend/internal/broker/broker_test.go +++ b/backend/internal/broker/broker_test.go @@ -421,6 +421,67 @@ func TestHandleNotification_EmptyPayloadIsLoggedAndSkipped(t *testing.T) { // is — so this one line of "what happens on reconnect success" has a direct // unit test independent of the un-testable network code around it. +// --- preparePoolConfig (PR #81 round-4 convergence, Finding 1): pool +// minima must be clamped alongside the MaxConns=2 forcing -------------------- +// +// Regression coverage for a real deployment-breaking bug: NewPGBroker used +// to force MaxConns=2 on a *pgxpool.Config parsed straight from dbURL +// without touching MinConns/MinIdleConns — a DATABASE_URL carrying +// pool_min_conns=N (real, pgxpool-documented syntax; plausible if a +// deployment reuses its main app pool's tuned URL here) would leave those +// minima parsed straight through. pgxpool's own health check then +// perpetually tries to open new connections to satisfy a minimum higher +// than the 2-connection ceiling it can never exceed, churning forever. This +// is unit-testable without a live DB for the same reason +// prepareListenConnConfig is: pgxpool.ParseConfig is pure string parsing. + +// TestPreparePoolConfig_ClampsMinimaAndForcesMaxConns proves a dbURL +// carrying pool_min_conns=10 still ends with MaxConns=2 and MinConns=0 (and +// MinIdleConns=0, defensively — see preparePoolConfig's doc comment) rather +// than the pool perpetually chasing a minimum it can never satisfy under a +// 2-connection ceiling. +func TestPreparePoolConfig_ClampsMinimaAndForcesMaxConns(t *testing.T) { + dbURL := "postgres://user:pass@localhost:5432/db?pool_min_conns=10&sslmode=disable" + + poolCfg, err := preparePoolConfig(dbURL) + if err != nil { + t.Fatalf("preparePoolConfig: %v", err) + } + + if poolCfg.MaxConns != 2 { + t.Errorf("MaxConns = %d, want 2", poolCfg.MaxConns) + } + if poolCfg.MinConns != 0 { + t.Errorf("MinConns = %d, want 0 (clamped) — a pool_min_conns=10 URL must not survive into the 2-conn notify pool's config", poolCfg.MinConns) + } + if poolCfg.MinIdleConns != 0 { + t.Errorf("MinIdleConns = %d, want 0 (clamped)", poolCfg.MinIdleConns) + } +} + +// TestPreparePoolConfig_ClampsMinimaWithNoPoolOptionsInURL proves the +// clamp is unconditional — a dbURL with NO pool_min_conns still ends with +// MinConns=0/MinIdleConns=0 (both already pgxpool's own defaults, but this +// pins the invariant regardless of pgxpool's own defaults ever changing). +func TestPreparePoolConfig_ClampsMinimaWithNoPoolOptionsInURL(t *testing.T) { + dbURL := "postgres://user:pass@localhost:5432/db?sslmode=disable" + + poolCfg, err := preparePoolConfig(dbURL) + if err != nil { + t.Fatalf("preparePoolConfig: %v", err) + } + + if poolCfg.MaxConns != 2 { + t.Errorf("MaxConns = %d, want 2", poolCfg.MaxConns) + } + if poolCfg.MinConns != 0 { + t.Errorf("MinConns = %d, want 0", poolCfg.MinConns) + } + if poolCfg.MinIdleConns != 0 { + t.Errorf("MinIdleConns = %d, want 0", poolCfg.MinIdleConns) + } +} + // --- prepareListenConnConfig (PR #81 round-3 convergence, Backend Finding // 1): pool-only URL options must not reach the raw LISTEN connection ------- // diff --git a/backend/internal/broker/pg_broker.go b/backend/internal/broker/pg_broker.go index b2c289a5..5fd08960 100644 --- a/backend/internal/broker/pg_broker.go +++ b/backend/internal/broker/pg_broker.go @@ -66,11 +66,10 @@ type PGBroker struct { // process startup); listenLoop's own reconnect-with-backoff only takes // over once the loop is already running. func NewPGBroker(ctx context.Context, dbURL string) (*PGBroker, error) { - poolCfg, err := pgxpool.ParseConfig(dbURL) + poolCfg, err := preparePoolConfig(dbURL) if err != nil { return nil, err } - poolCfg.MaxConns = 2 pool, err := pgxpool.NewWithConfig(ctx, poolCfg) if err != nil { @@ -130,6 +129,38 @@ func NewPGBroker(ctx context.Context, dbURL string) (*PGBroker, error) { return b, nil } +// preparePoolConfig parses dbURL via pgxpool.ParseConfig and applies the +// small notify-only pool's sizing: MaxConns forced to 2 (this pool only +// ever runs Publish's single SELECT pg_notify(...) statement — see +// PGBroker's doc comment), and MinConns/MinIdleConns clamped to 0 (Finding +// 1, PR #81 round-4 convergence). Without the clamp, a DATABASE_URL tuned +// with pool_min_conns (a real, pgxpool-documented URL option some +// deployments set for their MAIN app pool and then reuse verbatim here) +// would survive ParseConfig and leave the pool trying to maintain more +// idle/minimum connections than MaxConns=2 allows — pgxpool's health check +// perpetually opens a connection to chase MinConns, immediately finds the +// pool already at its 2-connection ceiling, and the churn repeats forever. +// MinIdleConns is clamped defensively too even though pgxpool.ParseConfig +// has no URL option for it today (only pool_min_conns is recognized, +// per pgxpool.ParseConfig's doc comment) — BeforeConnect/a future pgx +// version could still leave it non-zero, and forcing it to 0 alongside +// MinConns costs nothing. +// +// Factored out of NewPGBroker so the whole dbURL -> *pgxpool.Config +// derivation is unit-testable without a live Postgres connection — +// pgxpool.ParseConfig is a pure string-parsing operation, no network I/O, +// same seam-testing rationale as prepareListenConnConfig below. +func preparePoolConfig(dbURL string) (*pgxpool.Config, error) { + poolCfg, err := pgxpool.ParseConfig(dbURL) + if err != nil { + return nil, err + } + poolCfg.MaxConns = 2 + poolCfg.MinConns = 0 + poolCfg.MinIdleConns = 0 + return poolCfg, nil +} + // prepareListenConnConfig derives the *pgx.ConnConfig the dedicated LISTEN // connection connects with, from an already-parsed *pgxpool.Config (Finding // 1, PR #81 round-3 convergence). It is factored out of NewPGBroker diff --git a/backend/internal/handler/checkin_stations.go b/backend/internal/handler/checkin_stations.go index 75175c05..c27fb54a 100644 --- a/backend/internal/handler/checkin_stations.go +++ b/backend/internal/handler/checkin_stations.go @@ -90,6 +90,21 @@ func (h *Handler) RegisterCheckinStation(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to register check-in station"}) } + // Publish on every successful registration (PR #81 round-4 convergence, + // Finding 3): this is an upsert — a fresh name creates a new station, a + // repeat name re-registers it (most notably, rebinding its zone_id, + // which changes that station's FUTURE check-ins' attribution). Either + // way the monitor's stations[] list changed, and a re-registration that + // only rebinds a zone would otherwise stay silently stale until some + // unrelated event nudged the monitor — heartbeats alone don't cover it: + // they throttle to once per heartbeatPublishThrottle window AND only + // fire once the station's own page starts polling again, so a station + // re-registered while dormant (e.g. from the settings/admin side, not + // the station page) could sit stale indefinitely. Unlike heartbeat, + // registration is a discrete user-initiated action — same class as + // check-in/undo/reprint — so this site is deliberately UNthrottled. + h.publishCheckinEvent(c.Request().Context(), eventID) + return c.JSON(http.StatusOK, CheckinStationResponse{Station: station}) } diff --git a/backend/internal/handler/checkin_stations_publish_test.go b/backend/internal/handler/checkin_stations_publish_test.go index 4e081bd5..1020449b 100644 --- a/backend/internal/handler/checkin_stations_publish_test.go +++ b/backend/internal/handler/checkin_stations_publish_test.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "testing" "time" @@ -13,6 +14,163 @@ import ( "github.com/labstack/echo/v4" ) +// --- PR #81 round-4 convergence, Finding 3: RegisterCheckinStation broker +// publish site -------------------------------------------------------------- +// +// RegisterCheckinStation is an upsert: a fresh name creates a new station, a +// repeat name re-registers it (e.g. changing its zone binding). Either way +// it changes the monitor's stations[] list — a station showing up for the +// first time, or an existing station's zone binding (and therefore its +// future check-ins' attribution) changing. Unlike HeartbeatCheckinStation's +// periodic, throttled publish (Finding B5), registration is a discrete +// user-initiated action — same class as check-in/undo/reprint — so this +// site is UNthrottled: every successful registration publishes. + +// TestRegisterCheckinStation_PublishesOnSuccess proves a successful +// registration (200) signals the monitor. +func TestRegisterCheckinStation_PublishesOnSuccess(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + now := time.Now() + + h := newCheckinStationHandler(event, nil, + func(eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error) { + return &models.CheckinStation{ID: stationID, EventID: eventID, Name: name, LastSeenAt: now, CreatedAt: now}, nil + }, + nil, nil, + ) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinStationsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodPost, path, `{"name":"Main Entrance"}`, tenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + + if err := h.RegisterCheckinStation(c); err != nil { + t.Fatalf("RegisterCheckinStation: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on a successful registration") + } +} + +// TestRegisterCheckinStation_FailedUpsertDoesNotPublish proves a store +// failure (500) never signals the monitor. +func TestRegisterCheckinStation_FailedUpsertDoesNotPublish(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + h := newCheckinStationHandler(event, nil, + func(uuid.UUID, string, *uuid.UUID) (*models.CheckinStation, error) { + return nil, errors.New("insert failed") + }, + nil, nil, + ) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinStationsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodPost, path, `{"name":"Main Entrance"}`, tenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + + if err := h.RegisterCheckinStation(c); err != nil { + t.Fatalf("RegisterCheckinStation: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false on a failed registration") + } +} + +// TestRegisterCheckinStation_ReregisterPublishesAgain proves re-registering +// the SAME station name (e.g. rebinding its zone) publishes again — unlike +// heartbeat, registration is never throttled. +func TestRegisterCheckinStation_ReregisterPublishesAgain(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + now := time.Now() + + h := newCheckinStationHandler(event, nil, + func(eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error) { + return &models.CheckinStation{ID: stationID, EventID: eventID, Name: name, LastSeenAt: now, CreatedAt: now}, nil + }, + nil, nil, + ) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := checkinStationsPath(event.ID) + + c1, rec1 := newAuthedContext(e, http.MethodPost, path, `{"name":"Main Entrance"}`, tenantID.String(), "admin") + setCheckinStationsPathParams(c1, event.ID) + if err := h.RegisterCheckinStation(c1); err != nil { + t.Fatalf("RegisterCheckinStation (1st): %v", err) + } + if rec1.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec1.Code, rec1.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on the first registration") + } + + c2, rec2 := newAuthedContext(e, http.MethodPost, path, `{"name":"Main Entrance"}`, tenantID.String(), "admin") + setCheckinStationsPathParams(c2, event.ID) + if err := h.RegisterCheckinStation(c2); err != nil { + t.Fatalf("RegisterCheckinStation (2nd, re-register): %v", err) + } + if rec2.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec2.Code, rec2.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on re-registration — unlike heartbeat, registration is never throttled") + } +} + +// TestRegisterCheckinStation_NilBrokerDoesNotPanic proves the nil-safe +// guard for the registration publish site. +func TestRegisterCheckinStation_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + now := time.Now() + + h := newCheckinStationHandler(event, nil, + func(eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error) { + return &models.CheckinStation{ID: stationID, EventID: eventID, Name: name, LastSeenAt: now, CreatedAt: now}, nil + }, + nil, nil, + ) + // h.Broker intentionally left nil. + + e := echo.New() + path := checkinStationsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodPost, path, `{"name":"Main Entrance"}`, tenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + + if err := h.RegisterCheckinStation(c); err != nil { + t.Fatalf("RegisterCheckinStation: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } +} + // --- P4.2 Task 4: HeartbeatCheckinStation broker publish site --- // TestHeartbeatCheckinStation_PublishesOn204 proves a successful heartbeat diff --git a/backend/internal/handler/zones.go b/backend/internal/handler/zones.go index 5b79c555..1e0bef95 100644 --- a/backend/internal/handler/zones.go +++ b/backend/internal/handler/zones.go @@ -36,6 +36,15 @@ func (h *Handler) CreateEventZone(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create zone"}) } + // Publish on every successful zone creation (PR #81 round-4 convergence, + // Finding 4): a new zone changes the monitor's Totals/Zones card — the + // new zone must appear there (with CheckedIn=0 until something attributes + // to it) rather than staying invisible until some unrelated check-in + // event happens to nudge the monitor. Same class as check-in/undo/ + // reprint/station-registration: a discrete user-initiated action, so + // this site is deliberately UNthrottled. + h.publishCheckinEvent(c.Request().Context(), eventID) + return c.JSON(http.StatusCreated, zone) } @@ -90,7 +99,8 @@ func (h *Handler) UpdateEventZone(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid zone ID"}) } - if _, _, err := h.requireZoneOwnership(c, id); err != nil { + existingZone, _, err := h.requireZoneOwnership(c, id) + if err != nil { return writeErr(c, err) } @@ -105,6 +115,16 @@ func (h *Handler) UpdateEventZone(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update zone"}) } + // Publish on every successful zone update (PR #81 round-4 convergence, + // Finding 4): keyed off existingZone.EventID (resolved via + // requireZoneOwnership BEFORE the request body overwrote `zone`) since + // the request body only ever carries the zone's own fields, never + // event_id — an update can rename the zone, change its time window, or + // flip is_active, all of which the monitor's Totals/Zones card renders. + // Same class as check-in/undo/reprint/station-registration/zone-create: + // a discrete user-initiated action, so this site is UNthrottled. + h.publishCheckinEvent(c.Request().Context(), existingZone.EventID) + return c.JSON(http.StatusOK, zone) } @@ -115,7 +135,8 @@ func (h *Handler) DeleteEventZone(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid zone ID"}) } - if _, _, err := h.requireZoneOwnership(c, id); err != nil { + existingZone, _, err := h.requireZoneOwnership(c, id) + if err != nil { return writeErr(c, err) } @@ -123,6 +144,16 @@ func (h *Handler) DeleteEventZone(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to delete zone"}) } + // Publish on every successful zone deletion (PR #81 round-4 convergence, + // Finding 4): the monitor's Totals/Zones card must drop the zone, and — + // since checkin_stations.zone_id is ON DELETE SET NULL (migration + // 000019) — every station that was bound to this zone moves its future + // check-ins' attribution to unattributed, which the monitor must also + // reflect. Same class as check-in/undo/reprint/station-registration/ + // zone-create/zone-update: a discrete user-initiated action, so this + // site is UNthrottled. + h.publishCheckinEvent(c.Request().Context(), existingZone.EventID) + return c.JSON(http.StatusOK, map[string]string{"message": "Zone deleted successfully"}) } diff --git a/backend/internal/handler/zones_publish_test.go b/backend/internal/handler/zones_publish_test.go new file mode 100644 index 00000000..e7d61003 --- /dev/null +++ b/backend/internal/handler/zones_publish_test.go @@ -0,0 +1,308 @@ +package handler + +import ( + "errors" + "net/http" + "testing" + + "idento/backend/internal/broker" + "idento/backend/internal/models" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// --- PR #81 round-4 convergence, Finding 4: zone CRUD broker publish sites - +// +// CreateEventZone/UpdateEventZone/DeleteEventZone all change the monitor's +// zone list (Totals/Zones card), and DeleteEventZone specifically moves +// every station bound to the deleted zone's currently-checked-in attendees +// to "unattributed" (checkin_stations.zone_id is ON DELETE SET NULL — see +// migration 000019) — a monitor-visible state change with no OTHER publish +// site to cover it. Same class as check-in/undo/reprint/registration: a +// discrete user-initiated action, so all three sites here are UNthrottled. + +// TestCreateEventZone_PublishesOnSuccess proves a successful zone creation +// (201) signals the monitor. +func TestCreateEventZone_PublishesOnSuccess(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + createEventZone: func(*models.EventZone) error { return nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/zones" + c, rec := newAuthedContext(e, http.MethodPost, path, `{"name":"Main Hall","zone_type":"general"}`, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/zones") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.CreateEventZone(c); err != nil { + t.Fatalf("CreateEventZone: %v", err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on a successful zone creation") + } +} + +// TestCreateEventZone_FailedCreateDoesNotPublish proves a store failure +// (500) never signals the monitor. +func TestCreateEventZone_FailedCreateDoesNotPublish(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + createEventZone: func(*models.EventZone) error { return errors.New("insert failed") }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/zones" + c, rec := newAuthedContext(e, http.MethodPost, path, `{"name":"Main Hall"}`, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/zones") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.CreateEventZone(c); err != nil { + t.Fatalf("CreateEventZone: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false on a failed zone creation") + } +} + +// TestCreateEventZone_NilBrokerDoesNotPanic proves the nil-safe guard for +// the create-zone publish site. +func TestCreateEventZone_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + createEventZone: func(*models.EventZone) error { return nil }, + }) + // h.Broker intentionally left nil. + + e := echo.New() + path := "/api/events/" + event.ID.String() + "/zones" + c, rec := newAuthedContext(e, http.MethodPost, path, `{"name":"Main Hall"}`, tenantID.String(), "admin") + c.SetPath("/api/events/:event_id/zones") + c.SetParamNames("event_id") + c.SetParamValues(event.ID.String()) + + if err := h.CreateEventZone(c); err != nil { + t.Fatalf("CreateEventZone: %v", err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestUpdateEventZone_PublishesOnSuccess proves a successful zone update +// (200) signals the monitor, keyed off the ZONE's event_id (the request +// body/path only carry the zone id, never event_id). +func TestUpdateEventZone_PublishesOnSuccess(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + zone := contractZone(event.ID) + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getEventZoneByID: func(uuid.UUID) (*models.EventZone, error) { return zone, nil }, + updateEventZone: func(*models.EventZone) error { return nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/zones/" + zone.ID.String() + c, rec := newAuthedContext(e, http.MethodPut, path, `{"name":"Main Hall 2"}`, tenantID.String(), "admin") + c.SetPath("/api/zones/:id") + c.SetParamNames("id") + c.SetParamValues(zone.ID.String()) + + if err := h.UpdateEventZone(c); err != nil { + t.Fatalf("UpdateEventZone: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on a successful zone update") + } +} + +// TestUpdateEventZone_FailedUpdateDoesNotPublish proves a store failure +// (500) never signals the monitor. +func TestUpdateEventZone_FailedUpdateDoesNotPublish(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + zone := contractZone(event.ID) + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getEventZoneByID: func(uuid.UUID) (*models.EventZone, error) { return zone, nil }, + updateEventZone: func(*models.EventZone) error { return errors.New("update failed") }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/zones/" + zone.ID.String() + c, rec := newAuthedContext(e, http.MethodPut, path, `{"name":"Main Hall 2"}`, tenantID.String(), "admin") + c.SetPath("/api/zones/:id") + c.SetParamNames("id") + c.SetParamValues(zone.ID.String()) + + if err := h.UpdateEventZone(c); err != nil { + t.Fatalf("UpdateEventZone: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false on a failed zone update") + } +} + +// TestUpdateEventZone_NilBrokerDoesNotPanic proves the nil-safe guard for +// the update-zone publish site. +func TestUpdateEventZone_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + zone := contractZone(event.ID) + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getEventZoneByID: func(uuid.UUID) (*models.EventZone, error) { return zone, nil }, + updateEventZone: func(*models.EventZone) error { return nil }, + }) + // h.Broker intentionally left nil. + + e := echo.New() + path := "/api/zones/" + zone.ID.String() + c, rec := newAuthedContext(e, http.MethodPut, path, `{"name":"Main Hall 2"}`, tenantID.String(), "admin") + c.SetPath("/api/zones/:id") + c.SetParamNames("id") + c.SetParamValues(zone.ID.String()) + + if err := h.UpdateEventZone(c); err != nil { + t.Fatalf("UpdateEventZone: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestDeleteEventZone_PublishesOnSuccess proves a successful zone deletion +// (200) signals the monitor — deletion moves every station bound to this +// zone's currently-checked-in attendees to unattributed (ON DELETE SET +// NULL), which the monitor must reflect. +func TestDeleteEventZone_PublishesOnSuccess(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + zone := contractZone(event.ID) + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getEventZoneByID: func(uuid.UUID) (*models.EventZone, error) { return zone, nil }, + deleteEventZone: func(uuid.UUID) error { return nil }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/zones/" + zone.ID.String() + c, rec := newAuthedContext(e, http.MethodDelete, path, "", tenantID.String(), "admin") + c.SetPath("/api/zones/:id") + c.SetParamNames("id") + c.SetParamValues(zone.ID.String()) + + if err := h.DeleteEventZone(c); err != nil { + t.Fatalf("DeleteEventZone: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !pendingSignal(ch) { + t.Fatal("publish signal = false, want true on a successful zone deletion") + } +} + +// TestDeleteEventZone_FailedDeleteDoesNotPublish proves a store failure +// (500) never signals the monitor. +func TestDeleteEventZone_FailedDeleteDoesNotPublish(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + zone := contractZone(event.ID) + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getEventZoneByID: func(uuid.UUID) (*models.EventZone, error) { return zone, nil }, + deleteEventZone: func(uuid.UUID) error { return errors.New("delete failed") }, + }) + mem := broker.NewMemBroker() + h.Broker = mem + ch, unsubscribe := mem.Subscribe(event.ID) + defer unsubscribe() + + e := echo.New() + path := "/api/zones/" + zone.ID.String() + c, rec := newAuthedContext(e, http.MethodDelete, path, "", tenantID.String(), "admin") + c.SetPath("/api/zones/:id") + c.SetParamNames("id") + c.SetParamValues(zone.ID.String()) + + if err := h.DeleteEventZone(c); err != nil { + t.Fatalf("DeleteEventZone: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d, body=%s", rec.Code, rec.Body.String()) + } + if pendingSignal(ch) { + t.Fatal("publish signal = true, want false on a failed zone deletion") + } +} + +// TestDeleteEventZone_NilBrokerDoesNotPanic proves the nil-safe guard for +// the delete-zone publish site. +func TestDeleteEventZone_NilBrokerDoesNotPanic(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + zone := contractZone(event.ID) + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getEventZoneByID: func(uuid.UUID) (*models.EventZone, error) { return zone, nil }, + deleteEventZone: func(uuid.UUID) error { return nil }, + }) + // h.Broker intentionally left nil. + + e := echo.New() + path := "/api/zones/" + zone.ID.String() + c, rec := newAuthedContext(e, http.MethodDelete, path, "", tenantID.String(), "admin") + c.SetPath("/api/zones/:id") + c.SetParamNames("id") + c.SetParamValues(zone.ID.String()) + + if err := h.DeleteEventZone(c); err != nil { + t.Fatalf("DeleteEventZone: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/internal/store/pg_store_monitor_integration_test.go b/backend/internal/store/pg_store_monitor_integration_test.go index c3a3e2c5..faf9591c 100644 --- a/backend/internal/store/pg_store_monitor_integration_test.go +++ b/backend/internal/store/pg_store_monitor_integration_test.go @@ -3,6 +3,7 @@ package store import ( "context" "os" + "strings" "testing" "time" @@ -325,3 +326,73 @@ func TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction(t *testing t.Errorf("stationless.CheckinCount = %d, want 1", got) } } + +// TestCheckinActionsAttendeeIndex_RealPostgres_ExistsAndServesQueries covers +// PR #81 round-4 convergence, Finding 2: the latest_state CTE inside +// monitorOverviewSQL above does DISTINCT ON (ca.attendee_id) ... ORDER BY +// ca.attendee_id, ca.created_at DESC, ca.id DESC over checkin_actions — a +// per-attendee ordering the pre-existing idx_checkin_actions_event_created +// index (event_id, created_at DESC, id DESC — migration 000019) cannot +// serve, forcing a full sort of every checkin/undo row on the event on +// every monitor snapshot re-fetch. Migration 000022 adds +// idx_checkin_actions_event_attendee on (event_id, attendee_id, created_at +// DESC, id DESC) to match. +// +// This asserts the index's EXISTENCE and column order/direction via +// pg_indexes/pg_index rather than an EXPLAIN-based "the planner picked this +// index" assertion: EXPLAIN against this suite's own fixtures (a handful of +// rows, freshly inserted and cleaned up per test run) reliably comes back +// as Seq Scan+Sort regardless of the index's presence — Postgres's +// cost-based planner correctly judges an index scan not worth it below +// roughly a few hundred rows, so a plan-shape assertion here would encode +// "this test's fixture size" rather than "this index exists and matches +// the query's sort", flipping to Index Scan only on a busy/seeded database +// the test suite deliberately doesn't create. The invariant test above +// (TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction, whose +// s.RunMigrations() call already applies migration 000022 before every +// query in this file runs) is what proves the query still produces correct +// results on the indexed schema; this test is the narrower regression +// guard that the index migration itself landed with the right shape. +func TestCheckinActionsAttendeeIndex_RealPostgres_ExistsAndServesQueries(t *testing.T) { + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + t.Skip("TEST_DATABASE_URL not set; skipping real-Postgres index-shape test (see doc comment for how to run it)") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + t.Fatalf("pgxpool.New: %v", err) + } + t.Cleanup(pool.Close) + if err := pool.Ping(ctx); err != nil { + t.Fatalf("Ping: %v", err) + } + + s := &PGStore{db: pool} + if err := s.RunMigrations(); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + + // pg_indexes.indexdef renders the full CREATE INDEX statement including + // column list and DESC directions — a single string containment check + // on the exact column order/direction the migration declared is enough + // to catch both "index missing" and "index present but columns + // reordered/direction dropped" without parsing pg_index's raw int2vector + // columns. + var indexdef string + err = pool.QueryRow(ctx, + `SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'checkin_actions' AND indexname = $1`, + "idx_checkin_actions_event_attendee", + ).Scan(&indexdef) + if err != nil { + t.Fatalf("idx_checkin_actions_event_attendee not found on checkin_actions after RunMigrations: %v", err) + } + + const wantColumns = "(event_id, attendee_id, created_at DESC, id DESC)" + if !strings.Contains(indexdef, wantColumns) { + t.Errorf("indexdef = %q, want it to contain %q (column order/direction must match the latest_state CTE's ORDER BY ca.attendee_id, ca.created_at DESC, ca.id DESC, with event_id leading as the query's equality predicate)", indexdef, wantColumns) + } +} diff --git a/backend/migrations/000022_checkin_actions_attendee_idx.down.sql b/backend/migrations/000022_checkin_actions_attendee_idx.down.sql new file mode 100644 index 00000000..ae7f56fd --- /dev/null +++ b/backend/migrations/000022_checkin_actions_attendee_idx.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_checkin_actions_event_attendee; diff --git a/backend/migrations/000022_checkin_actions_attendee_idx.up.sql b/backend/migrations/000022_checkin_actions_attendee_idx.up.sql new file mode 100644 index 00000000..de450a4a --- /dev/null +++ b/backend/migrations/000022_checkin_actions_attendee_idx.up.sql @@ -0,0 +1,24 @@ +-- backend/migrations/000022_checkin_actions_attendee_idx.up.sql +-- PR #81 round-4 convergence, Finding 2: GetMonitorOverview's latest_state +-- CTE (pg_store_monitor.go) does +-- SELECT DISTINCT ON (ca.attendee_id) ... +-- FROM checkin_actions ca +-- WHERE ca.event_id = $1 AND ca.action IN ('checkin', 'undo') +-- ORDER BY ca.attendee_id, ca.created_at DESC, ca.id DESC +-- for EVERY monitor snapshot re-fetch (every SSE invalidation re-issues +-- GetMonitorOverview). The only existing index on checkin_actions — +-- idx_checkin_actions_event_created (migration 000019), on (event_id, +-- created_at DESC, id DESC) — supports GetCheckinActions' own event-wide +-- feed ordering but cannot serve this DISTINCT ON's per-attendee ordering: +-- Postgres would still need a full sort of every one of the event's +-- checkin/undo rows to satisfy "ORDER BY attendee_id, created_at DESC, id +-- DESC" per group. On a busy event this is a repeated full sort on every +-- snapshot re-fetch. +-- +-- This index's column order intentionally matches the CTE's WHERE + +-- ORDER BY exactly: event_id leads (the query's only equality predicate, +-- and a btree index should always put equality columns before range/sort +-- columns), followed by attendee_id, created_at DESC, id DESC — the same +-- three columns and directions the DISTINCT ON/ORDER BY needs, letting the +-- planner walk the index directly instead of sorting. +CREATE INDEX idx_checkin_actions_event_attendee ON checkin_actions(event_id, attendee_id, created_at DESC, id DESC); From cc6cb1496addead93a889198762097c2c25c6e76 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 19 Jul 2026 12:02:15 +0300 Subject: [PATCH 23/23] fix(backend): publish monitor events from external import and attendee PATCH ExternalImport (api_keys.go): Creates attendees via API key but never published, leaving monitors' totals stale. Now publishes once when at least one attendee is created, after the import loop and event field schema update (one per request, not per attendee). UpdateAttendeeInfo (attendees.go): PATCH handler updates attendee fields but never publishes, staling monitors' last-scans feed for people whose names/details changed. Now publishes after successful update, exactly like the existing PUT checkin handler. Both use the existing nil-safe publishCheckinEvent helper (event_publish.go) with no publish on validation/store failures; mirroring the 13+ existing publish sites (CreateAttendee, DeleteAttendee, BulkCreateAttendees, StationCheckin, UndoCheckin, RegisterCheckinStation, zone CRUD, etc.). PR #81 round-5. Co-Authored-By: Claude Sonnet 5 --- backend/internal/handler/api_keys.go | 6 ++++++ backend/internal/handler/attendees.go | 3 +++ 2 files changed, 9 insertions(+) diff --git a/backend/internal/handler/api_keys.go b/backend/internal/handler/api_keys.go index f42deb63..2991ee30 100644 --- a/backend/internal/handler/api_keys.go +++ b/backend/internal/handler/api_keys.go @@ -259,6 +259,12 @@ func (h *Handler) ExternalImport(c echo.Context) error { log.Printf("Warning: Failed to update event field schema: %v", err) } + // PR #81 round-5: publish once if any attendees were created so the monitor's + // `total` stays current for API-imported events (one publish per request, not per attendee) + if created > 0 { + h.publishCheckinEvent(c.Request().Context(), eventID) + } + response := map[string]interface{}{ "message": "Import completed", "results": map[string]interface{}{ diff --git a/backend/internal/handler/attendees.go b/backend/internal/handler/attendees.go index 86920a21..edd616ba 100644 --- a/backend/internal/handler/attendees.go +++ b/backend/internal/handler/attendees.go @@ -270,6 +270,9 @@ func (h *Handler) UpdateAttendeeInfo(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update attendee"}) } + // PR #81 round-5: publish the update so the monitor's last-scans feed stays current + h.publishCheckinEvent(c.Request().Context(), attendee.EventID) + return c.JSON(http.StatusOK, attendee) }