diff --git a/backend/internal/broker/broker.go b/backend/internal/broker/broker.go new file mode 100644 index 00000000..bc777702 --- /dev/null +++ b/backend/internal/broker/broker.go @@ -0,0 +1,43 @@ +// 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. + // + // 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/broker/broker_test.go b/backend/internal/broker/broker_test.go new file mode 100644 index 00000000..7fcd27cb --- /dev/null +++ b/backend/internal/broker/broker_test.go @@ -0,0 +1,570 @@ +package broker + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// --- 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() +} + +// --- 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) { + 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: + } +} + +// --- 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. + +// --- 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 ------- +// +// 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() + 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 new file mode 100644 index 00000000..0844855b --- /dev/null +++ b/backend/internal/broker/mem_broker.go @@ -0,0 +1,101 @@ +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 +} + +// 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()) { + 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..5fd08960 --- /dev/null +++ b/backend/internal/broker/pg_broker.go @@ -0,0 +1,345 @@ +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{} + + // 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 +// 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 := preparePoolConfig(dbURL) + if err != nil { + return nil, err + } + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + + // 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 + } + 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, + connConfig: connConfig, + cancel: cancel, + done: make(chan struct{}), + } + + go b.listenLoop(loopCtx, conn) + + 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 +// 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 +// 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) { + 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) + if !ok { + // 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 + } + + handleNotification(b.mem, notification.Payload) + } +} + +// 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 { + case <-ctx.Done(): + return nil, false + case <-time.After(backoff): + } + + conn, err := pgx.ConnectConfig(ctx, b.connConfig.Copy()) + 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) + } +} + +// 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 +// 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) + } +} 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/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/attendee_printed.go b/backend/internal/handler/attendee_printed.go index 1ff15eb7..3e54a9bf 100644 --- a/backend/internal/handler/attendee_printed.go +++ b/backend/internal/handler/attendee_printed.go @@ -166,6 +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 { + // 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. 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/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/attendees.go b/backend/internal/handler/attendees.go index ad9b691e..edd616ba 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, @@ -263,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) } @@ -278,6 +288,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 +352,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) } @@ -410,5 +436,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/handler/checkin.go b/backend/internal/handler/checkin.go index de874370..21bcd2fe 100644 --- a/backend/internal/handler/checkin.go +++ b/backend/internal/handler/checkin.go @@ -170,6 +170,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. 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 if updated.CheckedInAt != nil { byEmail := "" @@ -231,6 +244,14 @@ 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. 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_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..c27fb54a 100644 --- a/backend/internal/handler/checkin_stations.go +++ b/backend/internal/handler/checkin_stations.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "strings" + "time" "idento/backend/internal/models" "idento/backend/internal/store" @@ -89,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}) } @@ -116,9 +132,68 @@ 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. 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 new file mode 100644 index 00000000..1020449b --- /dev/null +++ b/backend/internal/handler/checkin_stations_publish_test.go @@ -0,0 +1,416 @@ +package handler + +import ( + "errors" + "net/http" + "testing" + "time" + + "idento/backend/internal/broker" + "idento/backend/internal/models" + "idento/backend/internal/store" + + "github.com/google/uuid" + "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 +// (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") + } +} + +// --- 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) { + 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/checkins_batch.go b/backend/internal/handler/checkins_batch.go index 10c6ebad..143bb33c 100644 --- a/backend/internal/handler/checkins_batch.go +++ b/backend/internal/handler/checkins_batch.go @@ -37,6 +37,21 @@ 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 + // 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)) for i := range items { item := items[i] @@ -69,6 +84,9 @@ func (h *Handler) BatchCheckin(c echo.Context) error { } switch outcome { case store.BatchCheckinCreated: + 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 @@ -82,5 +100,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 45c45aad..b87e34fe 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -4,8 +4,10 @@ package handler import ( + "sync" "time" + "idento/backend/internal/broker" "idento/backend/internal/config" "idento/backend/internal/middleware" "idento/backend/internal/store" @@ -16,8 +18,28 @@ 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 + + // 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. @@ -86,6 +108,8 @@ 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/: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/legacy_publish_test.go b/backend/internal/handler/legacy_publish_test.go new file mode 100644 index 00000000..e1389ad6 --- /dev/null +++ b/backend/internal/handler/legacy_publish_test.go @@ -0,0 +1,586 @@ +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_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) { + 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_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) { + 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.go b/backend/internal/handler/monitor.go new file mode 100644 index 00000000..98a6a72c --- /dev/null +++ b/backend/internal/handler/monitor.go @@ -0,0 +1,146 @@ +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 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"` + 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, zoneCounts, unattributed, err := h.Store.GetMonitorOverview(ctx, eventID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch monitor overview"}) + } + + // 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 rate buckets"}) + } + + 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 recent check-in count"}) + } + + 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(recentCount, 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..5631448f --- /dev/null +++ b/backend/internal/handler/monitor_rates.go @@ -0,0 +1,84 @@ +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 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 +// 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 (P4.2 Task +// 3, spec §3.1; reshaped by PR #81 bot-review round, Finding A3): +// +// - 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(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 peakBucket *store.MinuteBucket + for i := range buckets { + b := &buckets[i] + if peakBucket == nil || b.Count > peakBucket.Count { + peakBucket = b + } + } + 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..df67c2dc --- /dev/null +++ b/backend/internal/handler/monitor_rates_test.go @@ -0,0 +1,172 @@ +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) + +// --- 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) + } + if peak != nil { + t.Errorf("peak = %+v, want nil", peak) + } + if estDoneAt != nil { + t.Errorf("estDoneAt = %v, want nil", *estDoneAt) + } +} + +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) + + if rate != 1.0 { + t.Errorf("rate = %v, want 1.0", rate) + } + if estDoneAt == nil { + t.Fatalf("estDoneAt = nil, want non-nil (rate above floor, remaining > 0)") + } +} + +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{ + {Minute: morningSpike, Count: 14}, + {Minute: recentSmall, Count: 3}, + } + + rate, peak, _ := computeRates(3, 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_RateBelowFloor_NilETA(t *testing.T) { + // 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) + } + 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. + rate, _, estDoneAt := computeRates(10, nil, 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). + _, _, estDoneAt := computeRates(10, nil, fixedNow, 100, 105) + + if estDoneAt != nil { + t.Errorf("estDoneAt = %v, want nil (checkedIn > total)", *estDoneAt) + } +} + +func TestComputeRates_EstDoneAtProjection(t *testing.T) { + // 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) + } + 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 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_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(0, 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) + } +} diff --git a/backend/internal/handler/monitor_stream.go b/backend/internal/handler/monitor_stream.go new file mode 100644 index 00000000..ba442f19 --- /dev/null +++ b/backend/internal/handler/monitor_stream.go @@ -0,0 +1,112 @@ +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 +// 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 +// (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) + } + + // 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") + 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. 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") { + 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..6fce2bfc --- /dev/null +++ b/backend/internal/handler/monitor_stream_test.go @@ -0,0 +1,358 @@ +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_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. + + e := echo.New() + path := monitorStreamPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", tenantID.String(), "staff") + setMonitorStreamPathParams(c, event.ID) + + if err := h.GetEventMonitorStream(c); err != nil { + t.Fatalf("GetEventMonitorStream: %v", err) + } + 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) + } + 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 +// 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/internal/handler/openapi_contract_monitor_p4_test.go b/backend/internal/handler/openapi_contract_monitor_p4_test.go new file mode 100644 index 00000000..c4559f22 --- /dev/null +++ b/backend/internal/handler/openapi_contract_monitor_p4_test.go @@ -0,0 +1,227 @@ +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 }, + getMonitorOverview: func(eventID uuid.UUID) (int, int, []store.MonitorZoneCount, int, error) { + if 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 100, 60, []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 + }, + 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) + } + 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 }, + 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 }, + }) + + 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 }, + 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 + }, + }) + + 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/sync.go b/backend/internal/handler/sync.go index 9168f15a..d9722948 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) @@ -190,11 +207,22 @@ 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) // 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/internal/handler/testsupport_test.go b/backend/internal/handler/testsupport_test.go index 6b5b609b..ece44abe 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 + 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 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) 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) +} func (f *fakeStore) CreateTenantWithDefaultSubscription(_ context.Context, tenant *models.Tenant) error { return f.createTenantWithDefaultSubscription(tenant) 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/interface.go b/backend/internal/store/interface.go index 314b5af6..fae3cc94 100644 --- a/backend/internal/store/interface.go +++ b/backend/internal/store/interface.go @@ -243,6 +243,65 @@ 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 + // 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 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, 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). + 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 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 + // 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 +481,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..67c8f8d1 --- /dev/null +++ b/backend/internal/store/pg_store_monitor.go @@ -0,0 +1,274 @@ +package store + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// 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. 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. 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' 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 +// 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, 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 + ), + attributed AS ( + 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' + 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 + 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 '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` + +// 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) — 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 +// 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 0, 0, nil, 0, err + } + defer rows.Close() + + for rows.Next() { + var rowKind string + var zoneID *uuid.UUID + var name *string + var count int + var sortKey *int + var totalCol *int + if err := rows.Scan(&rowKind, &zoneID, &name, &count, &sortKey, &totalCol); err != nil { + return 0, 0, nil, 0, err + } + 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 + } + } + } + if err := rows.Err(); err != nil { + 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 count, 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. 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 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) + 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..faf9591c --- /dev/null +++ b/backend/internal/store/pg_store_monitor_integration_test.go @@ -0,0 +1,398 @@ +package store + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestGetMonitorOverview_RealPostgres_InvariantHoldsByConstruction proves, +// against a REAL Postgres database, the load-bearing correctness property +// 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 +// 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 — 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 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)") + } + + 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. + // 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. + // 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 + checkedInAt time.Time + }{ + {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 + 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, a.checkedInAt, deletedAt, now, + ); err != nil { + t.Fatalf("insert attendee[%d]: %v", i, err) + } + } + 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() + 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 + 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. + 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) + } + + // 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 != 7 { + t.Errorf("total = %d, want 7 (excludes the soft-deleted A6)", total) + } + 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 { + 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 — 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 != 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 + 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 != 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 + // 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) + 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', 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 { + t.Errorf("station2.CheckinCount = %d, want 2", got) + } + if got := byID[stationless].CheckinCount; got != 1 { + 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/internal/store/pg_store_monitor_test.go b/backend/internal/store/pg_store_monitor_test.go new file mode 100644 index 00000000..24cf4ad9 --- /dev/null +++ b/backend/internal/store/pg_store_monitor_test.go @@ -0,0 +1,405 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + pgxmock "github.com/pashagolub/pgxmock/v4" +) + +// --- 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, 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. +func monitorOverviewRows() *pgxmock.Rows { + return pgxmock.NewRows([]string{"row_kind", "zone_id", "name", "count", "sort_key", "total"}) +} + +// 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) + } + defer mock.Close() + + eventID := uuid.New() + zoneA := uuid.New() + zoneB := uuid.New() + zoneEmpty := uuid.New() + + mock.ExpectQuery(getMonitorOverviewSQL). + WithArgs(eventID). + 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} + total, checkedIn, zones, unattributed, err := s.GetMonitorOverview(context.Background(), eventID) + if err != nil { + 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)) + } + 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 + } + 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) + } +} + +// 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) + } + defer mock.Close() + + eventID := uuid.New() + mock.ExpectQuery(getMonitorOverviewSQL). + WithArgs(eventID). + 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} + total, checkedIn, zones, unattributed, err := s.GetMonitorOverview(context.Background(), eventID) + if err != nil { + 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)) + } + if unattributed != 0 { + t.Errorf("unattributed = %d, want 0", unattributed) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// 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. +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) + } +} + +// --- 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. 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() + 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 } diff --git a/backend/main.go b/backend/main.go index 16437fbb..00e51206 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,26 @@ 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. + // + // 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) + } + 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 +87,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/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); diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 662fd49a..6dfcf334 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,138 @@ 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/{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" } + "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/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..19b96a55 --- /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 (`- [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). + +**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. + +- [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` + +--- + +### 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. + +- [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` + +--- + +### 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`). + +- [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` + +--- + +### 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. + +- [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` + +--- + +### 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. + +- [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` + +--- + +### 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. + +- [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` + +--- + +### 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). + +- [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` + +--- + +### 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". + +- [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` + +--- + +### 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). + +- [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 + +- [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` + +--- + +## 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. 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). diff --git a/packages/ui/src/components/status-pill.test.tsx b/packages/ui/src/components/status-pill.test.tsx index fb8923c4..cc8b5d6d 100644 --- a/packages/ui/src/components/status-pill.test.tsx +++ b/packages/ui/src/components/status-pill.test.tsx @@ -26,4 +26,114 @@ 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(); + }); + }); + + // 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 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 the status-colored dot -- no pill chrome, no icon", () => { + const { container } = render(); + 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 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)", () => { + 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 757fa044..25364869 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,10 +24,71 @@ 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; + /** + * 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 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"; } -export function StatusPill({ status, label, icon, className }: 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} + + {/* 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} + + ); + } + 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/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/home/LiveStrip.test.tsx b/panel/src/features/home/LiveStrip.test.tsx index 98b5f782..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"; @@ -9,33 +9,81 @@ import type { components } from "../../shared/api/schema"; import "../../shared/i18n"; type ApiEvent = components["schemas"]["Event"]; +type MonitorSnapshot = components["schemas"]["MonitorSnapshot"]; function apiEvent(overrides: Partial & { id: string; name: string }): ApiEvent { return { tenant_id: "t1", created_at: "", updated_at: "", ...overrides }; } -// LiveStrip renders a `Link` to `/events/$eventId`, which needs a router -// context to resolve — same minimal single-route harness LoginScreen.test.tsx -// uses (these tests exercise LiveStrip's own rendering, not routing). +// LiveStrip renders `Link`s to `/events/$eventId` and `/events/$eventId/monitor`, +// which need a router context to resolve — same minimal single-route harness +// LoginScreen.test.tsx uses (these tests exercise LiveStrip's own rendering, +// not routing). `Link`'s `to` prop type-checks against the REAL registered +// router (app/router.tsx's module augmentation), not this local test router, +// so an unregistered-here-but-real route still type-checks and resolves an +// href via path interpolation. const testRouter = createRouter({ routeTree: createRootRoute({ component: () => null }) }); 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 { + return { + totals: { checked_in: 120, total: 200, rate_per_min: 3.4, peak: null, est_done_at: null }, + zones: [ + { zone_id: "z-main", name: "Main hall", checked_in: 100 }, + { zone_id: "z-vip", name: "VIP", checked_in: 15 }, + ], + unattributed: 5, + stations: [], + recent: [], + ...overrides, + }; +} + +// Task 9 -- RunningCard mounts `useMonitorStream`, which opens a fetch- +// streaming connection to `.../monitor/stream` unconditionally (same "mock +// every endpoint this page/card hits" discipline MonitorPage.test.tsx's own +// `monitorStreamHandler` documents) -- an empty, never-closing stream is +// enough since these tests don't assert live-pill/reconnect nuances (that's +// MonitorPage's own concern). +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" } }); + }); +} + +let statsGetCount = 0; + const server = startMswServer( - http.get("http://api.test/api/events/:eventId/stats", () => - HttpResponse.json({ - total_attendees: 200, - checked_in: 120, - zone_stats: { allowed: 100, no_access: 15, not_registered: 5 }, - }), - ), + http.get("http://api.test/api/events/:eventId/monitor", () => HttpResponse.json(monitorSnapshotBody())), + monitorStreamHandler(), + // Task 9 dropped RunningCard's `useEventStats(poll)` call entirely -- this + // handler stays registered (not removed) specifically so the "no /stats + // request" test below has something concrete to count against, rather + // than a negative assertion that would pass just as well if the endpoint + // were unmocked (MSW's `onUnhandledRequest: "error"` would only catch a + // stray call if the harness happened to hit an unmocked URL by accident, + // not prove the absence of a request to a URL that IS mocked). + http.get("http://api.test/api/events/:eventId/stats", () => { + statsGetCount += 1; + return HttpResponse.json({ total_attendees: 200, checked_in: 120 }); + }), http.get("http://api.test/api/events/:id/readiness", () => HttpResponse.json({ ready: false, @@ -54,9 +102,10 @@ void server; describe("LiveStrip", () => { beforeEach(() => { window.__ENV__ = { API_URL: "http://api.test" }; + statsGetCount = 0; }); - it("renders the running-event card: LIVE NOW pill, name, checked-in counter, progress bar, Open-event link", async () => { + it("renders the running-event card: LIVE NOW pill, name, checked-in counter, progress bar, Open-event link, Open-monitor link", async () => { const running = apiEvent({ id: "evt-running", name: "Tech Summit", @@ -72,14 +121,48 @@ describe("LiveStrip", () => { expect(screen.getByText(/200/)).toBeInTheDocument(); expect(screen.getByRole("progressbar")).toBeInTheDocument(); - const link = screen.getByRole("link", { name: /Open event/ }); - expect(link).toHaveAttribute("href", "/events/evt-running"); + const openEventLink = screen.getByRole("link", { name: /Open event/ }); + expect(openEventLink).toHaveAttribute("href", "/events/evt-running"); + + const openMonitorLink = screen.getByRole("link", { name: /Open monitor/ }); + expect(openMonitorLink).toHaveAttribute("href", "/events/evt-running/monitor"); + + // Counters/progress now come from the monitor snapshot (Task 5/6), not + // the old per-verdict `zone_stats` read -- this is real zone-NAME data, + // so it's expected (and required) to render actual zone names now, + // unlike the pre-Task-9 test which asserted the opposite. + expect(await screen.findByTestId("home-zone-z-main")).toHaveTextContent("Main hall: 100"); + expect(screen.getByTestId("home-zone-z-vip")).toHaveTextContent("VIP: 15"); + + // unattributed = 5 (> 0) in this fixture -- the mini zone line includes it. + expect(screen.getByTestId("home-zone-unattributed")).toHaveTextContent("Unattributed: 5"); + + // Binding regression: RunningCard must never hit the old per-event stats + // endpoint anymore. + expect(statsGetCount).toBe(0); + }); + + it("omits the unattributed mini-line when unattributed is 0", async () => { + server.use( + http.get("http://api.test/api/events/:eventId/monitor", () => + HttpResponse.json( + monitorSnapshotBody({ + zones: [{ zone_id: "z-main", name: "Main hall", checked_in: 120 }], + unattributed: 0, + }), + ), + ), + ); + const running = apiEvent({ + id: "evt-no-unattributed", + name: "Perfect Coverage Event", + start_date: "2026-07-14T09:00:00Z", + end_date: "2026-07-14T18:00:00Z", + }); + renderWithProviders(); - // zone_stats is a per-VERDICT breakdown (allowed/no_access/not_registered), - // never per-zone-name — asserting the honest verdict labels render, and - // that no fabricated zone name (e.g. "Main hall") ever appears. - expect(await screen.findByText(/100/)).toBeInTheDocument(); - expect(screen.queryByText(/Main hall/i)).not.toBeInTheDocument(); + expect(await screen.findByTestId("home-zone-z-main")).toHaveTextContent("Main hall: 120"); + expect(screen.queryByTestId("home-zone-unattributed")).not.toBeInTheDocument(); }); it("shows an 'All day' label instead of a fabricated midnight time range for a date-only running event", async () => { @@ -98,7 +181,7 @@ describe("LiveStrip", () => { expect(screen.queryByText(/12:00 AM/)).not.toBeInTheDocument(); }); - it("shows a loading state (not fabricated zero check-ins) while stats are still loading", () => { + it("shows a loading state (not fabricated zero check-ins) while the monitor snapshot is still loading", () => { const running = apiEvent({ id: "evt-loading", name: "Loading Event", @@ -107,15 +190,17 @@ describe("LiveStrip", () => { }); renderWithProviders(); - // Before the MSW-mocked stats response resolves, the real counter/progress - // bar must not be visible with a misleading "0 / 0". + // Before the MSW-mocked snapshot response resolves, the real counter/ + // progress bar/zone line must not be visible with misleading fabricated + // values. expect(screen.queryByText(/0 \/ 0/)).not.toBeInTheDocument(); expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + expect(screen.queryByText(/Unattributed/)).not.toBeInTheDocument(); }); - it("shows an error message (not fabricated zero check-ins) when stats fail to load", async () => { + it("shows an error message (not fabricated zero check-ins) when the monitor snapshot fails to load", async () => { server.use( - http.get("http://api.test/api/events/:eventId/stats", () => HttpResponse.json({ error: "boom" }, { status: 500 })), + http.get("http://api.test/api/events/:eventId/monitor", () => HttpResponse.json({ error: "boom" }, { status: 500 })), ); const running = apiEvent({ id: "evt-stats-error", @@ -130,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", @@ -146,6 +266,10 @@ describe("LiveStrip", () => { const link = screen.getByRole("link", { name: /Open event/ }); expect(link).toHaveAttribute("href", "/events/evt-upcoming"); + + // UpcomingCard regression: Task 9 is RunningCard-only -- no monitor CTA + // or snapshot data on the upcoming-fallback card. + expect(screen.queryByRole("link", { name: /Open monitor/ })).not.toBeInTheDocument(); }); it("renders nothing when there is neither a running nor an upcoming event", () => { diff --git a/panel/src/features/home/LiveStrip.tsx b/panel/src/features/home/LiveStrip.tsx index 673508d7..27ecbead 100644 --- a/panel/src/features/home/LiveStrip.tsx +++ b/panel/src/features/home/LiveStrip.tsx @@ -1,9 +1,11 @@ -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"; import { isDateOnly, type ApiEvent } from "../events/eventTiming"; -import { useEventReadiness, useEventStats } from "../events/hooks"; +import { useEventReadiness } from "../events/hooks"; +import { useMonitorSnapshot } from "../monitor/hooks"; +import { useMonitorStream } from "../monitor/useMonitorStream"; export interface LiveStripProps { running: ApiEvent | undefined; @@ -43,38 +45,72 @@ function formatRunningWindow(event: ApiEvent, locale: string, allDayLabel: strin return parts.length > 0 ? parts.join(" · ") : null; } +// P4.2 Task 9 -- RunningCard's counters/progress used to come from +// `useEventStats(event.id, {poll: true})` (15s polling) plus a dead +// `stats.data?.zone_stats` read (always undefined -- that field only +// appears with a `?zone=` param the hook never sent; it's the unrelated P2 +// per-VERDICT access-control breakdown, not a per-zone-name one). Both are +// replaced by Task 5/6's live monitor data layer: `useMonitorSnapshot` +// fetches the same totals/zones the monitor page itself renders (board 7e), +// 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. 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 stats = useEventStats(event.id, { poll: true }); - const total = stats.data?.total_attendees ?? 0; - const checkedIn = stats.data?.checked_in ?? 0; + const snapshot = useMonitorSnapshot(event.id); + useMonitorStream(event.id); + const total = snapshot.data?.totals.total ?? 0; + const checkedIn = snapshot.data?.totals.checked_in ?? 0; + const zones = snapshot.data?.zones ?? []; + const unattributed = snapshot.data?.unattributed ?? 0; const timing = formatRunningWindow(event, i18n.language, t("homeAllDay")); - const zoneStats = stats.data?.zone_stats; return (
- - - - - - {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}
- + {/* Board 1c/1d precedent (p4.2-board-7e-extract.md): "Open monitor" + sits beside the running card's existing CTA. */} +
+ + +
- {stats.isLoading ? ( + {/* 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 ? ( - ) : stats.isError ? ( + ) : !snapshot.data ? (

{t("homeStatsLoadError")}

) : (

@@ -85,22 +121,32 @@ 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.data ? ( + <> + + {/* 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 ? ( + + {/* 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} +
+ ) : null} + ) : null}
diff --git a/panel/src/features/monitor/MonitorPage.test.tsx b/panel/src/features/monitor/MonitorPage.test.tsx new file mode 100644 index 00000000..0e43b300 --- /dev/null +++ b/panel/src/features/monitor/MonitorPage.test.tsx @@ -0,0 +1,765 @@ +// 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 { act, render, screen, waitFor, within } 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. */} + + , + ); + // `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) { + 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" } }); + }); +} + +// 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)), + 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(); + }); +}); + +// 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(); + // 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(); + // 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(); + }); + + 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(); + 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-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 }, + ], + }); + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("Kiosk C"); + // 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 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"); + }); +}); + +// 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); + + // 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 () => { + 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); + }); + + // 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 }], + 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. +// 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(); + 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(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(liveRing()).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(liveRing()).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, + ); + + // 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 new file mode 100644 index 00000000..c4167d48 --- /dev/null +++ b/panel/src/features/monitor/MonitorPage.tsx @@ -0,0 +1,221 @@ +// 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 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). +// +// 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, StatusPill } 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"; +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 ( +
+ + +
+ ); + } + + // 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 ( +
+ {/* 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")}

+ +
+ ); + } + + 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 · (reconnecting badge, when the stream is + down) · Exit. */} +
+ {stream.status === "error" ? ( + // Finding C3: a terminal stream failure (401/403 tenant_suspended/ + // documented 4xx) has already stopped reconnecting for good -- + // showing "LIVE" or "Reconnecting" here would misrepresent a dead + // connection as merely degraded. The global handling this status + // triggers (useMonitorStream.ts -- session redirect / suspension + // takeover) runs independently of this badge. + + + + ) : ( + <> + + + + {/* 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} + + )} +

{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 ? ( + <> +
+ + +
+
+ + +
+ + ) : !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. + <> +
+ + +

+ {t("monitorSnapshotLoadError")} +

+
+
+
+
+ + ) : ( + <> +
+ + +
+ + {/* Right column (board 7e) -- Stations card (liveness, Task 8) + above the read-only Last-scans card, which grows to fill + the remaining height (`flex-1` on the wrapper, matching the + board's own "flex:1" note on this card). */} +
+
+ +
+
+ +
+
+ + )} +
+
+ ); +} diff --git a/panel/src/features/monitor/RecentFeedCard.tsx b/panel/src/features/monitor/RecentFeedCard.tsx new file mode 100644 index 00000000..4b53dfa0 --- /dev/null +++ b/panel/src/features/monitor/RecentFeedCard.tsx @@ -0,0 +1,123 @@ +// P4.2 Task 8 -- the live monitor's "Last scans" card (board 7e, +// p4.2-board-7e-extract.md): compact, READ-ONLY rows -- bare stroke icon +// (no circle badge) + name/zone + mono timestamp. Unlike P4.1's station +// rail (RecentScansRail.tsx), there are NO action buttons here at all -- +// the olabel is explicit ("glanceable from across the room, read-only, no +// prep chrome"). +// +// `recent[]` reuses CheckinActionRow (the same shape as the check-in +// station's own feed) -- `action` is one of "checkin" | "undo" | "reprint". +// Only "checkin" rows are a verdict (and always the SAME verdict: +// checkin_actions only ever logs a 'checkin' row on outcome "checked_in", +// never on "already_checked_in"/"blocked" -- see backend +// pg_store_checkin_test.go's own comment, "never already_checked_in, never +// an [other outcome]"), so `verdictClasses.allowed` is the ONLY verdict +// token this card ever reaches for. `undo`/`reprint` are NOT verdicts -- +// they're neutral, muted icons per the plan's Global Constraints +// (`RotateCcw`/`Printer`, `text-muted-foreground`), never colored via +// `verdictClasses`. +// +// Zone name is derived, not stored on the row: `station_id` -> the +// snapshot's own `stations[]` -> that station's `zone_id` -> the +// snapshot's own `zones[]`. Omitted (not a placeholder dash) whenever any +// link in that chain is missing (station-less row, station with no zone, +// or -- defensively -- a zone_id that doesn't match a current zone). +import { CheckCircle2, Printer, RotateCcw } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle, verdictClasses } from "@idento/ui"; +import { useTranslation } from "react-i18next"; +import type { components } from "../../shared/api/schema"; + +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[]; + zones: MonitorZone[]; +} + +// Hand-rolled UTC HH:MM:SS formatter -- same convention (duplicated +// per-file on purpose) as VerdictCard.tsx's/RecentScansRail.tsx's own +// formatUtcHHMM: a viewer's local timezone must never shift a +// server-recorded check-in-domain moment. Seconds are included here (board +// 7e's "Last scans" rows are the ONLY monitor-screen timestamps precise +// enough to need them -- Totals/peak only need HH:MM). +function formatUtcHHMMSS(iso: string): string { + const d = new Date(iso); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const mm = String(d.getUTCMinutes()).padStart(2, "0"); + const ss = String(d.getUTCSeconds()).padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +function zoneNameFor(row: CheckinActionRow, stations: MonitorStationRow[], zones: MonitorZone[]): string | null { + if (!row.station_id) return null; + const station = stations.find((s) => s.id === row.station_id); + if (!station || !station.zone_id) return null; + const zone = zones.find((z) => z.zone_id === station.zone_id); + return zone ? zone.name : null; +} + +export function RecentFeedCard({ recent, stations, zones }: RecentFeedCardProps) { + const { t } = useTranslation(); + + return ( + + + {t("monitorRecentTitle")} + + + {recent.length === 0 ? ( +

