ADR-008
One attendance record — interested, going, ticket holder — with one visibility rule
Context
"Who is going" is answered by two unrelated tables with opposite privacy defaults, and the product promise "no fake friends" (RAZ-83) depends on which one a page happens to read.
- "Going" clicks are
plan_itemsrows withitem_typefestival/eventand an opt-inshare_goingflag (schema.ts:568-573), defaultfalse(migrations/0020_opt_in_attendees.sql). Festival cards, Who's Going lists, the landing proof card and the discover deck read them (api/festivals.get.ts:53-57,plan.ts:150-190,912-1030; RAZ-62, RAZ-77, RAZ-86, RAZ-115). - Sign-ups and tickets are
festival_signups(schema.ts:74-97): a free spot, the €1 social unlock, a Stripe ticket or a TicketTailor order, withverified_ticket_holderand aroster_visibilitythat defaults topublic_minimal(93). Only one hardcoded festival page shows this roster (RAZ-83 notes); organiser insights count it (festivalInsights.ts:68-79, RAZ-79). - So one festival has two attendee numbers: the listing counts Going clicks (
api/festivals.get.ts:11), the organiser dashboard counts sign-ups. - Faces on most pages come from unverified clicks, which is what RAZ-83 calls fake friends.
- Waves reference the festival by slug (
schema.ts:721), draw candidates fromplan_items(wave.ts:209-220) butwave.sendaccepts any dancer id, attending or not (wave.ts:11-26). - Per-festival logistics live in the browser. "Ticket bought", "travel booked", "stay booked", "partner found", "vacation booked" are localStorage toggles (
app/pages/my-plan.vue:452-458), so Match Rate (RAZ-128), Find a Partner (RAZ-102) and partner matching (RAZ-61) have no server data. - Check-in does not exist (map:meetup-1.7-activity-check-in).
- Rides and roommates ignore the dancer's privacy setting (ADR-003).
Options
- Keep both tables and union them in a view. No migration, but two visibility settings with opposite defaults still have to be reconciled on every read, and writes still go to two places.
- One
attendancestable with a status ladder and one visibility setting; ticket holders are attendances backed by an order. - Interest stays in plans; attendance is tickets only. Clean for paid festivals, but socials and classes have no tickets, so "going" to a Thursday social would have nowhere to live.
Decision
Option 2.
attendances(id, event_id, dancer_id NULL, buyer_email, status, source, order_id NULL, visibility, partner_status, travel_booked_at, stay_booked_at, vacation_booked_at, checked_in_at, verified_at, created_at, updated_at).status:interested→going→ticket_holder. A dancer setsinterested/going; only a paid order or a verified TicketTailor order setsticket_holder(andverified_at). Cancelling a going never removes a ticket.source:click·free_pass·social_pass·stripe·tickettailor·claim.- Unique
(event_id, dancer_id); ticket-holder stubs keepdancer_id NULLandbuyer_emailuntil a dancer with that verified email claims them (ADR-003); unique(event_id, lower(buyer_email))wheredancer_id IS NULL. partner_status(looking·found) and the logistics timestamps replace the localStorage toggles;checked_in_atis set by the organiser's door flow.- One visibility setting per attendance:
hidden·public_minimal(name + city) ·public_full(+ photo). It replaces bothshare_goingandroster_visibility. - One public rule, implemented once (
publicAttendees(eventId)): a dancer appears in any public list only ifstatus IN ('going','ticket_holder'),visibility <> 'hidden'anddancers.profile_public. Ticket holders carry a "verified" badge; counts reportgoingandticket_holderseparately so a page can never present clicks as tickets. Hidden attendees are counted, never listed. - Waves, rides and roommates hang off attendance:
waves(event_id, from_dancer_id, to_dancer_id)with an FK toevents; sending a wave requires both dancers to have a visible attendance for that event. Ride shares and roommate requests useevent_idand the same public rule. - Backfill (both sources into one row per dancer and event, taking the higher status and the more private visibility when they disagree):
plan_itemsfestival/event→going;share_going = true→public_full(today's list shows photos), elsehidden. Course enrolments (metadata.type = 'class') go toseries_enrolmentsinstead (ADR-009).festival_signupsverified →ticket_holderwithroster_visibility; paid but unverified (€1 unlock, free spot) →goingwith asocial_passorder (ADR-007); stubs keepbuyer_email.plan_itemswhoseitem_idis a booking id map to the event created from that booking if it was accepted, otherwise they are reported and left out.
Rendering diagram…
Diagram source
erDiagram
events ||--o{ attendances : "attended by"
dancers |o--o{ attendances : "attends"
orders |o--o| attendances : "proves ticket"
events ||--o{ waves : "scoped to"
dancers ||--o{ waves : "from / to"
events ||--o{ ride_shares : "to"
events ||--o{ roommate_requests : "at"
attendances {
uuid id PK
uuid event_id FK
uuid dancer_id FK "null for unclaimed ticket stubs"
citext buyer_email
text status "interested, going, ticket_holder"
text visibility "hidden, public_minimal, public_full"
uuid order_id FK
text partner_status "looking, found"
timestamptz checked_in_at
}Open questions — decided
- Default visibility for new attendances (RAZ-313):
hidden. A new "Going" click is hidden until the dancer opts in to the public list. This matches today'sshare_goingdefault and avoids "fake friends" on the roster. The UI prompts the dancer to opt in after the click. Decided 2026-10-15. - Public faces — verified ticket holders only? (RAZ-314): No. Both
goingandticket_holderattendees appear in the public list when they opt in. Ticket holders carry a "verified" badge so the page never presents clicks as tickets. Decided 2026-10-15. - City battles and giveaways (RAZ-317): Kept. They are not removed in the contract step. Decided 2026-10-12.
Consequences
- One number per event for "going" and one for "verified", everywhere.
- Organiser insights and the public roster finally describe the same people.
- The default visibility for new attendances is
hidden(opt-in), with an explicit prompt after the click (RAZ-313). - The two existing opt-in UIs (RAZ-115 and the ticket roster) collapse into one control.
Stories unblocked
RAZ-83 (no fake friends), RAZ-62, RAZ-86, RAZ-115, RAZ-34, RAZ-77 (one attendee list), RAZ-79 and RAZ-150 (insights on the same attendees), RAZ-82, RAZ-65, RAZ-61, RAZ-124 (waves between real attendees), RAZ-102 and RAZ-128 (partner status on the server), RAZ-99 (ticket state from orders), map:meetup-1.7-activity-check-in, map:meetup-8.3-next-festival-invite (who you met is now queryable).