Skip to content

feat(app-space): checkpoint prize barcode + capped scanner handover - #2861

Merged
jung-thomas merged 15 commits into
DEVfrom
worktree-app-space-checkpoint-qr
Oct 20, 2026
Merged

jung-thomas merged 15 commits into
DEVfrom
worktree-app-space-checkpoint-qr

Conversation

@jung-thomas

Copy link
Copy Markdown
Contributor

App Space Checkpoint Prize Barcode

Makes a track's Checkpoint render a scannable identity barcode once the track is complete; the instructor's scanner resolves it to a "prizes claimed: N of 2" count and records a handover, server-capped at 2 per visitor per event.

Fixes the reported bug (no QR at the checkpoint for ?eventId=10000015) — root cause was that the per-track barcode flow was never wired up in the backend, plus the checkpoint rendered blank because the resolver ignored checkpointTitle.

What changed

  • Checkpoint title — getAppSpaceProgress/getEventProgress now fall back to CompletionPathItems.checkpointTitle (bug fix).
  • Session barcode endpoint — new authenticated GET /api/my-prize-barcode?eventId= mints the QR from the logged-in session, encoding { uid: <user UUID>, payload: { eventId } }. Identity never appears in a client URL; Cache-Control: private, no-store.
  • App Space (AppSpace.vue) — renders the checkpoint barcode when the track is complete and authenticated; sign-in hint otherwise; dead PRIZE/EARNED block retired.
  • Scanner backend (ScannerService) — getContestant(accountNumber, eventId) resolves by user UUID, returns event-scoped claimedCount; new claimPrizeForContestant(accountNumber, eventId) inserts a CLAIMED PrizeRecords row atomically (.forUpdate() lock on the Users row, held across count+insert), capped at 2.
  • Scanners (both) — the Vue scanner (/scanner-vue/) and the UI5 scanner (/scanner-ui/) both show "N of 2" and a cap-gated "Record prize handover" button on the new contract.

Design & plan

  • Spec: docs/superpowers/specs/2026-10-20-app-space-checkpoint-prize-barcode-design.md
  • Plan: docs/superpowers/plans/2026-10-20-app-space-checkpoint-prize-barcode.md

Decisions made during build (please sanity-check)

  • Identity = DB user UUID (Users.ID), not sapId/legacyId — works for any login provider; legacyId resolution dropped (no working legacy flow existed).
  • Cap is per-user, per-event, enforced DB-atomically via a Users-row forUpdate lock.
  • No xs-app.json change — the existing ^/api/(.*)$ XSUAA catch-all already authenticates /api/my-prize-barcode.

Known / deferred

  • HANA concurrency not proven by a unit test — SQLite ignores forUpdate(); the mechanism is correct and documented, but the true multi-connection proof needs npm run test:hybrid on real HANA (see post-deploy below).
  • 3 pre-existing page-fallback/page-serve unit failures are not from this branch (fail identically at merge-base 79e59da5).
  • Minor follow-up: getContestant silently falls back to an unscoped count on an unresolved eventId (the paired claim stays event-scoped, so no over-claim risk).

Post-deploy verification (DEV) — required before calling done

Local cds bind can't reach the app's HDI container, so verify on the deployed DEV app:

  1. Complete the CX track logged-in → checkpoint shows a barcode (and renders its title).
  2. Barcode absent for an incomplete track and for an anonymous session.
  3. Scan with the scanner → "0 of 2"; handover → "1 of 2" → "2 of 2"; 3rd attempt → "cap reached".
  4. npm run test:hybrid (with cf login) to exercise the atomic cap on real HANA.

🤖 Generated with Claude Code

Adds GET /api/my-prize-barcode?eventId=<legacyId> — a session-authenticated
endpoint that mints a QR PNG encoding { uid: <Users.ID>, payload: { eventId } }
for the logged-in visitor. Identity is resolved from the XSUAA session via
resolveDbUserScoped; client cannot inject a uid via query string.

Cache-Control: private, no-store (identity-bearing; no CDN/browser cache).
401 when no DB user resolves; 500 on QR generation failure.

Route registered immediately after app.use(basicAuthMiddleware) in server.js,
alongside the existing /api/qrcode handler.
Tasks 3 + 4:
- Add pure helpers checkpointBarcodeUrl / shouldShowCheckpointBarcode
  in checkpoint-barcode.ts (testable without mounting the component)
- Wire authenticated ref from getAppSpaceProgress data.authenticated
- Replace dead PRIZE/EARNED QR block with CHECKPOINT barcode section:
  shows <img src="/api/my-prize-barcode?eventId=..."> when track
  complete + authenticated; shows sign-in hint when not authenticated
- Remove dead qrCodeUrl helper (no other callers confirmed via grep)
- Add 10 tests covering URL shape, gate conditions, and render paths
…helper

