← All stories

BRANCH · ef-066-qr-reader-checkin

QR reader check-in

EF-066 Persona: Event-day staff Stage: Day-of Roots in: day-of-operations EF reference: EventFarm doc

A staff member at the door scans a guest's QR — from email, from Apple Wallet, from a printed badge. The scan resolves to a guest, the staff sees who they are, taps Check In, the gate opens. The story's center is the failure modes: scanner unavailable, dim lighting, smudged screen, network drop mid-rush, two staff scanning the same guest simultaneously, expired or tampered QR.

Happy path

  1. Staff opens the staff console at the door.

    Lands on the event's guest-list view (day-of-operations trunk). Tap the scan affordance — a button or quick-access icon at the top of the screen.

  2. Camera viewfinder opens.

    Full-screen camera view with a square reticule guide. The phone's flashlight toggle is available in low light. Scan happens continuously — staff doesn't need to tap a button to capture; the QR is read as soon as it's framed.

  3. Guest presents QR; viewfinder reads it; haptic feedback fires.

    A short vibration (navigator.vibrate on Android; ignored on iOS where vibration is privileged) signals successful read. The screen transitions to the guest confirmation screen within ~300ms.

  4. Confirmation screen.

    Guest's name (large), photo if available, access type, any per-guest notes (e.g., "VIP table 3"). Two actions: Check in (primary) and Cancel (secondary, returns to scan view).

  5. Tap Check In. Server records.

    Brief success animation. Auto-returns to scan view ready for the next guest. The just-checked-in row updates in the staff console list (live-sync).

Failure modes

Two staff scan the same guest simultaneously

Trigger: guest with QR walks past staff A's station; staff B at the next station scans them at the same moment.

Server-side idempotency: the second request returns 200 with body indicating "already checked in at <time> by <name>". Both staff see the same final state. Neither sees an error. Audit log records both attempts but only one check-in.

Recovery: Idempotent. Both staff get clean acknowledgments.

Network drops mid-rush

Trigger: 200 guests in 10 minutes; venue WiFi flakes. Check-ins start failing on the network layer.

Per the day-of-operations trunk's offline contract: check-ins queue locally with a per-attempt timestamp. Staff sees the offline banner. Confirmation screens still show success to the attendee (don't lie to a real human at the door). When network returns, queue flushes; conflicts surface for review.

Recovery: Auto-flush on reconnect.

QR is expired (event-day check-in window is closed)

Trigger: the event ended an hour ago; a late guest scans their pre-event QR.

Server returns 410 with code CHECK_IN_WINDOW_CLOSED. Confirmation screen shows: "<Event name> check-in is closed. (Event ended <X minutes/hours ago>.)" Staff can override if they have permission (one-time button "Force check in"); otherwise the guest is informed.

Recovery: Staff override (audit-logged) OR guest is turned away gracefully.

Tampered QR — signature invalid

Trigger: someone tries to scan a QR that's been edited (a friend tried to forge an extra ticket).

Server's HMAC verification fails. The screen shows: "We couldn't recognize that code." Same message as for an unknown / malformed QR — anti-probing principle. Staff is shown a small "Search by name instead" affordance. Server-side log entry at higher severity for security review.

Recovery: Search by name; staff investigates.

Guest is on the waitlist, not confirmed

Trigger: guest holds an invitation QR but their RSVP is in waitlist state (capacity was met earlier).

