ADR-006
One event model — events, series, festival details, bookings that become events
Context
"An event" lives in three places today, plus a flag:
events(schema.ts:604-648) — created out of band on 2026-07-15 (scripts/migrate/00-schema.mjs:8-28). It holds 9,580 archived v4 events and the live v3 mirror (~1,300–1,600 upcoming; 11,192 rows in total,docs/research/2026-10-12-city-canonicalisation.md:24). State is two booleans,archived(default true,620) andpublished(621); festival-type rows carryis_festival(613).booking_requestsact as events: "a booking IS a scheduled event" (schema.ts:369-370). The city feed merges them witheventsat read time (app/pages/cities/[city]/index.vue:83, RAZ-162),/events/[id]tries a booking first and aneventsrow second (app/pages/events/[id].vue:41-45), and pending, unapproved requests appear publicly — the public feeds only filter outdeclined(booking.ts:79,111) whilebooking.requestis a public mutation (booking.ts:149).festivals(schema.ts:54-72, 6 rows). The detail page prefers hardcoded mocks (app/pages/festivals/[slug].vue:27-34) and falls back to the database. A festival can exist both as afestivalsrow and as aneventsrow withis_festival(RAZ-105 notes). Festival children referencefestivals.id(dinners, sign-ups, rides, roommates, referrals) or the slug as text (festival_waves.festival_slug,schema.ts:721).- Recurrence is a text
events.series_id(schema.ts:636) with no series record. Courses are copied intoplan_items.metadataat enrolment (app/pages/my-plan.vue:1047-1048), so a weekly class is one frozen occurrence. - People are linked by text:
venue_username,organizer_username(617-618) andartistsas a JSON list of handles or names (638), matched by string with a name fallback (events.ts:80-87). - Provenance is half-modelled: v3 rows have
source/source_id(625-626), v4 rows onlysource_ref(622). - Nobody can create an event in the product.
eventsrows come only from the v3 sync, bookings only via a venue booking (RAZ-126 notes). - The Commander locked the festival rule on 2026-07-13: an event is a festival when it is multi-day or has tickets/workshops (
docs/data-migration-plan.md§8, decision 3).
Options
- Keep three stores and unify at read time (a SQL view over
events, accepted bookings and festivals). No migration, but every FK that should point at "an event" (attendance, plans, reviews, tickets) still has to pick a table. - One wide
eventstable with every festival column nullable (single-table inheritance). One identity, but festival-only columns (free spots, early bird, branding, provider mapping) bloat 11k rows that never use them, and theirNOT NULL-ness cannot be expressed. - One
eventsidentity, a 1:1festival_detailsextension, anevent_seriesparent for recurrence, and bookings that produce events (class-table inheritance).
Decision
Option 3.
eventsis the only event identity. Every reference to "an event" — attendance, plans, reviews, tickets, waves, dinners — points atevents.id.kind(check):social·party·class·course·workshop·festival·congress·weekender·concert·show·private.status(check):draft·published·cancelled·archived, replacingarchived+published. Mapping: v4 imports →archived; v3 rows that are listed →published; v3 rows no longer listed upstream →archived(the sync cannot tell a cancellation from an unlisting,server/utils/v3EventSync.ts:12-13).cancelledis reserved for organisers cancelling natively.title,slugbecomeNOT NULL(backfill:<title>-<yyyy-mm-dd>-<4 chars>for rows without a slug);starts_at/ends_atbecometimestamptz(ADR-002) andtimezoneis required whenstatus = 'published'.- Prices: keep the display text (
price_text) and addprice_from_cents+currencyso "free taster" is a query (price_from_cents = 0 AND kind = 'class'), not a client-side guess. - Festivals are events of kind
festival,congressorweekender— the locked heuristic decides the kind. Festival-only data moves tofestival_details(event_id PK/FK, logo_url, accent_color, early_bird_deadline, early_bird_offer_name, tickettailor_event_id, legacy_festival_id).festivalsrows migrate intoevents+festival_details, keeping their slugs so/festivals/<slug>URLs do not change;legacy_festival_idmaps the old id for the child-table backfill. When afestivalsrow and a v3eventsrow describe the same festival (same title, start date and city), the details attach to the existing event instead of creating a second one; ambiguous matches are listed for a human. event_seriesholds recurrence:title,kind,rrule(RFC 5545 text),timezone, default venue/organiser,city_id,source/source_id(the v3 series id). Occurrences are materialised rows (events.series_idFK): date range queries stay simple index scans, and cancelling one week is one row's status. The v3 sync already materialises; a scheduled job materialises native series a fixed number of weeks ahead.- A booking request is a request, not an event.
booking_requestskeeps the workflow (space, requester, terms, decision). Accepting it creates theeventsrow (source = 'booking',space_id,venue_profile_id,organizer_profile_id) and storesbooking_requests.event_id(unique). Public feeds readeventsonly, so a pending request is never public. - People are references.
venue_profile_id,organizer_profile_id(FKs toprofiles) andevent_artists(id, event_id, profile_id NULL, display_name, role, sort)(unique(event_id, profile_id)whereprofile_idis not null). The name/address columns stay as a snapshot for venues and artists without a profile. The v3 sync writes these FKs instead of usernames. - Provenance is uniform.
source(check):wedance·wedance-v3·wedance-v4·booking;(source, source_id)unique;source_ref jsonbkeeps the raw provenance. v4 rows getsource = 'wedance-v4'andsource_idfromsource_ref. Async_locked_atcolumn stops the v3 sync from overwriting an event once its organiser edits it natively.
Rendering diagram…
Diagram source
erDiagram
event_series ||--o{ events : "occurrence"
events ||--o| festival_details : "festival extension"
events ||--o{ event_artists : "lineup"
profiles |o--o{ event_artists : "artist"
profiles |o--o{ events : "venue / organiser"
booking_requests |o--o| events : "accepted into"
bookable_spaces |o--o{ events : "held in"
cities ||--o{ events : "in"
events {
uuid id PK
text slug UK
text kind
text status
timestamptz starts_at
timestamptz ends_at
text timezone
uuid series_id FK
uuid venue_profile_id FK
uuid organizer_profile_id FK
uuid city_id FK
text source
text source_id
}
festival_details {
uuid event_id PK, FK
uuid legacy_festival_id UK
text tickettailor_event_id UK
date early_bird_deadline
}Consequences
- One
/events/<id>lookup, one city feed query, one FK target. - The festival detail page reads the database; the hardcoded mock festivals are migrated as data (ADR-007) and deleted from
app/data/. - Children of
festivalsmove toevent_idthrough expand/backfill/contract (migration plan, phase 3);festival_wavesmoves from slug toevent_id. - Archived v4 events stay queryable for history and organiser back-catalogues but never reach public feeds.
- Accepting a booking is now a write with two rows; it runs in a transaction (ADR-002).
Stories unblocked
RAZ-162 and RAZ-304 (one feed, one event page), RAZ-126 (list a weekly social or class), RAZ-105 and RAZ-32 (festival pages from the database), RAZ-108 and RAZ-113 (courses as series), RAZ-28 and RAZ-30 (free tasters as data), RAZ-55 and RAZ-161 (artist schedules by FK), RAZ-129 and RAZ-158 (accepted bookings become events), RAZ-251 and RAZ-253 (sync writes references).