◆ Host side · experience module

Host console — see who bought tickets & who joined

How a host moves from their list of experiences → one event's dashboard → the buyers list → the door (show the QR / scan a guest) → the live checked-in roster. Every step is tied to a real endpoint (built) or a planned one (to add), and to the matching mockup in design/index.html.

Base http://localhost:5001/api/experiences Auth host-only (owner of the experience) Buyers GET /:id/ticket-holders Joined GET /:id/attendees
📱 Design screens (design/index.html) — this flow lives on 55 Host home58 Host scanner · door56 Broadcast59 Venue offer builder, with edges on 50 Dispute a check-in39 Sold out · waitlist.
built — live in the API today to add — core to this flow later — broader host side

01 · The host lifecycle

Ticketing & check-in only matter after an experience is created and published. This page picks up at "it's live" and follows the host through the two questions they actually ask on event day: who bought? and who's here?

1
My experiences
pick a live event
2
Dashboard
sold · revenue · in
3
Tickets
who bought
4
The door
show QR / scan
5
Attendees
who joined
The spine — a published experience carries a unique qrCodeToken (the check-in code) and accumulates ExperienceTicket rows (buyers) and ExperienceAttendance rows (joined). "Who bought" reads tickets; "who joined" reads attendance; the door either shows that token (built) or scans the guest's ticket token (to add).

02 · Step 1 — My experiences

9:415G
$2,410
Earnings
3
Live events
YOUR EXPERIENCES
Natural Wine & Strangers
PUBLISHED
Jul 6 · The Aviary, LES
7 sold · 6 inOpen ›
Rooftop Supper Club
DRAFT
Jul 12 · not published
STEP 1 · the host

Every experience the host owns — with its check-in code

The host lands on their own list. Each card is compact (listing fields only) and — because this list is host-only — also carries qrToken, the check-in code. That's why there's no separate "get QR" endpoint: the code rides along here.

Screen55 · Host home APIGET /api/experiences/mine built Returnsdata[] = { id, title, coverImage, startTime, status, locationName, currentGuests, capacity, qrToken } · meta { page, limit, total, totalPages } NoteqrToken is null until the experience is published (that's when it's minted).
curl "$BASE/experiences/mine" -H "Authorization: Bearer $HOST_TOKEN" \
  | jq '.data[] | {id, title, status, currentGuests, capacity, qrToken}'
Gap to add — the card shows "7 sold · 6 in", but /mine only returns currentGuests (checked-in), not sold. Add a soldCount to each card, or fetch it from the per-event dashboard (next step).

03 · Step 2 — One event's dashboard

9:415G
7
Tickets sold
6
Checked in
$84
Revenue
70%
Sold
BY TIER
Early Bird5 / 5
General2 / 5
Open the door ›
STEP 2 · the host

Sold, revenue, checked-in — at a glance

Opening one experience shows its numbers. Today the host would stitch this from three calls (ticket-holders meta for sold+revenue, attendees meta for checked-in, GET /:id for tiers). One dashboard endpoint collapses that into a single read.

Screen55 · Host home (event) → 60 · Venue dashboard APIGET /api/experiences/:id/dashboard to add Todayderivable from ticket-holders meta{soldTotal,revenueCents} + attendees meta{currentGuests,capacity} + GET /:id tiers built Returns{ sold, capacity, checkedIn, noShows, revenueCents, pctSold, byTier:[{tierId,name,sold,remaining,revenueCents}], recentCheckIns:[…] }
Until /dashboard exists, the client composes the header from the two list endpoints' meta — nothing blocks the screen; the new endpoint is a convenience + one round-trip.

04 · Step 3 — Who bought tickets

9:415G
AllVALIDUSED
A
Aanya S.
Early Bird · $12
✓ in
L
Leo C.
Early Bird · $12
✓ in
M
Maya P.
General · $12
VALID
R
Rohan D.
General · $12
VALID
S
Sara K.
Early Bird · $12
✓ in
STEP 3 · the host

The buyers list — each with a "checked in?" flag

Every ticket purchased, newest first, with the buyer, tier, price, status, and whether they've already walked in. Paginated and filterable by ticket status; the meta carries the totals the dashboard shows.

