◆ Flow · Attendee lifecycle

From buying a ticket to rating the host

The complete, logically-ordered attendee journey — buy a ticket → travel to the venue → scan the check-in QR → join the room → rate the host. Each step gates the next: you can't check in without a ticket, and you can't rate without having attended.

1
Buy ticket
on the detail page
2
Get ticket
QR + directions
3
Scan QR
at the venue
4
Join
checked in
5
Rate host
after it ends
built every endpoint below is implemented in the experience module — the only stub is payment capture (Stripe isn't wired; purchase assumes it's paid)

01 · The journey, as a flow

Every arrow is guarded. The diamonds are the checks that keep the flow honest — a wrong QR, a missing ticket, an early arrival, or a duplicate all bounce back instead of corrupting state.

flowchart TD A([Open experience detail]) --> B{Ticketed?} B -- yes --> C[Buy ticket · pay all-in] B -- free --> C2[Request to join] C --> D[[Ticket = VALID]] C2 --> D2[[Join request approved]] D --> E[Travel to the venue] D2 --> E E --> F[Scan the event check-in QR] F --> G{Valid token AND holds ticket AND in time window AND not already in?} G -- no --> X[Show reason and stay out] --> F G -- yes --> H[[Attendance = CHECKED_IN · ticket USED · currentGuests plus 1]] H --> I[Joined: event chat opens, see who is here] I --> J{Event ended AND attended AND not yet reviewed?} J -- not yet --> I J -- yes --> K[Rate host 1 to 5 plus a note] K --> L[[Host rating updated]]

02 · Step 1 — Buy the ticket

9:415G
● Live · filling fast
Natural Wine & Strangers
The Aviary, LES · Tonight 8:00 PM
Early Bird
14 left
$12
★ On check-in🥂 2 free cocktails
Total · all-in$12.00
Pay $12
STEP 1 · the attendee

Pick a tier and pay one all-in price

On the experience detail page the guest taps Get tickets, picks a tier, and pays. No fees added at checkout — the price is the price.

ScreenDetail → Checkout → Payment (Stripe sheet) APIPOST /api/experiences/:id/purchase built ReadsGET /api/experiences/:id built — tiers & price Writescreates ExperienceTicket(VALID); atomic tier.quantitySold += 1; returns the ticket + its ticketToken. Payment capture is stubbed (assumed paid).
Guard — tier has remaining > 0 (oversell-safe atomic decrement) and one active ticket per user (a DB partial-unique — a concurrent double-buy rolls the whole purchase back). The host can't buy their own.

03 · Step 2 — Your ticket & directions

9:415G
🎟 You're in
Early Bird · Natural Wine & Strangers
Show at the door if asked — check-in is by scanning the venue code
📍 The Aviary, LESDirections
Tonight · 8:00 PM · free cancel until Jul 5
Get directions
STEP 2 · the attendee

Hold the proof, navigate to the room

The ticket lives in the wallet with its own QR (proof of purchase). The card shows the venue and time; one tap opens maps to the latitude/longitude from the experience.

ScreenYou're in · ticket + calendar + directions APIGET /api/experiences/:id built — called with the attendee's token, it returns myTicket (the ticket + its ticketToken/QR = the proof) and checkedIn, plus the location for directions. Anonymous callers get myTicket: null. StateTicket stays VALID until it is used at check-in (or expires / is refunded)
Guard — only the ticket holder sees the ticket & its token. The venue location is public on the detail; the personal ticketToken is not.

04 · Step 3 — Scan the check-in QR at the venue

9:415G
Check in
Point at the code at the door
The venue displays the event's rotating check-in code
STEP 3 · the attendee scans

The guest scans the event's code — not the other way around