{t("monitorRecentEmpty")}

+ ) : ( + recent.map((row) => { + const zoneName = zoneNameFor(row, stations, zones); + const Icon = row.action === "checkin" ? CheckCircle2 : row.action === "undo" ? RotateCcw : Printer; + const iconClass = row.action === "checkin" ? verdictClasses.allowed.text : "text-muted-foreground"; + return ( +
+ + + {t(ACTION_LABEL_KEY[row.action])} + + + + {row.attendee.first_name} {row.attendee.last_name} + + {zoneName ? ( + + · {zoneName} + + ) : null} + + + {formatUtcHHMMSS(row.created_at)} + +
+ ); + }) + )} +
+
+ ); +} diff --git a/panel/src/features/monitor/StationsCard.tsx b/panel/src/features/monitor/StationsCard.tsx new file mode 100644 index 00000000..a371f1d1 --- /dev/null +++ b/panel/src/features/monitor/StationsCard.tsx @@ -0,0 +1,115 @@ +// P4.2 Task 8 -- the live monitor's "Stations" card (board 7e, +// p4.2-board-7e-extract.md): one row per check-in station -- a colored dot +// (green = fresh, amber = stale) + name + running check-in count. The +// board extract explicitly flags amber as an overloaded token (it's ALSO +// the verdict-warning color for "not_registered" -- see RecentFeedCard.tsx) +// and calls out its own answer: a stale row ALSO carries a text duration +// label ("stale 40 s"), so staleness is never conveyed by color alone +// (WCAG 1.4.1, the same discipline VerdictCard.tsx's own comment +// establishes for verdict colors). +// +// `now` is NOT read internally (no second ticker/interval here) -- it's +// 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. +// +// 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 -- `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"; +import { stationStaleness } from "./liveness"; + +type MonitorStationRow = components["schemas"]["MonitorStationRow"]; + +export interface StationsCardProps { + stations: MonitorStationRow[]; + now: number; +} + +export function StationsCard({ stations, now }: StationsCardProps) { + const { t, i18n } = useTranslation(); + const numberFmt = new Intl.NumberFormat(i18n.language); + + return ( + + + {t("monitorStationsTitle")} + + + {stations.length === 0 ? ( +