Confirmation screen shows: "<Guest name> is on the waitlist." Staff has options: Promote to confirmed (if they have permission AND there's now capacity), Decline entry, Send to lobby. The guest is not silently checked in.

Recovery: Staff judgment with a clear UI.

Wrong event — QR is for a different event in the same workspace

Trigger: guest brings a QR for last week's event by mistake.

Server validates token against the current event-id; mismatch returns 404 with code QR_FOR_DIFFERENT_EVENT. Confirmation screen: "This QR is for <Other event name>, not <Current event>." Staff can search by name to see if the guest is on the right list.

Recovery: Search by name on the correct list.

Camera unavailable / permission denied

Trigger: staff's phone has not granted camera permission to the staff app.

Per the day-of-operations trunk: scan view shows "Camera unavailable" message and routes to name search. The trunk's kiosk-camera-unavailable attribute applies to the staff console too — same fallback pattern. The branch does not need to re-spec this failure.

Recovery: Name search.

QR reads but server returns 500

Trigger: QR is valid; events worker is degraded.

Per the trunk's soft-success-on-503 pattern: confirmation screen shows the guest's check-in attempt, queues locally, kiosk-success animation fires. When server recovers, queue drains. Staff sees a small "queued — will sync" indicator that they can ignore unless it persists.

Recovery: Auto-drain.

Already-checked-in re-scan

Trigger: a guest who's been inside and stepped out tries to re-enter; their QR is scanned again.

Confirmation screen shows: "<Guest> already checked in at <time>." Staff has a clear "Allow re-entry" button (no-op on database — they're already checked in). No duplicate row created, no audit event for "already checked in" beyond a log entry.

Recovery: Allow re-entry; no state change needed.

Phone goes to lock screen mid-scan

Trigger: staff's phone locks after 30 seconds of inactivity. Camera permission revoked when re-opening.

Re-opening the app should restore the scan view if it was the active screen. Camera re-prompts only if iOS has revoked permission entirely. The harness can't fully test lock-screen behavior in headless Playwright — flagged as a manual-verification probe.

Recovery: Re-grant permission if needed; resume.

Edge cases

QR on Apple Wallet pass

Apple Wallet QR codes are standard QR — same scan path. The wallet pass also auto-displays the QR when the phone is at the venue's geofence (organizer-configured). The branch doesn't need special handling.

Printed badge with worn / smudged QR

Camera autofocus + the QR error correction (Reed-Solomon) handle moderate damage. If a scan fails 3 times in a row, staff gets a "Try name search" suggestion.

Guest with multiple QRs (registered multiple times under different access types)

Each registration has its own QR. Scanning either one checks in the corresponding registration row. Staff sees which registration they're checking in (access type pill).

Bulk check-in (group of 5 arriving together)

Out of scope for this branch. EF-067 wireless badge printing covers some bulk scenarios. For QR-based bulk: scan each in turn — the success animation is short enough (~300ms) that 5 guests can be processed in ~10 seconds.

Page evaluation

SurfaceDiscoverabilityError UXLayoutOrientation
Staff console · scan affordance Camera-icon button at the top of the staff-console, always visible. If camera permission has not been granted, button label updates to "Enable camera" and tap takes user to settings deep-link. Top right of the console header, thumb-reachable on mobile. Icon plus "Scan" label so first-time users know what it does.
Camera viewfinder Reticule guide centered. Flashlight toggle and Cancel button visible. Camera errors render as overlay messages without breaking the surface. Full-screen. Hardware orientation respected (portrait / landscape). "Point at QR code" hint visible on first open of the session.
Guest confirmation screen Guest name is the largest visual element. Check-in button is the primary action. If guest is in unusual state (waitlist / wrong event / already-checked-in), the confirmation screen explicitly shows that state INSTEAD of the normal Check In button. Full-screen card. Photo, name, access type, notes, action buttons. Past-tense success message: "Welcome, <name>!" after check-in.

Acceptance signals

  • Server-side check-in: POST /v1/admin/events/:id/check-ins returns 200 (or 200-with-already-checked-in body) on a valid QR.
  • event_guests.attendance_status = checked_in for the resolved guest_id.
  • Audit log row exists with actor=staff_id, action=check-in, target=guest_id.
  • Confirmation screen renders within 300ms of QR read.
  • Live-sync: the staff console's guest list reflects the check-in within 2s.
  • No console errors at severity ≥ warn.

Stable test attributes

Branch-specific only; trunk and component attributes inherited.

Visibility teeth. Each attribute must be present AND effectively visible when the relevant state is active. Hiding without removal is a Ratchet violation.

data-testWherePurpose
scan-ctaStaff console headerTap to open camera viewfinder
scan-viewfinderFull-screen overlayCamera + reticule
scan-flashlight-toggleInside viewfinderFlashlight on/off
scan-cancel-ctaInside viewfinderReturn to staff console
scan-hintInside viewfinder"Point at QR code" first-open hint
scan-confirm-screenAfter successful readGuest confirmation screen
scan-confirm-nameInside scan-confirm-screenLarge guest name
scan-confirm-access-typeInside scan-confirm-screenAccess type pill
scan-confirm-photoInside scan-confirm-screenGuest photo if available
scan-confirm-notesInside scan-confirm-screenPer-guest notes (VIP, vegetarian, etc.)
scan-confirm-checkin-ctaInside scan-confirm-screenPrimary check-in button
scan-confirm-cancel-ctaInside scan-confirm-screenReturn to scan view
scan-successAfter check-inBrief success animation; auto-returns to scan view
scan-window-closedQR-but-window-closed state"<Event> check-in is closed" with optional Force check-in
scan-force-checkin-ctaInside scan-window-closed (permission-gated)Override button for staff with permission
scan-waitlisted-stateQR resolves to waitlisted guestThree-button decision UI
scan-promote-from-waitlistInside scan-waitlisted-statePromote to confirmed
scan-decline-entryInside scan-waitlisted-stateDecline entry
scan-send-to-lobbyInside scan-waitlisted-stateSend to lobby for later
scan-wrong-eventQR for different event"This QR is for <Other event>"
scan-already-checked-inRe-scan"Already checked in at <time>" with allow-re-entry button
scan-allow-reentry-ctaInside scan-already-checked-inNo-op confirmation
scan-invalid-qrTampered or unknown QRAnti-probing-safe message + name search affordance
scan-search-by-name-fallbackInside any error stateQuick link to name search

Agent test plan

Inherits day-of-operations trunk preconditions. Camera-based probes are limited in headless Playwright — fixtures provide pre-decoded QR payload as a query parameter to bypass the actual camera read.

Probe list
- scan-cta-visible: assert scan-cta visible on staff-console
- valid-qr-scan: navigate to /scan?token=${fixture.validQr}, assert scan-confirm-screen visible AND scan-confirm-name contains guest name AND scan-confirm-checkin-cta visible
- check-in-success: click scan-confirm-checkin-cta, assert API POST returns 200 AND scan-success visible AND auto-return to scan view
- two-staff-idempotent: stub two parallel POST check-ins, assert both return 200 with idempotent body
- expired-window: with token from event ended 2h ago, assert scan-window-closed visible AND scan-force-checkin-cta visible IF user has permission
- tampered-qr: with tampered token, assert scan-invalid-qr visible AND content byte-identical to invalid-qr fixture
- waitlisted-three-options: with token whose guest is waitlisted, assert scan-waitlisted-state visible AND all three options visible
- wrong-event: with token for different event, assert scan-wrong-event visible AND scan-search-by-name-fallback visible
- already-checked-in-reentry: scan a guest who's already checked in, assert scan-already-checked-in visible AND scan-allow-reentry-cta visible
- network-drop-queues: stub POST /check-ins to abort, scan and check in, assert kiosk-offline-banner visible (inherited from trunk) AND scan-success still shows
- 503-soft-success: stub POST to 503, assert scan-success appears for staff (queued internally)
- live-sync-updates-list: check in guest, observe staff-counts-checked-in increments within 2s