Skip to content

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

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.

  1. Upsert the participant with onConflictDoNothing, which absorbs the race of two first scans. joinSource records first touch; the conflict clause preserves an earlier source, so someone who joined from the rally page is not relabelled booth_scan.

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

  3. Insert the stamp with onConflictDoNothing on (participantId, boothId). Zero rows returned means the booth was already scanned: return already_scanned with the original timestamp. This is what makes a double scan — or a customer re-opening the deep link — harmless.

  4. Recount stamps inside the transaction. Never trust a count read before the lock.

  5. First stamp only, gated on count === 1, record firstStampAt for time-to-first-stamp.

  6. Completion edge. participant.completedAt was 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_participants
    SET completed_at = now()
    WHERE id = $1 AND completed_at IS NULL
    RETURNING id

    justCompletedNow is 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.

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