Data model
70 tables, introspected from the Drizzle schema on every build. Grouped by bounded context; each table links to its columns, keys, indexes and the code that uses it.
All tables
Every table and foreign key. Open a context below for keys and columns.
Rendering diagram…
Diagram source
erDiagram
ai_usage
attendances
availability_slots
bookable_spaces
booking_inquiries
booking_requests
brand_looks
cities
city_battle_votes
city_videos
community_group_reports
community_groups
dancer_goals
dancers
deliveries
dinner_group_members
dinner_groups
dinner_signups
dinners
discover_swipes
election_candidates
election_vote_history
election_votes
event_flyers
event_posts
events
festival_ride_shares
festival_roommate_lookups
festival_signups
festival_submissions
festival_waves
festivals
follows
gig_applications
gigs
giveaway_entries
giveaway_proposals
giveaways
guideline_versions
handles
hangout_rsvps
hangouts
inbound_requests
legacy_follows
mcp_audit
mcp_rate_limits
moderator_elections
network_links
oauth_clients
oauth_codes
oauth_grants
oauth_tokens
organizer_subscriptions
plan_items
profile_access_log
profile_affiliations
profile_claims
profile_contacts
profile_invites
profile_members
profile_sites
profile_suggestions
profiles
recommendation_requests
referrals
reviews
sessions
tracked_link_actions
tracked_links
video_votes
events |o--o{ ai_usage : "event_id"
event_flyers |o--o{ ai_usage : "flyer_id"
dancers |o--o{ ai_usage : "dancer_id"
events ||--o{ attendances : "event_id"
dancers |o--o{ attendances : "dancer_id"
bookable_spaces ||--o{ availability_slots : "space_id"
profiles ||--o{ bookable_spaces : "profile_id"
profiles ||--o{ booking_inquiries : "profile_id"
dancers |o--o{ booking_inquiries : "requester_id"
bookable_spaces ||--o{ booking_requests : "space_id"
profiles ||--o{ booking_requests : "profile_id"
profiles |o--o{ booking_requests : "organizer_id"
dancers |o--o{ booking_requests : "requester_id"
dancers |o--o{ booking_requests : "moderated_by_id"
profiles ||--o{ brand_looks : "profile_id"
dancers |o--o{ brand_looks : "created_by"
dancers |o--o{ city_battle_votes : "voter_dancer_id"
dancers |o--o{ city_videos : "dancer_id"
community_groups ||--o{ community_group_reports : "group_id"
dancers ||--o{ community_group_reports : "dancer_id"
dancers ||--o{ dancer_goals : "dancer_id"
dinner_groups ||--o{ dinner_group_members : "group_id"
dancers ||--o{ dinner_group_members : "dancer_id"
dinners ||--o{ dinner_groups : "dinner_id"
dinners ||--o{ dinner_signups : "dinner_id"
dancers ||--o{ dinner_signups : "dancer_id"
festivals ||--o{ dinners : "festival_id"
dancers ||--o{ discover_swipes : "dancer_id"
moderator_elections ||--o{ election_candidates : "election_id"
dancers ||--o{ election_candidates : "dancer_id"
moderator_elections ||--o{ election_vote_history : "election_id"
dancers ||--o{ election_vote_history : "voter_dancer_id"
moderator_elections ||--o{ election_votes : "election_id"
dancers ||--o{ election_votes : "voter_dancer_id"
election_candidates ||--o{ election_votes : "candidate_id"
events ||--o{ event_flyers : "event_id"
event_posts |o--o{ event_flyers : "post_id"
brand_looks |o--o{ event_flyers : "look_id"
dancers |o--o{ event_flyers : "created_by"
events ||--o{ event_posts : "event_id"
dancers |o--o{ event_posts : "created_by"
profiles |o--o{ events : "host_profile_id"
dancers |o--o{ events : "edited_by"
festivals ||--o{ festival_ride_shares : "festival_id"
dancers ||--o{ festival_ride_shares : "dancer_id"
festivals ||--o{ festival_roommate_lookups : "festival_id"
dancers ||--o{ festival_roommate_lookups : "dancer_id"
festivals ||--o{ festival_signups : "festival_id"
dancers |o--o{ festival_signups : "dancer_id"
dancers |o--o{ festival_submissions : "submitted_by_id"
dancers ||--o{ festival_waves : "dancer_id"
dancers ||--o{ festival_waves : "target_dancer_id"
dancers ||--o{ follows : "dancer_id"
profiles |o--o{ follows : "profile_id"
dancers |o--o{ follows : "target_dancer_id"
gigs ||--o{ gig_applications : "gig_id"
dancers ||--o{ gig_applications : "dancer_id"
profiles |o--o{ gig_applications : "profile_id"
dancers |o--o{ gig_applications : "decided_by"
dancers |o--o{ gigs : "dancer_id"
profiles |o--o{ gigs : "poster_profile_id"
giveaways ||--o{ giveaway_entries : "giveaway_id"
dancers |o--o{ giveaway_entries : "dancer_id"
events ||--o{ giveaway_proposals : "event_id"
dancers |o--o{ giveaway_proposals : "proposed_by"
profiles ||--o{ giveaway_proposals : "profile_id"
giveaways |o--o{ giveaway_proposals : "giveaway_id"
dancers |o--o{ giveaway_proposals : "decided_by"
profiles ||--o{ guideline_versions : "profile_id"
moderator_elections |o--o{ guideline_versions : "election_id"
dancers |o--o{ guideline_versions : "moderator_dancer_id"
dancers |o--o{ handles : "dancer_id"
profiles |o--o{ handles : "profile_id"
hangouts ||--o{ hangout_rsvps : "hangout_id"
dancers ||--o{ hangout_rsvps : "dancer_id"
events |o--o{ hangouts : "event_id"
dancers ||--o{ hangouts : "dancer_id"
dancers |o--o{ inbound_requests : "dancer_id"
events |o--o{ inbound_requests : "event_id"
profiles |o--o{ inbound_requests : "profile_id"
gigs |o--o{ inbound_requests : "gig_id"
dancers ||--o{ legacy_follows : "dancer_id"
oauth_grants |o--o{ mcp_audit : "grant_id"
dancers |o--o{ mcp_audit : "dancer_id"
profiles |o--o{ mcp_audit : "profile_id"
profiles ||--o{ moderator_elections : "profile_id"
profiles ||--o{ network_links : "profile_id"
profiles ||--o{ network_links : "other_profile_id"
dancers |o--o{ network_links : "created_by"
oauth_grants ||--o{ oauth_codes : "grant_id"
dancers ||--o{ oauth_grants : "dancer_id"
oauth_clients ||--o{ oauth_grants : "client_id"
oauth_grants ||--o{ oauth_tokens : "grant_id"
profiles ||--o{ organizer_subscriptions : "profile_id"
dancers ||--o{ organizer_subscriptions : "dancer_id"
dancers ||--o{ plan_items : "dancer_id"
profiles ||--o{ profile_access_log : "profile_id"
dancers |o--o{ profile_access_log : "actor_id"
dancers |o--o{ profile_access_log : "subject_id"
profiles ||--o{ profile_affiliations : "artist_profile_id"
profiles ||--o{ profile_affiliations : "organizer_profile_id"
profiles ||--o{ profile_claims : "profile_id"
dancers ||--o{ profile_claims : "dancer_id"
dancers |o--o{ profile_claims : "decided_by"
profiles ||--o{ profile_contacts : "profile_id"
dancers |o--o{ profile_contacts : "dancer_id"
profiles ||--o{ profile_invites : "profile_id"
events |o--o{ profile_invites : "event_id"
dancers |o--o{ profile_invites : "created_by"
profiles ||--o{ profile_members : "profile_id"
dancers ||--o{ profile_members : "dancer_id"
dancers |o--o{ profile_members : "added_by"
profiles ||--o{ profile_sites : "profile_id"
dancers |o--o{ profile_sites : "updated_by"
profiles ||--o{ profile_suggestions : "profile_id"
events |o--o{ profile_suggestions : "event_id"
dancers |o--o{ profile_suggestions : "suggested_by"
dancers ||--o{ recommendation_requests : "asker_id"
festivals ||--o{ referrals : "festival_id"
dancers ||--o{ referrals : "referrer_id"
dancers ||--o{ referrals : "referee_id"
dancers ||--o{ reviews : "dancer_id"
dancers ||--o{ sessions : "dancer_id"
tracked_links ||--o{ tracked_link_actions : "link_id"
dancers |o--o{ tracked_link_actions : "dancer_id"
events ||--o{ tracked_links : "event_id"
profiles ||--o{ tracked_links : "profile_id"
dancers |o--o{ tracked_links : "created_by"
city_videos ||--o{ video_votes : "winner_video_id"
city_videos ||--o{ video_votes : "loser_video_id"
dancers |o--o{ video_votes : "voter_dancer_id"Identity & auth
Dancer accounts, credentials and login sessions, and the apps (MCP assistants) dancers connect via OAuth.
A registered app. `id` is the client_id: a random id for Dynamic Client Registration (RFC 7591), or the https URL of a Client ID Metadata Document, whose fetched document is cached here (`metadataFetchedAt`).
What a dancer allowed an app: scopes and the profiles it may act for. One live grant per dancer + app; consenting again replaces scopes and profiles. Revoking (settings → Connected apps, or refresh-token reuse) ends every token.
Authorization codes: single use, a few minutes, bound to the PKCE challenge, the exact redirect_uri and the resource (RFC 8707) they were issued for.
Access tokens (1 h) and refresh tokens (30 d, rotating). A refresh token is spent once (`usedAt`); presenting a spent one again is reuse and revokes the whole grant.
Every MCP tool call: who (grant / dancer / app), which tool, what for (`target`: a profile handle or event id), a hash of the input (never the input), the outcome and how long it took. `confirmNonce` makes a publish confirmation single use.
Fixed-window rate limits for /mcp and the OAuth endpoints: one row per key (token grant or client IP hash) per window. Old windows are pruned as we go.
ER diagram for Identity & auth
Rendering diagram…
Diagram source
erDiagram
dancers {
uuid id PK
text email UK
text neon_auth_id UK
text username UK
}
mcp_audit {
uuid id PK
uuid grant_id FK
uuid dancer_id FK
uuid profile_id FK
text confirm_nonce UK
}
mcp_rate_limits {
}
oauth_clients {
text id PK
}
oauth_codes {
text code_hash PK
uuid grant_id FK
}
oauth_grants {
uuid id PK
uuid dancer_id FK
text client_id FK
}
oauth_tokens {
uuid id PK
uuid grant_id FK
text token_hash UK
}
profiles {
uuid id PK
text username UK
}
sessions {
uuid id PK
uuid dancer_id FK
text token UK
}
oauth_grants |o--o{ mcp_audit : "grant_id"
dancers |o--o{ mcp_audit : "dancer_id"
profiles |o--o{ mcp_audit : "profile_id"
oauth_grants ||--o{ oauth_codes : "grant_id"
dancers ||--o{ oauth_grants : "dancer_id"
oauth_clients ||--o{ oauth_grants : "client_id"
oauth_grants ||--o{ oauth_tokens : "grant_id"
dancers ||--o{ sessions : "dancer_id"Festivals & ticketing
Festival listings, sign-ups and verified tickets, referrals, organiser submissions, and the per-festival social layer (waves, rides, roommates).
Festival submissions from the /organizers/create wizard. There is no live self-serve publish yet — the wizard captures the full draft here for the team to review and onboard, instead of pretending to go live. The whole draft is stored as a JSON payload so the schema doesn't have to track every wizard field.
Referrals — ticket referral tracking for the "buy through a friend's link" discount flow. A referral row is created when a dancer opens a checkout via a referral link (?ref=<username>). Status transitions: pending → completed (on successful Stripe payment) or expired (if never paid). The referrer is credited once the referee's payment succeeds.
RAZ-82: "Wave" social gesture — a lightweight, per-festival expression of "I'd love to dance with you there." Mutual waves = festival friends.
ER diagram for Festivals & ticketing
Rendering diagram…
Diagram source
erDiagram
dancers {
uuid id PK
text email UK
text neon_auth_id UK
text username UK
}
festival_ride_shares {
uuid id PK
uuid festival_id FK
uuid dancer_id FK
}
festival_roommate_lookups {
uuid id PK
uuid festival_id FK
uuid dancer_id FK
}
festival_signups {
uuid id PK
uuid festival_id FK
uuid dancer_id FK
text tickettailor_order_id UK
}
festival_submissions {
uuid id PK
uuid submitted_by_id FK
}
festival_waves {
uuid id PK
uuid dancer_id FK
uuid target_dancer_id FK
}
festivals {
uuid id PK
text slug UK
}
referrals {
uuid id PK
uuid festival_id FK
uuid referrer_id FK
uuid referee_id FK
}
festivals ||--o{ festival_ride_shares : "festival_id"
dancers ||--o{ festival_ride_shares : "dancer_id"
festivals ||--o{ festival_roommate_lookups : "festival_id"
dancers ||--o{ festival_roommate_lookups : "dancer_id"
festivals ||--o{ festival_signups : "festival_id"
dancers |o--o{ festival_signups : "dancer_id"
dancers |o--o{ festival_submissions : "submitted_by_id"
dancers ||--o{ festival_waves : "dancer_id"
dancers ||--o{ festival_waves : "target_dancer_id"
festivals ||--o{ referrals : "festival_id"
dancers ||--o{ referrals : "referrer_id"
dancers ||--o{ referrals : "referee_id"Festival dinners
Dinner slots at festivals, sign-ups and the generated dinner groups.
ER diagram for Festival dinners
Rendering diagram…
Diagram source
erDiagram
dancers {
uuid id PK
text email UK
text neon_auth_id UK
text username UK
}
dinner_group_members {
uuid id PK
uuid group_id FK
uuid dancer_id FK
}
dinner_groups {
uuid id PK
uuid dinner_id FK
}
dinner_signups {
uuid id PK
uuid dinner_id FK
uuid dancer_id FK
}
dinners {
uuid id PK
uuid festival_id FK
}
festivals {
uuid id PK
text slug UK
}
dinner_groups ||--o{ dinner_group_members : "group_id"
dancers ||--o{ dinner_group_members : "dancer_id"
dinners ||--o{ dinner_groups : "dinner_id"
dinners ||--o{ dinner_signups : "dinner_id"
dancers ||--o{ dinner_signups : "dancer_id"
festivals ||--o{ dinners : "festival_id"Events & plans
Dated public events (v4 archive + live v3 mirror), their flyers and the hosts' saved flyer looks, each dancer's week/year plan, and the unified attendance model.
Events — dated, public dance events (socials, classes, workshops, festivals). Created outside Drizzle on 2026-07-15 by scripts/migrate/00-schema.mjs to hold the 9,580 historical v4 events (archived=true, hidden). Since 2026-10 it also holds the LIVE upcoming events mirrored from wedance.vip (v3 Firestore) by scripts/sync-v3-events.ts — those carry source='wedance-v3' + source_id=<post id> and archived=false, published=true while still upcoming in v3. Times: start_date / end_date are `timestamp without time zone` holding UTC (the convention 04-events.mjs established). `timezone` is the IANA zone the event happens in — render wall-clock times in it, never in the viewer's zone. Rollback for synced rows: DELETE FROM events WHERE source = 'wedance-v3'.
Discover-deck decisions on /my-plan ("Widen your year"). One row per dancer per card: a Yes or a Skip on another dancer, an event or a festival, so a card never comes back once decided (migration 0046). Kept apart from festival_waves on purpose: a wave is a message the other dancer sees, a deck Yes is private bookkeeping. `festival_slug` records the festival that put a dancer on the card — Yes on two or more dancers heading to the same festival raises the "add it to your year?" nudge.
Attendances (ADR-008, migration 0044). One row per dancer per event: replaces the festival/event rows of `plan_items` (Going clicks) and `festival_signups` (ticket holders). Status ladder: interested → going → ticket_holder. One visibility setting replaces both `plan_items.share_going` and `festival_signups.roster_visibility`. Default visibility = hidden (opt-in). Phase 4 expand step: this table is created alongside the old tables. New Going clicks dual-write here and to `plan_items`; the old reads stay on `plan_items`/`festival_signups` until the switch step. `order_id` is nullable and has no FK yet (the `orders` table ships in Phase 3 Step 22).
Dancer goals (ADR-009, migration 0044). Goals were stored as plan_items with item_type='goal' and the goal itself in metadata. Now they have their own table with typed columns.
Content plan (migration 0037): an event's posts, each with its own flyer versions (event_flyers.post_id) and one kept. Kinds: shared/utils/posts.ts.
AI usage log (migration 0038): every Gemini call, for /admin/ai-usage.
ER diagram for Events & plans
Rendering diagram…
Diagram source
erDiagram
ai_usage {
uuid id PK
uuid event_id FK
uuid flyer_id FK
uuid dancer_id FK
}
attendances {
uuid id PK
uuid event_id FK
uuid dancer_id FK
}
brand_looks {
uuid id PK
uuid profile_id FK
uuid created_by FK
}
dancer_goals {
uuid id PK
uuid dancer_id FK
}
dancers {
uuid id PK
text email UK
text neon_auth_id UK
text username UK
}
discover_swipes {
uuid id PK
uuid dancer_id FK
}
event_flyers {
uuid id PK
uuid event_id FK
uuid post_id FK
uuid look_id FK
uuid created_by FK
}
event_posts {
uuid id PK
uuid event_id FK
uuid created_by FK
}
events {
uuid id PK
uuid host_profile_id FK
uuid edited_by FK
}
plan_items {
uuid id PK
uuid dancer_id FK
}
profiles {
uuid id PK
text username UK
}
events |o--o{ ai_usage : "event_id"
event_flyers |o--o{ ai_usage : "flyer_id"
dancers |o--o{ ai_usage : "dancer_id"
events ||--o{ attendances : "event_id"
dancers |o--o{ attendances : "dancer_id"
profiles ||--o{ brand_looks : "profile_id"
dancers |o--o{ brand_looks : "created_by"
dancers ||--o{ dancer_goals : "dancer_id"
dancers ||--o{ discover_swipes : "dancer_id"
events ||--o{ event_flyers : "event_id"
event_posts |o--o{ event_flyers : "post_id"
brand_looks |o--o{ event_flyers : "look_id"
dancers |o--o{ event_flyers : "created_by"
events ||--o{ event_posts : "event_id"
dancers |o--o{ event_posts : "created_by"
profiles |o--o{ events : "host_profile_id"
dancers |o--o{ events : "edited_by"
dancers ||--o{ plan_items : "dancer_id"Profiles & cities
Venue / artist / organiser profiles under /@handle, canonical cities, follows and the per-city community-group directory.
Professional profiles — venues, artists, organizers — under the unified /@<username> handle namespace (v4-style). Dancers keep their own row + handle in `dancers`; the /@handle route resolves either. Reviews attach polymorphically by (type, username).
Who manages a profile (hosting mode). Created when an admin approves a claim or when a dancer creates a new profile. `claimed` on the profile mirrors "has at least one owner" so the v3 sync never overwrites it.
"This is me / my venue" requests for existing profiles. An admin approves (→ profile_members owner row + profiles.claimed) or rejects.
Who got, lost or changed access to a profile, and who did it (0027) — the facts an admin needs to judge a reclaim dispute.
Artist confirmation (migration 0033). An organizer's photos, video and name for an artist in their line-up are suggestions on the artist's profile until the artist approves them through a personal link (profile_invites).
Follows — unified follow graph (ADR-004, Step 15). A dancer follows either a professional profile (venue/artist/organizer) OR another dancer. Exactly one of profile_id / target_dancer_id is set per row. Renamed from `artist_follows` (migration 0044). Mutual dancer→dancer follows = friends.
Legacy follows from v4 that couldn't be resolved to a profile or dancer during the Step 15 migration. Kept for provenance; no app code reads this.
Step 14 (ADR-004): artist ↔ organiser affiliations ("teaches at", "resident DJ at", "member of"). Links an artist profile to an organiser/venue profile.
Handle registry (ADR-004, Step 12). One namespace for dancer and profile URLs: `/@<handle>` resolves through this table. citext for case-insensitive uniqueness. Exactly one of dancer_id / profile_id is set.
--- Organizer subscriptions (RAZ-106) ---------------------------------------- An organizer profile can hold one active annual subscription plan. The Network plan (€199/year) covers unlimited events under a single annual price. Stripe runs the billing; this table mirrors the subscription state so entitlement checks stay inside the DB without a Stripe API call.
Business "Network" curation (migration 0042, RAZ-408). The network itself is derived from shared events; a row here means a page's team ADDED someone by hand or HID a derived contact. One row per (my page, their page).
A profile's own website, managed on WeDance (RAZ-412, migration 0045): the page's sections and words in every language (shared/types/profileSite.ts). The site renders GET /api/profiles/<username>/site.
People who signed up on a profile's website (RAZ-412, migration 0045): the profile's contacts on WeDance. One row per email per profile; signing up again updates it. Linked to a dancer when the email has a WeDance account.
Cities — canonical, Google-Places-backed (migration 0024). One row per Google place: `name` is the English name ("Munich"), `local_name` the name in the country's language ("München"), `alt_names` every other spelling we have resolved or seen typed ("Мюнхен", "Munique", "munchen"). The CityCombobox only ever saves one of these rows, so "München", "Мюнхен" and "munich" all end up as the same city. Free-text `city` columns elsewhere are migrated to a `city_id` FK later — plan: docs/research/2026-10-12-city-canonicalisation.md.
Community groups (WhatsApp / Telegram / …) per city — the cold-start directory for cities where WeDance has no events yet.
Per-user report tracking for community groups — prevents a single user from hiding a group by reporting it multiple times.
ER diagram for Profiles & cities
Rendering diagram…
Diagram source
erDiagram
cities {
uuid id PK
}
community_group_reports {
uuid id PK
uuid group_id FK
uuid dancer_id FK
}
community_groups {
uuid id PK
}
dancers {
uuid id PK
text email UK
text neon_auth_id UK
text username UK
}
events {
uuid id PK
uuid host_profile_id FK
uuid edited_by FK
}
follows {
uuid id PK
uuid dancer_id FK
uuid profile_id FK
uuid target_dancer_id FK
}
handles {
text handle PK
uuid dancer_id FK,UK
uuid profile_id FK,UK
}
legacy_follows {
uuid id PK
uuid dancer_id FK
}
network_links {
uuid id PK
uuid profile_id FK
uuid other_profile_id FK
uuid created_by FK
}
organizer_subscriptions {
uuid id PK
uuid profile_id FK
uuid dancer_id FK
text stripe_subscription_id UK
}
profile_access_log {
uuid id PK
uuid profile_id FK
uuid actor_id FK
uuid subject_id FK
}
profile_affiliations {
uuid id PK
uuid artist_profile_id FK
uuid organizer_profile_id FK
}
profile_claims {
uuid id PK
uuid profile_id FK
uuid dancer_id FK
uuid decided_by FK
}
profile_contacts {
uuid id PK
uuid profile_id FK
uuid dancer_id FK
}
profile_invites {
uuid id PK
uuid profile_id FK
uuid event_id FK
uuid created_by FK
}
profile_members {
uuid id PK
uuid profile_id FK
uuid dancer_id FK
uuid added_by FK
}
profile_sites {
uuid profile_id PK,FK
uuid updated_by FK
}
profile_suggestions {
uuid id PK
uuid profile_id FK
uuid event_id FK
uuid suggested_by FK
}
profiles {
uuid id PK
text username UK
}
community_groups ||--o{ community_group_reports : "group_id"
dancers ||--o{ community_group_reports : "dancer_id"
profiles |o--o{ events : "host_profile_id"
dancers |o--o{ events : "edited_by"
dancers ||--o{ follows : "dancer_id"
profiles |o--o{ follows : "profile_id"
dancers |o--o{ follows : "target_dancer_id"
dancers |o--o{ handles : "dancer_id"
profiles |o--o{ handles : "profile_id"
dancers ||--o{ legacy_follows : "dancer_id"
profiles ||--o{ network_links : "profile_id"
profiles ||--o{ network_links : "other_profile_id"
dancers |o--o{ network_links : "created_by"
profiles ||--o{ organizer_subscriptions : "profile_id"
dancers ||--o{ organizer_subscriptions : "dancer_id"
profiles ||--o{ profile_access_log : "profile_id"
dancers |o--o{ profile_access_log : "actor_id"
dancers |o--o{ profile_access_log : "subject_id"
profiles ||--o{ profile_affiliations : "artist_profile_id"
profiles ||--o{ profile_affiliations : "organizer_profile_id"
profiles ||--o{ profile_claims : "profile_id"
dancers ||--o{ profile_claims : "dancer_id"
dancers |o--o{ profile_claims : "decided_by"
profiles ||--o{ profile_contacts : "profile_id"
dancers |o--o{ profile_contacts : "dancer_id"
profiles ||--o{ profile_invites : "profile_id"
events |o--o{ profile_invites : "event_id"
dancers |o--o{ profile_invites : "created_by"
profiles ||--o{ profile_members : "profile_id"
dancers ||--o{ profile_members : "dancer_id"
dancers |o--o{ profile_members : "added_by"
profiles ||--o{ profile_sites : "profile_id"
dancers |o--o{ profile_sites : "updated_by"
profiles ||--o{ profile_suggestions : "profile_id"
events |o--o{ profile_suggestions : "event_id"
dancers |o--o{ profile_suggestions : "suggested_by"Venue booking & governance
Bookable spaces, availability and booking requests, plus moderator elections and guideline versions for free community spaces.
A profile (venue) can offer N bookable areas — e.g. Pinakothek's 5 spaces.
Recurring weekly availability windows per bookable space. A venue publishes when each space is open for bookings (e.g. "Monday 18:00–23:00"). When slots exist for a space, booking requests are validated against them; when none exist the space is treated as always-available (backward-compat).
Booking requests — connector model (no payment held). An organizer requests a date for a space; the venue responds. T&C accepted at request time.
Booking inquiries (migration 0039): anyone asks to book an artist or venue from their page, no account needed. The page's team answers by email.
The election itself + its lifecycle (G801). One active (non-closed) election per profile is enforced in the router.
A self-nominated candidate + the guidelines they run on (G802). One candidacy per dancer per election; a real (verified) profile is required — enforced in the router by requiring the dancer to have a username.
The CURRENT vote per voter — one row per (election, voter), upserted on change (G803). One real profile = one vote (G801 anti-stuffing). The tally uses this table (latest vote per voter); the full trail lives in electionVoteHistory.
Immutable append-only log of every cast/change (G803). Makes "is my vote still X?" verifiable — a vote is never silently overwritten, each change is a row.
Per-term guideline versions (G804): on close, the winner's guidelines become the space's active ruleset; every term is preserved so governance history is auditable across elections.
Inbound requests (ADR-010, Step 16). Every public-facing request — event enquiries, problem reports, gig applications, general contact — is stored here first, then delivered to Linear / email by the delivery outbox.
Delivery outbox (ADR-010, Step 16). Every notification that leaves the app (Linear issue, email, push). A cron picks up pending deliveries, sends them, and marks them delivered. Retries capped at 3.
ER diagram for Venue booking & governance
Rendering diagram…
Diagram source
erDiagram
availability_slots {
uuid id PK
uuid space_id FK
}
bookable_spaces {
uuid id PK
uuid profile_id FK
}
booking_inquiries {
uuid id PK
uuid profile_id FK
uuid requester_id FK
}
booking_requests {
uuid id PK
uuid space_id FK
uuid profile_id FK
uuid organizer_id FK
uuid requester_id FK
uuid moderated_by_id FK
}
dancers {
uuid id PK
text email UK
text neon_auth_id UK
text username UK
}
deliveries {
uuid id PK
}
election_candidates {
uuid id PK
uuid election_id FK
uuid dancer_id FK
}
election_vote_history {
uuid id PK
uuid election_id FK
uuid voter_dancer_id FK
}
election_votes {
uuid id PK
uuid election_id FK
uuid voter_dancer_id FK
uuid candidate_id FK
}
events {
uuid id PK
uuid host_profile_id FK
uuid edited_by FK
}
gigs {
uuid id PK
uuid dancer_id FK
uuid poster_profile_id FK
}
guideline_versions {
uuid id PK
uuid profile_id FK
uuid election_id FK
uuid moderator_dancer_id FK
}
inbound_requests {
uuid id PK
uuid dancer_id FK
uuid event_id FK
uuid profile_id FK
uuid gig_id FK
}
moderator_elections {
uuid id PK
uuid profile_id FK
}
profiles {
uuid id PK
text username UK
}
bookable_spaces ||--o{ availability_slots : "space_id"
profiles ||--o{ bookable_spaces : "profile_id"
profiles ||--o{ booking_inquiries : "profile_id"
dancers |o--o{ booking_inquiries : "requester_id"
bookable_spaces ||--o{ booking_requests : "space_id"
profiles ||--o{ booking_requests : "profile_id"
profiles |o--o{ booking_requests : "organizer_id"
dancers |o--o{ booking_requests : "requester_id"
dancers |o--o{ booking_requests : "moderated_by_id"
moderator_elections ||--o{ election_candidates : "election_id"
dancers ||--o{ election_candidates : "dancer_id"
moderator_elections ||--o{ election_vote_history : "election_id"
dancers ||--o{ election_vote_history : "voter_dancer_id"
moderator_elections ||--o{ election_votes : "election_id"
dancers ||--o{ election_votes : "voter_dancer_id"
election_candidates ||--o{ election_votes : "candidate_id"
profiles |o--o{ events : "host_profile_id"
dancers |o--o{ events : "edited_by"
dancers |o--o{ gigs : "dancer_id"
profiles |o--o{ gigs : "poster_profile_id"
profiles ||--o{ guideline_versions : "profile_id"
moderator_elections |o--o{ guideline_versions : "election_id"
dancers |o--o{ guideline_versions : "moderator_dancer_id"
dancers |o--o{ inbound_requests : "dancer_id"
events |o--o{ inbound_requests : "event_id"
profiles |o--o{ inbound_requests : "profile_id"
gigs |o--o{ inbound_requests : "gig_id"
profiles ||--o{ moderator_elections : "profile_id"Community engagement
City video voting and battles, giveaways (and the ones promoters propose), reviews, "ask locals" recommendations, and promoters' tracked links with what they brought.
Cross-city battle votes — the tier above per-city voting. Each row is one vote in a head-to-head between two cities' champion videos (the current month's highest-ELO approved video per city). Ranks CITIES, not videos, into the "Top dance cities" leaderboard. videoIds are stored for provenance only (plain uuid, no FK — champions rotate as ELO shifts, and this must survive video deletion).
A promoter/influencer proposes a giveaway for an event; only admins create giveaways directly, so the event's organizer approves it and it becomes one.
Reviews & ratings — polymorphic by (targetType, targetSlug) so a review can attach to ANY entity (festival / venue / artist / organizer), including on-demand stubs created by "ask locals" recommendations, without needing a full entity table for each. `source` separates a normal review from an auto-5★ recommendation. One review per dancer per target.
"Ask locals" — a question posted for a city, answered by recommendations (which write into `reviews` with source='recommendation').
A promoter's (or influencer's) personal link to one event: /events/<slug>?via=<code>. One per promoter page + event. The caption cached here is the kit caption written for this promoter (promo_kit), so it isn't rewritten on every call.
What a tracked link brought: a visit (per browser), a sign-up, a plan add or a ticket (per dancer). Each counts once per link. Last touch wins: the link in the visitor's `wd_via` cookie when they act.
ER diagram for Community engagement
Rendering diagram…
Diagram source
erDiagram
city_battle_votes {
uuid id PK
uuid voter_dancer_id FK
}
city_videos {
uuid id PK
uuid dancer_id FK
}
dancers {
uuid id PK
text email UK
text neon_auth_id UK
text username UK
}
events {
uuid id PK
uuid host_profile_id FK
uuid edited_by FK
}
giveaway_entries {
uuid id PK
uuid giveaway_id FK
uuid dancer_id FK
}
giveaway_proposals {
uuid id PK
uuid event_id FK
uuid proposed_by FK
uuid profile_id FK
uuid giveaway_id FK
uuid decided_by FK
}
giveaways {
uuid id PK
}
profiles {
uuid id PK
text username UK
}
recommendation_requests {
uuid id PK
uuid asker_id FK
}
reviews {
uuid id PK
uuid dancer_id FK
}
tracked_link_actions {
uuid id PK
uuid link_id FK
uuid dancer_id FK
}
tracked_links {
uuid id PK
text code UK
uuid event_id FK
uuid profile_id FK
uuid created_by FK
}
video_votes {
uuid id PK
uuid winner_video_id FK
uuid loser_video_id FK
uuid voter_dancer_id FK
}
dancers |o--o{ city_battle_votes : "voter_dancer_id"
dancers |o--o{ city_videos : "dancer_id"
profiles |o--o{ events : "host_profile_id"
dancers |o--o{ events : "edited_by"
giveaways ||--o{ giveaway_entries : "giveaway_id"
dancers |o--o{ giveaway_entries : "dancer_id"
events ||--o{ giveaway_proposals : "event_id"
dancers |o--o{ giveaway_proposals : "proposed_by"
profiles ||--o{ giveaway_proposals : "profile_id"
giveaways |o--o{ giveaway_proposals : "giveaway_id"
dancers |o--o{ giveaway_proposals : "decided_by"
dancers ||--o{ recommendation_requests : "asker_id"
dancers ||--o{ reviews : "dancer_id"
tracked_links ||--o{ tracked_link_actions : "link_id"
dancers |o--o{ tracked_link_actions : "dancer_id"
events ||--o{ tracked_links : "event_id"
profiles ||--o{ tracked_links : "profile_id"
dancers |o--o{ tracked_links : "created_by"
city_videos ||--o{ video_votes : "winner_video_id"
city_videos ||--o{ video_votes : "loser_video_id"
dancers |o--o{ video_votes : "voter_dancer_id"Gigs & hangouts
The gig board (open roles and offered services, artists' applications) and tonight's hangouts with RSVPs.
Gigs — opportunity board for dance roles and service offerings kind: 'role' = organizer/festival posting an open role they need filled kind: 'offer' = artist posting a service they offer
An artist applies to a gig role. The gig's poster (or its page's team) accepts or declines; the applicant's account email is shared with the poster.
Tonight (Hangouts) — spontaneous evening activities where dancers can mark they're out (dinner, bar, ride, floor, etc.) and see who else is joining.
ER diagram for Gigs & hangouts
Rendering diagram…
Diagram source
erDiagram
dancers {
uuid id PK
text email UK
text neon_auth_id UK
text username UK
}
events {
uuid id PK
uuid host_profile_id FK
uuid edited_by FK
}
gig_applications {
uuid id PK
uuid gig_id FK
uuid dancer_id FK
uuid profile_id FK
uuid decided_by FK
}
gigs {
uuid id PK
uuid dancer_id FK
uuid poster_profile_id FK
}
hangout_rsvps {
uuid id PK
uuid hangout_id FK
uuid dancer_id FK
}
hangouts {
uuid id PK
uuid event_id FK
uuid dancer_id FK
}
profiles {
uuid id PK
text username UK
}
profiles |o--o{ events : "host_profile_id"
dancers |o--o{ events : "edited_by"
gigs ||--o{ gig_applications : "gig_id"
dancers ||--o{ gig_applications : "dancer_id"
profiles |o--o{ gig_applications : "profile_id"
dancers |o--o{ gig_applications : "decided_by"
dancers |o--o{ gigs : "dancer_id"
profiles |o--o{ gigs : "poster_profile_id"
hangouts ||--o{ hangout_rsvps : "hangout_id"
dancers ||--o{ hangout_rsvps : "dancer_id"
events |o--o{ hangouts : "event_id"
dancers ||--o{ hangouts : "dancer_id" Source: server/database/schema.ts. Context grouping: scripts/architecture/domains.ts (a test fails when a table is not assigned).