At the venue the host displays the check-in QR — read from GET /api/experiences/minedata[].qrToken (the unique check-in code; there's no separate "get QR" endpoint). The attendee opens Check in and scans it; the app sends just the scanned token — no experience id — and the server resolves which experience it is and finds the caller's VALID ticket.

ScreenQR check-in (scanner) APIPOST /api/experiences/check-in built Body{ "qrToken": "<scanned>", "ticketId": "<mine>" } Inputs from🔗 qrTokenscan the venue QR (the host reads it from GET /experiences/minedata[].qrToken). That one token identifies the experience — no :id is sent. · ticketId optional, from POST /:id/purchasedata.id (the server resolves your VALID ticket either way) Writeson success → ExperienceAttendance(CHECKED_IN), ticket → USED, currentGuests += 1
Guard (4 checks) — ① the qrToken resolves to a live experience (globally unique) · ② the caller holds a VALID ticket for it · ③ now is inside the check-in window (e.g. from startTime − 1h to endTime) · ④ the caller isn't already checked in. Fail any → a specific 4xx, no state change.

05 · Step 4 — Join the event

9:415G
You're in!
Welcome to Natural Wine & Strangers
Who's here7 of 10
M
Maya Chen
★ 4.9 host
Say hi
Open event chat
STEP 4 · joined

Checked in = in the room

The successful check-in is the moment of joining: the attendee is counted (currentGuests), the event chat opens, and they can see who else is here. The host's scanner/dashboard ticks up live.

ScreenYou're in · who's here · event chat APIthe check-in response + GET /api/experiences/:id built for the live count StateAttendance CHECKED_IN unlocks the chat membership & the post-event review (later)
Check-in is the single source of truth for "attended". Everything downstream (chat, review eligibility, the host's earnings/headcount) keys off this one record.

06 · Step 5 — Rate the host

9:415G
How was it?
Natural Wine & Strangers · with Maya
M
★ ★ ★ ★ ★
Add a note for Maya…
Submit review
STEP 5 · after it ends

Only attendees can rate — and only once

After the event ends, the attendee rates the host 1–5 with an optional note. The score rolls up into the host's rating (the ★ 4.9 you see on cards and detail).

ScreenPost-event review APIPOST /api/experiences/:id/review built Body{ "rating": 5, "comment": "Loved it" } Writescreates Review, recomputes the host's average rating
Guard (3 checks) — ① the caller has an Attendance(CHECKED_IN) for this experience (you attended) · ② the event has ended · ③ no existing review from this user (one review each). This is why buying + checking in must come first.

07 · QR check-in mechanics (the important bit)

The subtle part is who scans what. In this flow the venue shows the event's QR and the attendee scans it — so a guest can self-serve at the door. The event token proves "you're at the right event"; the ticket proves "you paid". You need both.

sequenceDiagram participant H as Host / door (shows QR) participant A as Attendee app participant API as API participant DB as Database Note over H: Displays the event check-in QR (encodes qrCodeToken) A->>A: Tap Check in and scan the code A->>API: POST /experiences/check-in {qrToken, ticketId} API->>DB: find experience by qrToken (unique qrCodeToken) — live? API->>DB: is ticketId a VALID ticket for this experience, owned by the caller? API->>API: is now inside the check-in window? already checked in? alt all four guards pass API->>DB: create Attendance CHECKED_IN, ticket to USED, currentGuests plus 1 API-->>A: 200 Welcome — you are in API-->>H: live headcount ticks up else a guard fails API-->>A: 4xx wrong code / no ticket / too early / already in end
Why not just scan the code? Because the code is visible to anyone in the room. Requiring a valid ticket owned by the caller means a passer-by who photographs the QR still can't check in. For extra safety the qrCodeToken can rotate (short TTL), so a screenshot shared later is already stale.

Alternative: host-scans-attendee

The mirror image also works and is sometimes preferred for gated venues: the attendee shows their ticket QR and the host's scanner reads it (POST /experiences/:id/scan {ticketToken}). Same result — an Attendance row — just the opposite direction. This repo already has a ExperienceAttendance model + scan module to build either on.

08 · State machines

Three tiny lifecycles keep the flow consistent. Nothing skips a state.

Ticket

stateDiagram-v2 [*] --> VALID: purchase paid VALID --> USED: checked in at the venue VALID --> REFUNDED: refund before start VALID --> EXPIRED: event ended, never used USED --> [*] REFUNDED --> [*] EXPIRED --> [*]

Attendance

stateDiagram-v2 [*] --> NOT_CHECKED_IN: holds a ticket NOT_CHECKED_IN --> CHECKED_IN: valid scan (4 guards pass) CHECKED_IN --> [*] note right of CHECKED_IN: unlocks chat + review eligibility

Review eligibility

stateDiagram-v2 [*] --> LOCKED: not attended yet LOCKED --> AVAILABLE: CHECKED_IN and event ended AVAILABLE --> SUBMITTED: rating 1 to 5 sent SUBMITTED --> [*]

09 · Why the flow is logically correct

Each action has preconditions that can only be satisfied by completing the earlier steps — so the sequence can't be short-circuited.

ActionPreconditions (must all be true)Bounced with
Buy ticketexperience PUBLISHED; tier ON_SALE; remaining > 0; payment captured409 sold out · 402 payment failed
Check inholds a VALID ticket; scanned qrToken matches the experience; within check-in window; not already checked in403 no ticket · 400 wrong/expired code · 409 too early / already in
Join (chat / count)has Attendance(CHECKED_IN)gated by check-in — no separate call
Rate hostattended (checked in); event ended; no prior review by this user403 didn't attend · 409 too early / already reviewed
The chain: ticket ⟶ (enables) ⟶ check-in ⟶ (enables) ⟶ join ⟶ (enables) ⟶ review. Remove any link and the next action has no way to satisfy its precondition — which is exactly what makes it tamper-resistant.

10 · API map

All of these are now implemented in the experience module (see the cURL docs, attendee-journey-api-docs.html). The only stub is payment capture — purchase assumes the payment was taken.

StepEndpointStatusNotes
ViewGET /api/experiences/:idbuiltdetail: tiers, price, location, host, offer
1 · BuyPOST /api/experiences/:id/purchasebuiltExperienceTicket; atomic quantitySold++
3 · Check inPOST /api/experiences/check-inbuiltresolves the experience from qrToken (no id); writes ExperienceAttendance; verifies ticket + time
4 · Joinbuilta consequence of check-in (count + chat)
5 · RatePOST /api/experiences/:id/reviewbuiltExperienceReview; recompute host average; attend-gated
HostGET /api/experiences/minebuilteach card carries qrToken — the check-in QR the host displays (owner-only); no separate get-QR route
HostGET /api/experiences/:id/ticket-holdersbuiltwho bought + a live checkedIn flag
HostGET /api/experiences/:id/attendeesbuiltwho checked in

Request/response — check-in

curl -X POST "http://localhost:5001/api/experiences/104821/check-in" \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "qrToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "ticketId": "9001" }'
{
  "success": true,
  "message": "You're in — welcome!",
  "data": {
    "experienceId": "104821",
    "attendance": { "status": "CHECKED_IN", "checkedInAt": "2026-07-06T20:04:00.000Z" },
    "ticket": { "id": "9001", "status": "USED" },
    "currentGuests": 7
  }
}
Scope note: the experience module today implements create · publish · detail · nearby · my-list · tickets. The purchase, check-in, and review endpoints above are the designed next steps — this page is the spec for building them. Want them implemented? They slot straight onto the existing qrCodeToken + ExperienceAttendance primitives.