Screen55 · Host home (guest list) APIGET /api/experiences/:id/ticket-holders built Query?page=1&limit=20&status=VALID|USED Row{ ticketId, tierId, tierName, price, currency, status, checkedIn, purchasedAt, usedAt, user:{id,name,avatarUrl} } Meta{ page, limit, total, totalPages, soldTotal, revenueCents }
curl "$BASE/experiences/1/ticket-holders?status=VALID&page=1&limit=20" \
  -H "Authorization: Bearer $HOST_TOKEN" | jq '{meta, rows: .data | length}'
Owner-guarded — loadOwned() 404s if the caller isn't the experience's host, so one host can never read another's buyer list.

05 · Step 4 — The door: show the QR / scan a guest

9:415G
Point at the guest's QR
L
Leo C. checked in
GA valid · 2-for-1 redeemed
Dashboard
Offers
Check-ins
$Payouts
STEP 4 · the host at the door

Two check-in models — pick one (or support both)

The design's screen 58 is a door scanner: the host points the phone at the guest's ticket QR. The built flow is the mirror image: the host displays the venue's qrToken and the guest scans it. Both end at the same write (attendance + ticket USED); they differ only in who scans whom.

⚠ Reconciliation — built today = attendee scans venue (POST /experiences/check-in with qrToken). Design screen 58 = host scans guest. To match the mockup, add a host-initiated scan; keep the built one for signage/self-serve.
Show QRGET /api/experiences/mine builtdata[].qrToken, rendered as a QR at the door RotatePOST /api/experiences/:id/qr/rotate built — invalidates old screenshots Host scanPOST /api/experiences/:id/scan to add — body { ticketToken } (the guest's), checks them in host-side
// POST /api/experiences/:id/scan   (host-initiated, matches screen 58)
// body: { "ticketToken": "<from the guest's ticket QR>" }
// 200 → {
//   guest: { id, name, avatarUrl },
//   ticket: { id, tierName, status: "USED" },
//   offer:  { label: "2-for-1", redeemed: true } | null,
//   currentGuests: 7
// }
// guards: ticket belongs to THIS experience · status VALID · not already in
Same guards, mirrored — host-scan validates the ticket by its ticketToken (resolve → must belong to :id, be VALID, owner not already checked in) instead of the attendee validating the venue token. One write path (ExperienceAttendance + ticket → USED + currentGuests += 1) serves both.

06 · Step 5 — Who joined (checked-in roster)

9:415G
L
Leo C.
8:58 PM · GA
A
Aanya S.
8:55 PM · Early Bird
S
Sara K.
8:52 PM · Early Bird
1 ticket holder not yet in
Maya P. · VALID
Dashboard
Offers
Check-ins
$Payouts
STEP 5 · the host

The live roster — who's actually in the room

The attendance list, newest check-in first, with each guest and the time they walked in. The meta carries the live headcount and capacity — that's the "6 / 10" in the header. Cross-referencing with ticket-holders gives the "bought but not yet in" set.

Screen58 · Host scanner (Check-ins tab) APIGET /api/experiences/:id/attendees built Query?page=1&limit=20 Row{ userId, checkedInAt, ticketId, user:{id,name,avatarUrl} } Meta{ page, limit, total, totalPages, currentGuests, capacity }
curl "$BASE/experiences/1/attendees?page=1&limit=20" \
  -H "Authorization: Bearer $HOST_TOKEN" | jq '{in: .meta.currentGuests, cap: .meta.capacity}'
Gaps to add — ① live updates (poll, or push an experience:checkin socket event so "6/10" ticks without refresh) · ② undo a mis-scan (screen 50 Dispute) · ③ a "not yet in" view (a diff of tickets − attendance).

07 · Navigation map & event-day sequence

Every arrow is a real transition from the prototype's link map in design/index.html — the label is the button/tap that fires it. The venue console's bottom tab bar wires 60 Dashboard ⇄ 59 Offers ⇄ 58 Check-ins (Payouts tab is present but unlinked).

flowchart LR S55["55 · Host home"] -->|tap event card| S38["38 · Experience detail"] S55 -->|tap plus new| S52["52 · Create"] S55 -->|tap request row| S23["23 · Person preview"] S60["60 · Venue dashboard"] -->|tap event row| S38 S60 -->|Check-ins tab| S58["58 · Door scanner"] S60 -->|Offers tab| S59["59 · Offer builder"] S59 -->|Dashboard tab| S60 S59 -->|Check-ins tab| S58 S58 -->|Dashboard tab| S60 S58 -->|tap checked-in guest| S23 S56["56 · Broadcast"] -->|send broadcast| S60

Event-day sequence

Both check-in models on one timeline. Green = built, gold = to add.

sequenceDiagram autonumber actor Host participant App as Host app participant API as API (experience) participant DB as Database actor Guest Host->>App: Open my experiences App->>API: GET /experiences/mine API-->>App: cards + qrToken (per event) Host->>App: Open "Natural Wine" App->>API: GET /:id/ticket-holders (who bought) App->>API: GET /:id/attendees (who joined) API-->>App: buyers + live 6 of 10 Note over Host,Guest: At the door — two ways in alt Built · attendee scans venue Guest->>API: POST /experiences/check-in {qrToken} else To add · host scans guest (screen 58) Host->>API: POST /:id/scan {ticketToken} end API->>DB: create Attendance, ticket to USED, guests plus 1 API-->>App: experience:checkin (live count to add) App-->>Host: roster updates 7 of 10

08 · API plan — the full host-side build list

Everything the host side needs, split by status. Group A already ships the "see tickets & joined users" story end-to-end; Group B rounds out this flow to match the mockups; Group C is the wider host surface.

A · Built today live

Method · pathDoesScreen
GET /experiences/mineHost's own experiences (cards + qrToken), paginated55
GET /experiences/:idDetail; owner can view own drafts; carries tiers + host rating38 · 55
GET /experiences/:id/ticket-holdersWho bought — buyers + tier + checkedIn flag; meta.soldTotal, meta.revenueCents55
GET /experiences/:id/attendeesWho joined — checked-in roster; meta.currentGuests, meta.capacity58
POST /experiences/:id/qr/rotateMint a fresh check-in code (invalidate old QR screenshots)58
POST /experiences/:id/publishGo live (DRAFT→PUBLISHED) — mints the qrCodeToken54

B · To add — core to this flow next

Method · pathWhyShapeScreen
GET /experiences/:id/dashboardOne read for the event header instead of stitching 3 calls{ sold, capacity, checkedIn, noShows, revenueCents, pctSold, byTier[] }55·60
POST /experiences/:id/scanHost-initiated door check-in — matches screen 58 (host scans guest)body { ticketToken }{ guest, ticket, offer, currentGuests }58
POST /experiences/:id/attendees/:userId/undoReverse a mis-scan / mark no-showattendance removed, ticket→VALID, currentGuests−150
socket experience:checkinPush live headcount to the host room (no polling){ experienceId, currentGuests, guest }58
GET /experiences/:id/not-checked-inBought-but-not-yet-in (tickets − attendance) for door reconciliationholder rows where checkedIn=false58

C · Later — broader host surface backlog

Method · pathWhyScreen
POST /experiences/:id/broadcastHost→attendee push, audience-scoped (ATTENDEES/FOLLOWERS/BOTH), rate-limited56·57
GET /experiences/:id/ticket-holders/exportCSV guest list for the door / records55
GET /experiences/:id/offer/redemptionsTrack ExperienceHostOffer perk redemptions at scan58·59
POST /experiences/:id/waitlist · GET …/waitlistJoin / list the waitlist when every tier is sold out39
GET /experiences/:id/payoutsRevenue reconciliation — belongs to the money module, referenced here58 (Payouts)
Bottom line — "how the host sees tickets & joined users" is already fully answered by Group A (/mine/ticket-holders/attendees). Group B is what turns those reads into the polished screen-58 door experience (host-scan, live count, undo, one-call dashboard).

09 · Data model touchpoints

Nothing new is needed for Group A; Group B reuses the same tables. The read/write map:

ModelRead byWritten by
Experience (qrCodeToken, currentGuests, capacity, status)/mine, /:id, /dashboardpublish (mint token), qr/rotate, check-in (currentGuests)
ExperienceTicket (buyer, tier, status, ticketToken)/ticket-holderspurchase (VALID), check-in / scan (USED), undo (back to VALID)
ExperienceAttendance (@@unique(experienceId,userId))/attendees, /dashboardcheck-in / scan (create), undo (delete)
ExperienceTicketTier/:id, /dashboard (byTier)tickets create, purchase (quantitySold++)
ExperienceHostOffer/:id, redemptions (later)scan (redeem perk — later)
Ownership — every host endpoint runs through loadOwned(hostId, id), which 404s a non-owner. The buyer/attendee lists expose only { id, name, avatarUrl } per user — never contact details.