UC-3 Student checks in at an event via QR
The core loop. Everything else exists to make this one tap trustworthy.
| Primary actor | Student |
| Supporting actors | Event Attendant (displays the rotating code), or nobody: a printed poster on a wall |
| Priority | Must |
| Satisfies | FR-D1, FR-D2, FR-D4, FR-D5, FR-D7, FR-D8, FR-E1 |
| Precondition | The student is a current student; the task's event is running. For the rotating path, signed in and in the app; for the poster path, a session is established on the way (a signed-out student is sent through sign-in and resumed). |
| Trigger | The student taps "Scan" and captures the event QR, or types the event's 7-character backup code; or points their phone's own camera at the printed event poster and opens the link it carries. |
Every week runs on exactly one of two credentials (ADR 0006): the rotating code on a staffed screen, or the printed poster for a location nobody staffs. The server enforces the choice: a poster week mints no rotating token and refuses one, a rotating week refuses a poster token. Both paths land on the same scan endpoint and the same result sheet.
Main success scenario (rotating code, a staffed table)
- The attendant displays the event QR (a signed token, re-minted every 30 seconds) on a projector via the display link or the live-event screen.
- The student scans it in the app.
- The app posts the token with the session cookie.
- The system verifies the signature, expiry and challenge binding, then records a check-in with
method = event_qr, one per student per week. - The week flips to complete, the countdown updates, and a post-check-in tip is shown (UC-6).
Alternative success scenario (printed poster, nobody at the location)
- An admin has printed the week's poster from Live (UC-11). Its QR encodes
https://<host>/checkin#t=<token>: a signed token withtyp = event_poster, valid from the week's window start (nbf) to its window end (exp), carrying the week's printout generation (gen). The token rides in the URL fragment, which no browser sends to any server, so it enters no access log. - The student points their phone's own camera at the poster and opens the link. The app's
/checkinroute stashes the token in session storage. - With no session, the student sees the sign-in screen with one added line, Sign in to finish checking in, and never the event's name (the app cannot know it yet). After SAML the callback returns through
/checkin, and now there is a session. /checkinhands off to the passport, which takes the stashed token exactly once, removes it before the request so a reload cannot resubmit it, and posts it to the same scan endpoint a camera scan uses, showing Checking you in….- The system reads the token's claimed type, verifies the poster signature, resolves the week in this campus's active challenge, confirms the week still runs on the poster and the printout is current, and checks the window against the campus clock. It records a check-in with
method = event_poster. - The same result sheet and tip as a rotating scan (UC-6). Reports count the poster in its own bucket, never as automatic or verified (UC-10).
A student who opens the app first and points the in-app scanner at the poster also succeeds: the scanner recognises the same-origin /checkin#t= URL and submits its token.
Extensions
- 4a. Duplicate scan. 409, "already checked in this week". Nothing is double-counted.
- 4b. Expired code. 400 asking for a rescan of the current code. A slow scan is not a forgery.
- 4c. Invalid, tampered, or another challenge's code. 400, invalid token.
- 4d. Not a current student. 403 before any check-in logic runs.
- No camera or bad lighting. The attendant reads out the backup code; it is accepted through the same path. On a poster week the code is printed in a ruled box under the poster's QR and the student types it the same way; it is keyed on the printout generation, so a reprint changes it, and it records
event_poster(PASS-753, ADR 0006 §7.3). - Poster refusals (400 with a structured
{code, message}detail, each with its own copy that never mentions an attendant or a screen): not_yet_valid— before the window: This event isn't open yet. It opens Thursday, Oct 1 at 10:00 AM. Not logged as a rejection.expired— after the window: This event has ended. If you were there, Student Health Services can check you in. Not logged as a rejection; catch-up on a poster week is staff-only.stale_generation— a printout voided by "Reprint and void old copies": This poster has been replaced. Look for the newer copy at the location. Logged, with the reason on the PASS-738 metric line.wrong_method— a poster token for a week now on the rotating code, or a rotating token (a screenshot, an old display link) for a week now on the poster. Logged.- A repeat scan is the same 409 as the rotating path, and the app draws it in the success register: You've already completed this week.
- Poster offline. The pending check-in is refused with the offline sheet; nothing is recorded and the poster is there to scan again.
- Poster, not enrolled. Recorded anyway: neither scan path has an enrolment gate, and the passport's join banner invites the student to join (ADR 0006 §7.6).
- Legacy self-service check-in. Retired. A student's own check-in requires the live event code; staff can verify a student directly (UC-4) or override with an audit row (UC-11).
Acceptance criteria (Gherkin)
| Feature file | Test module |
|---|---|
qr_event_binding.feature |
test_qr_event_binding_bdd.py |
rotating_qr_token.feature |
test_rotating_qr_token_bdd.py |
scanned_qr_only_checkin.feature and frontend/scanned_qr_only_checkin.feature |
test_scanned_qr_only_checkin_bdd.py |
event_backup_code.feature |
test_event_backup_code_bdd.py |
week_sheet_checkin_cta.feature |
test_week_sheet_checkin_cta_bdd.py |
Plain pytest: test_qr_checkin.py, test_rotating_qr_token.py, test_event_backup_code.py, test_task_qr_ttl.py; for the poster path, test_event_poster.py (mint, verify, the two kinds never cross, inside/before/after the window with the exact copy, reissue voiding the old sheet, both wrong-method directions, the typed code refused, reports) and test_task_checkin_method.py. Frontend: QrScanner.test.tsx; for the poster path, passport/posterCheckIn.test.ts, PosterCheckIn.test.tsx, AuthCallback.test.tsx and the poster scenarios in Passport.test.tsx.
Views
- Process: UC-3 Event QR check-in
- Logical:
CheckInand its one-per-week constraint in the domain model;services/qrandservices/checkinsin system components
Code: backend/app/services/qr.py, backend/app/services/passport.py (record_event_qr_checkin, record_poster_checkin), backend/app/routers/passport.py, frontend/src/components/Passport/QrScanner.tsx, frontend/src/passport/posterCheckIn.ts, frontend/src/components/PosterCheckIn/. ADR 0005 records the display-credential decision and ADR 0006 the poster.
Operational note: rotating WP_QR_SECRET invalidates every live code and every printed poster, which must then be reprinted; the runbook has the procedure.