feat(kiosk): ⚠ N need review corner count on the door display (#1757) - #1764
feat(kiosk): ⚠ N need review corner count on the door display (#1757)#1764jee7s wants to merge 4 commits into
Conversation
A number only, riding the counts object every full-access consumer already reads (the page's own signed poll and the client.py SSE re-broadcast alike), so no new route and no kiosk-client change. Renders only on the kiosk display, with no names, reasons, or link — the panel stays the copy of record. Closes #1757 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URSrfChi89qKn1hLbjaX3K
|
Adversarial cross-check (standing Fable gate): zero confirmed defects. Attacks traced and refuted: no leak to limited/staging/anonymous callers (count runs only in the full-access branch, after every gate); nothing new of value crosses the kiosk's wildcard postMessage (bare aggregate; the payload already carries the display roster); both SSE push shapes deliver or refresh the count, so the badge can't go stale or flicker; the badge/panel predicates are textually identical so the number can't disagree with the queue it advertises. Review-ready. |
attendanceKioskAuth and participantPiiMinimization exercise GET /api/attendance without a database; the needReview aggregate is the one live query on that path, so both now mock it to zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URSrfChi89qKn1hLbjaX3K
|
CI's coverage-config unit run caught what my plain local tier ordering missed: two DB-less suites ( |
| // ⚠ corner count for the door display (§2 D7): parked scans awaiting | ||
| // a human. An aggregate only — names and reasons stay on the panel. | ||
| const needReview = await prisma.rawBadgeLog.count({ | ||
| where: { reviewReason: { not: null }, reviewedAt: null }, |
There was a problem hiding this comment.
This predicate matches the unsynced-scans panel exactly, so it reads as a safe reuse — but the frequency changed, and that's the part worth flagging. The panel query runs on-demand (an admin opens the panel); this same query now runs on every /api/attendance poll — the kiosk polls every 60s and never idle-stops, plus once per poll for every signed-in admin viewer.
RawBadgeLog has no index covering this predicate — only @@index([personId, timestamp]). It's the raw scan log: one append-only row per badge scan, unbounded. So this count() is a full sequential scan of the whole table, running once a minute on the door display forever. Invisible at seed size; a per-minute full-table scan once the log reaches 100k+ rows.
Fix: a partial index matching the predicate (same shape as the repo's existing one-open-visit partial index), which drops the cost to O(queue size):
WHERE reviewReason IS NOT NULL AND reviewedAt IS NULL
Prisma can't express a partial WHERE in schema.prisma, so this needs a hand-written migration — and per the boundary/migration rules it ships as its own migration. Everything else in the PR looks right: additive optional field, aggregate-only (no PII on the kiosk rebroadcast), gated behind isAdmin, predicate == panel size.
🤖 Posted by Claude Code on behalf of @thpr
There was a problem hiding this comment.
Right — the frequency is the whole story, and the panel-shaped reuse hid it. Fixed in 269d47c: hand-written partial index RawBadgeLog_review_queue on exactly this predicate (WHERE reviewReason IS NOT NULL AND reviewedAt IS NULL), same shape as the one-open-visit partial index and its own migration, since Prisma can't express the WHERE. Verified with CI's exact drift command against a scratch DB (migrate deploy → migrate diff --from-config-datasource --exit-code): no difference detected — matching the tombstone-migration precedent for hand-written partials. Also merged main up to the v1.2.2 sync point; full unit + integration tiers green locally.
The queue predicate is counted on every full-access attendance poll; on an append-only scan log with only a (personId, timestamp) index that is a sequential scan per minute, forever. The partial index keeps it O(queue size). Hand-written: Prisma cannot express a partial WHERE. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URSrfChi89qKn1hLbjaX3K
| -- no covering index. A partial index keeps that count O(queue size) instead of | ||
| -- a sequential scan of the whole table. Same shape as the one-open-visit | ||
| -- partial index; Prisma cannot express a partial WHERE, so it is hand-written. | ||
| CREATE INDEX "RawBadgeLog_review_queue" ON "RawBadgeLog"("id") |
There was a problem hiding this comment.
The query-cost fix is right, but this index build regresses the pattern the sibling migration on this exact table already established. 20260818140000_raw_badge_log_client_event_id builds its index with CREATE UNIQUE INDEX CONCURRENTLY and documents why: "RawBadgeLog is a live, high-traffic table — a plain index build would hold a lock across it."
This one is a plain CREATE INDEX. During the deploy migration step it takes a SHARE lock on RawBadgeLog for the full build, blocking every scan INSERT — the check-in hot path — until it completes. It's momentary on a small table today, but the whole justification for this index is that the log grows unbounded; at the size where the index matters, the build blocks door scans.
Match the sibling migration:
CREATE INDEX CONCURRENTLY IF NOT EXISTS "RawBadgeLog_review_queue" ON "RawBadgeLog"("id")
WHERE ("reviewReason" IS NOT NULL AND "reviewedAt" IS NULL);CONCURRENTLY runs outside a transaction automatically (Prisma detects it); IF NOT EXISTS is the retry-safety the sibling calls out — a CONCURRENTLY build killed mid-way leaves an invalid index that a plain re-run trips on.
🤖 Posted by Claude Code on behalf of @thpr
Closes #1757 (#1347 D7 follow-on).
What
GET /api/attendancefull-access responses carrycounts.needReview— theRawBadgeLog reviewReason != null AND reviewedAt = nullqueue size (the same predicate that IS the unsynced-scans panel)./attendance/current?mode=kioskrenders ⚠ N need review next to People Present — kiosk display only, a number only: no names, no reasons, no link that invites a login at the door. The panel stays the copy of record.How it stays small
The count rides inside
counts, which already flows through both delivery paths — the page's own signed poll and client.py's SSE re-broadcast — so there is no new route and zero kiosk-client change (invariant 3: no keyboard, no login).Tests
🤖 Generated with Claude Code
https://claude.ai/code/session_01URSrfChi89qKn1hLbjaX3K