Stamp rally flow
A rally is a set of booths. A participant scans each booth’s QR, and once they hold enough stamps they can claim a prize from staff. Two properties carry the whole design: a booth scan is idempotent, and the completion side effects fire exactly once.
Primary code: packages/core/src/server/trpc/routers/stampRallyPublic.ts
(participant) and stampRally.ts (admin and staff).
flowchart TD
A[Booth QR] -->|token| B[scanBooth]
B --> C{token verifies?}
C -->|no| X[400 Invalid QR]
C -->|yes| D{booth active<br/>rally active<br/>inside startsAt/endsAt?}
D -->|no| Y[400 with the specific reason]
D -->|yes| E[upsert participant]
E --> F[advisory lock on participant]
F --> G{stamp already exists?}
G -->|yes| H[already_scanned + original time]
G -->|no| I[insert stamp, recount in tx]
I --> J{count >= requiredStamps?}
J -->|no| K[success]
J -->|yes| L[flip completedAt WHERE completedAt IS NULL]
L --> M{row flipped?}
M -->|yes| N[success + email + notification]
M -->|no| K
Scanning a booth
Section titled “Scanning a booth”scanBooth takes a signed token, never a raw booth id — verifyStampRallyQRToken
resolves it, and an unverifiable token is a 400.
Four gates before anything is written:
| Gate | Why |
|---|---|
Booth exists and isActive |
Retired booths stop working immediately |
Rally status = 'active' |
A rally that has not opened takes no stamps |
startsAt has passed (when set) |
Narrows the abuse window for a leaked QR |
endsAt has not passed (when set) |
Same |
requiredStamps falls back to the count of active booths when it is not
set, so removing a booth mid-rally does not strand participants at an
unreachable target.
Each scan also records keyed device and network hashes (uaHash, ipHash) via
deriveScanAttribution for abuse triage. They are hashes — never raw PII.
Inside the transaction
Section titled “Inside the transaction”-
Upsert the participant with
onConflictDoNothing, which absorbs the race of two first scans.joinSourcerecords first touch; the conflict clause preserves an earlier source, so someone who joined from the rally page is not relabelledbooth_scan. -
Take an advisory lock on the participant id.
SELECT pg_advisory_xact_lock(hashtext('<participantId>'));Without it, two concurrent scans on different booths under READ COMMITTED each see only their own new stamp and both miss the completion edge.
-
Insert the stamp with
onConflictDoNothingon(participantId, boothId). Zero rows returned means the booth was already scanned: returnalready_scannedwith the original timestamp. This is what makes a double scan — or a customer re-opening the deep link — harmless. -
Recount stamps inside the transaction. Never trust a count read before the lock.
-
First stamp only, gated on
count === 1, recordfirstStampAtfor time-to-first-stamp. -
Completion edge.
participant.completedAtwas read before the lock and may be stale, so the flip is conditional and the returned row count is the only source of truth for “completed just now”:UPDATE stamp_rally_participantsSET completed_at = now()WHERE id = $1 AND completed_at IS NULLRETURNING idjustCompletedNowis true only when a row came back — which is how the completion email and notification fire exactly once even when two scans cross the threshold together.
Claiming a prize
Section titled “Claiming a prize”Staff-side, gated to stamp_rally_staff and superadmin, on
/admin/scanner/stamp-rally-prize.
previewPrizeClaim and claimPrize share one preview function, so the screen
and the write agree. Four outcomes:
| Status | Meaning | Write? |
|---|---|---|
claimed |
A claim row already exists | No |
incomplete |
Not enough stamps | No |
profile_required |
Participant has not completed their profile | No |
| otherwise | Eligible | Insert the claim |
The claim row is unique per participant (onConflictDoNothing on
participantId), so a double tap at the counter cannot hand out two prizes; if
the insert returns nothing, the preview is re-read and a genuine conflict raises
PRIZE_CLAIM_CONFLICT.
Each claim snapshots the completion basis at claim time —
stampsCollectedAtClaim, requiredStampsAtClaim, activeBoothsAtClaim — so
the record survives a later edit to requiredStamps or the booth list.
completedAt is only stamped here when it is still null. scanBooth records
the real completion moment, and overwriting it with the claim time would corrupt
that signal; the guard exists for the edge where an admin lowered
requiredStamps after the fact, so scanBooth never saw a completion.
Every claim is audit-logged with the staff user, the participant and the rally.
voidPrizeClaim is admin-only.
Admin surface
Section titled “Admin surface”/admin/stamp-rally — rallies, booths, the vendor directory, QR generation
(getBoothQR, getRallyQRs), participants, claims and analytics. Creating and
editing is admin + editor; deleting a rally, booth or vendor is admin
only.
