Host Experience Creation Flow
A production-ready database, API, and backend architecture specification derived from UI screens 52 · 53 · 53B · 54 — the host "Create" wizard that forks on intent, captures venue & offer details, then builds ticket tiers and timed release waves.
Create · the fork
Step 1 of 2. The host chooses “I’ll bring my crowd” (Bringer) or “Help me fill it” (Seeker). This single choice reconfigures copy, money, and safety defaults.
Create · venue & offer
Step 2. Title, categories, a partner-venue picker or map pin, date/time/cap, predicted attendance, women-only, and an optional gold host offer.
Create tickets · tiers
Name each tier, set price & quantity, and choose release timing. All-in pricing — no fees added at checkout.
Release waves & tiers
Early-bird → general → door tiers that auto-release the next wave at 90% sold. Scarcity without velvet ropes.
1. Feature Analysis
This section documents the backend behind the JoinMeIn Host Experience Creation flow as expressed across mobile UI screens 52, 53, 53b and 54. The flow is a single linear wizard that forks at screen 52 on the host's creation_intent: a SEEKER host ("Help me fill it") builds a free, platform-curated event and publishes straight after the details screen, while a BRINGER host ("I'll bring my crowd") builds a ticketed event and continues through ticket tiers and release waves before publishing.
creation_intent set on screen 52 decides everything downstream. SEEKER → 53 → Publish (is_ticketed=false, no tiers, uses predicted attendance + curated join requests). BRINGER → 53 → 53b → 54 → Publish (is_ticketed=true, tiers become release waves).
1.1 Purpose of Each Screen
Screen 52 — Create · the fork (Step 1 of 2)
Screen 52 is the entry point and decision fork of the entire creation flow. Titled "New experience" with a 50% progress bar and an X close control, it asks a single question — "Will you bring people, or should we help fill it?" — presented as two mutually exclusive radio option cards. Selecting "I'll bring my crowd" (megaphone icon) sets creation_intent = BRINGER and routes the host toward the ticketed setup; selecting "Help me fill it" (sparkle icon, the default selection) sets creation_intent = SEEKER and routes toward a curated free event. The safety note ("Your intent sets the safety defaults") signals that this choice also seeds defaults for visibility, join_type and verification rules. The "Next — the details" CTA persists a fresh DRAFT event via POST /api/v1/events with only {creationIntent}, returning a 201 with the new event id and creation_step=1.
Screen 53 — Create · venue & offer (Step 2 of 2, SEEKER path)
Screen 53 is the core details capture screen, shared by both branches but shown here for the SEEKER path where it is also the final step before publishing. It collects the event title, one or more category chips (mapped through event_category_map, with the first/primary selection stored on primary_category_id), and the location via an expandable venue selector that supports either dropping a custom map pin (location_type=CUSTOM_PIN, capturing latitude/longitude plus a reverse-geocoded location_name) or choosing a nearby partner venue from a distance-sorted list (location_type=VENUE, binding venue_id). It also captures start_at (date + time), capacity, the women-only switch (women_only), an optional Host Offer (upserted 1:1 into host_offers), and renders a read-only Predicted Attendance card ("~9 likely to join within 3 km", 72% confidence) computed from nearby interest. Field edits are saved via PATCH /api/v1/events/:id; the offer via PUT /api/v1/events/:id/offer; the "Publish experience" CTA fires POST /api/v1/events/:id/publish.
Screen 53b — Create tickets · tiers (BRINGER path)
Screen 53b is the ticket tier builder, reached only on the BRINGER branch after screen 53. Sub-titled "Set each tier — name, price and how many tickets. Release order is next.", it presents a repeatable list of tier cards under a "TICKET TIERS" header, each capturing a tier name (e.g. "Early Bird", "General"), an all-in price (stored as price_cents), a quantity_total, and a release timing dropdown that maps to release_type (e.g. "Available now" → AVAILABLE_NOW, "When prev. tier hits 90%" → AFTER_PREV_TIER_THRESHOLD with release_threshold_pct=90). The "+ Add another tier" button appends cards and each "Remove" link deletes one. The all-in note enforces that the displayed price is exactly what guests pay (is_all_in=true). Tiers are persisted as a set via the bulk PUT /api/v1/events/:id/ticket-tiers; the "Continue → Set release waves" CTA advances to screen 54.
Screen 54 — Release waves & tiers
Screen 54 is the release orchestration screen, the final BRINGER step. Each ticket tier from 53b is rendered as a live status card showing name, quantity, price and current sales state — "Early bird — 20 @ $12" sold out at 100%, "General — 40 @ $15" on sale with 26 left at 35%, "Door — 10 @ $20" opening day-of at 0%. These map onto ticket_tier_status (SOLD_OUT, ON_SALE, SCHEDULED) with progress derived from quantity_sold / quantity_total. An Auto-release toggle ("Auto-release next wave — When the current one hits 90% sold") binds the event-level auto_release_enabled and auto_release_threshold_pct fields. Live data is read via GET /api/v1/events/:id/ticket-releases; the "Save releases" CTA persists settings via PATCH /api/v1/events/:id/release-settings, after which the host can publish.
Adjacent screens (context only)
- Screen 51 — Account type: the role-selection step (Guest / Host / Venue) where roles stack onto
users.roleandaccount_type; a host must exist before reaching screen 52. - Screen 55 — Host home: the post-publish dashboard (rating, $ this month, guests met, tonight card) surfacing the curated
event_join_requestslist with Approve/Skip per guest — the consumer of the SEEKER curated-fill model.
1.2 Complete User Flow (52 → 53 → 53b → 54)
- Enter the wizard (52). Host opens "New experience". Progress 50%. The "Help me fill it" (SEEKER) card is selected by default.
- Choose intent (52). Host picks a radio card. "I'll bring my crowd" sets
creation_intent=BRINGER; "Help me fill it" setscreation_intent=SEEKER. Tapping "Next — the details" callsPOST /api/v1/eventswith{creationIntent}, creating aDRAFTevent (creation_step=1) and returning its id. - Capture details (53). Both branches land here. Host fills title, selects category chips, picks a venue (partner venue →
venue_id, or custom map pin →latitude/longitude+ reverse-geocoded label), sets date, time and capacity, optionally toggles women-only, and optionally adds a Host Offer. The Predicted Attendance card refreshes as location/categories change (POST /api/v1/events/predicted-attendance). Edits autosave viaPATCH /api/v1/events/:id; the offer viaPUT /api/v1/events/:id/offer. - Branch decision.
- SEEKER: The CTA reads "Publish experience". Host taps it →
POST /api/v1/events/:id/publishvalidates (verified host, required fields) and flipsstatus=PUBLISHED,is_ticketed=false. Flow ends; the event now appears on screen 55 and accumulates curatedevent_join_requests. 53b and 54 are skipped. - BRINGER: Flow continues to step 5.
- SEEKER: The CTA reads "Publish experience". Host taps it →
- Build ticket tiers (53b). Host adds one or more tiers (name, price, qty, release timing). Each tier is one release wave. "+ Add another tier" appends; "Remove" deletes. "Continue → Set release waves" bulk-saves all tiers via
PUT /api/v1/events/:id/ticket-tiersand setsis_ticketed=true. - Configure release waves (54). Host reviews live tier status cards (
GET /api/v1/events/:id/ticket-releases) and sets the auto-release toggle + threshold. "Save releases" persists viaPATCH /api/v1/events/:id/release-settings. - Publish (BRINGER). Host publishes via
POST /api/v1/events/:id/publish→status=PUBLISHED. The ticketed event goes live with its first wave on sale.
status=DRAFT at step 2 and ends status=PUBLISHED at step 7. creation_step advances 1 (52) → 2 (53) → 3 (53b) → 4 (54). is_ticketed is set true only on the BRINGER branch when tiers are saved.
1.3 Exhaustive UI Element Inventory
Every interactive and computed element across screens 52, 53, 53b and 54, with its UI type, the bound database field or backend action, and its validation/behavior.
| Screen | Element | Type | Bound field / action | Validation or behavior |
|---|---|---|---|---|
| 52 | Close (X) button | button | Discard / exit wizard | Abandons draft (DRAFT remains soft-discardable). No persistence. |
| 52 | Progress bar 50% | computed-card | creation_step |
Read-only step indicator; step 1 of 2. |
| 52 | "I'll bring my crowd" card | chips (radio) | creation_intent = BRINGER |
Single-select; mutually exclusive with (b). Seeds ticketed-path safety defaults. |
| 52 | "Help me fill it" card | chips (radio) | creation_intent = SEEKER |
Single-select; SELECTED by default. Seeds curated/free safety defaults. |
| 52 | Safety note | text | n/a (informational) | Static copy; explains intent drives visibility/verification defaults. |
| 52 | "Next — the details" CTA | button | POST /api/v1/events body {creationIntent} |
Requires a selected intent. Creates DRAFT event → 201; advances to 53. |
| 53 | Back button | button | Navigate to 52 | Preserves draft; intent editable until publish. |
| 53 | Progress bar 100% | computed-card | creation_step |
Read-only; step 2 of 2 (SEEKER terminal step). |
| 53 | Title input | input/text | events.title |
required on publish; max 120 chars (VARCHAR(120)). |
| 53 | Category chips (Friends, Networking, Mixer, Coffee, Dining, Party) | chips | event_category_map rows; first selection → primary_category_id |
Multi-select; at least one expected. Each chip = an event_categories slug. Friends shown selected. |
| 53 | Venue selector (expandable) | dropdown / selector | location_type (VENUE | CUSTOM_PIN) |
Expands to reveal custom-pin option OR partner venue list. Drives which sub-fields apply. |
| 53 | "Select your own location" map pin | map-pin | latitude, longitude, location_type=CUSTOM_PIN, location_name |
Draggable pin; reverse-geocoded label (e.g. "Lower East Side, NY"). Lat in [-90,90], lng in [-180,180]. |
| 53 | "Use my location" button | button | latitude, longitude (device geolocation) |
Populates pin from device GPS; triggers prediction refresh. |
| 53 | Partner venue list (distance-sorted) | selector (list) | venue_id, location_type=VENUE |
Fed by GET /api/v1/venues (near=lat,lng); shows distance (0.3 mi, 0.6 mi, 1.2 mi). Selecting one binds venue_id and copies venue coords. |
| 53 | Date picker ("Sun, Jul 6") | input (date) | events.start_at (date part) |
required; combined with time. Must be in the future on publish. |
| 53 | Time picker ("9:00 AM") | input (time) | events.start_at (time part), timezone |
required; merged with date into start_at DATETIME. |
| 53 | Cap (capacity) input (12) | input (number) | events.capacity |
Unsigned int; CHECK capacity >= 0. Must be >= current_guests. |
| 53 | Predicted Attendance card | computed-card | predicted_attendance_count, predicted_attendance_radius_km, predicted_attendance_confidence_pct |
Read-only. "~9 likely to join within 3 km", 72%. Refreshed via POST /api/v1/events/predicted-attendance; may be cached in attendance_predictions. |
| 53 | Women-only toggle | toggle | events.women_only |
Boolean; OFF by default. Affects join eligibility / safety defaults. |
| 53 | "+ Add offer" pill | button | Reveals offer editor | Toggles the host-offer sub-form open. |
| 53 | Offer title input | input/text | host_offers.title |
Required when offer present; max 120 chars. e.g. "Free coffee for everyone". |
| 53 | Offer short description input | input/text | host_offers.description |
Max 20 words (app-layer enforced), VARCHAR(160). |
| 53 | Offer live Preview (gold pill) | computed-card | Renders host_offers.title |
Read-only live preview; updates as the host types. |
| 53 | "Publish experience" CTA (SEEKER) | button | PATCH /api/v1/events/:id then POST /api/v1/events/:id/publish |
Requires verified host + valid required fields. Sets status=PUBLISHED, is_ticketed=false. 409 if already published. Offer saved via PUT /events/:id/offer. |
| 53b | Back button | button | Navigate to 53 | Preserves entered tiers (unsaved kept client-side). |
| 53b | Tier card (repeatable) | status-card / group | One ticket_tiers row |
Each card = one tier = one release wave. sort_order assigned by position. |
| 53b | "Remove" link | link | DELETE /api/v1/ticket-tiers/:tierId |
Removes one tier; 204. Renumbers remaining sort_order. |
| 53b | Tier name input ("Early Bird") | input/text | ticket_tiers.name |
required; max 80 chars. UNIQUE per event — 409 on duplicate. |
| 53b | Price ($) input (12 / 15) | input (number) | ticket_tiers.price_cents |
Captured in dollars, stored as integer minor units. CHECK price_cents >= 0. All-in (is_all_in=true). |
| 53b | Qty (tickets) input (20 / 40) | input (number) | ticket_tiers.quantity_total |
Unsigned int > 0. CHECK quantity_sold <= quantity_total. |
| 53b | Release timing dropdown | dropdown | ticket_tiers.release_type (+ release_threshold_pct / scheduled_release_at) |
"Available now" → AVAILABLE_NOW; "When prev. tier hits 90%" → AFTER_PREV_TIER_THRESHOLD with threshold 90; also SCHEDULED, DAY_OF. |
| 53b | "+ Add another tier" button | button | Append new tier card | Adds an empty tier with next sort_order. |
| 53b | All-in pricing note | text | n/a (informational) | Static; reinforces no checkout fees (is_all_in=true). |
| 53b | "Continue → Set release waves" CTA | button | PUT /api/v1/events/:id/ticket-tiers body {tiers:[...]} |
Bulk upsert/replace all tiers; sets is_ticketed=true; advances to 54. 400 on tier validation, 409 on duplicate name. |
| 54 | Back button | button | Navigate to 53b | Returns to tier editor. |
| 54 | Tier status card "Early bird — 20 @ $12" | status-card | ticket_tiers.status = SOLD_OUT, progress = sold/total |
Read-only live. "Sold out" pill, bar 100%. |
| 54 | Tier status card "General — 40 @ $15" | status-card | ticket_tiers.status = ON_SALE; remaining = total - sold |
Read-only live. "on sale · 26 left", bar 35%. |
| 54 | Tier status card "Door — 10 @ $20" | status-card | ticket_tiers.status = SCHEDULED, release_type=DAY_OF |
Read-only live. "opens day-of", bar 0%. |
| 54 | Auto-release toggle (ON) | toggle | events.auto_release_enabled + events.auto_release_threshold_pct |
ON by default; threshold default 90%. "Auto-release next wave when current hits 90% sold". |
| 54 | "Save releases" CTA | button | PATCH /api/v1/events/:id/release-settings body {autoReleaseEnabled,autoReleaseThresholdPct} |
Persists release settings; 200. Host may then publish the ticketed event. |
events (+ event_category_map, optional host_offers), then ticket_tiers (one row per wave), and finally event-level release settings. A complete SEEKER run touches events (+ event_category_map, optional host_offers) only, with predicted attendance fed from attendance_predictions and downstream curation via event_join_requests.
2. Database Design
The schema is anchored on the events table, the central entity for the Host Experience Creation flow (screens 52 → 53 → 53b → 54). The creation_intent column forks every event into one of two product models: SEEKER (a free, curated experience that the platform fills — 52 → 53 → Publish) or BRINGER (a ticketed experience where the host brings their crowd — 52 → 53 → 53b → 54 → Publish). All tables use snake_case names, BIGINT auto-increment primary keys, and a soft-delete deleted_at column where applicable.
is_ticketed=true is set only on the BRINGER branch (it is the flag that gates screens 53b/54). SEEKER events keep is_ticketed=false and rely on predicted_attendance_* columns plus the event_join_requests curated list instead of ticket_tiers.
2.1 Table: users (pre-existing — abbreviated)
Authentication and identity. Shown as an abbreviated subset; role and the verification flags drive the host-role and requireVerified middleware gates on publish.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| id | BIGINT UNSIGNED | NO | PK | AUTO_INCREMENT | Primary key |
| full_name | VARCHAR(120) | YES | — | NULL | Display name |
| VARCHAR(160) | NO | UNIQUE | — | Login identifier | |
| phone | VARCHAR(20) | YES | — | NULL | Optional contact |
| role | ENUM(GUEST,HOST) | NO | — | GUEST | Host-role gate for event mutations |
| account_type | ENUM(INDIVIDUAL,BUSINESS) | NO | — | INDIVIDUAL | Screen 51 account type |
| is_verified | BOOL | NO | — | false | Required by requireVerified on publish |
| is_id_verified | BOOL | NO | — | false | Feeds guest reliability scoring (screen 55) |
| created_at | DATETIME | NO | — | CURRENT_TIMESTAMP | — |
| updated_at | DATETIME | NO | — | CURRENT_TIMESTAMP | ON UPDATE |
2.2 Table: business_profiles (pre-existing — abbreviated)
Optional business identity for BUSINESS accounts. Referenced (nullable) from both venues and events so a host can attribute an experience to their business.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| id | BIGINT UNSIGNED | NO | PK | AUTO_INCREMENT | Primary key |
| user_id | BIGINT UNSIGNED | NO | FK, UNIQUE | — | FK → users.id; one profile per user |
| business_name | VARCHAR(160) | YES | — | NULL | — |
| business_type | ENUM(CLUB,RESTAURANT,HOTEL,RESORT,CAFE,OTHER) | YES | — | NULL | — |
| city | VARCHAR(120) | YES | — | NULL | — |
| latitude | DECIMAL(10,7) | YES | — | NULL | — |
| longitude | DECIMAL(10,7) | YES | — | NULL | — |
| created_at | DATETIME | NO | — | CURRENT_TIMESTAMP | — |
| updated_at | DATETIME | NO | — | CURRENT_TIMESTAMP | ON UPDATE |
2.3 Table: venues
Partner and host-owned venues. Populates the partner-venue list in screen 53 (“Partner Cafe · LES”, etc.) and is referenced by events.venue_id when location_type=VENUE.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| id | BIGINT UNSIGNED | NO | PK | AUTO_INCREMENT | Primary key |
| owner_user_id | BIGINT UNSIGNED | NO | FK | — | FK → users.id (ON DELETE CASCADE) |
| business_profile_id | BIGINT UNSIGNED | YES | FK | NULL | FK → business_profiles.id (ON DELETE SET NULL) |
| name | VARCHAR(160) | NO | — | — | Display name |
| venue_type | ENUM(CLUB,RESTAURANT,HOTEL,RESORT,CAFE,BAR,ROOFTOP,OTHER) | NO | — | OTHER | — |
| description | VARCHAR(500) | YES | — | NULL | — |
| address | VARCHAR(255) | YES | — | NULL | — |
| city | VARCHAR(120) | YES | IDX(city) | NULL | — |
| latitude | DECIMAL(10,7) | YES | IDX(latitude,longitude) | NULL | Distance sort for nearby search |
| longitude | DECIMAL(10,7) | YES | IDX(latitude,longitude) | NULL | — |
| is_partner | BOOL | NO | IDX(is_partner,is_active) | false | Partner venues shown in screen 53 list |
| is_active | BOOL | NO | IDX(is_partner,is_active) | true | — |
| created_at | DATETIME | NO | — | CURRENT_TIMESTAMP | — |
| updated_at | DATETIME | NO | — | CURRENT_TIMESTAMP | ON UPDATE |
Indexes: (latitude,longitude), (city), (is_partner,is_active).
2.4 Table: event_categories
Lookup of selectable category chips (screen 53). Seeded with Friends, Networking, Mixer, Coffee, Dining, Party.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| id | BIGINT UNSIGNED | NO | PK | AUTO_INCREMENT | Primary key |
| name | VARCHAR(60) | NO | — | — | Chip label |
| slug | VARCHAR(60) | NO | UNIQUE | — | e.g. friends, networking, mixer |
| icon | VARCHAR(16) | YES | — | NULL | Emoji / icon key |
| sort_order | INT | NO | — | 0 | Display ordering |
| is_active | BOOL | NO | — | true | Soft hide |
| created_at | DATETIME | NO | — | CURRENT_TIMESTAMP | — |
| updated_at | DATETIME | NO | — | CURRENT_TIMESTAMP | ON UPDATE |
Seed rows: Friends/friends, Networking/networking, Mixer/mixer, Coffee/coffee, Dining/dining, Party/party.
2.5 Table: events (central table)
The heart of the schema. Built incrementally across the wizard via creation_step and status=DRAFT, then flipped to PUBLISHED at POST /api/v1/events/:id/publish. The predicted_attendance_* columns back the screen-53 Predicted Attendance card; the auto_release_* columns back the screen-54 auto-release toggle.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| id | BIGINT UNSIGNED | NO | PK | AUTO_INCREMENT | Primary key |
| host_id | BIGINT UNSIGNED | NO | FK, IDX(host_id) | — | FK → users.id (ON DELETE RESTRICT; owner) |
| business_profile_id | BIGINT UNSIGNED | YES | FK | NULL | FK → business_profiles.id (ON DELETE SET NULL) |
| creation_intent | ENUM(BRINGER,SEEKER) | NO | IDX(creation_intent) | — | The screen-52 fork |
| title | VARCHAR(120) | NO | — | — | e.g. “Sunday Founders Coffee” |
| description | VARCHAR(500) | YES | — | NULL | — |
| primary_category_id | BIGINT UNSIGNED | YES | FK, IDX | NULL | FK → event_categories.id (ON DELETE SET NULL) |
| location_type | ENUM(VENUE,CUSTOM_PIN) | NO | — | CUSTOM_PIN | Partner venue vs dropped pin |
| venue_id | BIGINT UNSIGNED | YES | FK, IDX(venue_id) | NULL | FK → venues.id (ON DELETE SET NULL); set when location_type=VENUE |
| location_name | VARCHAR(160) | YES | — | NULL | Reverse-geocoded label e.g. “Lower East Side, NY” |
| address | VARCHAR(255) | YES | — | NULL | — |
| latitude | DECIMAL(10,7) | YES | IDX(latitude,longitude) | NULL | Map pin lat |
| longitude | DECIMAL(10,7) | YES | IDX(latitude,longitude) | NULL | Map pin lng |
| geohash | VARCHAR(12) | YES | IDX(geohash) | NULL | Proximity bucketing |
| start_at | DATETIME | NO | IDX(status,start_at) | — | “Sun, Jul 6 · 9:00 AM” |
| end_at | DATETIME | YES | — | NULL | — |
| timezone | VARCHAR(64) | NO | — | UTC | IANA tz name |
| capacity | INT UNSIGNED | NO | — | 0 | Screen-53 Cap field; CHECK capacity >= 0 |
| current_guests | INT UNSIGNED | NO | — | 0 | Confirmed attendees |
| women_only | BOOL | NO | — | false | Screen-53 women-only toggle |
| is_ticketed | BOOL | NO | — | false | true only on BRINGER branch (gates 53b/54) |
| auto_release_enabled | BOOL | NO | — | true | Screen-54 auto-release toggle |
| auto_release_threshold_pct | TINYINT UNSIGNED | NO | — | 90 | “hits 90% sold” |
| visibility | ENUM(PUBLIC,PRIVATE,INVITE_ONLY) | NO | IDX(visibility) | PUBLIC | Intent sets safety default |
| join_type | ENUM(REQUEST,OPEN) | NO | — | REQUEST | SEEKER uses curated REQUEST flow |
| status | ENUM(DRAFT,PUBLISHED,ONGOING,COMPLETED,CANCELLED) | NO | IDX(status,start_at) | DRAFT | Lifecycle |
| creation_step | TINYINT | YES | — | 1 | Wizard progress (1=52, 2=53, ...) |
| predicted_attendance_count | INT | YES | — | NULL | “~9 likely to join” |
| predicted_attendance_radius_km | DECIMAL(5,2) | YES | — | NULL | “within 3 km” |
| predicted_attendance_confidence_pct | TINYINT | YES | — | NULL | Progress 72% |
| cover_image | VARCHAR(255) | YES | — | NULL | Upload path |
| qr_code_token | VARCHAR(64) | YES | UNIQUE | NULL | Check-in token; UNIQUE(qr_code_token) |
| published_at | DATETIME | YES | — | NULL | Set on publish |
| created_at | DATETIME | NO | — | CURRENT_TIMESTAMP | — |
| updated_at | DATETIME | NO | — | CURRENT_TIMESTAMP | ON UPDATE |
| deleted_at | DATETIME | YES | — | NULL | Soft delete (paranoid) |
Indexes: (host_id), (status,start_at), (visibility), (latitude,longitude), (geohash), (venue_id), (creation_intent), (primary_category_id), UNIQUE(qr_code_token). Check: capacity >= 0.
2.6 Table: event_category_map (junction)
Many-to-many between events and event_categories — screen 53 allows multiple category chips. Composite primary key prevents duplicate pairs.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| event_id | BIGINT UNSIGNED | NO | PK, FK | — | FK → events.id (ON DELETE CASCADE) |
| category_id | BIGINT UNSIGNED | NO | PK, FK, IDX(category_id) | — | FK → event_categories.id (ON DELETE CASCADE) |
Primary key: (event_id, category_id). Index: (category_id) for reverse lookups (events in a category).
2.7 Table: host_offers (1:1 with events)
Backs the screen-53 Host Offer editor and gold preview pill. One offer per event enforced by a UNIQUE constraint on event_id.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| id | BIGINT UNSIGNED | NO | PK | AUTO_INCREMENT | Primary key |
| event_id | BIGINT UNSIGNED | NO | FK, UNIQUE, IDX(event_id) | — | FK → events.id (ON DELETE CASCADE); 1:1 |
| title | VARCHAR(120) | NO | — | — | e.g. “Free coffee for everyone” |
| description | VARCHAR(160) | YES | — | NULL | Max 20 words (enforced in app layer) |
| is_active | BOOL | NO | — | true | — |
| created_at | DATETIME | NO | — | CURRENT_TIMESTAMP | — |
| updated_at | DATETIME | NO | — | CURRENT_TIMESTAMP | ON UPDATE |
2.8 Table: ticket_tiers (1:N from events — each tier is a release wave)
Backs screens 53b (tier authoring) and 54 (live release waves). Each row is simultaneously a price tier and a release wave: sort_order is the release order, release_type/release_threshold_pct/scheduled_release_at describe when it opens, and status reflects the live state shown on screen 54.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| id | BIGINT UNSIGNED | NO | PK | AUTO_INCREMENT | Primary key |
| event_id | BIGINT UNSIGNED | NO | FK, IDX(event_id,sort_order) | — | FK → events.id (ON DELETE CASCADE) |
| name | VARCHAR(80) | NO | UNIQUE(event_id,name) | — | e.g. “Early Bird”, “General”, “Door” |
| price_cents | INT UNSIGNED | NO | — | — | All-in, integer minor units; CHECK price_cents >= 0 |
| currency | CHAR(3) | NO | — | USD | ISO 4217 |
| quantity_total | INT UNSIGNED | NO | — | — | Qty field on 53b |
| quantity_sold | INT UNSIGNED | NO | — | 0 | CHECK quantity_sold <= quantity_total |
| sort_order | INT | NO | IDX(event_id,sort_order) | 0 | Release order |
| release_type | ENUM(AVAILABLE_NOW,AFTER_PREV_TIER_THRESHOLD,SCHEDULED,DAY_OF) | NO | — | AVAILABLE_NOW | Release timing dropdown |
| release_threshold_pct | TINYINT UNSIGNED | YES | — | 90 | “When prev. tier hits 90%” |
| scheduled_release_at | DATETIME | YES | — | NULL | Used when release_type=SCHEDULED |
| status | ENUM(SCHEDULED,ON_SALE,SOLD_OUT,CLOSED) | NO | IDX(event_id,status) | SCHEDULED | Live state on screen 54 |
| is_all_in | BOOL | NO | — | true | No fees added at checkout |
| created_at | DATETIME | NO | — | CURRENT_TIMESTAMP | — |
| updated_at | DATETIME | NO | — | CURRENT_TIMESTAMP | ON UPDATE |
| deleted_at | DATETIME | YES | — | NULL | Soft delete |
Indexes: (event_id,sort_order), (event_id,status), UNIQUE(event_id,name). Checks: quantity_sold <= quantity_total, price_cents >= 0.
2.9 Table: event_join_requests (curated list — adjacent, screen 55)
Included briefly for completeness. Backs the curated REQUESTS list (Approve/Skip) on the host home; primarily exercised by SEEKER events whose join_type=REQUEST.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| id | BIGINT UNSIGNED | NO | PK | AUTO_INCREMENT | Primary key |
| event_id | BIGINT UNSIGNED | NO | FK, UNIQUE(event_id,user_id), IDX(event_id,status) | — | FK → events.id (ON DELETE CASCADE) |
| user_id | BIGINT UNSIGNED | NO | FK, UNIQUE(event_id,user_id) | — | FK → users.id (ON DELETE CASCADE) |
| status | ENUM(PENDING,APPROVED,SKIPPED,REJECTED) | NO | IDX(event_id,status) | PENDING | Approve/Skip outcome |
| message | VARCHAR(280) | YES | — | NULL | Guest note |
| created_at | DATETIME | NO | — | CURRENT_TIMESTAMP | — |
| updated_at | DATETIME | NO | — | CURRENT_TIMESTAMP | ON UPDATE |
Unique: (event_id,user_id) — one request per guest per event. Index: (event_id,status).
2.10 Table: attendance_predictions (optional cache)
Optional persistence for the Predicted Attendance card. May be computed on-the-fly at POST /api/v1/events/predicted-attendance instead of stored; when stored, the latest snapshot is mirrored onto the events.predicted_attendance_* columns.
| Column | Type | Null | Key/Index | Default | Constraint/Notes |
|---|---|---|---|---|---|
| id | BIGINT UNSIGNED | NO | PK | AUTO_INCREMENT | Primary key |
| event_id | BIGINT UNSIGNED | YES | FK, UNIQUE | NULL | FK → events.id (ON DELETE CASCADE); NULL for preview-only |
| host_id | BIGINT UNSIGNED | NO | FK | — | FK → users.id (ON DELETE CASCADE) |
| category_ids | JSON | YES | — | NULL | Array of category ids used as inputs |
| latitude | DECIMAL(10,7) | NO | — | — | Prediction center |
| longitude | DECIMAL(10,7) | NO | — | — | — |
| radius_km | DECIMAL(5,2) | NO | — | 3.00 | “within 3 km” |
| predicted_count | INT | YES | — | NULL | “~9” |
| confidence_pct | TINYINT | YES | — | NULL | 72% |
| computed_at | DATETIME | YES | — | NULL | Cache timestamp |
Unique: (event_id) — at most one cached prediction per draft event.
2.11 MySQL DDL
Production-ready CREATE TABLE statements for the six core flow tables (InnoDB, utf8mb4). FKs and CHECK constraints are declared inline; comparison operators are shown escaped for the documentation.
CREATE TABLE venues (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
owner_user_id BIGINT UNSIGNED NOT NULL,
business_profile_id BIGINT UNSIGNED NULL,
name VARCHAR(160) NOT NULL,
venue_type ENUM('CLUB','RESTAURANT','HOTEL','RESORT','CAFE','BAR','ROOFTOP','OTHER')
NOT NULL DEFAULT 'OTHER',
description VARCHAR(500) NULL,
address VARCHAR(255) NULL,
city VARCHAR(120) NULL,
latitude DECIMAL(10,7) NULL,
longitude DECIMAL(10,7) NULL,
is_partner BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_venues_lat_lng (latitude, longitude),
KEY idx_venues_city (city),
KEY idx_venues_partner_active (is_partner, is_active),
CONSTRAINT fk_venues_owner FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT fk_venues_bizprof FOREIGN KEY (business_profile_id) REFERENCES business_profiles (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_categories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(60) NOT NULL,
slug VARCHAR(60) NOT NULL,
icon VARCHAR(16) NULL,
sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_event_categories_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
host_id BIGINT UNSIGNED NOT NULL,
business_profile_id BIGINT UNSIGNED NULL,
creation_intent ENUM('BRINGER','SEEKER') NOT NULL,
title VARCHAR(120) NOT NULL,
description VARCHAR(500) NULL,
primary_category_id BIGINT UNSIGNED NULL,
location_type ENUM('VENUE','CUSTOM_PIN') NOT NULL DEFAULT 'CUSTOM_PIN',
venue_id BIGINT UNSIGNED NULL,
location_name VARCHAR(160) NULL,
address VARCHAR(255) NULL,
latitude DECIMAL(10,7) NULL,
longitude DECIMAL(10,7) NULL,
geohash VARCHAR(12) NULL,
start_at DATETIME NOT NULL,
end_at DATETIME NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
capacity INT UNSIGNED NOT NULL DEFAULT 0,
current_guests INT UNSIGNED NOT NULL DEFAULT 0,
women_only BOOLEAN NOT NULL DEFAULT FALSE,
is_ticketed BOOLEAN NOT NULL DEFAULT FALSE,
auto_release_enabled BOOLEAN NOT NULL DEFAULT TRUE,
auto_release_threshold_pct TINYINT UNSIGNED NOT NULL DEFAULT 90,
visibility ENUM('PUBLIC','PRIVATE','INVITE_ONLY') NOT NULL DEFAULT 'PUBLIC',
join_type ENUM('REQUEST','OPEN') NOT NULL DEFAULT 'REQUEST',
status ENUM('DRAFT','PUBLISHED','ONGOING','COMPLETED','CANCELLED')
NOT NULL DEFAULT 'DRAFT',
creation_step TINYINT NULL DEFAULT 1,
predicted_attendance_count INT NULL,
predicted_attendance_radius_km DECIMAL(5,2) NULL,
predicted_attendance_confidence_pct TINYINT NULL,
cover_image VARCHAR(255) NULL,
qr_code_token VARCHAR(64) NULL,
published_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_events_qr_token (qr_code_token),
KEY idx_events_host (host_id),
KEY idx_events_status_start (status, start_at),
KEY idx_events_visibility (visibility),
KEY idx_events_lat_lng (latitude, longitude),
KEY idx_events_geohash (geohash),
KEY idx_events_venue (venue_id),
KEY idx_events_intent (creation_intent),
KEY idx_events_category (primary_category_id),
CONSTRAINT fk_events_host FOREIGN KEY (host_id) REFERENCES users (id) ON DELETE RESTRICT,
CONSTRAINT fk_events_bizprof FOREIGN KEY (business_profile_id) REFERENCES business_profiles (id) ON DELETE SET NULL,
CONSTRAINT fk_events_category FOREIGN KEY (primary_category_id) REFERENCES event_categories (id) ON DELETE SET NULL,
CONSTRAINT fk_events_venue FOREIGN KEY (venue_id) REFERENCES venues (id) ON DELETE SET NULL,
CONSTRAINT chk_events_capacity CHECK (capacity >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_category_map (
event_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (event_id, category_id),
KEY idx_ecm_category (category_id),
CONSTRAINT fk_ecm_event FOREIGN KEY (event_id) REFERENCES events (id) ON DELETE CASCADE,
CONSTRAINT fk_ecm_category FOREIGN KEY (category_id) REFERENCES event_categories (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE host_offers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(120) NOT NULL,
description VARCHAR(160) NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_host_offers_event (event_id),
KEY idx_host_offers_event (event_id),
CONSTRAINT fk_host_offers_event FOREIGN KEY (event_id) REFERENCES events (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE ticket_tiers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(80) NOT NULL,
price_cents INT UNSIGNED NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
quantity_total INT UNSIGNED NOT NULL,
quantity_sold INT UNSIGNED NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
release_type ENUM('AVAILABLE_NOW','AFTER_PREV_TIER_THRESHOLD','SCHEDULED','DAY_OF')
NOT NULL DEFAULT 'AVAILABLE_NOW',
release_threshold_pct TINYINT UNSIGNED NULL DEFAULT 90,
scheduled_release_at DATETIME NULL,
status ENUM('SCHEDULED','ON_SALE','SOLD_OUT','CLOSED') NOT NULL DEFAULT 'SCHEDULED',
is_all_in BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_ticket_tiers_event_name (event_id, name),
KEY idx_ticket_tiers_event_sort (event_id, sort_order),
KEY idx_ticket_tiers_event_status (event_id, status),
CONSTRAINT fk_ticket_tiers_event FOREIGN KEY (event_id) REFERENCES events (id) ON DELETE CASCADE,
CONSTRAINT chk_tiers_sold_lte_total CHECK (quantity_sold <= quantity_total),
CONSTRAINT chk_tiers_price_nonneg CHECK (price_cents >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.12 Entity-Relationship Diagram
2.13 Relationship Explanation
- users → events (1:N, ON DELETE RESTRICT): every event has exactly one owning host (
host_id). RESTRICT prevents deleting a user who still owns events; events are removed via soft delete instead. - users → business_profiles (1:0..1): a UNIQUE
user_idgives each user at most one business profile (screen-51 BUSINESS account type). - business_profiles → events / venues (1:N, nullable, ON DELETE SET NULL): an event or venue may be attributed to a business; deleting the profile detaches rather than cascading.
- venues → events (1:N, nullable, ON DELETE SET NULL): set when
location_type=VENUE(a partner venue chosen on screen 53). ForCUSTOM_PINthevenue_idis NULL and the inlinelatitude/longitude/location_namecolumns carry the dropped-pin location. - event_categories ↔ events via event_category_map (M:N): an event can carry multiple category chips, and a category tags many events. The dedicated
primary_category_idFK oneventsadditionally records the single “primary” chip for fast filtering and display. - events → host_offers (1:0..1, ON DELETE CASCADE): the UNIQUE
event_idenforces a single optional host offer (screen-53 gold pill); deleting the event removes its offer. - events → ticket_tiers (1:N, ON DELETE CASCADE): only BRINGER events (
is_ticketed=true) populate tiers. Each tier doubles as a release wave; the set is authored in 53b and monitored in 54. - events → event_join_requests (1:N, ON DELETE CASCADE): the curated join list, primarily for SEEKER events with
join_type=REQUEST; UNIQUE(event_id,user_id)blocks duplicate requests. - events → attendance_predictions (1:0..1, ON DELETE CASCADE): an optional cached prediction snapshot; a NULL
event_idrepresents a preview computed before the draft exists.
PUBLISHED with zero ticket_tiers rows (it skips 53b/54) and leans on predicted_attendance_* plus event_join_requests. A BRINGER event must have at least one ticket_tiers row and is_ticketed=true before publish validation passes. This single fork keeps both product models in one events table rather than two parallel schemas.
2.14 Normalization Considerations
First Normal Form (1NF)
Every column holds a single atomic value. Repeating groups — the multiple category chips and the multiple ticket tiers per event — are pushed into their own rows (event_category_map, ticket_tiers) rather than comma-joined columns. The only deliberate JSON column, attendance_predictions.category_ids, is discussed under JSON tradeoffs below.
Second Normal Form (2NF)
Tables with composite keys carry no partial dependencies. event_category_map’s key is exactly (event_id, category_id) and the table holds no non-key attributes, so there is nothing that could depend on only part of the key. All descriptive attributes about a category live in event_categories, not in the junction.
Third Normal Form (3NF)
Non-key columns depend only on their table’s key, not on other non-key columns. Category labels/icons are not duplicated onto events; only the FK primary_category_id is stored. Venue address/name are not copied onto an event that points at a venue — they are read through venue_id. (The inline events.latitude/longitude/location_name are not a 3NF violation: they exist precisely for the CUSTOM_PIN case where there is no venue row to depend on.)
Why categories are a junction table
An event may select several chips and a chip applies to many events — a genuine many-to-many relationship. Modeling it as event_category_map keeps the schema in 1NF, lets us add or drop a tag without rewriting the event row, and supports efficient reverse queries (“all events in networking”) via the (category_id) index. A denormalized CSV column would break 1NF and make category filtering a full-table LIKE scan.
Why each tier embeds its release wave (vs. a separate releases table)
In this product a release wave is one-to-one with a price tier: “Early Bird” is both a price point and a wave. Embedding the wave fields (sort_order, release_type, release_threshold_pct, scheduled_release_at, status) directly on ticket_tiers avoids an obligatory 1:1 join on every read of screens 53b/54 and keeps tier authoring atomic.
- Alternative — a separate
ticket_releasestable (tier_idFK, wave settings, status). This pays off only if a single tier could later have multiple independent release windows, or if release scheduling needs its own audit/event-sourced history. Until that requirement appears, a separate table is a speculative 1:1 split (sometimes called over-normalization) that adds a join with no integrity benefit. - Chosen tradeoff: embed now; the embedded columns are nullable/defaulted so a future extraction is a non-destructive migration (copy columns into
ticket_releases, backfill, then drop).
JSON usage tradeoffs
The lone JSON column is attendance_predictions.category_ids. It stores the inputs to a one-shot prediction computation, not relational state that other tables join against.
- Why JSON is acceptable here: the prediction row is a cache/snapshot; the category id array is read back as an opaque blob to recompute or display, never joined or filtered in SQL. Normalizing it into a
prediction_category_mapjunction would add a table and writes for a transient, often-recomputed-on-the-fly artifact. - Costs we accept: no FK integrity on the ids inside the JSON, no efficient “predictions that included category X” query without a generated/functional index, and looser typing. These are tolerable precisely because the column is non-authoritative.
- Where we deliberately did NOT use JSON: event-to-category links (the queryable, integrity-bearing relationship) use the proper
event_category_mapjunction instead, keeping the core flow fully normalized and indexable.
3. API Design
This section specifies the complete REST surface for the Host Experience Creation flow (mobile screens 52 → 53 → 53b → 54) plus its adjacent host/curation endpoints. All routes are mounted under the base path /api/v1. Unless explicitly marked PUBLIC, every endpoint requires a valid JWT access token via the Authorization: Bearer <token> header. The authenticate middleware sets req.user = {id, role}; mutating routes additionally enforce requireHost (role HOST) and per-record ownership (event.host_id === req.user.id). Publishing additionally enforces requireVerified.
creation_intent. SEEKER ("Help me fill it") is a free, curated event: 52 → 53 → Publish, with is_ticketed = false — it skips screens 53b and 54 entirely. BRINGER ("I'll bring my crowd") is a ticketed event: 52 → 53 → 53b → 54 → Publish, with is_ticketed = true. The ticket-tier and release-wave endpoints are valid only on the BRINGER branch.
3.1 Endpoint Overview
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/v1/events | Create DRAFT event (step 1, screen 52 fork) | HOST |
| GET | /api/v1/events/:id | Full event detail (categories, venue, offer, tiers) | JWT |
| PATCH | /api/v1/events/:id | Update details (step 2 / general edit) | HOST + owner |
| POST | /api/v1/events/:id/publish | Validate & publish event | HOST + owner + verified |
| PATCH | /api/v1/events/:id/status | Status transition (cancel / complete / ...) | HOST + owner |
| DELETE | /api/v1/events/:id | Soft delete event | HOST + owner |
| GET | /api/v1/events | Public list: pagination, search, filter, sort | PUBLIC |
| GET | /api/v1/events/mine | Host's own events (any status) | HOST |
| PATCH | /api/v1/events/bulk/status | Bulk status update | HOST + owner |
| DELETE | /api/v1/events/bulk | Bulk soft delete | HOST + owner |
| GET | /api/v1/event-categories | Category catalogue (chips) | PUBLIC |
| GET | /api/v1/venues | Nearby partner venues by distance | PUBLIC |
| GET | /api/v1/venues/:id | Venue detail | PUBLIC |
| POST | /api/v1/events/predicted-attendance | Preview prediction (no event yet) | HOST |
| GET | /api/v1/events/:id/predicted-attendance | Prediction for a draft | HOST + owner |
| PUT | /api/v1/events/:id/offer | Upsert host offer | HOST + owner |
| GET | /api/v1/events/:id/offer | Get host offer | JWT |
| DELETE | /api/v1/events/:id/offer | Remove host offer | HOST + owner |
| GET | /api/v1/events/:id/ticket-tiers | List ticket tiers | HOST + owner |
| POST | /api/v1/events/:id/ticket-tiers | Create one tier | HOST + owner |
| PUT | /api/v1/events/:id/ticket-tiers | Bulk upsert/replace all tiers (screen 53b save) | HOST + owner |
| PATCH | /api/v1/ticket-tiers/:tierId | Update one tier | HOST + owner |
| DELETE | /api/v1/ticket-tiers/:tierId | Remove one tier | HOST + owner |
| GET | /api/v1/events/:id/ticket-releases | Tiers with live release status (screen 54) | HOST + owner |
| PATCH | /api/v1/events/:id/release-settings | Auto-release toggle & threshold | HOST + owner |
| POST | /api/v1/ticket-tiers/:tierId/open | Manually open a wave (ON_SALE) | HOST + owner |
| POST | /api/v1/ticket-tiers/:tierId/close | Close a wave | HOST + owner |
| GET | /api/v1/host/dashboard | Host home stats (screen 55) | HOST |
| GET | /api/v1/events/:id/join-requests | Curated join requests | HOST + owner |
| POST | /api/v1/join-requests/:id/approve | Approve a curated request | HOST + owner |
| POST | /api/v1/join-requests/:id/skip | Skip a curated request | HOST + owner |
3.2 Response Envelopes & Status Codes
Every response uses a uniform envelope produced by ApiResponse (success) or the global error.middleware.js handler (failure).
// Success
{
"success": true,
"data": { /* resource or collection */ },
"meta": { "page": 1, "limit": 20, "total": 134, "totalPages": 7 } // optional, list endpoints only
}
// Error
{
"success": false,
"statusCode": 400,
"message": "Validation failed",
"errors": [
{ "field": "title", "message": "title is required" }
]
}
| Code | Meaning in this API |
|---|---|
200 | OK — read, update, publish, status change. |
201 | Created — new event / new tier. |
204 | No Content — soft delete, offer/tier removal. |
400 | Validation error (Joi) — malformed body / query. |
401 | Missing or invalid JWT. |
403 | Authenticated but not HOST, or not the owner of the record. |
404 | Event / venue / tier / request not found (or soft-deleted). |
409 | State conflict — publish-invalid, already-published, duplicate tier name, quantity_sold > quantity_total. |
422 | Semantic rule violation (optional; e.g. offer description > 20 words). |
429 | Rate limit exceeded (prediction preview, publish). |
500 | Unhandled server error. |
3.3 Wizard Lifecycle
The diagram below maps the canonical endpoints onto the screen narrative and shows the SEEKER/BRINGER fork.
3.4 Events / Wizard
/api/v1/eventsPurpose
Creates a new event in DRAFT status at screen 52. The only meaningful input is creationIntent, which forks the wizard and sets safety defaults (visibility/verification). The server derives is_ticketed from intent: BRINGER → true, SEEKER → false. creation_step is set to 1.
Authentication
Required. authenticate + requireHost. The new event's host_id is taken from req.user.id.
Path Parameters
None.
Query Parameters
None.
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
creationIntent | string (enum) | required | One of BRINGER, SEEKER. Defaults to SEEKER in the UI but must be sent explicitly. |
businessProfileId | integer | optional | FK → business_profiles.id; only for BUSINESS accounts. Ownership validated. |
Success Response
201 Created. Returns the draft event with server-derived fields.
{
"success": true,
"data": {
"id": 8801,
"hostId": 42,
"creationIntent": "SEEKER",
"isTicketed": false,
"status": "DRAFT",
"creationStep": 1,
"visibility": "PUBLIC",
"joinType": "REQUEST",
"createdAt": "2026-06-30T14:02:11.000Z"
}
}
Error Responses
400—creationIntentmissing or not in the enum.401— missing/invalid JWT.403— caller is not aHOST, orbusinessProfileIdnot owned by caller.
/api/v1/events/:idPurpose
Returns the full event aggregate: scalar fields plus eager-loaded categories (via event_category_map), venue, hostOffer, and ticketTiers. Used to hydrate the wizard for editing and the public detail view.
Authentication
Required (JWT). A DRAFT event is visible only to its owner; PUBLISHED+ events respect visibility.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | BIGINT event id; must exist and not be soft-deleted. |
Query Parameters
| Param | Type | Required | Validation |
|---|---|---|---|
include | string (csv) | optional | Subset of categories,venue,offer,tiers,prediction. Default: all. |
Request Body
None.
Success Response
200 OK with the nested event object (data.ticketTiers is an empty array for SEEKER events).
Error Responses
401— missing/invalid JWT.403— draft owned by another host, or private/invite-only visibility.404— not found or soft-deleted.
/api/v1/events/:idPurpose
Partial update of event details — the screen 53 "venue & offer" save and any later general edit. Sets creation_step = 2 when wizard fields are first completed. Accepts category assignment, venue/custom-pin location, schedule, capacity, and the women-only toggle. Only editable while status = DRAFT (post-publish edits are restricted to a safe subset).
Authentication
Required. requireHost + ownership (event.host_id === req.user.id).
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Event id owned by caller. |
Query Parameters
None.
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
title | string | optional | 1–120 chars. e.g. "Sunday Founders Coffee". |
description | string | optional | Max 500 chars. |
primaryCategoryId | integer | optional | FK → event_categories.id. The "selected" chip. |
categoryIds | integer[] | optional | Replaces event_category_map. Each must reference an active category. |
locationType | string (enum) | optional | VENUE or CUSTOM_PIN. |
venueId | integer | conditional | required when locationType = VENUE. FK → venues.id. |
locationName | string | optional | Reverse-geocoded label e.g. "Lower East Side, NY". Max 160. |
address | string | optional | Max 255. |
latitude | decimal | conditional | required when locationType = CUSTOM_PIN. Range -90..90, 7 dp. |
longitude | decimal | conditional | Required with latitude. Range -180..180, 7 dp. |
startAt | datetime (ISO 8601) | optional | Must be in the future at publish. Maps "Sun, Jul 6" + "9:00 AM". |
endAt | datetime | optional | Must be > startAt when present. |
timezone | string | optional | IANA tz; default UTC. |
capacity | integer | optional | >= 0 (DB CHECK). UI "Cap" e.g. 12. |
womenOnly | boolean | optional | Default false. |
visibility | string (enum) | optional | PUBLIC | PRIVATE | INVITE_ONLY. |
joinType | string (enum) | optional | REQUEST | OPEN. |
coverImage | string | optional | Stored path/URL, max 255. |
Success Response
200 OK with the updated event (re-includes categories and venue if changed).
Error Responses
400— failed Joi validation (e.g.endAt <= startAt, lat/long out of range, missingvenueIdfor VENUE type).401/403— auth / not owner.404— event or referenced venue/category not found.409— event is in a non-editable status.
/api/v1/events/:id/publishPurpose
Validates the complete draft and transitions it DRAFT → PUBLISHED. This is the CTA on screen 53 (SEEKER: "Publish experience") and the terminal step after screen 54 (BRINGER). On success the server stamps published_at, mints a unique qr_code_token, and (BRINGER) sets initial tier statuses per release rules.
Authentication
Required. requireHost + ownership + requireVerified (host must satisfy is_verified; ID verification may be required by safety defaults).
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Draft event owned by caller. |
Query Parameters
None.
Request Body
None (empty body). All values come from prior PATCH/PUT calls.
Publish validation rules
- Always:
title,startAt, location resolved (venue or pin), at least one category. - SEEKER:
is_ticketedmust befalse; no ticket tiers may exist. - BRINGER: at least one
ticket_tier; every tier passes its own checks (quantity_total > 0,quantity_sold <= quantity_total, validrelease_type); exactly the first wave isAVAILABLE_NOW/ opens immediately.
Success Response
200 OK.
{
"success": true,
"data": {
"id": 8801,
"status": "PUBLISHED",
"publishedAt": "2026-06-30T14:20:00.000Z",
"qrCodeToken": "evt_9f2c7a1b8e4d",
"isTicketed": false
}
}
Error Responses
400— missing required draft fields (returned as field-levelerrors[]).401/403— auth, not owner, or host not verified.404— event not found.409— already published, or publish-invalid (e.g. SEEKER with tiers, BRINGER with zero tiers).429— publish rate limit.
/api/v1/events/:id/statusPurpose
Explicit status transition for a single event: cancel a published event, mark it ongoing/completed, etc. Transitions are guarded by a state machine (see 3.3).
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Event owned by caller. |
Query Parameters
None.
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
status | string (enum) | required | Target in PUBLISHED,ONGOING,COMPLETED,CANCELLED; transition must be legal. |
reason | string | optional | Free-text, stored in audit log (esp. for CANCELLED). |
Success Response
200 OK with { id, status }.
Error Responses
400— unknown status value.401/403— auth / not owner.404— not found.409— illegal transition (e.g.COMPLETED → DRAFT).
/api/v1/events/:idPurpose
Soft-deletes an event by stamping deleted_at (Sequelize paranoid). The row remains for referential integrity but is excluded from all reads.
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Event owned by caller. |
Query / Body
None.
Success Response
204 No Content (empty body).
Error Responses
401/403— auth / not owner.404— already deleted or not found.409— refuse to delete anONGOINGevent (cancel first).
/api/v1/eventsPurpose
Public discovery feed. Combines pagination, full-text search, multi-field filtering, geo-radius search, and sorting. Returns only events the caller may see (public, non-deleted, published+).
Authentication
PUBLIC — no JWT required. When a token is present, the host's own non-public events may also appear.
Path Parameters
None.
Query Parameters
| Param | Type | Required | Validation |
|---|---|---|---|
page | integer | optional | >= 1, default 1. |
limit | integer | optional | 1–100, default 20. |
q | string | optional | Search over title/description/location_name. Max 120. |
status | string (enum) | optional | Filter by event status. |
intent | string (enum) | optional | BRINGER | SEEKER. |
categoryId | integer | optional | Joins event_category_map. |
womenOnly | boolean | optional | Filter the women-only flag. |
isTicketed | boolean | optional | BRINGER vs SEEKER shorthand. |
dateFrom | date | optional | Lower bound on start_at. |
dateTo | date | optional | Upper bound; must be >= dateFrom. |
near | string "lat,lng" | optional | Geo center; pairs with radiusKm. |
radiusKm | decimal | optional | 0.1–100, default 3.0 when near given. |
sortBy | string (enum) | optional | startAt | createdAt | popularity. Default startAt. |
order | string (enum) | optional | asc | desc. Default asc. |
Request Body
None.
Success Response
200 OK with an array in data and pagination in meta.
{
"success": true,
"data": [ { "id": 8801, "title": "Sunday Founders Coffee", "creationIntent": "SEEKER", "startAt": "2026-07-06T13:00:00.000Z", "distanceKm": 0.3 } ],
"meta": { "page": 1, "limit": 20, "total": 134, "totalPages": 7 }
}
Error Responses
400— bad enum/date/geo format,dateTo < dateFrom,limit > 100.
/api/v1/events/minePurpose
Lists the authenticated host's own events across all statuses including DRAFT — the data source for "continue your draft" and the host event list. Same pagination/sort/filter grammar as the public list, scoped to host_id = req.user.id.
Authentication
Required. requireHost.
Path Parameters
None.
Query Parameters
Same as GET /events (page,limit,q,status,intent,categoryId,sortBy,order); visibility filters are ignored since the caller owns every row.
Request Body
None.
Success Response
200 OK with data[] + meta (drafts included).
Error Responses
401/403— auth / not a host.
Bulk actions
/api/v1/events/bulk/statusPurpose
Applies one status transition to many owned events in a single transaction (e.g. cancel several at once). Each id is individually ownership- and transition-checked; the whole batch rolls back on any failure unless partial=true.
Authentication
Required. requireHost + ownership of every id.
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
eventIds | integer[] | required | 1–100 unique ids, all owned by caller. |
status | string (enum) | required | Target status; transition legal for each. |
partial | boolean | optional | If true, skip invalid ids instead of failing the batch. |
Success Response
200 OK with { updated: [...ids], skipped: [{id, reason}] }.
Error Responses
400— empty/oversized list or bad status.403— one or more ids not owned.409— illegal transition (whole batch, unlesspartial).
/api/v1/events/bulkPurpose
Soft-deletes multiple owned events at once.
Authentication
Required. requireHost + ownership of every id.
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
eventIds | integer[] | required | 1–100 unique ids, all owned by caller. |
Success Response
204 No Content.
Error Responses
400— empty/oversized list.403— an id not owned.409— one of the events isONGOING.
3.5 Categories & Venues
/api/v1/event-categoriesPurpose
Returns the active category catalogue that renders the selectable chips on screen 53 (Friends, Networking, Mixer, Coffee, Dining, Party), ordered by sort_order.
Authentication
PUBLIC.
Path Parameters
None.
Query Parameters
| Param | Type | Required | Validation |
|---|---|---|---|
activeOnly | boolean | optional | Default true — hides is_active=false rows. |
Request Body
None.
Success Response
200 OK.
{
"success": true,
"data": [
{ "id": 1, "name": "Friends", "slug": "friends", "icon": null, "sortOrder": 0 },
{ "id": 2, "name": "Networking", "slug": "networking", "sortOrder": 1 }
]
}
Error Responses
500— unexpected server error.
/api/v1/venuesPurpose
Returns nearby partner venues sorted by distance — the partner list on screen 53 ("Partner Cafe · LES" 0.3 mi, "The Aviary · Wine Bar" 0.6 mi, "Rooftop & Co · Midtown" 1.2 mi). Uses the (latitude,longitude) index and the haversine formula.
Authentication
PUBLIC.
Path Parameters
None.
Query Parameters
| Param | Type | Required | Validation |
|---|---|---|---|
lat | decimal | required | -90..90. |
lng | decimal | required | -180..180. |
radiusKm | decimal | optional | 0.1–50, default 3.0. |
q | string | optional | Name search, max 160. |
type | string (enum) | optional | One of venue_type values (CLUB,RESTAURANT,HOTEL,RESORT,CAFE,BAR,ROOFTOP,OTHER). |
limit | integer | optional | 1–50, default 20. |
Request Body
None.
Success Response
200 OK with venues including a computed distanceKm, filtered to is_partner=true and is_active=true, ordered ascending.
Error Responses
400— missing/invalidlat/lngor badtype.
/api/v1/venues/:idPurpose
Returns full detail for a single venue (name, type, description, address, coordinates, partner flag).
Authentication
PUBLIC.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Venue id; must be active. |
Query / Body
None.
Success Response
200 OK with the venue object.
Error Responses
404— not found or inactive.
3.6 Predicted Attendance
POST form is a stateless preview before an event id exists; the GET form binds to a saved draft and may read the attendance_predictions cache or recompute on the fly.
/api/v1/events/predicted-attendancePurpose
Computes a prediction preview from raw inputs (location + categories + time), with no event persisted. Rate-limited.
Authentication
Required. requireHost.
Path Parameters
None.
Query Parameters
None.
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
latitude | decimal | required | -90..90. |
longitude | decimal | required | -180..180. |
categoryIds | integer[] | required | 1+ active category ids. |
startAt | datetime | required | ISO 8601, future. |
radiusKm | decimal | optional | 0.5–25, default 3.0. |
Success Response
200 OK.
{
"success": true,
"data": { "predictedCount": 9, "radiusKm": 3.0, "confidencePct": 72 }
}
Error Responses
400— missing geo/category/time inputs.401/403— auth / not host.429— preview rate limit.
/api/v1/events/:id/predicted-attendancePurpose
Returns the prediction for a saved draft using its own location/categories/schedule. Mirrors predicted_attendance_count, predicted_attendance_radius_km, and predicted_attendance_confidence_pct on the event.
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Draft event owned by caller. |
Query Parameters
| Param | Type | Required | Validation |
|---|---|---|---|
refresh | boolean | optional | If true, bypass the cache and recompute. |
Request Body
None.
Success Response
200 OK with { predictedCount, radiusKm, confidencePct, computedAt }.
Error Responses
400— event lacks location/categories needed to predict.401/403— auth / not owner.404— event not found.
3.7 Host Offer
The host offer (screen 53, "+ Add offer" → gold preview pill) is 1:1 with an event. description is capped at 20 words in the app layer (returns 422 when violated) and 160 chars at the DB layer.
/api/v1/events/:id/offerPurpose
Upserts the host offer for an event (creates if absent, replaces if present).
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Event owned by caller. |
Query Parameters
None.
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
title | string | required | 1–120 chars. e.g. "Free coffee for everyone". |
description | string | optional | Max 160 chars AND <= 20 words. |
isActive | boolean | optional | Default true. |
Success Response
200 OK (or 201 if newly created) with the offer object.
Error Responses
400— missing/oversized title.401/403— auth / not owner.404— event not found.422— description exceeds 20 words.
/api/v1/events/:id/offerPurpose
Returns the event's host offer (for the preview pill / detail view).
Authentication
Required (JWT).
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Event id. |
Query / Body
None.
Success Response
200 OK with the offer; 404 when no offer exists.
Error Responses
401— missing JWT.404— event or offer not found.
/api/v1/events/:id/offerPurpose
Removes the host offer from an event.
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Event owned by caller. |
Query / Body
None.
Success Response
204 No Content.
Error Responses
401/403— auth / not owner.404— event or offer not found.
3.8 Ticket Tiers BRINGER only
creation_intent = BRINGER events (is_ticketed = true). Calling them on a SEEKER event returns 409. Each tier doubles as a release wave (screen 54): sort_order is the release order. Prices are all-in integer minor units (price_cents) — "no fees added at checkout".
/api/v1/events/:id/ticket-tiersPurpose
Lists all tiers for an event ordered by sort_order — the data behind screen 53b's tier cards.
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | BRINGER event owned by caller. |
Query / Body
None.
Success Response
200 OK with data[] of tiers.
Error Responses
401/403/404— auth / not owner / not found.409— event is SEEKER (non-ticketed).
/api/v1/events/:id/ticket-tiersPurpose
Creates a single tier (the "+ Add another tier" action). sort_order defaults to the next slot.
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | BRINGER event owned by caller. |
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
name | string | required | 1–80 chars; unique per event. e.g. "Early Bird". |
priceCents | integer | required | >= 0. UI "$12" → 1200. |
currency | string | optional | ISO 4217, default USD. |
quantityTotal | integer | required | > 0. UI "Qty 20". |
sortOrder | integer | optional | Release order; default appended. |
releaseType | string (enum) | optional | AVAILABLE_NOW | AFTER_PREV_TIER_THRESHOLD | SCHEDULED | DAY_OF. Default AVAILABLE_NOW. |
releaseThresholdPct | integer | conditional | 1–100, default 90; used when AFTER_PREV_TIER_THRESHOLD ("When prev. tier hits 90%"). |
scheduledReleaseAt | datetime | conditional | required when releaseType = SCHEDULED. |
isAllIn | boolean | optional | Default true. |
Success Response
201 Created with the new tier (initial status per release type; SCHEDULED unless wave 1).
Error Responses
400— invalid price/qty/enum.401/403/404— auth / not owner / not found.409— duplicate tier name, or event is SEEKER.
/api/v1/events/:id/ticket-tiersPurpose
Bulk save from screen 53b — replaces the event's tier set in one transaction (create new, update existing by id, delete omitted). sort_order is taken from array position. The canonical "Continue → Set release waves" save.
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | BRINGER event owned by caller. |
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
tiers | object[] | required | 1–20 items. Each item has the same fields as POST, plus optional id to update an existing tier. |
{
"tiers": [
{ "name": "Early Bird", "priceCents": 1200, "quantityTotal": 20, "releaseType": "AVAILABLE_NOW" },
{ "name": "General", "priceCents": 1500, "quantityTotal": 40, "releaseType": "AFTER_PREV_TIER_THRESHOLD", "releaseThresholdPct": 90 }
]
}
Success Response
200 OK with the full resulting tier list (ordered).
Error Responses
400— empty/oversized array or any item invalid.401/403/404— auth / not owner / not found.409— duplicate names within the payload, attempt to delete a tier withquantity_sold > 0, or event is SEEKER.
/api/v1/ticket-tiers/:tierIdPurpose
Updates a single tier (price, qty, name, release rule). Ownership resolved via the tier's parent event.
Authentication
Required. requireHost + ownership of parent event.
Path Parameters
| Param | Type | Rules |
|---|---|---|
tierId | integer | Tier whose event is owned by caller. |
Request Body
Any subset of the POST fields. Reducing quantityTotal below quantity_sold is rejected.
Success Response
200 OK with the updated tier.
Error Responses
400— invalid field value.401/403/404— auth / not owner / tier not found.409— duplicate name, orquantityTotal < quantitySold.
/api/v1/ticket-tiers/:tierIdPurpose
Removes a single tier (the "Remove" link on screen 53b). Soft-deletes via deleted_at; blocked once tickets have sold.
Authentication
Required. requireHost + ownership of parent event.
Path Parameters
| Param | Type | Rules |
|---|---|---|
tierId | integer | Tier whose event is owned by caller. |
Query / Body
None.
Success Response
204 No Content.
Error Responses
401/403/404— auth / not owner / not found.409—quantity_sold > 0(cannot delete a tier with sales).
3.9 Release Waves BRINGER only
Screen 54 surfaces each tier as a live release wave with progress bars ("Early bird — 20 @ $12 / Sold out", "General — 40 @ $15 / on sale · 26 left", "Door — 10 @ $20 / opens day-of") plus the auto-release toggle.
/api/v1/events/:id/ticket-releasesPurpose
Returns each tier with live release status and sell-through progress for screen 54. Derived fields: remaining = quantity_total - quantity_sold, progressPct, and a human statusLabel.
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | BRINGER event owned by caller. |
Query / Body
None.
Success Response
200 OK.
{
"success": true,
"data": {
"autoReleaseEnabled": true,
"autoReleaseThresholdPct": 90,
"tiers": [
{ "id": 1, "name": "Early bird", "priceCents": 1200, "quantityTotal": 20, "quantitySold": 20, "remaining": 0, "status": "SOLD_OUT", "progressPct": 100, "statusLabel": "Sold out" },
{ "id": 2, "name": "General", "priceCents": 1500, "quantityTotal": 40, "quantitySold": 14, "remaining": 26, "status": "ON_SALE", "progressPct": 35, "statusLabel": "on sale · 26 left" },
{ "id": 3, "name": "Door", "priceCents": 2000, "quantityTotal": 10, "quantitySold": 0, "remaining": 10, "status": "SCHEDULED","progressPct": 0, "statusLabel": "opens day-of" }
]
}
}
Error Responses
401/403/404— auth / not owner / not found.409— event is SEEKER (non-ticketed).
/api/v1/events/:id/release-settingsPurpose
Persists the screen 54 "Save releases" action: the auto-release toggle and its threshold ("Auto-release next wave — when the current one hits 90% sold"). Writes auto_release_enabled and auto_release_threshold_pct on the event.
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | BRINGER event owned by caller. |
Request Body
| Field | Type | Required | Rules |
|---|---|---|---|
autoReleaseEnabled | boolean | required | Toggle state. |
autoReleaseThresholdPct | integer | optional | 1–100, default 90; applied when enabled. |
Success Response
200 OK with { autoReleaseEnabled, autoReleaseThresholdPct }.
Error Responses
400— threshold out of range.401/403/404— auth / not owner / not found.409— event is SEEKER.
/api/v1/ticket-tiers/:tierId/openPurpose
Manually opens a wave: transitions a tier to ON_SALE (overrides scheduled/threshold gating).
Authentication
Required. requireHost + ownership of parent event.
Path Parameters
| Param | Type | Rules |
|---|---|---|
tierId | integer | Tier whose event is owned by caller. |
Query / Body
None.
Success Response
200 OK with the tier now status: "ON_SALE".
Error Responses
401/403/404— auth / not owner / not found.409— tier alreadySOLD_OUT/CLOSED, or event not published.
/api/v1/ticket-tiers/:tierId/closePurpose
Closes a wave: transitions a tier to CLOSED, halting further sales.
Authentication
Required. requireHost + ownership of parent event.
Path Parameters
| Param | Type | Rules |
|---|---|---|
tierId | integer | Tier whose event is owned by caller. |
Query / Body
None.
Success Response
200 OK with the tier now status: "CLOSED".
Error Responses
401/403/404— auth / not owner / not found.409— tier alreadyCLOSED.
3.10 Host & Curated Requests adjacent
/api/v1/host/dashboardPurpose
Aggregated host-home stats: rating, earnings this month, guests met, the "tonight" card, and a curated request count.
Authentication
Required. requireHost.
Path / Query / Body
None.
Success Response
200 OK with the dashboard summary object.
Error Responses
401/403— auth / not host.
/api/v1/events/:id/join-requestsPurpose
Lists the curated join requests for an event (the Approve/Skip list), each scored by reliability / streak / ID-verification.
Authentication
Required. requireHost + ownership.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Event owned by caller. |
Query Parameters
| Param | Type | Required | Validation |
|---|---|---|---|
status | string (enum) | optional | Filter by PENDING,APPROVED,SKIPPED,REJECTED. Default PENDING. |
page | integer | optional | >= 1. |
limit | integer | optional | 1–100, default 20. |
Request Body
None.
Success Response
200 OK with data[] + meta.
Error Responses
401/403/404— auth / not owner / not found.
/api/v1/join-requests/:id/approvePurpose
Approves a curated request: sets status = APPROVED and increments the event's current_guests within capacity.
Authentication
Required. requireHost + ownership of the parent event.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Join-request id whose event is owned by caller. |
Query / Body
None.
Success Response
200 OK with the updated request.
Error Responses
401/403/404— auth / not owner / not found.409— event at capacity, or request notPENDING.
/api/v1/join-requests/:id/skipPurpose
Skips a curated request: sets status = SKIPPED (no capacity change).
Authentication
Required. requireHost + ownership of the parent event.
Path Parameters
| Param | Type | Rules |
|---|---|---|
id | integer | Join-request id whose event is owned by caller. |
Query / Body
None.
Success Response
200 OK with the updated request.
Error Responses
401/403/404— auth / not owner / not found.409— request not in a skippable state.
4. Request & Response Examples
Every example below uses the canonical envelopes: success responses are
{"success":true,"data":{...},"meta":{...}} and error responses are
{"success":false,"statusCode":N,"message":"...","errors":[...]}.
All JSON keys are camelCase on the wire even though the underlying
MySQL columns are snake_case. Unless an endpoint is marked
PUBLIC, send Authorization: Bearer <jwt>.
4.1 — Create draft (Screen 52, the fork)
/api/v1/events
The first call of the flow. Only creationIntent is required;
the server creates a DRAFT row at creationStep = 1 and derives
isTicketed from the intent (true only for BRINGER).
Picking SEEKER — the default-selected "Help me fill it" card — yields an
untickeded, curated event.
Request
{
"creationIntent": "SEEKER"
}
Response 201 Created
{
"success": true,
"data": {
"id": "104821",
"hostId": "7741",
"businessProfileId": null,
"creationIntent": "SEEKER",
"title": null,
"description": null,
"primaryCategoryId": null,
"locationType": "CUSTOM_PIN",
"venueId": null,
"capacity": 0,
"currentGuests": 0,
"womenOnly": false,
"isTicketed": false,
"autoReleaseEnabled": true,
"autoReleaseThresholdPct": 90,
"visibility": "PUBLIC",
"joinType": "REQUEST",
"status": "DRAFT",
"creationStep": 1,
"createdAt": "2026-06-30T14:02:11.000Z",
"updatedAt": "2026-06-30T14:02:11.000Z"
}
}
BRINGER request body ({"creationIntent":"BRINGER"}) returns the same
shape with "isTicketed": true, routing the host on to screen 53b.
4.2 — Update details (Screen 53, SEEKER step 2)
/api/v1/events/104821
Saves the title, category chips, custom-pin location, date/time, capacity and the women-only toggle.
Because locationType = CUSTOM_PIN, the client sends latitude/longitude
plus a reverse-geocoded locationName and leaves venueId null. The first chip
becomes primaryCategoryId and the full set is written through to
event_category_map. The server advances creationStep to 2.
Request
{
"title": "Sunday Founders Coffee",
"categoryIds": ["1"],
"locationType": "CUSTOM_PIN",
"locationName": "Lower East Side, NY",
"address": "171 Ludlow St, New York, NY 10002",
"latitude": 40.7211230,
"longitude": -73.9877450,
"startAt": "2026-07-06T13:00:00.000Z",
"timezone": "America/New_York",
"capacity": 12,
"womenOnly": false
}
Response 200 OK
{
"success": true,
"data": {
"id": "104821",
"hostId": "7741",
"creationIntent": "SEEKER",
"title": "Sunday Founders Coffee",
"description": null,
"primaryCategoryId": "1",
"categories": [
{ "id": "1", "name": "Friends", "slug": "friends", "icon": "🤝" }
],
"locationType": "CUSTOM_PIN",
"venueId": null,
"locationName": "Lower East Side, NY",
"address": "171 Ludlow St, New York, NY 10002",
"latitude": 40.7211230,
"longitude": -73.9877450,
"geohash": "dr5ru7n",
"startAt": "2026-07-06T13:00:00.000Z",
"endAt": null,
"timezone": "America/New_York",
"capacity": 12,
"currentGuests": 0,
"womenOnly": false,
"isTicketed": false,
"visibility": "PUBLIC",
"joinType": "REQUEST",
"status": "DRAFT",
"creationStep": 2,
"updatedAt": "2026-06-30T14:05:48.000Z"
}
}
4.3 — Upsert host offer (Screen 53, "+ Add offer")
/api/v1/events/104821/offer
Idempotent 1:1 upsert against host_offers. The description is capped at
20 words in the app layer (the column is VARCHAR(160)). Returns 200
whether the offer was created or replaced.
Request
{
"title": "Free coffee for everyone",
"description": "First round is on the house — every guest gets a coffee when they arrive."
}
Response 200 OK
{
"success": true,
"data": {
"id": "3310",
"eventId": "104821",
"title": "Free coffee for everyone",
"description": "First round is on the house — every guest gets a coffee when they arrive.",
"isActive": true,
"createdAt": "2026-06-30T14:07:02.000Z",
"updatedAt": "2026-06-30T14:07:02.000Z"
}
}
4.4 — Predicted attendance preview (Screen 53 card)
/api/v1/events/predicted-attendance
Drives the read-only "~9 likely to join within 3 km" card. It is computed from the pin location,
selected categories and start time; radiusKm defaults to 3.00 when omitted.
The result may be cached in attendance_predictions or computed on the fly.
Request
{
"latitude": 40.7211230,
"longitude": -73.9877450,
"categoryIds": ["1"],
"startAt": "2026-07-06T13:00:00.000Z",
"radiusKm": 3.0
}
Response 200 OK
{
"success": true,
"data": {
"predictedCount": 9,
"radiusKm": 3.0,
"confidencePct": 72,
"computedAt": "2026-06-30T14:08:19.000Z"
}
}
4.5 — Bulk save ticket tiers (Screen 53b, BRINGER)
/api/v1/events/104821/ticket-tiers
The "save all" call from screen 53b. It replaces the full tier set for the event in one transaction;
sortOrder doubles as the release order. Prices are integer minor units
(priceCents), all-in. This example assumes a BRINGER event whose
isTicketed = true.
Request
{
"tiers": [
{
"name": "Early Bird",
"priceCents": 1200,
"currency": "USD",
"quantityTotal": 20,
"sortOrder": 0,
"releaseType": "AVAILABLE_NOW"
},
{
"name": "General",
"priceCents": 1500,
"currency": "USD",
"quantityTotal": 40,
"sortOrder": 1,
"releaseType": "AFTER_PREV_TIER_THRESHOLD",
"releaseThresholdPct": 90
},
{
"name": "Door",
"priceCents": 2000,
"currency": "USD",
"quantityTotal": 10,
"sortOrder": 2,
"releaseType": "DAY_OF"
}
]
}
Response 200 OK
{
"success": true,
"data": [
{
"id": "8801",
"eventId": "104821",
"name": "Early Bird",
"priceCents": 1200,
"currency": "USD",
"quantityTotal": 20,
"quantitySold": 0,
"sortOrder": 0,
"releaseType": "AVAILABLE_NOW",
"releaseThresholdPct": 90,
"scheduledReleaseAt": null,
"status": "ON_SALE",
"isAllIn": true
},
{
"id": "8802",
"eventId": "104821",
"name": "General",
"priceCents": 1500,
"currency": "USD",
"quantityTotal": 40,
"quantitySold": 0,
"sortOrder": 1,
"releaseType": "AFTER_PREV_TIER_THRESHOLD",
"releaseThresholdPct": 90,
"scheduledReleaseAt": null,
"status": "SCHEDULED",
"isAllIn": true
},
{
"id": "8803",
"eventId": "104821",
"name": "Door",
"priceCents": 2000,
"currency": "USD",
"quantityTotal": 10,
"quantitySold": 0,
"sortOrder": 2,
"releaseType": "DAY_OF",
"releaseThresholdPct": 90,
"scheduledReleaseAt": null,
"status": "SCHEDULED",
"isAllIn": true
}
]
}
4.6 — Update release settings (Screen 54 toggle)
/api/v1/events/104821/release-settings
Persists the "Auto-release next wave" toggle and its threshold onto the parent event
(auto_release_enabled, auto_release_threshold_pct).
Request
{
"autoReleaseEnabled": true,
"autoReleaseThresholdPct": 90
}
Response 200 OK
{
"success": true,
"data": {
"id": "104821",
"autoReleaseEnabled": true,
"autoReleaseThresholdPct": 90,
"updatedAt": "2026-06-30T14:12:40.000Z"
}
}
4.7 — Live ticket releases (Screen 54 status cards)
/api/v1/events/104821/ticket-releases
Returns each tier with its live status and a progressPct derived from
quantitySold / quantityTotal. This snapshot maps directly to the three cards on
screen 54: Early Bird sold out, General on sale with 26 left, and Door opening day-of.
Response 200 OK
{
"success": true,
"data": {
"eventId": "104821",
"autoReleaseEnabled": true,
"autoReleaseThresholdPct": 90,
"tiers": [
{
"id": "8801",
"name": "Early Bird",
"priceCents": 1200,
"currency": "USD",
"quantityTotal": 20,
"quantitySold": 20,
"quantityRemaining": 0,
"progressPct": 100,
"status": "SOLD_OUT",
"releaseType": "AVAILABLE_NOW",
"label": "Sold out"
},
{
"id": "8802",
"name": "General",
"priceCents": 1500,
"currency": "USD",
"quantityTotal": 40,
"quantitySold": 14,
"quantityRemaining": 26,
"progressPct": 35,
"status": "ON_SALE",
"releaseType": "AFTER_PREV_TIER_THRESHOLD",
"label": "on sale · 26 left"
},
{
"id": "8803",
"name": "Door",
"priceCents": 2000,
"currency": "USD",
"quantityTotal": 10,
"quantitySold": 0,
"quantityRemaining": 10,
"progressPct": 0,
"status": "SCHEDULED",
"releaseType": "DAY_OF",
"label": "opens day-of"
}
]
}
}
4.8 — Publish (Screen 53/54 CTA)
/api/v1/events/104821/publish
Validates the draft, requires a verified host, sets status = PUBLISHED, stamps
publishedAt and mints a qrCodeToken. For SEEKER this fires after screen 53;
for BRINGER after the tiers and release waves are set. No request body is required.
Response 200 OK
{
"success": true,
"data": {
"id": "104821",
"creationIntent": "SEEKER",
"title": "Sunday Founders Coffee",
"status": "PUBLISHED",
"isTicketed": false,
"visibility": "PUBLIC",
"qrCodeToken": "qr_8f2c1a9b7d4e6033",
"publishedAt": "2026-06-30T14:15:00.000Z",
"predictedAttendanceCount": 9,
"predictedAttendanceRadiusKm": 3.0,
"predictedAttendanceConfidencePct": 72,
"updatedAt": "2026-06-30T14:15:00.000Z"
}
}
4.9 — Public event list with pagination
/api/v1/events?page=1&limit=2&intent=SEEKER&womenOnly=false&sortBy=startAt&order=asc
The PUBLIC discovery list. Pagination details live in meta;
each item is a trimmed event card. Supported filters include status, intent,
categoryId, womenOnly, isTicketed, dateFrom/dateTo,
and near=lat,lng&radiusKm.
Response 200 OK
{
"success": true,
"data": [
{
"id": "104821",
"creationIntent": "SEEKER",
"title": "Sunday Founders Coffee",
"primaryCategoryId": "1",
"locationName": "Lower East Side, NY",
"latitude": 40.7211230,
"longitude": -73.9877450,
"startAt": "2026-07-06T13:00:00.000Z",
"capacity": 12,
"currentGuests": 4,
"womenOnly": false,
"isTicketed": false,
"status": "PUBLISHED",
"coverImage": null
},
{
"id": "104977",
"creationIntent": "SEEKER",
"title": "Midtown Networking Mixer",
"primaryCategoryId": "2",
"locationName": "Midtown, NY",
"latitude": 40.7549000,
"longitude": -73.9840000,
"startAt": "2026-07-09T23:00:00.000Z",
"capacity": 30,
"currentGuests": 11,
"womenOnly": false,
"isTicketed": false,
"status": "PUBLISHED",
"coverImage": null
}
],
"meta": {
"page": 1,
"limit": 2,
"total": 37,
"totalPages": 19,
"hasNextPage": true,
"hasPrevPage": false,
"sortBy": "startAt",
"order": "asc"
}
}
4.10 — Validation error (multiple fields)
/api/v1/events/104821
A 400 from the Joi validate.middleware.js runner. The
errors array carries one entry per failed field.
Response 400 Bad Request
{
"success": false,
"statusCode": 400,
"message": "Validation failed",
"errors": [
{ "field": "title", "message": "\"title\" is required" },
{ "field": "capacity", "message": "\"capacity\" must be greater than or equal to 0" },
{ "field": "latitude", "message": "\"latitude\" must be a number between -90 and 90" },
{ "field": "categoryIds", "message": "\"categoryIds\" must contain at least 1 item" }
]
}
4.11 — Unauthorized (missing / invalid JWT)
/api/v1/events
Returned by authenticate when the Authorization header is absent, malformed
or carries an expired token.
Response 401 Unauthorized
{
"success": false,
"statusCode": 401,
"message": "Authentication required: missing or invalid access token",
"errors": []
}
4.12 — Forbidden (not the owner)
/api/v1/events/104821
Returned when a valid, host-role JWT belongs to a user who does not own the target event. Ownership is
enforced on every mutation after requireHost.
Response 403 Forbidden
{
"success": false,
"statusCode": 403,
"message": "You do not have permission to modify this event",
"errors": []
}
4.13 — Conflict (publish-invalid state)
/api/v1/events/104821/publish
A 409 raised on an invalid state transition — here, attempting to publish a
ticketed (BRINGER) event that has zero ticket tiers. The same code covers
already-published events, duplicate tier names, and any quantitySold > quantityTotal guard.
Response 409 Conflict
{
"success": false,
"statusCode": 409,
"message": "Cannot publish: a ticketed event requires at least one ticket tier",
"errors": [
{ "field": "ticketTiers", "message": "At least one ticket tier is required before publishing a BRINGER event" }
]
}
5. Backend Architecture
The Host Experience Creation flow is implemented as a set of cohesive, single-responsibility modules on a Node.js + Express 5 (ESM) stack, persisting to MySQL 8 through Sequelize, and protected by JWT bearer auth. The events module is the central aggregate; ticketing, venues, offers, and categories orbit it. The screen 52 fork on creation_intent (SEEKER → 53 → Publish, BRINGER → 53 → 53b → 54 → Publish) is enforced at the service layer, not in the routes.
error.middleware.js that emits the canonical envelope.
5.1 Folder Structure
Each domain lives under src/modules/<name>/ with the same seven-file shape so engineers can navigate any module by muscle memory. Cross-module association wiring is centralized in src/models/index.js.
src/
├── app.js # Express 5 app: mounts /api/v1 router, error middleware last
├── server.js # bootstraps HTTP server + DB connection
├── config/
│ ├── db.js # Sequelize instance (MySQL 8 dialect)
│ ├── redis.js # Redis client (rate-limit + prediction cache)
│ └── env.js # validated process.env (JWT_SECRET, DB_*, ...)
├── middlewares/
│ ├── auth.middleware.js # authenticate -> sets req.user = { id, role }
│ ├── requireVerified.middleware.js # requireVerified, requireHost (new)
│ ├── validate.middleware.js # Joi runner (body/params/query)
│ ├── error.middleware.js # central error -> canonical envelope
│ ├── rateLimit.middleware.js # 429 guard (publish, predicted-attendance)
│ └── upload.middleware.js # cover_image multipart handling
├── utils/
│ ├── ApiError.js # typed error (statusCode, message, errors[])
│ ├── ApiResponse.js # { success, data, meta } envelope helper
│ └── asyncHandler.js # wraps async controllers -> next(err)
├── models/
│ └── index.js # imports every *.model.js, wires associations
└── modules/
├── events/
│ ├── events.controller.js
│ ├── events.service.js # intent fork, publish validation, transactions
│ ├── events.repository.js
│ ├── event.model.js # events (CENTRAL TABLE)
│ ├── events.routes.js
│ ├── events.validation.js # Joi: create, details, publish, list filters
│ ├── events.utils.js # geohash, predicted-attendance helpers
│ └── README.md
├── ticketing/
│ ├── ticketing.controller.js
│ ├── ticketing.service.js # tier bulk-upsert, release waves (53b/54)
│ ├── ticketing.repository.js
│ ├── ticketTier.model.js # ticket_tiers (1 tier = 1 release wave)
│ ├── ticketing.routes.js
│ ├── ticketing.validation.js
│ ├── ticketing.utils.js
│ └── README.md
├── venues/
│ ├── venues.controller.js
│ ├── venues.service.js # nearby partner venues, distance sort
│ ├── venues.repository.js
│ ├── venue.model.js # venues
│ ├── venues.routes.js
│ ├── venues.validation.js
│ ├── venues.utils.js
│ └── README.md
├── offers/
│ ├── offers.controller.js
│ ├── offers.service.js # 1:1 host offer upsert, 20-word rule
│ ├── offers.repository.js
│ ├── hostOffer.model.js # host_offers
│ ├── offers.routes.js
│ ├── offers.validation.js
│ ├── offers.utils.js
│ └── README.md
└── categories/
├── categories.controller.js
├── categories.service.js
├── categories.repository.js
├── eventCategory.model.js # event_categories (+ event_category_map join)
├── categories.routes.js
├── categories.validation.js
├── categories.utils.js
└── README.md
5.2 The Layers
Routes
Routes are declarative wiring only: HTTP verb → path → ordered middleware chain → controller method. Every mutating route stacks authenticate, then requireHost, then a Joi validate(schema), then the controller. Ownership is checked downstream in the service (it needs the loaded row). Public reads (GET /events, GET /event-categories, GET /venues) skip authenticate.
Controller
Controllers are thin adapters wrapped in asyncHandler. They translate the validated request into a service call and shape the result through ApiResponse. No business logic, no Sequelize, no try/catch — thrown errors propagate to the error middleware.
Service
The home of business rules: the creation_intent fork, is_ticketed derivation (true only for BRINGER), creation_step progression, publish validation (verified host, required fields, tier integrity), and multi-table transactions (e.g. replacing all tiers in one commit). Services enforce ownership (event.host_id === req.user.id) and role beyond what middleware guarantees.
Repository
The only layer that touches Sequelize. It exposes intention-revealing methods (createDraft, findFullById, replaceTiers) and accepts an optional transaction so services can compose atomic operations. No HTTP concepts leak in.
Model
One Sequelize model per canonical table, using snake_case columns, BIGINT auto-increment PKs, and paranoid: true where a deleted_at exists (events, ticket_tiers). Associations are declared centrally in src/models/index.js after all models load, to avoid circular imports.
Validation
Joi schemas per module validate body, params, and query before the controller runs. Enum fields are constrained to the canonical enum values; the 20-word host-offer description and tier quantity_sold <= quantity_total are validated in the app layer to mirror the DB CHECK constraints.
Middleware
authenticate— verifies the JWT access token, setsreq.user = { id, role }, else 401.requireHost— 403 unlessreq.user.role === 'HOST'.requireVerified— 403 unless the hostis_verified(used on publish).validate(schema)— runs Joi, collects allerrors[], throws 400.rateLimit— 429 on hot endpoints (publish, predicted-attendance preview).error.middleware.js— terminal handler, mounted last.
Auth / Authorization
JWT bearer access token in Authorization: Bearer <token>. Authorization is three-tiered: authentication (401), role via requireHost (403), and ownership via the service comparing host_id (403). Publish additionally gates on requireVerified.
Error Handling
All thrown ApiError instances (and unexpected errors) converge on error.middleware.js, which emits { success:false, statusCode, message, errors[] }. Sequelize validation/unique errors are normalized to 400/409.
5.3 Request Lifecycle
5.4 Illustrative Code
Sequelize Model — event.model.js
// src/modules/events/event.model.js
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '../../config/db.js';
// Associations (wired centrally in src/models/index.js):
// Event.belongsTo(User, { foreignKey: 'host_id', as: 'host' })
// Event.belongsTo(Venue, { foreignKey: 'venue_id', as: 'venue' })
// Event.hasOne(HostOffer, { foreignKey: 'event_id', as: 'offer' })
// Event.hasMany(TicketTier, { foreignKey: 'event_id', as: 'tiers' })
// Event.belongsToMany(EventCategory, { through: EventCategoryMap, as: 'categories' })
export class Event extends Model {}
Event.init({
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
host_id: { type: DataTypes.BIGINT, allowNull: false },
business_profile_id: { type: DataTypes.BIGINT, allowNull: true },
creation_intent: { type: DataTypes.ENUM('BRINGER', 'SEEKER'), allowNull: false },
title: { type: DataTypes.STRING(120), allowNull: false },
description: { type: DataTypes.STRING(500), allowNull: true },
primary_category_id: { type: DataTypes.BIGINT, allowNull: true },
location_type: { type: DataTypes.ENUM('VENUE', 'CUSTOM_PIN'), allowNull: false, defaultValue: 'CUSTOM_PIN' },
venue_id: { type: DataTypes.BIGINT, allowNull: true },
location_name: { type: DataTypes.STRING(160), allowNull: true },
address: { type: DataTypes.STRING(255), allowNull: true },
latitude: { type: DataTypes.DECIMAL(10, 7), allowNull: true },
longitude: { type: DataTypes.DECIMAL(10, 7), allowNull: true },
geohash: { type: DataTypes.STRING(12), allowNull: true },
start_at: { type: DataTypes.DATE, allowNull: false },
end_at: { type: DataTypes.DATE, allowNull: true },
timezone: { type: DataTypes.STRING(64), defaultValue: 'UTC' },
capacity: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 0 },
current_guests: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 0 },
women_only: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
is_ticketed: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
auto_release_enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
auto_release_threshold_pct: { type: DataTypes.TINYINT.UNSIGNED, allowNull: false, defaultValue: 90 },
visibility: { type: DataTypes.ENUM('PUBLIC', 'PRIVATE', 'INVITE_ONLY'), allowNull: false, defaultValue: 'PUBLIC' },
join_type: { type: DataTypes.ENUM('REQUEST', 'OPEN'), allowNull: false, defaultValue: 'REQUEST' },
status: { type: DataTypes.ENUM('DRAFT', 'PUBLISHED', 'ONGOING', 'COMPLETED', 'CANCELLED'), allowNull: false, defaultValue: 'DRAFT' },
creation_step: { type: DataTypes.TINYINT, defaultValue: 1 },
predicted_attendance_count: { type: DataTypes.INTEGER, allowNull: true },
predicted_attendance_radius_km: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
predicted_attendance_confidence_pct: { type: DataTypes.TINYINT, allowNull: true },
cover_image: { type: DataTypes.STRING(255), allowNull: true },
qr_code_token: { type: DataTypes.STRING(64), allowNull: true, unique: true },
published_at: { type: DataTypes.DATE, allowNull: true },
}, {
sequelize,
modelName: 'Event',
tableName: 'events',
underscored: true, // snake_case columns + created_at/updated_at
paranoid: true, // soft delete via deleted_at
indexes: [
{ fields: ['host_id'] },
{ fields: ['status', 'start_at'] },
{ fields: ['visibility'] },
{ fields: ['latitude', 'longitude'] },
{ fields: ['geohash'] },
{ fields: ['venue_id'] },
{ fields: ['creation_intent'] },
{ fields: ['primary_category_id'] },
{ unique: true, fields: ['qr_code_token'] },
],
});
Joi Validation — events.validation.js (create + details)
// src/modules/events/events.validation.js
import Joi from 'joi';
// Screen 52 — step 1: only the intent fork is required to open a DRAFT.
export const createEventSchema = {
body: Joi.object({
creationIntent: Joi.string().valid('BRINGER', 'SEEKER').required(),
}),
};
// Screen 53 — step 2 details (SEEKER fields / general edit).
export const updateDetailsSchema = {
params: Joi.object({ id: Joi.number().integer().positive().required() }),
body: Joi.object({
title: Joi.string().max(120),
description: Joi.string().max(500).allow(null, ''),
categoryIds: Joi.array().items(Joi.number().integer().positive()).max(6),
primaryCategoryId: Joi.number().integer().positive(),
locationType: Joi.string().valid('VENUE', 'CUSTOM_PIN'),
venueId: Joi.number().integer().positive().allow(null),
locationName: Joi.string().max(160).allow(null, ''),
address: Joi.string().max(255).allow(null, ''),
latitude: Joi.number().min(-90).max(90),
longitude: Joi.number().min(-180).max(180),
startAt: Joi.date().iso(),
endAt: Joi.date().iso().greater(Joi.ref('startAt')).allow(null),
timezone: Joi.string().max(64),
capacity: Joi.number().integer().min(0), // CHECK capacity >= 0
womenOnly: Joi.boolean(),
visibility: Joi.string().valid('PUBLIC', 'PRIVATE', 'INVITE_ONLY'),
joinType: Joi.string().valid('REQUEST', 'OPEN'),
}).min(1),
};
Routes — events.routes.js
// src/modules/events/events.routes.js
import { Router } from 'express';
import { authenticate } from '../../middlewares/auth.middleware.js';
import { requireHost, requireVerified } from '../../middlewares/requireVerified.middleware.js';
import { validate } from '../../middlewares/validate.middleware.js';
import * as ctrl from './events.controller.js';
import { createEventSchema, updateDetailsSchema } from './events.validation.js';
const router = Router();
// PUBLIC reads
router.get('/', ctrl.listEvents);
// Authenticated host reads
router.get('/mine', authenticate, requireHost, ctrl.listMyEvents);
router.get('/:id', authenticate, ctrl.getEvent);
// Mutations: authenticate -> requireHost -> validate -> controller
router.post('/', authenticate, requireHost, validate(createEventSchema), ctrl.createEvent);
router.patch('/:id', authenticate, requireHost, validate(updateDetailsSchema), ctrl.updateDetails);
router.post('/:id/publish', authenticate, requireHost, requireVerified, ctrl.publishEvent);
router.delete('/:id', authenticate, requireHost, ctrl.deleteEvent);
export default router;
Controller — events.controller.js
// src/modules/events/events.controller.js
import { asyncHandler } from '../../utils/asyncHandler.js';
import { ApiResponse } from '../../utils/ApiResponse.js';
import * as eventsService from './events.service.js';
// POST /api/v1/events -> create DRAFT (step 1, screen 52 fork)
export const createEvent = asyncHandler(async (req, res) => {
const event = await eventsService.createDraftEvent(req.user.id, req.body);
return res.status(201).json(new ApiResponse(201, event));
});
// PATCH /api/v1/events/:id -> step 2 details (screen 53)
export const updateDetails = asyncHandler(async (req, res) => {
const event = await eventsService.updateDetails(req.user.id, req.params.id, req.body);
return res.status(200).json(new ApiResponse(200, event));
});
Service — events.service.js (createDraftEvent with ownership)
// src/modules/events/events.service.js
import { ApiError } from '../../utils/ApiError.js';
import * as eventsRepo from './events.repository.js';
// Screen 52: open a DRAFT. is_ticketed is derived from the intent fork —
// true ONLY on the BRINGER branch; SEEKER stays free/untickted.
export async function createDraftEvent(hostId, dto) {
const isBringer = dto.creationIntent === 'BRINGER';
const event = await eventsRepo.createDraft({
host_id: hostId, // ownership stamped at creation
creation_intent: dto.creationIntent,
is_ticketed: isBringer,
title: 'Untitled experience', // placeholder until screen 53
start_at: null,
status: 'DRAFT',
creation_step: 1,
// SEEKER safety defaults differ from BRINGER (visibility/verification):
visibility: isBringer ? 'PUBLIC' : 'PRIVATE',
join_type: 'REQUEST',
});
return event;
}
// Ownership guard reused by updateDetails/publish/delete.
export async function assertOwnership(hostId, eventId) {
const event = await eventsRepo.findById(eventId);
if (!event) throw new ApiError(404, 'Event not found');
if (event.host_id !== hostId) throw new ApiError(403, 'Not the event owner');
return event;
}
export async function updateDetails(hostId, eventId, dto) {
const event = await assertOwnership(hostId, eventId);
return eventsRepo.updateAndReturn(event, { ...dto, creation_step: 2 });
}
Repository — events.repository.js
// src/modules/events/events.repository.js
import { Event } from './event.model.js';
import { Venue } from '../venues/venue.model.js';
import { HostOffer } from '../offers/hostOffer.model.js';
import { TicketTier } from '../ticketing/ticketTier.model.js';
import { EventCategory } from '../categories/eventCategory.model.js';
export function createDraft(payload, transaction = null) {
return Event.create(payload, { transaction });
}
export function findById(id, transaction = null) {
return Event.findByPk(id, { transaction });
}
// Full detail for GET /events/:id — categories, venue, offer, tiers.
export function findFullById(id) {
return Event.findByPk(id, {
include: [
{ model: Venue, as: 'venue' },
{ model: HostOffer, as: 'offer' },
{ model: TicketTier, as: 'tiers' },
{ model: EventCategory, as: 'categories', through: { attributes: [] } },
],
});
}
export async function updateAndReturn(event, patch, transaction = null) {
await event.update(patch, { transaction });
return event;
}
Central Error Middleware — error.middleware.js
// src/middlewares/error.middleware.js
import { ValidationError, UniqueConstraintError } from 'sequelize';
import { ApiError } from '../utils/ApiError.js';
// Mounted LAST in app.js. Emits the canonical error envelope.
export function errorMiddleware(err, req, res, _next) {
let statusCode = 500;
let message = 'Internal server error';
let errors = [];
if (err instanceof ApiError) {
statusCode = err.statusCode;
message = err.message;
errors = err.errors || [];
} else if (err instanceof UniqueConstraintError) {
statusCode = 409; // e.g. duplicate tier name, qr_code_token
message = 'Resource already exists';
errors = err.errors.map((e) => ({ field: e.path, message: e.message }));
} else if (err instanceof ValidationError) {
statusCode = 400;
message = 'Validation failed';
errors = err.errors.map((e) => ({ field: e.path, message: e.message }));
}
if (statusCode >= 500) console.error(err);
return res.status(statusCode).json({
success: false,
statusCode,
message,
...(errors.length > 0 ? { errors } : {}),
});
}
Association Wiring — src/models/index.js
// src/models/index.js — imported once at boot, after all models load.
import { sequelize } from '../config/db.js';
import { User } from '../modules/users/user.model.js';
import { BusinessProfile } from '../modules/users/businessProfile.model.js';
import { Event } from '../modules/events/event.model.js';
import { Venue } from '../modules/venues/venue.model.js';
import { HostOffer } from '../modules/offers/hostOffer.model.js';
import { TicketTier } from '../modules/ticketing/ticketTier.model.js';
import { EventCategory, EventCategoryMap } from '../modules/categories/eventCategory.model.js';
// --- events <-> users / venues / business_profiles ---
Event.belongsTo(User, { foreignKey: 'host_id', as: 'host' }); // RESTRICT/CASCADE owner
User.hasMany(Event, { foreignKey: 'host_id', as: 'events' });
Event.belongsTo(BusinessProfile, { foreignKey: 'business_profile_id', as: 'businessProfile' });
Event.belongsTo(Venue, { foreignKey: 'venue_id', as: 'venue' });
Venue.hasMany(Event, { foreignKey: 'venue_id', as: 'events' });
// --- events 1:N ticket_tiers (each tier = one release wave, 53b/54) ---
Event.hasMany(TicketTier, { foreignKey: 'event_id', as: 'tiers', onDelete: 'CASCADE' });
TicketTier.belongsTo(Event, { foreignKey: 'event_id', as: 'event' });
// --- events 1:1 host_offers (SEEKER host offer, screen 53) ---
Event.hasOne(HostOffer, { foreignKey: 'event_id', as: 'offer', onDelete: 'CASCADE' });
HostOffer.belongsTo(Event, { foreignKey: 'event_id', as: 'event' });
// --- events M:N event_categories THROUGH event_category_map ---
Event.belongsToMany(EventCategory, {
through: EventCategoryMap, foreignKey: 'event_id', otherKey: 'category_id', as: 'categories',
});
EventCategory.belongsToMany(Event, {
through: EventCategoryMap, foreignKey: 'category_id', otherKey: 'event_id', as: 'events',
});
// --- primary category convenience belongsTo ---
Event.belongsTo(EventCategory, { foreignKey: 'primary_category_id', as: 'primaryCategory' });
export { sequelize, User, BusinessProfile, Event, Venue, HostOffer, TicketTier, EventCategory, EventCategoryMap };
is_ticketed true only for BRINGER) is decided once in createDraftEvent; SEEKER events publish straight from screen 53 with no tier rows, while BRINGER events continue through ticketing (53b) and release waves (54) before publish.
6. Business Logic
This section codifies the server-side rules that govern the Host Experience Creation wizard (screens 52 → 53 → 53b → 54). The flow forks at screen 52 on creation_intent: a SEEKER event (“Help me fill it”) is free and curated, advancing 52 → 53 → Publish (it skips 53b/54 and keeps is_ticketed=false); a BRINGER event (“I’ll bring my crowd”) is ticketed and runs the full 52 → 53 → 53b → 54 → Publish path with is_ticketed=true. All rules below are enforced in the *.validation.js (Joi) and *.service.js layers; database CHECK constraints provide the last line of defense.
CHECK / UNIQUE constraints guarantee data integrity even under concurrent writes.
6.1 Validation Rules
Rules are split by enforcement moment. Draft-time rules run on POST /api/v1/events and PATCH /api/v1/events/:id (lenient, partial). Publish-time rules run on POST /api/v1/events/:id/publish (strict, full-object).
| Field | Rule | When enforced | On failure |
|---|---|---|---|
creationIntent | Required on create; one of BRINGER, SEEKER; immutable once any ticket_tiers exist for the event. | Create / pre-publish | 400 / 409 |
title | Required (non-empty after trim); length 1–120 chars. | Publish (draftable empty) | 400 |
description | Optional; max 500 chars. | Always | 400 |
categoryIds | At least 1 required at publish; every id must exist in event_categories and be is_active=true; deduplicated; persisted to event_category_map (first element copied to primary_category_id). | Publish | 400 / 404 |
locationType | One of VENUE, CUSTOM_PIN. If VENUE → venueId required and must reference an active venue. If CUSTOM_PIN → latitude AND longitude both required. | Publish | 400 |
venueId | Required iff locationType=VENUE; FK must resolve to venues.is_active=true. Mutually exclusive with custom pin fields. | Publish | 400 / 404 |
latitude / longitude | Required together iff locationType=CUSTOM_PIN. latitude ∈ [-90, 90], longitude ∈ [-180, 180], DECIMAL(10,7). Server computes geohash (len ≤ 12). | Publish | 400 |
startAt | Required; valid ISO-8601 datetime; must be strictly in the future relative to server now (startAt > NOW()). | Publish | 400 |
endAt | Optional; if present must satisfy endAt > startAt. | Always | 400 |
timezone | Optional; valid IANA tz string; default UTC. | Always | 400 |
capacity | Integer; ≥ 0 at draft, ≥ 1 at publish. For BRINGER, must be ≥ SUM(quantity_total) of tiers. Never below current_guests. | Publish / edit | 400 / 409 |
womenOnly | Boolean; default false. When true, safety defaults force visibility away from PUBLIC (see Edge Cases). | Always | 400 |
visibility | One of PUBLIC, PRIVATE, INVITE_ONLY; default derived from intent + womenOnly safety policy. | Always | 400 |
joinType | One of REQUEST, OPEN; SEEKER defaults REQUEST (curated list). | Always | 400 |
Host offer title | Required when offer present; 1–120 chars. | Offer upsert | 400 |
Host offer description | Optional; max 160 chars AND max 20 words (word count enforced in app layer, regex split on whitespace). | Offer upsert | 400 |
Tier name | Required; 1–80 chars; unique per event (UNIQUE(event_id,name)). | Tier save (53b) | 400 / 409 |
Tier priceCents | Integer minor units; ≥ 0 (CHECK price_cents >= 0). All-in price (is_all_in=true) — no checkout fees. | Tier save | 400 |
Tier quantityTotal | Integer; ≥ 1. | Tier save | 400 |
Tier quantitySold | System-managed; ≤ quantity_total (CHECK quantity_sold <= quantity_total). Cannot be reduced by edits. | Always | 409 |
Tier releaseType | One of AVAILABLE_NOW, AFTER_PREV_TIER_THRESHOLD, SCHEDULED, DAY_OF. Drives required companion fields below. | Tier save | 400 |
Tier releaseThresholdPct | Required (1–100) when releaseType=AFTER_PREV_TIER_THRESHOLD; default 90. Ignored otherwise. | Tier save | 400 |
Tier scheduledReleaseAt | Required future datetime when releaseType=SCHEDULED; null otherwise. | Tier save | 400 |
Tier sortOrder | Integer; defines release-wave ordering; AFTER_PREV_TIER_THRESHOLD tiers require a predecessor (sortOrder > 0). | Tier save | 400 / 409 |
autoReleaseEnabled | Boolean; default true (screen 54 toggle). | Release settings | 400 |
autoReleaseThresholdPct | TINYINT 1–100; default 90 (“hits 90% sold”). | Release settings | 400 |
Word-count check for host offer (app layer)
// host-offers.validation.js — enforced because DB only caps chars (160), not words
const wordCount = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
if (offer.description && wordCount(offer.description) > 20) {
throw new ApiError(400, 'Offer description must be 20 words or fewer', [
{ field: 'description', message: 'max 20 words' },
]);
}
6.2 Required vs Optional Fields per Wizard Step
Drafts are saved incrementally (creation_step advances 1→4). A field marked required is mandatory to advance past that step; publish re-validates the union of all required fields for the intent.
| Step / Screen | Intent | Required | Optional |
|---|---|---|---|
| 52 — Intent fork (step 1) | Both | creationIntent | — |
| 53 — Details, venue & offer (step 2) | Both | title, categoryIds (≥1), locationType (+ venueId or lat&lng), startAt, capacity (≥1) | description, endAt, timezone, womenOnly, visibility, joinType, coverImage, host offer (title+desc), predicted-attendance preview |
| SEEKER terminus — Publish after 53 | SEEKER | All step-53 required fields valid; verified host | 53b / 54 are skipped; is_ticketed=false |
| 53b — Ticket tiers (step 3) | BRINGER | ≥1 tier; per tier: name, priceCents, quantityTotal, releaseType | releaseThresholdPct (req. if threshold type), scheduledReleaseAt (req. if scheduled), currency (default USD), sortOrder |
| 54 — Release waves (step 4) | BRINGER | autoReleaseEnabled, autoReleaseThresholdPct | manual open/close per tier (post-publish ops) |
| BRINGER terminus — Publish after 54 | BRINGER | All of 53 + ≥1 valid tier; verified host; capacity ≥ SUM(quantity_total) |
— is_ticketed=true |
6.3 State Transitions
Event status lifecycle
Status lives in events.status (default DRAFT). Publishing stamps published_at and a unique qr_code_token. Time-based transitions (PUBLISHED→ONGOING→COMPLETED) are driven by start_at/end_at via a scheduler; CANCELLED is a manual terminal action allowed from any non-terminal state.
Ticket tier status lifecycle
Each tier (= one release wave) carries ticket_tiers.status. A tier opens either automatically (auto-release / threshold cascade / schedule) or manually via the open/close endpoints. The 90% rule: when a tier’s quantity_sold / quantity_total ≥ auto_release_threshold_pct (default 90) and auto_release_enabled=true, the next tier (by sort_order) flips SCHEDULED→ON_SALE.
sort_order. Only the immediate next SCHEDULED tier is opened per crossing event — the cascade never skips a wave, so Door (sort 3) cannot open before General (sort 2) has crossed threshold.
6.4 User Permissions Matrix
authenticate sets req.user = {id, role} (role ∈ GUEST, HOST; a Venue is a HOST with a business_profiles row; admin is an operational super-role). Mutations require requireHost + ownership (event.host_id === req.user.id); publishing additionally requires requireVerified (is_verified=true).
| Action | Guest | Host (owner) | Host (non-owner) | Venue (HOST + business) | Admin |
|---|---|---|---|---|---|
| List / view PUBLIC events, categories, venues | ✓ | ✓ | ✓ | ✓ | ✓ |
Create draft (POST /events) | ✗ | ✓ | — | ✓ | ✓ |
| Edit details / tiers / offer / releases | ✗ | ✓ | ✗ (403) | ✓ (own) | ✓ |
| Publish (verified host required) | ✗ | ✓ if is_verified | ✗ | ✓ if is_verified | ✓ |
| Manual open/close tier wave | ✗ | ✓ | ✗ | ✓ (own) | ✓ |
| Cancel / complete / soft-delete event | ✗ | ✓ | ✗ | ✓ (own) | ✓ |
| Approve / skip join requests (curated) | ✗ | ✓ | ✗ | ✓ (own) | ✓ |
| Request to join an event | ✓ | ✓ | ✓ | ✓ | ✓ |
| Bulk status / bulk delete own events | ✗ | ✓ (owned subset only) | ✗ | ✓ (owned) | ✓ |
| View host dashboard / mine | ✗ | ✓ | — | ✓ | ✓ |
6.5 Edge Cases
- Publishing a BRINGER event with no tiers.
publishblocks with 409 (publish-invalid): a ticketed event must have≥1ticket_tiersrow and at least one tier that becomes purchasable (AVAILABLE_NOWor a valid scheduled/threshold chain). - Switching intent after tiers exist.
creation_intentis immutable once anyticket_tiersrow exists. Changing BRINGER→SEEKER is rejected 409 until all tiers are deleted; switching also resetsis_ticketed=falseand clears release settings. - Custom pin without geocode.
CUSTOM_PINwith missinglatitude/longitude→ 400. The reverse-geocodedlocation_name/addressare best-effort; if geocoding fails the pin still publishes (coords are source of truth, label optional) butgeohashis always computed server-side. - Predicted attendance for venue events. Prediction (
predicted_attendance_*) is a SEEKER-oriented signal computed from nearby category interest withinradius_km. ForVENUEevents the venue’s lat/lng seeds the query; for BRINGER (ticketed) events the card is hidden and prediction is not required to publish. - Women-only safety default. Setting
women_only=trueforces the safety policy to demotevisibilityfromPUBLICto at leastPRIVATEand biasesjoin_type=REQUEST(curated). Attemptingwomen_only=true+visibility=PUBLICis silently corrected toPRIVATEwith a warning in the responsemeta, not a hard 400. - Capacity smaller than tickets sold. Editing
capacitybelowSUM(quantity_sold)(or belowcurrent_guests) → 409. Capacity may only be lowered to≥already-committed seats. - Deleting a published event with sold tickets. Soft-delete (
DELETE) of a PUBLISHED event withquantity_sold > 0is blocked 409; the host must CANCEL first (which triggers refund/notification flows out of scope here), after which soft-delete setsdeleted_at. - 90% threshold cascade ordering. Concurrent purchases crossing 90% are reconciled in a single transaction keyed on
sort_order; only the immediate nextSCHEDULEDtier opens. Idempotent: re-evaluation never opens an alreadyON_SALE/CLOSEDtier and never skips waves. - Duplicate tier name. Two tiers with the same
namewithin one event → 409 (UNIQUE(event_id,name)), surfaced during the bulkPUT /ticket-tierssave. - startAt drifting into the past. A long-lived draft whose
start_athas passed fails publish with 400 until the host picks a future datetime. - Editing core fields on a published ticketed event. Price/quantity reductions on tiers with sales are rejected 409; only increases (more inventory, raised capacity) are permitted post-publish.
- Bulk operations partial ownership.
PATCH /events/bulk/statussilently filters to events owned by the caller; ids not owned are reported inmeta.skipped[]rather than failing the whole batch.
6.6 Error Scenarios
| Scenario | HTTP | message (and errors[] hint) |
|---|---|---|
| Joi validation failure (e.g. title 0 chars, bad enum) | 400 | “Validation failed” — errors:[{field, message}] |
CUSTOM_PIN missing lat/lng, or VENUE missing venueId | 400 | “Location coordinates are required for a custom pin” |
startAt not in the future at publish | 400 | “Start time must be in the future” |
| Host offer description exceeds 20 words | 400 | “Offer description must be 20 words or fewer” |
| Missing / malformed JWT | 401 | “Authentication required” |
| Expired access token | 401 | “Token expired” |
| GUEST attempts to create/edit an event | 403 | “Host role required” |
| Host edits an event they do not own | 403 | “You are not the owner of this event” |
| Event / venue / category / tier id not found | 404 | “Event not found” |
Referenced categoryIds include unknown/inactive id | 404 | “One or more categories do not exist” |
| Publish a BRINGER event with no tiers | 409 | “Ticketed event requires at least one ticket tier” (publish-invalid) |
Change creationIntent after tiers exist | 409 | “Cannot change intent after tiers are created” |
| Duplicate tier name within event | 409 | “A tier with this name already exists” |
| Publish an already-published event | 409 | “Event is already published” |
Capacity below committed seats / quantity_sold > quantity_total | 409 | “Capacity cannot be below tickets already sold” |
| Soft-delete published event with sold tickets | 409 | “Cancel the event before deleting” |
| Publish by an unverified host (semantic gate) | 422 | “Host must be verified to publish” |
| Too many requests (preview prediction / publish spam) | 429 | “Too many requests, please retry shortly” |
| Unhandled server / DB error | 500 | “Internal server error” |
{ success:true, data, meta? }; error → { success:false, statusCode, message, errors? }. The error.middleware.js maps thrown ApiError instances to these shapes centrally.
7. API Flow
This section traces the runtime request/response choreography for the Host Experience Creation flow. Every request enters through the Express 5 stack in the same order — AuthMiddleware (JWT) → Validator (Joi) → Controller → Service → Repository → MySQL — and unwinds back through the controller, which wraps the payload in the standard { success, data, meta } envelope. The five diagrams below cover the SEEKER happy path, the BRINGER ticketing path, predicted-attendance computation, the auto-release wave, and the layered error pipeline.
AuthMiddleware verifies the JWT and sets req.user = { id, role }. Validator runs the Joi schema for the route. The Controller orchestrates and shapes the HTTP response. The Service holds business rules, ownership checks, and transactions. The Repository is the only layer that touches Sequelize/MySQL.
7.1 Happy path — SEEKER: create draft → details → offer → publish
A “Help me fill it” host creates a free curated event. Screen 52 posts the intent to mint a DRAFT, screen 53 patches details and upserts the host offer, then Publish flips the event to PUBLISHED. No ticket tiers are involved on this branch.
7.2 BRINGER ticketing — bulk save tiers → release settings → publish
The “I’ll bring my crowd” path adds screens 53b and 54. The frontend bulk-replaces all ticket tiers in one PUT, saves auto-release settings, then publishes. The publish step sets is_ticketed = true and requires at least one tier.
7.3 Predicted attendance computation
The screen-53 prediction card asks the API to estimate likely joiners. The service queries nearby interest by category within a radius and returns a count, radius, and confidence — either computed on the fly or read from the attendance_predictions cache.
7.4 Auto-release wave at 90%
When a ticket sale completes, the service recomputes quantity_sold for the current wave. If auto-release is enabled and the wave crosses the threshold, the next tier by sort_order is opened to ON_SALE and the sold-out wave is marked SOLD_OUT.
7.5 Error flow — 401 → 400 → 403 → 409, by layer
Each failure class is raised at a distinct point in the chain. Missing/invalid JWT fails in AuthMiddleware (401); a bad body fails in Validator (400); a non-owner fails in the Service ownership check (403); and an invalid publish state (e.g. unverified host, missing tiers, or already published) fails as a 409 conflict in the Service. All are normalized by error.middleware.js into the error envelope.
AuthMiddleware runs before Validator, an unauthenticated request with a malformed body still returns 401, not 400. Ownership (403) and state conflicts (409) are intentionally deferred to the Service so they apply uniformly to single, bulk, and nested-resource routes.
8. Database Migration Order
Because every table in the Host Experience Creation flow is wired together with foreign keys, migrations
must run in topological (dependency) order: a table can only be created after every table
its FKs point at already exists. users and business_profiles are
pre-existing (shipped by the Auth/Onboarding modules) and are listed only as
anchor points for the FK targets that follow. The remaining tables of this module are created in the order
below.
events is the central table. It depends on users,
business_profiles, event_categories and venues, so all four must be
migrated before it. Every other table in the flow (event_category_map, host_offers,
ticket_tiers, event_join_requests, attendance_predictions) is a child of
events and therefore comes after it.
8.1 Ordered CREATE TABLE steps (FK dependency order)
-
userspre-existing — root identity table. No FK dependencies; every*_user_id/host_id/owner_user_idFK resolves here, so it must exist first. -
business_profilespre-existing — FKuser_id→users.id. Requiresusers. Targeted later byvenues.business_profile_idandevents.business_profile_id. -
event_categories— no FK dependencies (standalone lookup table). Created early and seeded so thatevents.primary_category_idandevent_category_map.category_idhave valid rows to reference. -
venues— FKsowner_user_id→users.idandbusiness_profile_id→business_profiles.id(NULL). Requiresusers+business_profiles. Must precedeevents, which referencesvenue_id. -
events— FKshost_id→users.id,business_profile_id→business_profiles.id(NULL),primary_category_id→event_categories.id(NULL), andvenue_id→venues.id(NULL). Requires all four prior tables. Central parent of the remaining five. -
event_category_map— junction; FKsevent_id→events.idandcategory_id→event_categories.id(bothON DELETE CASCADE). Requires botheventsandevent_categories. -
host_offers— FKevent_id→events.id(ON DELETE CASCADE, UNIQUE / 1:1). Requiresevents. Screen 53 SEEKER gold pill. -
ticket_tiers— FKevent_id→events.id(ON DELETE CASCADE, 1:N). Requiresevents. Screens 53b/54 BRINGER waves. -
event_join_requests— FKsevent_id→events.idanduser_id→users.id(bothON DELETE CASCADE). Requiresevents+users. Adjacent (screen 55 curated list). -
attendance_predictionsoptional — FKsevent_id→events.id(UNIQUE, NULL) andhost_id→users.id. Requiresevents+users. Cache; may be computed on-the-fly instead, so it is migrated last.
8.2 Migration order & filenames
| # | Table | Depends-on (FK targets) | Sequelize migration filename |
|---|---|---|---|
| 1 | users pre-existing |
— (root) | 20260101000100-create-users.js |
| 2 | business_profiles pre-existing |
users |
20260101000200-create-business-profiles.js |
| 3 | event_categories |
— (lookup) | 20260101000300-create-event-categories.js |
| 4 | venues |
users, business_profiles |
20260101000400-create-venues.js |
| 5 | events |
users, business_profiles, event_categories, venues |
20260101000500-create-events.js |
| 6 | event_category_map |
events, event_categories |
20260101000600-create-event-category-map.js |
| 7 | host_offers |
events |
20260101000700-create-host-offers.js |
| 8 | ticket_tiers |
events |
20260101000800-create-ticket-tiers.js |
| 9 | event_join_requests |
events, users |
20260101000900-create-event-join-requests.js |
| 10 | attendance_predictions optional |
events, users |
20260101001000-create-attendance-predictions.js |
| 11 | event_categories (seed) |
event_categories |
20260101001100-seed-event-categories.js |
8.3 Dependency graph
8.4 Rollback (DOWN) order
Rollback runs in strict reverse: drop every child before its parent so no FK constraint is
left dangling. Sequelize replays down() in descending timestamp order automatically, but the
intent is:
- Un-seed
event_categories(delete seeded rows). - Drop
attendance_predictions. - Drop
event_join_requests. - Drop
ticket_tiers. - Drop
host_offers. - Drop
event_category_map. - Drop
events. - Drop
venues. - Drop
event_categories. - Drop
business_profilespre-existing (only on a full teardown). - Drop
userspre-existing (only on a full teardown).
ON DELETE CASCADE, but a DROP TABLE
on a parent that still has dependents will be rejected by MySQL 8 regardless of cascade rules. Always drop in
the order above, or temporarily run SET FOREIGN_KEY_CHECKS = 0; inside the migration transaction
and restore it with SET FOREIGN_KEY_CHECKS = 1; afterward.
8.5 Inline FKs vs. add-after, and seeding categories
Because the create order above is already a clean topological sort, every foreign key can be declared
inline in its CREATE TABLE (via the references option in
queryInterface.createTable) — the referenced table is guaranteed to exist by the time the
migration runs. Add-FK-after-the-fact (a separate addConstraint migration) is only needed for
genuine cycles (table A → B and B → A). None of the ten tables form a cycle, so inline FKs are the
recommended default here.
// 20260101000800-create-ticket-tiers.js — inline FK is safe: events already exists
await queryInterface.createTable('ticket_tiers', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
event_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'events', key: 'id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
// ...name, price_cents, quantity_total, quantity_sold, sort_order, release_type...
});
// CHECK quantity_sold <= quantity_total and CHECK price_cents >= 0
await queryInterface.addConstraint('ticket_tiers', {
type: 'check', fields: ['price_cents'],
where: { price_cents: { [Sequelize.Op.gte]: 0 } },
name: 'ck_ticket_tiers_price_cents_nonneg',
});
event_categories is a closed lookup set, so seed it in a separate seeder migration
(step 11) that runs after the table exists and uses bulkInsert with the canonical slugs. Run it
before any events rows are created so primary_category_id resolves. The seeder's
down() deletes exactly those slugs (idempotent re-seeding).
// 20260101001100-seed-event-categories.js
const now = new Date();
await queryInterface.bulkInsert('event_categories', [
{ name: 'Friends', slug: 'friends', icon: null, sort_order: 1, is_active: true, created_at: now, updated_at: now },
{ name: 'Networking', slug: 'networking', icon: null, sort_order: 2, is_active: true, created_at: now, updated_at: now },
{ name: 'Mixer', slug: 'mixer', icon: null, sort_order: 3, is_active: true, created_at: now, updated_at: now },
{ name: 'Coffee', slug: 'coffee', icon: null, sort_order: 4, is_active: true, created_at: now, updated_at: now },
{ name: 'Dining', slug: 'dining', icon: null, sort_order: 5, is_active: true, created_at: now, updated_at: now },
{ name: 'Party', slug: 'party', icon: null, sort_order: 6, is_active: true, created_at: now, updated_at: now },
]);
// down(): undo the seed only
// await queryInterface.bulkDelete('event_categories', {
// slug: ['friends','networking','mixer','coffee','dining','party'],
// });
npx sequelize-cli db:migrate applies steps 1–11 in ascending timestamp order
(clean FK build-up), and db:migrate:undo:all tears down in descending order (children before
parents) with no constraint violations.
9. Assumptions
The mobile screens (52, 53, 53b, 54) leave several behaviors implicit. The following assumptions were made where the UI was ambiguous. Each is production-reasonable and consistent with the canonical schema, enums, and endpoints defined elsewhere in this document. They are grouped by concern and numbered for reference.
9.1 Flow & Branching
-
The wizard forks at screen 52 on
creation_intent. SEEKER ("Help me fill it") completes details on screen 53 and then publishes directly — it skips 53b and 54. BRINGER ("I'll bring my crowd") continues from 53 to 53b (ticket tiers) and 54 (release waves) before publishing. Thereforeis_ticketed = trueis set on the BRINGER branch only; SEEKER events keepis_ticketed = falseand have noticket_tiersrows. The canonical linear narrative 52 → 53 → 53b → 54 describes the full ticketed (BRINGER) setup. -
The progress bar maps to a 2-step base wizard. Screen 52 is
"Step 1 of 2" (50%) and screen 53 is "Step 2 of 2" (100%). Screens 53b and 54
are treated as continuation steps of the BRINGER branch rather than renumbering
the bar;
creation_stepis advanced server-side (1→2→3→4) as the host saves each step so the client can resume a draft at the correct screen. -
A draft event row is created up-front.
POST
/api/v1/eventsis called from screen 52 with{ creationIntent }and returns astatus = DRAFTevent. All subsequent screens PATCH / PUT onto that same event id; the flow is never held entirely client-side.
9.2 Categories
-
Categories are multi-select with exactly one primary. The
chips on screen 53 allow selecting multiple categories persisted via the
event_category_mapjunction (many-to-many). The first/highlighted chip is also recorded onevents.primary_category_idfor ranking, iconography, and the "friends mixer vs networking" safety defaults. If only one chip is selected it is both the primary and the sole map row. At least one category is required to publish. -
Category seed set is fixed for this flow. Only the seeded rows
(Friends, Networking, Mixer, Coffee, Dining, Party) are selectable; hosts cannot
create new categories from the wizard.
event_categoriesis treated as admin-managed reference data exposed read-only via GET/api/v1/event-categories.
9.3 Host Offer (SEEKER)
-
At most one host offer per event. The "+ Add offer" editor maps
to a single
host_offersrow (1:1, enforced byUNIQUE(event_id)). The "live gold pill" is a client-side preview of that row; PUT/api/v1/events/:id/offerupserts it and DELETE removes it. -
Offer description is capped at 20 words, enforced in the app layer.
The column is
VARCHAR(160)but the "max 20 words" rule from the UI is a Joi custom validator (word count, not character count), since words and characters do not map cleanly. Validation failure returns 400 with a field-level error ondescription. - The host offer is a SEEKER-oriented affordance. It is exposed on the SEEKER details screen; it is permitted but not surfaced for BRINGER events. No schema constraint blocks a BRINGER offer, keeping the table reusable.
9.4 Predicted Attendance
-
Prediction is computed from nearby user interest within a radius.
The "~9 likely to join within 3 km / 72%" card is derived from users near the
pin whose interests intersect the selected categories. The default radius is
3.00km (predicted_attendance_radius_km); the result yieldspredicted_attendance_countandpredicted_attendance_confidence_pcton the event. -
The exact prediction algorithm is out-of-scope.
out-of-scope This flow specifies inputs
(
latitude,longitude,categoryIds,startAt,radiusKm) and outputs (predictedCount,radiusKm,confidencePct) only. Scoring weights, decay, and ML are owned by a separate service. -
Predictions may be cached or computed on the fly. The optional
attendance_predictionstable caches a draft's last computation (UNIQUE(event_id)). POST/api/v1/events/predicted-attendanceis a stateless preview (no persistence); GET/api/v1/events/:id/predicted-attendancemay return cached or fresh values. The card is read-only and never blocks publish.
9.5 Tickets, Pricing & Release Waves (BRINGER)
-
Prices are integer minor units, all-in, single currency. The
"$12 / $15 / $20" inputs are stored as
price_cents(INT UNSIGNED), never floats. The "all-in — no fees added at checkout" note is honored byis_all_in = true; the price the guest sees equalsprice_cents.currencydefaults toUSDand a single currency per event is assumed (no per-tier mixing). -
Each ticket tier equals exactly one release wave. There is no
separate "wave" entity; screen 53b tiers and screen 54 waves are the same
ticket_tiersrows.sort_orderis the release order (ascending = earliest), so Early Bird (0) releases before General (1) before Door (2). -
Release timing maps the dropdown to
release_type. "Available now" →AVAILABLE_NOW; "When prev. tier hits 90%" →AFTER_PREV_TIER_THRESHOLDwithrelease_threshold_pct = 90; "opens day-of" →DAY_OF; a future date →SCHEDULEDwithscheduled_release_at. -
Auto-release threshold defaults to 90% and is configurable. The
screen 54 toggle maps to
auto_release_enabledand the percentage toauto_release_threshold_pct(default 90), saved via PATCH/api/v1/events/:id/release-settings. When enabled, the backend opens the next wave (nextsort_order, statusSCHEDULED→ON_SALE) once the current on-sale tier crosses the threshold. -
Tier quantities and live status are server-authoritative. "Sold
out", "on sale · 26 left", and the progress bars are derived from
quantity_soldvsquantity_totalandstatus— not client-supplied. TheCHECK quantity_sold <= quantity_totalconstraint guards oversell; a violating write returns 409. -
Saving tiers on 53b is a full replace.
PUT
/api/v1/events/:id/ticket-tiersbulk-upserts the whole set so "Remove" links and reordering reconcile in one save. Removing a tier that already hasquantity_sold > 0is rejected (409); empty tiers soft-delete viadeleted_at. Tier names are unique per event (UNIQUE(event_id, name)) → duplicates return 409.
9.6 Safety, Visibility & Verification
-
Intent and
women_onlydrive safety defaults. Per the screen 52 note, intent sets initialvisibility,join_type, and verification expectations. Enabling the screen 53 women-only switch (women_only = true) raises the bar — it tightens visibility (away fromPUBLICtowardINVITE_ONLY) and favorsREQUESTjoin with stricter ID-verification on join requests. -
Publishing requires a verified host.
POST
/api/v1/events/:id/publishis gated byrequireHost+requireVerifiedand ownership; an unverified or non-owner host receives 403. Invalid state (missing title, no category, BRINGER with zero tiers) returns 409.
9.7 Venue & Location
-
location_typeresolves toVENUEorCUSTOM_PIN. Choosing a partner from the list setslocation_type = VENUEandvenue_id; "Select your own location" setsCUSTOM_PINwith a dropped/draggable pin populatinglatitude/longitude. -
Custom pins are reverse-geocoded to a label. The "Lower East
Side, NY" text is a reverse-geocode of the pin stored in
location_name(andaddress/citywhen available). The geocoding provider is out-of-scope; ageohashis also computed for proximity queries. -
Selectable venues are partner + active + nearby only.
GET
/api/v1/venuesreturns venues withis_partner = trueandis_active = truewithin the requested radius, sorted by distance (matching the "0.3 mi / 0.6 mi / 1.2 mi" list). Non-partner or inactive venues are not offered in the wizard.
9.8 Time, Lifecycle & Persistence
-
Timestamps are stored in UTC with an explicit timezone.
start_at/end_atare persisted as UTCDATETIMEwhile the host's local zone is kept intimezone(defaultUTC), so "Sun, Jul 6 · 9:00 AM" renders correctly per locale.end_atis optional. -
Drafts autosave between wizard steps. Each step persists via
PATCH/PUT so a
host can leave and resume; the event remains
DRAFTuntil publish.creation_steprecords progress for resume. -
The QR token is generated at publish, not at draft.
qr_code_token(UNIQUE) andpublished_atare populated only by POST/api/v1/events/:id/publish, which transitionsDRAFT→PUBLISHED. Re-publishing an already-published event returns 409. -
Events use soft deletes.
DELETE
/api/v1/events/:idsetsdeleted_at(Sequelizeparanoid) and returns 204; rows are excluded from public lists but retained for audit and referential integrity of past attendance.
9.9 Auth & Access Control
-
JWT access + refresh tokens; host role required to create.
authenticatevalidates the bearer access token and setsreq.user = { id, role }; a separate refresh token (longer-lived) is assumed for renewal. All creation/mutation endpoints requirerequireHostand ownership — a guest gets 403, a missing/invalid token gets 401. -
Ownership is enforced on every event-scoped mutation. A host may
only edit, publish, or delete events where
events.host_id = req.user.id(and may act on abusiness_profile_idthey own); cross-host access returns 403.
events row anchors the entire flow. SEEKER fills
details + offer + prediction and publishes after 53; BRINGER additionally builds
ticket_tiers (= release waves) across 53b/54 with
is_ticketed = true before publishing. All ambiguous UI behaviors above
resolve to explicit, testable backend rules.