{t("monitorStationsEmpty")}

+ ) : ( + stations.map((station) => { + const staleness = stationStaleness(station.last_seen_at, now); + // The dot's own accessible label -- exposed via StatusPill's + // `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"); + return ( +
+ + + + {station.name} + {staleness.stale ? ( + + {t("monitorStaleFor", { s: staleness.seconds })} + + ) : ( + + {t("monitorStationOnline")} + + )} + + {numberFmt.format(station.checkin_count)} + +
+ ); + }) + )} +
+
+ ); +} 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/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/liveness.test.ts b/panel/src/features/monitor/liveness.test.ts new file mode 100644 index 00000000..4f7bb940 --- /dev/null +++ b/panel/src/features/monitor/liveness.test.ts @@ -0,0 +1,37 @@ +// P4.2 Task 8 -- pure staleness math for the Stations card (board 7e: +// a per-row amber dot + "stale Ns" duration label, never a binary +// online/offline flag). STATION_STALE_MS is the plan's Global Constraints +// number verbatim (45s) -- heartbeat cadence itself stays 20s (P4.1, +// untouched), so a station is only flagged stale after missing more than +// two heartbeats. +import { STATION_STALE_MS, stationStaleness } from "./liveness"; + +describe("STATION_STALE_MS", () => { + it("is 45 seconds, per the plan's Global Constraints", () => { + expect(STATION_STALE_MS).toBe(45_000); + }); +}); + +describe("stationStaleness", () => { + const lastSeenAt = "2026-07-18T12:00:00.000Z"; + const lastSeenMs = Date.parse(lastSeenAt); + + it("is fresh just under the 45s threshold (44.9s since last-seen)", () => { + const now = lastSeenMs + 44_900; + expect(stationStaleness(lastSeenAt, now)).toEqual({ stale: false, seconds: 44 }); + }); + + it("is stale just over the 45s threshold (45.1s since last-seen), reporting the elapsed whole seconds", () => { + const now = lastSeenMs + 45_100; + expect(stationStaleness(lastSeenAt, now)).toEqual({ stale: true, seconds: 45 }); + }); + + it("is fresh at 0s (a heartbeat that just landed)", () => { + expect(stationStaleness(lastSeenAt, lastSeenMs)).toEqual({ stale: false, seconds: 0 }); + }); + + it("keeps reporting a growing seconds count well past the threshold", () => { + const now = lastSeenMs + 125_000; + expect(stationStaleness(lastSeenAt, now)).toEqual({ stale: true, seconds: 125 }); + }); +}); diff --git a/panel/src/features/monitor/liveness.ts b/panel/src/features/monitor/liveness.ts new file mode 100644 index 00000000..07074674 --- /dev/null +++ b/panel/src/features/monitor/liveness.ts @@ -0,0 +1,30 @@ +// P4.2 Task 8 -- pure staleness math for the live monitor's Stations card +// (board 7e, p4.2-board-7e-extract.md): a per-row amber dot PLUS a "stale +// Ns" duration label, never a binary online/offline flag -- the board's own +// answer to P4.1's punted "how stale is stale" question. Pure and +// unit-tested in isolation (liveness.test.ts) so StationsCard.tsx just +// calls this once per row per render, driven by MonitorPage's existing 1s +// ticker (`now`) -- no second ticker/interval is introduced here. +// +// 45s (not the 20s heartbeat cadence itself, which stays untouched from +// P4.1) per the plan's Global Constraints: a station is only flagged stale +// after missing more than two heartbeats, giving one heartbeat's worth of +// slack for ordinary network jitter before the operator sees anything. +export const STATION_STALE_MS = 45_000; + +export interface StationStaleness { + stale: boolean; + seconds: number; +} + +/** + * `lastSeenAt` -- an ISO-8601 timestamp (checkin_stations.last_seen_at, + * mirrored into MonitorStationRow). `now` -- caller-supplied epoch ms + * (MonitorPage's own 1s ticker state), never `Date.now()` read internally, + * so this stays pure and trivially testable. + */ +export function stationStaleness(lastSeenAt: string, now: number): StationStaleness { + const elapsedMs = Math.max(0, now - Date.parse(lastSeenAt)); + const seconds = Math.floor(elapsedMs / 1000); + return { stale: elapsedMs > STATION_STALE_MS, seconds }; +} diff --git a/panel/src/features/monitor/useMonitorStream.test.tsx b/panel/src/features/monitor/useMonitorStream.test.tsx new file mode 100644 index 00000000..f04b82bf --- /dev/null +++ b/panel/src/features/monitor/useMonitorStream.test.tsx @@ -0,0 +1,460 @@ +// 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. +// +// 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 +// 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 { 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 +// 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( + "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 }); + + await waitFor(() => expect(snapshotGetCount).toBe(1)); + await waitFor(() => expect(connections.length).toBe(1)); + connections[0].push("event: hello\ndata: {}\n\n"); + // 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)); + 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 resyncs on the RECONNECT's own hello -- not on the bare reconnect fetch resolving OK", + 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")); + // The initial hello's own coalesced resync (C5) lands here. + await waitFor(() => expect(snapshotGetCount).toBe(2), { timeout: 2000 }); + + 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 }); + + // 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, + ); + + 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); + }); +}); + +// 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 new file mode 100644 index 00000000..c3851938 --- /dev/null +++ b/panel/src/features/monitor/useMonitorStream.ts @@ -0,0 +1,212 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ApiError } from "../../shared/api/ApiError"; +import { handleApiError } from "../../shared/api/handleApiError"; +import { openSseStream } from "../../shared/api/sseStream"; +import { MONITOR_SNAPSHOT_KEY } from "./hooks"; + +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 +// 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 streaming + * connection to `GET /api/events/{eventId}/monitor/stream` (documented in + * openapi.yaml by Task 4) via `shared/api/sseStream.ts`'s `openSseStream` + * and reacts to the decoded frames it dispatches. That helper -- not this + * hook -- 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 transport + * 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"). PR #81 round-2 convergence Finding 3 + * moved that raw `fetch` call itself out of this hook and behind + * `openSseStream` -- this hook has zero direct `fetch` references now, only + * the shared transport does, and this file owns exclusively the + * state-machine logic below (statuses, backoff, coalescing, resync-on-hello, + * terminal-4xx stop). 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", 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` + * 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 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. + * + * 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(); + 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 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"); + const delay = backoffDelayMs(attempt); + attempt += 1; + backoffTimer = setTimeout(() => { + if (!cancelled) void connect(); + }, delay); + } + + async function connect() { + try { + // 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(); + } + }, + }); + + // 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 + // 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. + } + // Every other failure here is retryable (a network error, a stream + // read error, or a 5xx ApiError above). + if (!cancelled) scheduleReconnect(); + } + } + + void connect(); + + return () => { + cancelled = true; + controller.abort(); + if (backoffTimer !== undefined) clearTimeout(backoffTimer); + if (coalesceTimer !== undefined) clearTimeout(coalesceTimer); + }; + }, [eventId, queryClient]); + + return { status }; +} 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/api/parseSse.test.ts b/panel/src/shared/api/parseSse.test.ts new file mode 100644 index 00000000..104c4bbe --- /dev/null +++ b/panel/src/shared/api/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/shared/api/parseSse.ts b/panel/src/shared/api/parseSse.ts new file mode 100644 index 00000000..1aadff3a --- /dev/null +++ b/panel/src/shared/api/parseSse.ts @@ -0,0 +1,51 @@ +// Pure, incremental Server-Sent Events frame parser. No fetch, no React — +// 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 +// 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") }); + } + } + }; +} diff --git a/panel/src/shared/api/schema.d.ts b/panel/src/shared/api/schema.d.ts index c17ace41..b21655b2 100644 --- a/panel/src/shared/api/schema.d.ts +++ b/panel/src/shared/api/schema.d.ts @@ -382,6 +382,40 @@ 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/{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; @@ -1272,6 +1306,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 +3293,131 @@ 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"]; + }; + }; + }; + }; + 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"]; + }; + }; + /** @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: { parameters: { query?: never; 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 02391201..193d495c 100644 --- a/panel/src/shared/i18n/en.json +++ b/panel/src/shared/i18n/en.json @@ -34,11 +34,10 @@ "homeLiveNow": "LIVE NOW", "homeCheckedIn": "checked in", "homeOpenEvent": "Open event →", + "homeOpenMonitor": "Open monitor →", + "homeZoneUnattributed": "Unattributed", "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", @@ -621,5 +620,29 @@ "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", + "monitorLoadError": "Couldn't load this event.", + "monitorBackHome": "← Back to Home", + "monitorStreamError": "Live updates unavailable", + "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.", + "monitorStationsTitle": "Stations", + "monitorStationsEmpty": "No stations yet.", + "monitorStaleFor": "stale {{s}} s", + "monitorRecentTitle": "Last scans", + "monitorRecentEmpty": "No scans yet.", + "monitorStationFresh": "Online", + "monitorStationOnline": "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 76efa193..3ff72a9f 100644 --- a/panel/src/shared/i18n/ru.json +++ b/panel/src/shared/i18n/ru.json @@ -34,11 +34,10 @@ "homeLiveNow": "ИДЁТ СЕЙЧАС", "homeCheckedIn": "зарегистрировано", "homeOpenEvent": "Открыть мероприятие →", + "homeOpenMonitor": "Открыть монитор →", + "homeZoneUnattributed": "Без зоны", "homeNextUp": "Следующее", "homeReadyFraction": "{{done}} из {{total}} готово", - "homeStatsAllowed": "Разрешено", - "homeStatsNoAccess": "Нет доступа", - "homeStatsNotRegistered": "Не зарегистрированы", "homeStatsLoadError": "Не удалось загрузить статистику в реальном времени.", "homeAllDay": "Весь день", "homeUpcoming": "Предстоящие", @@ -623,5 +622,29 @@ "launchTestBadgeNoTemplate": "Сначала создайте шаблон бейджа, чтобы протестировать печать.", "launchStartCheckin": "Начать регистрацию", "launchUnsavedSettingsHint": "Сохраните настройки регистрации перед запуском.", - "launchRegisterError": "Не удалось зарегистрировать станцию. Попробуйте ещё раз." + "launchRegisterError": "Не удалось зарегистрировать станцию. Попробуйте ещё раз.", + "monitorLive": "В ЭФИРЕ", + "monitorUpdatedAgo": "Обновлено {{seconds}} с назад", + "monitorExit": "Выход", + "monitorLoadError": "Не удалось загрузить это мероприятие.", + "monitorBackHome": "← На главную", + "monitorStreamError": "Обновления в реальном времени недоступны", + "monitorTotalsTitle": "Итоги", + "monitorZonesTitle": "По зонам", + "monitorRate": "{{rate}} сканов/мин", + "monitorPeakAt": "пик {{rate}} в {{time}}", + "monitorEstDone": "ожид. завершение {{time}}", + "monitorUnattributed": "Без зоны", + "monitorSnapshotLoadError": "Не удалось загрузить данные монитора.", + "monitorStationsTitle": "Станции", + "monitorStationsEmpty": "Пока нет станций.", + "monitorStaleFor": "устарело {{s}} с", + "monitorRecentTitle": "Последние сканирования", + "monitorRecentEmpty": "Пока нет сканирований.", + "monitorStationFresh": "В сети", + "monitorStationOnline": "В сети", + "monitorRecentActionCheckin": "Зарегистрирован", + "monitorRecentActionUndo": "Отменено", + "monitorRecentActionReprint": "Перепечатано", + "monitorReconnecting": "Переподключение" }