shouldShowCheckpointBarcode now takes (item, unlocked, authenticated)
instead of (track, index, authenticated), eliminating the copy of the
CHECKPOINT unlock logic that was already in isItemUnlocked(). The
component wrapper passes isItemUnlocked(track, index) as the unlocked
arg — single source of truth. Tests updated to the flat signature.
- getContestant now accepts accountNumber as a UUID string (Users.ID),
  dropping the parseInt / legacyId lookup path.
- Adds eventId: Integer param; resolves Events by legacyId then scopes
  PrizeRecords count to that event_ID. Degrades gracefully (no event filter)
  when eventId is absent or unresolvable.
- Returns claimedCount: Integer + prizeRecords: '<N> of 2 prizes claimed'
  replacing the per-prize-title enumeration. Completion counts unchanged.
- claimPrize is untouched; all existing ownership tests still pass.
- New test: test/unit/scanner-contestant-count.test.js (4 cases: scoped
  count, event isolation, completion fields present, unknown UUID → 404).
Add ScannerService.claimPrizeForContestant(accountNumber, eventId):
- Inserts a CLAIMED PrizeRecords row for (user UUID, event legacyId) iff
  current CLAIMED count < 2; returns { claimedCount, message }.
- Atomicity: uses SELECT.forUpdate() on the user row to acquire an exclusive
  row-level write lock before reading the prize count. On HANA this
  serializes concurrent calls for the same user. On SQLite (unit tests)
  forUpdate() is a no-op but Node.js single-threaded event loop serializes
  the async chain within one cds.tx.
- Old claimPrize kept in place (still works on legacy pre-existing rows).
- legacyId left null on new rows — nullable on HANA, no @mandatory in CDS.

Tests: test/unit/scanner-claim-capped.test.js (5 tests, all pass)
  (a) 1 CLAIMED → claim → claimedCount===2, new row exists
  (b) 2 CLAIMED → claim → cap message, no new row
  (c) Promise.all two concurrent claims from count 1 → final count 2
…5 regression)

scanner-service-auth.test.js seeded { ID: 'u-alice', legacyId: 8001 } but
called getContestant({ accountNumber: '8001' }) — the legacyId. Task 5 changed
getContestant to resolve by Users.ID (UUID), so '8001' no longer matched.
Changed all three call sites to accountNumber: 'u-alice' (the UUID). Auth
assertions (MobileApp scope guard) are unchanged.
- types.ts: add eventId to ScanResult.payload; add claimedCount to ContestantData
- useScannerApi.ts: getContestant(uid, eventId) passes eventId in URL;
  replace claimPrize with claimPrizeForContestant(uid, eventId) returning
  {claimedCount, message}; old claimPrize wrapper removed (was dead code)
- ScannerApp.vue: parseScanData reads payload.eventId; loadContestant passes
  eventId to getContestant and stores claimedCount; template shows
  "Prizes claimed: N of 2"; handover button disabled at claimedCount >= 2;
  on success shows returned message and refreshes via getContestant;
  manual placeholder updated to new QR shape
- test: scanner-claim.test.ts covers URL shape for both new functions (7/7 pass)
…ybrid test

Finding #1 — UI5 App at /scanner-ui/ still used old payload.recordId shape and
called legacy claimPrize(). Updated to new contract:
- CONTESTANT_DEFAULTS: recordId/status -> eventId/claimedCount
- _getContestant: now accepts (uid, eventId) and sets /claimedCount from result
- _processScanResult: reads payload.eventId (Number), drops recordId/status
- onClaimPrize: calls /claimPrizeForContestant(...) with accountNumber + eventId;
  caps at claimedCount >= 2 (shows MessageBox.warning); shows returned message
- View: primary prize display is "N of 2" counter; button label "Record prize
  handover"; button disabled when claimedCount >= 2 or not loaded; removed
  stale recordId/status sub-line from identity header

Finding #2 — hybrid test scanner.getContestant call passed seed.userLegacyId
(numeric legacyId); getContestant now resolves Users by ID (UUID). Fixed to
pass seed.userId (UUID). No assertions weakened. Needs npm run test:hybrid to
confirm on real HANA.
…ckpoint-qr

# Conflicts:
#	srv/developer-service.js
@jung-thomas
jung-thomas marked this pull request as ready for review October 20, 2026 19:11
@jung-thomas
jung-thomas merged commit 0cba439 into DEV Oct 20, 2026
7 checks passed
@jung-thomas
jung-thomas deleted the worktree-app-space-checkpoint-qr branch October 20, 2026 19:11
jung-thomas added a commit that referenced this pull request Oct 20, 2026
…e-401

fix(#2861): App Space prize-barcode 401 for logged-in users
jung-thomas added a commit that referenced this pull request Oct 20, 2026
…arcode

fix(#2861): prize-barcode 401 — resolve identity from cds.context.user
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant