Skip to content

feat(kiosk): ⚠ N need review corner count on the door display (#1757) - #1764

Open
jee7s wants to merge 4 commits into
mainfrom
feat/kiosk-review-count
Open

feat(kiosk): ⚠ N need review corner count on the door display (#1757)#1764
jee7s wants to merge 4 commits into
mainfrom
feat/kiosk-review-count

Conversation

@jee7s

@jee7s jee7s commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Closes #1757 (#1347 D7 follow-on).

What

  • GET /api/attendance full-access responses carry counts.needReview — the RawBadgeLog reviewReason != null AND reviewedAt = null queue size (the same predicate that IS the unsynced-scans panel).
  • /attendance/current?mode=kiosk renders ⚠ 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

  • Integration: full-access payload counts exactly the un-reviewed parked rows (a dismissed row does not move it).
  • Page: badge shown in kiosk mode with a queue; hidden at zero and hidden off-kiosk; nothing clickable.
  • Full unit tier (298 suites) + integration tier (144 suites) green locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01URSrfChi89qKn1hLbjaX3K

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
@jee7s

jee7s commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

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
@jee7s

jee7s commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

CI's coverage-config unit run caught what my plain local tier ordering missed: two DB-less suites (attendanceKioskAuth, participantPiiMinimization) exercise GET /api/attendance against the real-but-unreachable prisma client, and the new needReview count is the first live query on that path — 500s under them. Fixed in ec8f254 by mocking the count to zero in both. Re-verified with CI's exact command (jest --ci --coverage ...): 298/298 suites, 3029 tests green.

// ⚠ 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 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 deploymigrate 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.

jee7s and others added 2 commits August 31, 2026 20:18
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

target:main PR targets main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kiosk: show ⚠ N need review on the door display (D7 corner count)

2 participants