◆ Backend Spec · JoinMeIn Mobile

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.

Flow 52 → 53 → 53B → 54 Fork Bringer vs. Seeker ORM Sequelize DB MySQL 8 Auth JWT Core tables 10
SCREEN 52

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.

SCREEN 53

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.

SCREEN 53B

Create tickets · tiers

Name each tier, set price & quantity, and choose release timing. All-in pricing — no fees added at checkout.

SCREEN 54

Release waves & tiers

Early-bird → general → door tiers that auto-release the next wave at 90% sold. Scarcity without velvet ropes.

Seeker → publishes after Screen 53 (free, curated)
Bringer → continues to 53B → 54 (ticketed)

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.

The fork in one line: 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.role and account_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_requests list with Approve/Skip per guest — the consumer of the SEEKER curated-fill model.

1.2 Complete User Flow (52 → 53 → 53b → 54)

  1. Enter the wizard (52). Host opens "New experience". Progress 50%. The "Help me fill it" (SEEKER) card is selected by default.
  2. Choose intent (52). Host picks a radio card. "I'll bring my crowd" sets creation_intent=BRINGER; "Help me fill it" sets creation_intent=SEEKER. Tapping "Next — the details" calls POST /api/v1/events with {creationIntent}, creating a DRAFT event (creation_step=1) and returning its id.
  3. 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 via PATCH /api/v1/events/:id; the offer via PUT /api/v1/events/:id/offer.
  4. Branch decision.
    • SEEKER: The CTA reads "Publish experience". Host taps it → POST /api/v1/events/:id/publish validates (verified host, required fields) and flips status=PUBLISHED, is_ticketed=false. Flow ends; the event now appears on screen 55 and accumulates curated event_join_requests. 53b and 54 are skipped.
    • BRINGER: Flow continues to step 5.
  5. 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-tiers and sets is_ticketed=true.
  6. 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 via PATCH /api/v1/events/:id/release-settings.
  7. Publish (BRINGER). Host publishes via POST /api/v1/events/:id/publishstatus=PUBLISHED. The ticketed event goes live with its first wave on sale.
flowchart TD A[Screen 52 New experience<br/>Choose creation_intent] -->|SEEKER Help me fill it| B[Screen 53 Details<br/>title categories venue<br/>date cap offer prediction] A -->|BRINGER I'll bring my crowd| C[Screen 53 Details<br/>same fields] B --> D{Publish experience} D --> P1[POST /events/:id/publish<br/>status PUBLISHED<br/>is_ticketed false] P1 --> END1([Curated free event live<br/>screen 55 join requests]) C --> E[Screen 53b Create tickets<br/>tier name price qty<br/>release timing] E -->|Continue Set release waves| F[Screen 54 Ticket releases<br/>live tier status cards<br/>auto-release toggle] F -->|Save releases| G{Publish experience} G --> P2[POST /events/:id/publish<br/>status PUBLISHED<br/>is_ticketed true] P2 --> END2([Ticketed event live<br/>first wave on sale])
State transition: every event starts 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.
Net data effect: a complete BRINGER run touches 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.

Intent-driven defaults. 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
idBIGINT UNSIGNEDNOPKAUTO_INCREMENTPrimary key
full_nameVARCHAR(120)YESNULLDisplay name
emailVARCHAR(160)NOUNIQUELogin identifier
phoneVARCHAR(20)YESNULLOptional contact
roleENUM(GUEST,HOST)NOGUESTHost-role gate for event mutations
account_typeENUM(INDIVIDUAL,BUSINESS)NOINDIVIDUALScreen 51 account type
is_verifiedBOOLNOfalseRequired by requireVerified on publish
is_id_verifiedBOOLNOfalseFeeds guest reliability scoring (screen 55)
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMPON 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
idBIGINT UNSIGNEDNOPKAUTO_INCREMENTPrimary key
user_idBIGINT UNSIGNEDNOFK, UNIQUEFK → users.id; one profile per user
business_nameVARCHAR(160)YESNULL
business_typeENUM(CLUB,RESTAURANT,HOTEL,RESORT,CAFE,OTHER)YESNULL
cityVARCHAR(120)YESNULL
latitudeDECIMAL(10,7)YESNULL
longitudeDECIMAL(10,7)YESNULL
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMPON 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
idBIGINT UNSIGNEDNOPKAUTO_INCREMENTPrimary key
owner_user_idBIGINT UNSIGNEDNOFKFK → users.id (ON DELETE CASCADE)
business_profile_idBIGINT UNSIGNEDYESFKNULLFK → business_profiles.id (ON DELETE SET NULL)
nameVARCHAR(160)NODisplay name
venue_typeENUM(CLUB,RESTAURANT,HOTEL,RESORT,CAFE,BAR,ROOFTOP,OTHER)NOOTHER
descriptionVARCHAR(500)YESNULL
addressVARCHAR(255)YESNULL
cityVARCHAR(120)YESIDX(city)NULL
latitudeDECIMAL(10,7)YESIDX(latitude,longitude)NULLDistance sort for nearby search
longitudeDECIMAL(10,7)YESIDX(latitude,longitude)NULL
is_partnerBOOLNOIDX(is_partner,is_active)falsePartner venues shown in screen 53 list
is_activeBOOLNOIDX(is_partner,is_active)true
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMPON 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
idBIGINT UNSIGNEDNOPKAUTO_INCREMENTPrimary key
nameVARCHAR(60)NOChip label
slugVARCHAR(60)NOUNIQUEe.g. friends, networking, mixer
iconVARCHAR(16)YESNULLEmoji / icon key
sort_orderINTNO0Display ordering
is_activeBOOLNOtrueSoft hide
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMPON 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
idBIGINT UNSIGNEDNOPKAUTO_INCREMENTPrimary key
host_idBIGINT UNSIGNEDNOFK, IDX(host_id)FK → users.id (ON DELETE RESTRICT; owner)
business_profile_idBIGINT UNSIGNEDYESFKNULLFK → business_profiles.id (ON DELETE SET NULL)
creation_intentENUM(BRINGER,SEEKER)NOIDX(creation_intent)The screen-52 fork
titleVARCHAR(120)NOe.g. “Sunday Founders Coffee”
descriptionVARCHAR(500)YESNULL
primary_category_idBIGINT UNSIGNEDYESFK, IDXNULLFK → event_categories.id (ON DELETE SET NULL)
location_typeENUM(VENUE,CUSTOM_PIN)NOCUSTOM_PINPartner venue vs dropped pin
venue_idBIGINT UNSIGNEDYESFK, IDX(venue_id)NULLFK → venues.id (ON DELETE SET NULL); set when location_type=VENUE
location_nameVARCHAR(160)YESNULLReverse-geocoded label e.g. “Lower East Side, NY”
addressVARCHAR(255)YESNULL
latitudeDECIMAL(10,7)YESIDX(latitude,longitude)NULLMap pin lat
longitudeDECIMAL(10,7)YESIDX(latitude,longitude)NULLMap pin lng
geohashVARCHAR(12)YESIDX(geohash)NULLProximity bucketing
start_atDATETIMENOIDX(status,start_at)“Sun, Jul 6 · 9:00 AM”
end_atDATETIMEYESNULL
timezoneVARCHAR(64)NOUTCIANA tz name
capacityINT UNSIGNEDNO0Screen-53 Cap field; CHECK capacity >= 0
current_guestsINT UNSIGNEDNO0Confirmed attendees
women_onlyBOOLNOfalseScreen-53 women-only toggle
is_ticketedBOOLNOfalsetrue only on BRINGER branch (gates 53b/54)
auto_release_enabledBOOLNOtrueScreen-54 auto-release toggle
auto_release_threshold_pctTINYINT UNSIGNEDNO90“hits 90% sold”
visibilityENUM(PUBLIC,PRIVATE,INVITE_ONLY)NOIDX(visibility)PUBLICIntent sets safety default
join_typeENUM(REQUEST,OPEN)NOREQUESTSEEKER uses curated REQUEST flow
statusENUM(DRAFT,PUBLISHED,ONGOING,COMPLETED,CANCELLED)NOIDX(status,start_at)DRAFTLifecycle
creation_stepTINYINTYES1Wizard progress (1=52, 2=53, ...)
predicted_attendance_countINTYESNULL“~9 likely to join”
predicted_attendance_radius_kmDECIMAL(5,2)YESNULL“within 3 km”
predicted_attendance_confidence_pctTINYINTYESNULLProgress 72%
cover_imageVARCHAR(255)YESNULLUpload path
qr_code_tokenVARCHAR(64)YESUNIQUENULLCheck-in token; UNIQUE(qr_code_token)
published_atDATETIMEYESNULLSet on publish
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMPON UPDATE
deleted_atDATETIMEYESNULLSoft 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
event_idBIGINT UNSIGNEDNOPK, FKFK → events.id (ON DELETE CASCADE)
category_idBIGINT UNSIGNEDNOPK, 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
idBIGINT UNSIGNEDNOPKAUTO_INCREMENTPrimary key
event_idBIGINT UNSIGNEDNOFK, UNIQUE, IDX(event_id)FK → events.id (ON DELETE CASCADE); 1:1
titleVARCHAR(120)NOe.g. “Free coffee for everyone”
descriptionVARCHAR(160)YESNULLMax 20 words (enforced in app layer)
is_activeBOOLNOtrue
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMPON 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
idBIGINT UNSIGNEDNOPKAUTO_INCREMENTPrimary key
event_idBIGINT UNSIGNEDNOFK, IDX(event_id,sort_order)FK → events.id (ON DELETE CASCADE)
nameVARCHAR(80)NOUNIQUE(event_id,name)e.g. “Early Bird”, “General”, “Door”
price_centsINT UNSIGNEDNOAll-in, integer minor units; CHECK price_cents >= 0
currencyCHAR(3)NOUSDISO 4217
quantity_totalINT UNSIGNEDNOQty field on 53b
quantity_soldINT UNSIGNEDNO0CHECK quantity_sold <= quantity_total
sort_orderINTNOIDX(event_id,sort_order)0Release order
release_typeENUM(AVAILABLE_NOW,AFTER_PREV_TIER_THRESHOLD,SCHEDULED,DAY_OF)NOAVAILABLE_NOWRelease timing dropdown
release_threshold_pctTINYINT UNSIGNEDYES90“When prev. tier hits 90%”
scheduled_release_atDATETIMEYESNULLUsed when release_type=SCHEDULED
statusENUM(SCHEDULED,ON_SALE,SOLD_OUT,CLOSED)NOIDX(event_id,status)SCHEDULEDLive state on screen 54
is_all_inBOOLNOtrueNo fees added at checkout
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMPON UPDATE
deleted_atDATETIMEYESNULLSoft 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
idBIGINT UNSIGNEDNOPKAUTO_INCREMENTPrimary key
event_idBIGINT UNSIGNEDNOFK, UNIQUE(event_id,user_id), IDX(event_id,status)FK → events.id (ON DELETE CASCADE)
user_idBIGINT UNSIGNEDNOFK, UNIQUE(event_id,user_id)FK → users.id (ON DELETE CASCADE)
statusENUM(PENDING,APPROVED,SKIPPED,REJECTED)NOIDX(event_id,status)PENDINGApprove/Skip outcome
messageVARCHAR(280)YESNULLGuest note
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMPON 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.

ColumnTypeNullKey/IndexDefaultConstraint/Notes
idBIGINT UNSIGNEDNOPKAUTO_INCREMENTPrimary key
event_idBIGINT UNSIGNEDYESFK, UNIQUENULLFK → events.id (ON DELETE CASCADE); NULL for preview-only
host_idBIGINT UNSIGNEDNOFKFK → users.id (ON DELETE CASCADE)
category_idsJSONYESNULLArray of category ids used as inputs
latitudeDECIMAL(10,7)NOPrediction center
longitudeDECIMAL(10,7)NO
radius_kmDECIMAL(5,2)NO3.00“within 3 km”
predicted_countINTYESNULL“~9”
confidence_pctTINYINTYESNULL72%
computed_atDATETIMEYESNULLCache 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

erDiagram users ||--o{ events : hosts users ||--o| business_profiles : owns users ||--o{ venues : owns users ||--o{ event_join_requests : requests users ||--o{ attendance_predictions : requests business_profiles ||--o{ events : attributes business_profiles ||--o{ venues : operates venues ||--o{ events : hosts_at event_categories ||--o{ events : primary_for event_categories ||--o{ event_category_map : tagged_in events ||--o{ event_category_map : has events ||--o| host_offers : has events ||--o{ ticket_tiers : has events ||--o{ event_join_requests : receives events ||--o| attendance_predictions : caches users { bigint id PK varchar full_name varchar email UK enum role enum account_type bool is_verified bool is_id_verified } business_profiles { bigint id PK bigint user_id FK varchar business_name enum business_type } venues { bigint id PK bigint owner_user_id FK bigint business_profile_id FK varchar name enum venue_type bool is_partner bool is_active } event_categories { bigint id PK varchar name varchar slug UK int sort_order bool is_active } events { bigint id PK bigint host_id FK bigint business_profile_id FK bigint primary_category_id FK bigint venue_id FK enum creation_intent varchar title enum location_type datetime start_at int capacity bool is_ticketed enum visibility enum status } event_category_map { bigint event_id PK_FK bigint category_id PK_FK } host_offers { bigint id PK bigint event_id FK_UK varchar title varchar description } ticket_tiers { bigint id PK bigint event_id FK varchar name int price_cents int quantity_total int quantity_sold int sort_order enum release_type enum status } event_join_requests { bigint id PK bigint event_id FK bigint user_id FK enum status } attendance_predictions { bigint id PK bigint event_id FK_UK bigint host_id FK json category_ids int predicted_count tinyint confidence_pct }

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_id gives 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). For CUSTOM_PIN the venue_id is NULL and the inline latitude/longitude/location_name columns 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_id FK on events additionally records the single “primary” chip for fast filtering and display.
  • events → host_offers (1:0..1, ON DELETE CASCADE): the UNIQUE event_id enforces 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_id represents a preview computed before the draft exists.
SEEKER vs BRINGER at the data layer. A SEEKER event reaches 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_releases table (tier_id FK, 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_map junction 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_map junction 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 fork. Screen 52 sets 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

MethodPathPurposeAuth
POST/api/v1/eventsCreate DRAFT event (step 1, screen 52 fork)HOST
GET/api/v1/events/:idFull event detail (categories, venue, offer, tiers)JWT
PATCH/api/v1/events/:idUpdate details (step 2 / general edit)HOST + owner
POST/api/v1/events/:id/publishValidate & publish eventHOST + owner + verified
PATCH/api/v1/events/:id/statusStatus transition (cancel / complete / ...)HOST + owner
DELETE/api/v1/events/:idSoft delete eventHOST + owner
GET/api/v1/eventsPublic list: pagination, search, filter, sortPUBLIC
GET/api/v1/events/mineHost's own events (any status)HOST
PATCH/api/v1/events/bulk/statusBulk status updateHOST + owner
DELETE/api/v1/events/bulkBulk soft deleteHOST + owner
GET/api/v1/event-categoriesCategory catalogue (chips)PUBLIC
GET/api/v1/venuesNearby partner venues by distancePUBLIC
GET/api/v1/venues/:idVenue detailPUBLIC
POST/api/v1/events/predicted-attendancePreview prediction (no event yet)HOST
GET/api/v1/events/:id/predicted-attendancePrediction for a draftHOST + owner
PUT/api/v1/events/:id/offerUpsert host offerHOST + owner
GET/api/v1/events/:id/offerGet host offerJWT
DELETE/api/v1/events/:id/offerRemove host offerHOST + owner
GET/api/v1/events/:id/ticket-tiersList ticket tiersHOST + owner
POST/api/v1/events/:id/ticket-tiersCreate one tierHOST + owner
PUT/api/v1/events/:id/ticket-tiersBulk upsert/replace all tiers (screen 53b save)HOST + owner
PATCH/api/v1/ticket-tiers/:tierIdUpdate one tierHOST + owner
DELETE/api/v1/ticket-tiers/:tierIdRemove one tierHOST + owner
GET/api/v1/events/:id/ticket-releasesTiers with live release status (screen 54)HOST + owner
PATCH/api/v1/events/:id/release-settingsAuto-release toggle & thresholdHOST + owner
POST/api/v1/ticket-tiers/:tierId/openManually open a wave (ON_SALE)HOST + owner
POST/api/v1/ticket-tiers/:tierId/closeClose a waveHOST + owner
GET/api/v1/host/dashboardHost home stats (screen 55)HOST
GET/api/v1/events/:id/join-requestsCurated join requestsHOST + owner
POST/api/v1/join-requests/:id/approveApprove a curated requestHOST + owner
POST/api/v1/join-requests/:id/skipSkip a curated requestHOST + 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" }
  ]
}
CodeMeaning in this API
200OK — read, update, publish, status change.
201Created — new event / new tier.
204No Content — soft delete, offer/tier removal.
400Validation error (Joi) — malformed body / query.
401Missing or invalid JWT.
403Authenticated but not HOST, or not the owner of the record.
404Event / venue / tier / request not found (or soft-deleted).
409State conflict — publish-invalid, already-published, duplicate tier name, quantity_sold > quantity_total.
422Semantic rule violation (optional; e.g. offer description > 20 words).
429Rate limit exceeded (prediction preview, publish).
500Unhandled server error.

3.3 Wizard Lifecycle

The diagram below maps the canonical endpoints onto the screen narrative and shows the SEEKER/BRINGER fork.

sequenceDiagram actor Host participant API Host->>API: POST /events {creationIntent} (screen 52) API-->>Host: 201 DRAFT id, creationStep 1 Host->>API: PATCH /events/:id (title, category, venue, date, cap) (screen 53) API-->>Host: 200 updated, creationStep 2 alt SEEKER (free, curated) Host->>API: PUT /events/:id/offer (host offer) Host->>API: GET /events/:id/predicted-attendance Host->>API: POST /events/:id/publish API-->>Host: 200 PUBLISHED (isTicketed false) else BRINGER (ticketed) Host->>API: PUT /events/:id/ticket-tiers (screen 53b) Host->>API: PATCH /events/:id/release-settings (screen 54) Host->>API: POST /events/:id/publish API-->>Host: 200 PUBLISHED (isTicketed true) end
stateDiagram-v2 [*] --> DRAFT: POST /events DRAFT --> PUBLISHED: POST /events/:id/publish PUBLISHED --> ONGOING: PATCH /events/:id/status ONGOING --> COMPLETED: PATCH /events/:id/status PUBLISHED --> CANCELLED: PATCH /events/:id/status DRAFT --> CANCELLED: PATCH /events/:id/status DRAFT --> [*]: DELETE /events/:id (soft)

3.4 Events / Wizard

POST/api/v1/events

Purpose

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

FieldTypeRequiredRules
creationIntentstring (enum)requiredOne of BRINGER, SEEKER. Defaults to SEEKER in the UI but must be sent explicitly.
businessProfileIdintegeroptionalFK → 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

  • 400creationIntent missing or not in the enum.
  • 401 — missing/invalid JWT.
  • 403 — caller is not a HOST, or businessProfileId not owned by caller.
GET/api/v1/events/:id

Purpose

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

ParamTypeRules
idintegerBIGINT event id; must exist and not be soft-deleted.

Query Parameters

ParamTypeRequiredValidation
includestring (csv)optionalSubset 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.
PATCH/api/v1/events/:id

Purpose

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

ParamTypeRules
idintegerEvent id owned by caller.

Query Parameters

None.

Request Body

FieldTypeRequiredRules
titlestringoptional1–120 chars. e.g. "Sunday Founders Coffee".
descriptionstringoptionalMax 500 chars.
primaryCategoryIdintegeroptionalFK → event_categories.id. The "selected" chip.
categoryIdsinteger[]optionalReplaces event_category_map. Each must reference an active category.
locationTypestring (enum)optionalVENUE or CUSTOM_PIN.
venueIdintegerconditionalrequired when locationType = VENUE. FK → venues.id.
locationNamestringoptionalReverse-geocoded label e.g. "Lower East Side, NY". Max 160.
addressstringoptionalMax 255.
latitudedecimalconditionalrequired when locationType = CUSTOM_PIN. Range -90..90, 7 dp.
longitudedecimalconditionalRequired with latitude. Range -180..180, 7 dp.
startAtdatetime (ISO 8601)optionalMust be in the future at publish. Maps "Sun, Jul 6" + "9:00 AM".
endAtdatetimeoptionalMust be > startAt when present.
timezonestringoptionalIANA tz; default UTC.
capacityintegeroptional>= 0 (DB CHECK). UI "Cap" e.g. 12.
womenOnlybooleanoptionalDefault false.
visibilitystring (enum)optionalPUBLIC | PRIVATE | INVITE_ONLY.
joinTypestring (enum)optionalREQUEST | OPEN.
coverImagestringoptionalStored 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, missing venueId for VENUE type).
  • 401 / 403 — auth / not owner.
  • 404 — event or referenced venue/category not found.
  • 409 — event is in a non-editable status.
POST/api/v1/events/:id/publish

Purpose

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

ParamTypeRules
idintegerDraft 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_ticketed must be false; no ticket tiers may exist.
  • BRINGER: at least one ticket_tier; every tier passes its own checks (quantity_total > 0, quantity_sold <= quantity_total, valid release_type); exactly the first wave is AVAILABLE_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-level errors[]).
  • 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.
PATCH/api/v1/events/:id/status

Purpose

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

ParamTypeRules
idintegerEvent owned by caller.

Query Parameters

None.

Request Body

FieldTypeRequiredRules
statusstring (enum)requiredTarget in PUBLISHED,ONGOING,COMPLETED,CANCELLED; transition must be legal.
reasonstringoptionalFree-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).
DELETE/api/v1/events/:id

Purpose

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

ParamTypeRules
idintegerEvent 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 an ONGOING event (cancel first).
GET/api/v1/events

Purpose

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

ParamTypeRequiredValidation
pageintegeroptional>= 1, default 1.
limitintegeroptional1–100, default 20.
qstringoptionalSearch over title/description/location_name. Max 120.
statusstring (enum)optionalFilter by event status.
intentstring (enum)optionalBRINGER | SEEKER.
categoryIdintegeroptionalJoins event_category_map.
womenOnlybooleanoptionalFilter the women-only flag.
isTicketedbooleanoptionalBRINGER vs SEEKER shorthand.
dateFromdateoptionalLower bound on start_at.
dateTodateoptionalUpper bound; must be >= dateFrom.
nearstring "lat,lng"optionalGeo center; pairs with radiusKm.
radiusKmdecimaloptional0.1–100, default 3.0 when near given.
sortBystring (enum)optionalstartAt | createdAt | popularity. Default startAt.
orderstring (enum)optionalasc | 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.
GET/api/v1/events/mine

Purpose

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

PATCH/api/v1/events/bulk/status

Purpose

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

FieldTypeRequiredRules
eventIdsinteger[]required1–100 unique ids, all owned by caller.
statusstring (enum)requiredTarget status; transition legal for each.
partialbooleanoptionalIf 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, unless partial).
DELETE/api/v1/events/bulk

Purpose

Soft-deletes multiple owned events at once.

Authentication

Required. requireHost + ownership of every id.

Request Body

FieldTypeRequiredRules
eventIdsinteger[]required1–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 is ONGOING.

3.5 Categories & Venues

GET/api/v1/event-categories

Purpose

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

ParamTypeRequiredValidation
activeOnlybooleanoptionalDefault 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.
GET/api/v1/venues

Purpose

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

ParamTypeRequiredValidation
latdecimalrequired-90..90.
lngdecimalrequired-180..180.
radiusKmdecimaloptional0.1–50, default 3.0.
qstringoptionalName search, max 160.
typestring (enum)optionalOne of venue_type values (CLUB,RESTAURANT,HOTEL,RESORT,CAFE,BAR,ROOFTOP,OTHER).
limitintegeroptional1–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/invalid lat/lng or bad type.
GET/api/v1/venues/:id

Purpose

Returns full detail for a single venue (name, type, description, address, coordinates, partner flag).

Authentication

PUBLIC.

Path Parameters

ParamTypeRules
idintegerVenue 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

The predicted-attendance card on screen 53 ("~9 likely to join within 3 km", 72% confidence) is powered by these two endpoints. The 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.
POST/api/v1/events/predicted-attendance

Purpose

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

FieldTypeRequiredRules
latitudedecimalrequired-90..90.
longitudedecimalrequired-180..180.
categoryIdsinteger[]required1+ active category ids.
startAtdatetimerequiredISO 8601, future.
radiusKmdecimaloptional0.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.
GET/api/v1/events/:id/predicted-attendance

Purpose

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

ParamTypeRules
idintegerDraft event owned by caller.

Query Parameters

ParamTypeRequiredValidation
refreshbooleanoptionalIf 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.

PUT/api/v1/events/:id/offer

Purpose

Upserts the host offer for an event (creates if absent, replaces if present).

Authentication

Required. requireHost + ownership.

Path Parameters

ParamTypeRules
idintegerEvent owned by caller.

Query Parameters

None.

Request Body

FieldTypeRequiredRules
titlestringrequired1–120 chars. e.g. "Free coffee for everyone".
descriptionstringoptionalMax 160 chars AND <= 20 words.
isActivebooleanoptionalDefault 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.
GET/api/v1/events/:id/offer

Purpose

Returns the event's host offer (for the preview pill / detail view).

Authentication

Required (JWT).

Path Parameters

ParamTypeRules
idintegerEvent 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.
DELETE/api/v1/events/:id/offer

Purpose

Removes the host offer from an event.

Authentication

Required. requireHost + ownership.

Path Parameters

ParamTypeRules
idintegerEvent 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

Ticket-tier endpoints are valid only for 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".
GET/api/v1/events/:id/ticket-tiers

Purpose

Lists all tiers for an event ordered by sort_order — the data behind screen 53b's tier cards.

Authentication

Required. requireHost + ownership.

Path Parameters

ParamTypeRules
idintegerBRINGER 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).
POST/api/v1/events/:id/ticket-tiers

Purpose

Creates a single tier (the "+ Add another tier" action). sort_order defaults to the next slot.

Authentication

Required. requireHost + ownership.

Path Parameters

ParamTypeRules
idintegerBRINGER event owned by caller.

Request Body

FieldTypeRequiredRules
namestringrequired1–80 chars; unique per event. e.g. "Early Bird".
priceCentsintegerrequired>= 0. UI "$12" → 1200.
currencystringoptionalISO 4217, default USD.
quantityTotalintegerrequired> 0. UI "Qty 20".
sortOrderintegeroptionalRelease order; default appended.
releaseTypestring (enum)optionalAVAILABLE_NOW | AFTER_PREV_TIER_THRESHOLD | SCHEDULED | DAY_OF. Default AVAILABLE_NOW.
releaseThresholdPctintegerconditional1–100, default 90; used when AFTER_PREV_TIER_THRESHOLD ("When prev. tier hits 90%").
scheduledReleaseAtdatetimeconditionalrequired when releaseType = SCHEDULED.
isAllInbooleanoptionalDefault 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.
PUT/api/v1/events/:id/ticket-tiers

Purpose

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

ParamTypeRules
idintegerBRINGER event owned by caller.

Request Body

FieldTypeRequiredRules
tiersobject[]required1–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 with quantity_sold > 0, or event is SEEKER.
PATCH/api/v1/ticket-tiers/:tierId

Purpose

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

ParamTypeRules
tierIdintegerTier 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, or quantityTotal < quantitySold.
DELETE/api/v1/ticket-tiers/:tierId

Purpose

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

ParamTypeRules
tierIdintegerTier whose event is owned by caller.

Query / Body

None.

Success Response

204 No Content.

Error Responses

  • 401 / 403 / 404 — auth / not owner / not found.
  • 409quantity_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.

GET/api/v1/events/:id/ticket-releases

Purpose

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

ParamTypeRules
idintegerBRINGER 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).
PATCH/api/v1/events/:id/release-settings

Purpose

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

ParamTypeRules
idintegerBRINGER event owned by caller.

Request Body

FieldTypeRequiredRules
autoReleaseEnabledbooleanrequiredToggle state.
autoReleaseThresholdPctintegeroptional1–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.
POST/api/v1/ticket-tiers/:tierId/open

Purpose

Manually opens a wave: transitions a tier to ON_SALE (overrides scheduled/threshold gating).

Authentication

Required. requireHost + ownership of parent event.

Path Parameters

ParamTypeRules
tierIdintegerTier 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 already SOLD_OUT/CLOSED, or event not published.
POST/api/v1/ticket-tiers/:tierId/close

Purpose

Closes a wave: transitions a tier to CLOSED, halting further sales.

Authentication

Required. requireHost + ownership of parent event.

Path Parameters

ParamTypeRules
tierIdintegerTier 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 already CLOSED.

3.10 Host & Curated Requests adjacent

These endpoints belong to screen 55 (Host home) and are included for completeness only; they are downstream of the creation flow documented above.
GET/api/v1/host/dashboard

Purpose

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.
GET/api/v1/events/:id/join-requests

Purpose

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

ParamTypeRules
idintegerEvent owned by caller.

Query Parameters

ParamTypeRequiredValidation
statusstring (enum)optionalFilter by PENDING,APPROVED,SKIPPED,REJECTED. Default PENDING.
pageintegeroptional>= 1.
limitintegeroptional1–100, default 20.

Request Body

None.

Success Response

200 OK with data[] + meta.

Error Responses

  • 401 / 403 / 404 — auth / not owner / not found.
POST/api/v1/join-requests/:id/approve

Purpose

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

ParamTypeRules
idintegerJoin-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 not PENDING.
POST/api/v1/join-requests/:id/skip

Purpose

Skips a curated request: sets status = SKIPPED (no capacity change).

Authentication

Required. requireHost + ownership of the parent event.

Path Parameters

ParamTypeRules
idintegerJoin-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>.

The narrative below follows one host through both forks: a SEEKER draft (52 → 53 → Publish) for examples 1–4 and 8, and a BRINGER ticketed draft (52 → 53 → 53b → 54) for examples 5–7. Examples 10–13 show the standard error bodies.

4.1 — Create draft (Screen 52, the fork)

POST/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"
  }
}
A 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)

PATCH/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")

PUT/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)

POST/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)

PUT/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)

PATCH/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)

GET/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)

POST/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

GET/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)

PATCH/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)

POST/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)

PATCH/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)

POST/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.

Layering at a glance: Route → Middleware (authenticate → requireHost → validate) → Controller (thin, asyncHandler) → Service (business rules, intent fork, transactions) → Repository (Sequelize queries) → Model. Errors bubble to a single 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, sets req.user = { id, role }, else 401.
  • requireHost — 403 unless req.user.role === 'HOST'.
  • requireVerified — 403 unless the host is_verified (used on publish).
  • validate(schema) — runs Joi, collects all errors[], 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

sequenceDiagram participant C as Mobile (screen 52-54) participant R as events.routes.js participant MW as authenticate / requireHost / validate participant Ctl as events.controller.js participant Svc as events.service.js participant Repo as events.repository.js participant DB as MySQL (Sequelize) C->>R: POST /api/v1/events { creationIntent } R->>MW: run middleware chain MW-->>R: req.user set, body valid R->>Ctl: createEvent(req,res) Ctl->>Svc: createDraftEvent(userId, dto) Svc->>Repo: createDraft(payload, tx) Repo->>DB: INSERT events (status=DRAFT, step=1) DB-->>Repo: row Repo-->>Svc: event Svc-->>Ctl: event Ctl-->>C: 201 { success:true, data }

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 };
Consistency note: Models use snake_case columns and JSON payloads use camelCase. The intent fork (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.

Validation is layered: (1) Joi schemas reject malformed input early (400); (2) service-layer business rules enforce intent-specific and cross-field invariants (typically 409/422); (3) MySQL 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).

FieldRuleWhen enforcedOn failure
creationIntentRequired on create; one of BRINGER, SEEKER; immutable once any ticket_tiers exist for the event.Create / pre-publish400 / 409
titleRequired (non-empty after trim); length 1–120 chars.Publish (draftable empty)400
descriptionOptional; max 500 chars.Always400
categoryIdsAt 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).Publish400 / 404
locationTypeOne of VENUE, CUSTOM_PIN. If VENUEvenueId required and must reference an active venue. If CUSTOM_PINlatitude AND longitude both required.Publish400
venueIdRequired iff locationType=VENUE; FK must resolve to venues.is_active=true. Mutually exclusive with custom pin fields.Publish400 / 404
latitude / longitudeRequired together iff locationType=CUSTOM_PIN. latitude ∈ [-90, 90], longitude ∈ [-180, 180], DECIMAL(10,7). Server computes geohash (len ≤ 12).Publish400
startAtRequired; valid ISO-8601 datetime; must be strictly in the future relative to server now (startAt > NOW()).Publish400
endAtOptional; if present must satisfy endAt > startAt.Always400
timezoneOptional; valid IANA tz string; default UTC.Always400
capacityInteger; ≥ 0 at draft, ≥ 1 at publish. For BRINGER, must be ≥ SUM(quantity_total) of tiers. Never below current_guests.Publish / edit400 / 409
womenOnlyBoolean; default false. When true, safety defaults force visibility away from PUBLIC (see Edge Cases).Always400
visibilityOne of PUBLIC, PRIVATE, INVITE_ONLY; default derived from intent + womenOnly safety policy.Always400
joinTypeOne of REQUEST, OPEN; SEEKER defaults REQUEST (curated list).Always400
Host offer titleRequired when offer present; 1–120 chars.Offer upsert400
Host offer descriptionOptional; max 160 chars AND max 20 words (word count enforced in app layer, regex split on whitespace).Offer upsert400
Tier nameRequired; 1–80 chars; unique per event (UNIQUE(event_id,name)).Tier save (53b)400 / 409
Tier priceCentsInteger minor units; ≥ 0 (CHECK price_cents >= 0). All-in price (is_all_in=true) — no checkout fees.Tier save400
Tier quantityTotalInteger; ≥ 1.Tier save400
Tier quantitySoldSystem-managed; ≤ quantity_total (CHECK quantity_sold <= quantity_total). Cannot be reduced by edits.Always409
Tier releaseTypeOne of AVAILABLE_NOW, AFTER_PREV_TIER_THRESHOLD, SCHEDULED, DAY_OF. Drives required companion fields below.Tier save400
Tier releaseThresholdPctRequired (1–100) when releaseType=AFTER_PREV_TIER_THRESHOLD; default 90. Ignored otherwise.Tier save400
Tier scheduledReleaseAtRequired future datetime when releaseType=SCHEDULED; null otherwise.Tier save400
Tier sortOrderInteger; defines release-wave ordering; AFTER_PREV_TIER_THRESHOLD tiers require a predecessor (sortOrder > 0).Tier save400 / 409
autoReleaseEnabledBoolean; default true (screen 54 toggle).Release settings400
autoReleaseThresholdPctTINYINT 1–100; default 90 (“hits 90% sold”).Release settings400

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 / ScreenIntentRequiredOptional
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.

stateDiagram-v2 [*] --> DRAFT : POST /events (intent) DRAFT --> DRAFT : PATCH details / save tiers / releases DRAFT --> PUBLISHED : POST /publish (valid + verified host) DRAFT --> CANCELLED : DELETE (soft) / status CANCEL PUBLISHED --> ONGOING : start_at reached PUBLISHED --> CANCELLED : status CANCEL ONGOING --> COMPLETED : end_at reached / status COMPLETE ONGOING --> CANCELLED : status CANCEL COMPLETED --> [*] CANCELLED --> [*] note right of PUBLISHED sets published_at + qr_code_token no further detail edits to ticketed core end note

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.

stateDiagram-v2 [*] --> SCHEDULED : tier created (release pending) SCHEDULED --> ON_SALE : AVAILABLE_NOW at publish SCHEDULED --> ON_SALE : prev tier hits 90% (auto-release) SCHEDULED --> ON_SALE : scheduled_release_at reached SCHEDULED --> ON_SALE : day-of trigger / manual open ON_SALE --> SOLD_OUT : quantity_sold == quantity_total ON_SALE --> CLOSED : manual close / event cancelled SOLD_OUT --> CLOSED : event ends SOLD_OUT --> ON_SALE : capacity increased (re-open) CLOSED --> [*] SOLD_OUT --> [*] note right of ON_SALE at 90% sold + auto_release_enabled cascade opens next tier by sort_order end note
Cascade ordering: the 90% auto-release evaluates tiers strictly in ascending 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).

ActionGuestHost (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
Non-owner mutation attempts return 403 (not 404) when the event exists, to keep error semantics clear for legitimate hosts. Anonymous mutation attempts return 401.

6.5 Edge Cases

  • Publishing a BRINGER event with no tiers. publish blocks with 409 (publish-invalid): a ticketed event must have ≥1 ticket_tiers row and at least one tier that becomes purchasable (AVAILABLE_NOW or a valid scheduled/threshold chain).
  • Switching intent after tiers exist. creation_intent is immutable once any ticket_tiers row exists. Changing BRINGER→SEEKER is rejected 409 until all tiers are deleted; switching also resets is_ticketed=false and clears release settings.
  • Custom pin without geocode. CUSTOM_PIN with missing latitude/longitude → 400. The reverse-geocoded location_name/address are best-effort; if geocoding fails the pin still publishes (coords are source of truth, label optional) but geohash is always computed server-side.
  • Predicted attendance for venue events. Prediction (predicted_attendance_*) is a SEEKER-oriented signal computed from nearby category interest within radius_km. For VENUE events 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=true forces the safety policy to demote visibility from PUBLIC to at least PRIVATE and biases join_type=REQUEST (curated). Attempting women_only=true + visibility=PUBLIC is silently corrected to PRIVATE with a warning in the response meta, not a hard 400.
  • Capacity smaller than tickets sold. Editing capacity below SUM(quantity_sold) (or below current_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 with quantity_sold > 0 is blocked 409; the host must CANCEL first (which triggers refund/notification flows out of scope here), after which soft-delete sets deleted_at.
  • 90% threshold cascade ordering. Concurrent purchases crossing 90% are reconciled in a single transaction keyed on sort_order; only the immediate next SCHEDULED tier opens. Idempotent: re-evaluation never opens an already ON_SALE/CLOSED tier and never skips waves.
  • Duplicate tier name. Two tiers with the same name within one event → 409 (UNIQUE(event_id,name)), surfaced during the bulk PUT /ticket-tiers save.
  • startAt drifting into the past. A long-lived draft whose start_at has 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/status silently filters to events owned by the caller; ids not owned are reported in meta.skipped[] rather than failing the whole batch.

6.6 Error Scenarios

ScenarioHTTPmessage (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 venueId400“Location coordinates are required for a custom pin”
startAt not in the future at publish400“Start time must be in the future”
Host offer description exceeds 20 words400“Offer description must be 20 words or fewer”
Missing / malformed JWT401“Authentication required”
Expired access token401“Token expired”
GUEST attempts to create/edit an event403“Host role required”
Host edits an event they do not own403“You are not the owner of this event”
Event / venue / category / tier id not found404“Event not found”
Referenced categoryIds include unknown/inactive id404“One or more categories do not exist”
Publish a BRINGER event with no tiers409“Ticketed event requires at least one ticket tier” (publish-invalid)
Change creationIntent after tiers exist409“Cannot change intent after tiers are created”
Duplicate tier name within event409“A tier with this name already exists”
Publish an already-published event409“Event is already published”
Capacity below committed seats / quantity_sold > quantity_total409“Capacity cannot be below tickets already sold”
Soft-delete published event with sold tickets409“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 error500“Internal server error”
Every response conforms to the envelope contract: success → { 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) → ControllerServiceRepositoryMySQL — 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.

Layer contract. 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.

sequenceDiagram autonumber participant FE as Frontend participant Auth as AuthMiddleware participant Val as Validator participant Ctl as Controller participant Svc as Service participant Repo as Repository participant DB as MySQL Note over FE,DB: Screen 52 - create DRAFT (Step 1) FE->>Auth: POST /api/v1/events body creationIntent SEEKER Auth->>Auth: verify JWT, set req.user id role Auth->>Val: pass, req.user attached Val->>Ctl: Joi createEvent OK Ctl->>Svc: createDraft(hostId, SEEKER) Svc->>Repo: insert event status DRAFT, creation_step 1 Repo->>DB: INSERT INTO events (...) DB-->>Repo: new event id 1001 Repo-->>Svc: event row Svc-->>Ctl: draft event Ctl-->>FE: 201 success data event Note over FE,DB: Screen 53 - save details (Step 2) FE->>Auth: PATCH /api/v1/events/1001 title, categories, venue, date, cap Auth->>Val: JWT OK Val->>Ctl: Joi updateEvent OK Ctl->>Svc: updateDetails(1001, hostId, fields) Svc->>Repo: assert owner, update event + category map Repo->>DB: UPDATE events SET ...; REPLACE event_category_map DB-->>Repo: affected rows Repo-->>Svc: updated event Svc-->>Ctl: event Ctl-->>FE: 200 success data event Note over FE,DB: Screen 53 - upsert host offer FE->>Auth: PUT /api/v1/events/1001/offer title, description Auth->>Val: JWT OK Val->>Ctl: Joi offer OK (desc max 20 words) Ctl->>Svc: upsertOffer(1001, hostId, offer) Svc->>Repo: insert or update host_offers by event_id Repo->>DB: INSERT ... ON DUPLICATE KEY UPDATE host_offers DB-->>Repo: offer row Repo-->>Svc: offer Svc-->>Ctl: offer Ctl-->>FE: 200 success data offer Note over FE,DB: Publish experience FE->>Auth: POST /api/v1/events/1001/publish Auth->>Val: JWT OK Val->>Ctl: no body schema Ctl->>Svc: publish(1001, hostId) Svc->>Svc: requireVerified host, validate title/venue/date/cap Svc->>Repo: set status PUBLISHED, published_at now, qr_code_token Repo->>DB: UPDATE events SET status PUBLISHED, published_at, qr_code_token DB-->>Repo: ok Repo-->>Svc: published event Svc-->>Ctl: event Ctl-->>FE: 200 success data event

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.

sequenceDiagram autonumber participant FE as Frontend participant Auth as AuthMiddleware participant Val as Validator participant Ctl as Controller participant Svc as Service participant Repo as Repository participant DB as MySQL Note over FE,DB: Screen 53b - bulk upsert tiers FE->>Auth: PUT /api/v1/events/2002/ticket-tiers tiers array Auth->>Val: JWT OK Val->>Ctl: Joi tiers (name, priceCents, qty, releaseType) Ctl->>Svc: replaceTiers(2002, hostId, tiers) Svc->>Svc: assert owner, assert event creationIntent BRINGER Svc->>Repo: begin tx, soft-delete removed, upsert each tier Repo->>DB: BEGIN; UPDATE/INSERT ticket_tiers (sort_order = release order) DB-->>Repo: tier rows Repo->>DB: COMMIT Repo-->>Svc: saved tiers Svc-->>Ctl: tiers Ctl-->>FE: 200 success data tiers Note over FE,DB: Screen 54 - save release settings FE->>Auth: PATCH /api/v1/events/2002/release-settings autoReleaseEnabled, thresholdPct Auth->>Val: JWT OK Val->>Ctl: Joi releaseSettings OK Ctl->>Svc: saveReleaseSettings(2002, hostId, settings) Svc->>Repo: update event auto_release flags Repo->>DB: UPDATE events SET auto_release_enabled, auto_release_threshold_pct DB-->>Repo: ok Repo-->>Svc: event Svc-->>Ctl: event Ctl-->>FE: 200 success data event Note over FE,DB: Publish ticketed experience FE->>Auth: POST /api/v1/events/2002/publish Auth->>Val: JWT OK Val->>Ctl: no body schema Ctl->>Svc: publish(2002, hostId) Svc->>Svc: requireVerified, assert at least one tier, set is_ticketed true Svc->>Repo: set status PUBLISHED, mark first wave ON_SALE Repo->>DB: UPDATE events ...; UPDATE ticket_tiers SET status ON_SALE WHERE sort_order 0 DB-->>Repo: ok Repo-->>Svc: published event Svc-->>Ctl: event Ctl-->>FE: 200 success data event

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.

sequenceDiagram autonumber participant FE as Frontend participant Auth as AuthMiddleware participant Val as Validator participant Ctl as Controller participant Svc as Service participant Repo as Repository participant DB as MySQL FE->>Auth: POST /api/v1/events/predicted-attendance latitude, longitude, categoryIds, startAt, radiusKm Auth->>Auth: verify JWT Auth->>Val: pass Val->>Ctl: Joi prediction OK (radiusKm default 3.00) Ctl->>Svc: predict(coords, categoryIds, startAt, radiusKm) Svc->>Repo: query nearby interested users by category and radius Repo->>DB: SELECT count over users joined interests within haversine radius DB-->>Repo: nearby interest sample Repo-->>Svc: raw counts Svc->>Svc: compute predictedCount, confidencePct from density and recency opt cache enabled Svc->>Repo: upsert attendance_predictions Repo->>DB: INSERT ... ON DUPLICATE KEY UPDATE attendance_predictions DB-->>Repo: cached end Svc-->>Ctl: predictedCount, radiusKm, confidencePct Ctl-->>FE: 200 success data prediction

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.

sequenceDiagram autonumber participant FE as Frontend participant Ctl as Controller participant Svc as Service participant Repo as Repository participant DB as MySQL Note over FE,DB: A sale completes for the current wave FE->>Ctl: sale completed for tier (event 2002, tier General) Ctl->>Svc: onSaleCompleted(eventId, tierId, qty) Svc->>Repo: begin tx, increment quantity_sold for tier Repo->>DB: BEGIN; UPDATE ticket_tiers SET quantity_sold = quantity_sold + qty DB-->>Repo: updated tier Svc->>Repo: load event auto_release flags + current tier totals Repo->>DB: SELECT auto_release_enabled, threshold, quantity_sold, quantity_total DB-->>Repo: tier and event state Svc->>Svc: pct = quantity_sold * 100 / quantity_total alt auto_release_enabled AND pct gte threshold (90) Svc->>Repo: mark current wave SOLD_OUT or CLOSED, open next by sort_order Repo->>DB: UPDATE ticket_tiers SET status SOLD_OUT WHERE id current Repo->>DB: UPDATE ticket_tiers SET status ON_SALE WHERE next sort_order DB-->>Repo: ok Repo->>DB: COMMIT Repo-->>Svc: next wave opened Svc-->>Ctl: released next wave else below threshold Repo->>DB: COMMIT Svc-->>Ctl: no release, current wave still ON_SALE end Ctl-->>FE: updated release state (screen 54 refresh)

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.

sequenceDiagram autonumber participant FE as Frontend participant Auth as AuthMiddleware participant Val as Validator participant Ctl as Controller participant Svc as Service participant Err as ErrorMiddleware Note over FE,Err: 401 - missing or invalid JWT FE->>Auth: PATCH /api/v1/events/3003 no or bad Bearer token Auth->>Err: throw ApiError 401 invalid token Err-->>FE: 401 success false message Unauthorized Note over FE,Err: 400 - Joi validation failure FE->>Auth: PATCH /api/v1/events/3003 title empty, cap negative Auth->>Val: JWT OK Val->>Err: throw ApiError 400 with errors field list Err-->>FE: 400 success false errors title cap Note over FE,Err: 403 - not the owner FE->>Auth: PATCH /api/v1/events/3003 valid body, other host Auth->>Val: JWT OK Val->>Ctl: Joi OK Ctl->>Svc: updateDetails(3003, requesterId, fields) Svc->>Svc: load event, event.host_id not equal requesterId Svc->>Err: throw ApiError 403 not owner Err-->>FE: 403 success false message Forbidden Note over FE,Err: 409 - publish conflict FE->>Auth: POST /api/v1/events/3003/publish Auth->>Val: JWT OK Val->>Ctl: no body schema Ctl->>Svc: publish(3003, hostId) Svc->>Svc: host not verified OR no tiers OR already PUBLISHED Svc->>Err: throw ApiError 409 publish conflict Err-->>FE: 409 success false message state conflict
Ordering matters. Because 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)

  1. users pre-existing — root identity table. No FK dependencies; every *_user_id / host_id / owner_user_id FK resolves here, so it must exist first.
  2. business_profiles pre-existing — FK user_idusers.id. Requires users. Targeted later by venues.business_profile_id and events.business_profile_id.
  3. event_categories — no FK dependencies (standalone lookup table). Created early and seeded so that events.primary_category_id and event_category_map.category_id have valid rows to reference.
  4. venues — FKs owner_user_idusers.id and business_profile_idbusiness_profiles.id (NULL). Requires users + business_profiles. Must precede events, which references venue_id.
  5. events — FKs host_idusers.id, business_profile_idbusiness_profiles.id (NULL), primary_category_idevent_categories.id (NULL), and venue_idvenues.id (NULL). Requires all four prior tables. Central parent of the remaining five.
  6. event_category_map — junction; FKs event_idevents.id and category_idevent_categories.id (both ON DELETE CASCADE). Requires both events and event_categories.
  7. host_offers — FK event_idevents.id (ON DELETE CASCADE, UNIQUE / 1:1). Requires events. Screen 53 SEEKER gold pill.
  8. ticket_tiers — FK event_idevents.id (ON DELETE CASCADE, 1:N). Requires events. Screens 53b/54 BRINGER waves.
  9. event_join_requests — FKs event_idevents.id and user_idusers.id (both ON DELETE CASCADE). Requires events + users. Adjacent (screen 55 curated list).
  10. attendance_predictions optional — FKs event_idevents.id (UNIQUE, NULL) and host_idusers.id. Requires events + 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

flowchart TD users["users (pre-existing)"] bp["business_profiles (pre-existing)"] cats["event_categories"] venues["venues"] events["events (central)"] ecm["event_category_map"] offers["host_offers"] tiers["ticket_tiers"] jr["event_join_requests"] preds["attendance_predictions"] users --> bp users --> venues bp --> venues users --> events bp --> events cats --> events venues --> events events --> ecm cats --> ecm events --> offers events --> tiers events --> jr users --> jr events --> preds users --> preds

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:

  1. Un-seed event_categories (delete seeded rows).
  2. Drop attendance_predictions.
  3. Drop event_join_requests.
  4. Drop ticket_tiers.
  5. Drop host_offers.
  6. Drop event_category_map.
  7. Drop events.
  8. Drop venues.
  9. Drop event_categories.
  10. Drop business_profiles pre-existing (only on a full teardown).
  11. Drop users pre-existing (only on a full teardown).
CASCADE caution. Most child FKs use 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'],
// });
Net effect: 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.

These assumptions are the contract the backend implements until product confirms otherwise. Anything labeled out-of-scope is intentionally deferred and not implemented in this flow.

9.1 Flow & Branching

  1. 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. Therefore is_ticketed = true is set on the BRINGER branch only; SEEKER events keep is_ticketed = false and have no ticket_tiers rows. The canonical linear narrative 52 → 53 → 53b → 54 describes the full ticketed (BRINGER) setup.
  2. 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_step is advanced server-side (1→2→3→4) as the host saves each step so the client can resume a draft at the correct screen.
  3. A draft event row is created up-front. POST/api/v1/events is called from screen 52 with { creationIntent } and returns a status = DRAFT event. All subsequent screens PATCH / PUT onto that same event id; the flow is never held entirely client-side.

9.2 Categories

  1. Categories are multi-select with exactly one primary. The chips on screen 53 allow selecting multiple categories persisted via the event_category_map junction (many-to-many). The first/highlighted chip is also recorded on events.primary_category_id for 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.
  2. 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_categories is treated as admin-managed reference data exposed read-only via GET/api/v1/event-categories.

9.3 Host Offer (SEEKER)

  1. At most one host offer per event. The "+ Add offer" editor maps to a single host_offers row (1:1, enforced by UNIQUE(event_id)). The "live gold pill" is a client-side preview of that row; PUT/api/v1/events/:id/offer upserts it and DELETE removes it.
  2. 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 on description.
  3. 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

  1. 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.00 km (predicted_attendance_radius_km); the result yields predicted_attendance_count and predicted_attendance_confidence_pct on the event.
  2. 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.
  3. Predictions may be cached or computed on the fly. The optional attendance_predictions table caches a draft's last computation (UNIQUE(event_id)). POST /api/v1/events/predicted-attendance is a stateless preview (no persistence); GET /api/v1/events/:id/predicted-attendance may return cached or fresh values. The card is read-only and never blocks publish.

9.5 Tickets, Pricing & Release Waves (BRINGER)

  1. 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 by is_all_in = true; the price the guest sees equals price_cents. currency defaults to USD and a single currency per event is assumed (no per-tier mixing).
  2. 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_tiers rows. sort_order is the release order (ascending = earliest), so Early Bird (0) releases before General (1) before Door (2).
  3. Release timing maps the dropdown to release_type. "Available now" → AVAILABLE_NOW; "When prev. tier hits 90%" → AFTER_PREV_TIER_THRESHOLD with release_threshold_pct = 90; "opens day-of" → DAY_OF; a future date → SCHEDULED with scheduled_release_at.
  4. Auto-release threshold defaults to 90% and is configurable. The screen 54 toggle maps to auto_release_enabled and the percentage to auto_release_threshold_pct (default 90), saved via PATCH /api/v1/events/:id/release-settings. When enabled, the backend opens the next wave (next sort_order, status SCHEDULED→ON_SALE) once the current on-sale tier crosses the threshold.
  5. Tier quantities and live status are server-authoritative. "Sold out", "on sale · 26 left", and the progress bars are derived from quantity_sold vs quantity_total and status — not client-supplied. The CHECK quantity_sold <= quantity_total constraint guards oversell; a violating write returns 409.
  6. Saving tiers on 53b is a full replace. PUT/api/v1/events/:id/ticket-tiers bulk-upserts the whole set so "Remove" links and reordering reconcile in one save. Removing a tier that already has quantity_sold > 0 is rejected (409); empty tiers soft-delete via deleted_at. Tier names are unique per event (UNIQUE(event_id, name)) → duplicates return 409.

9.6 Safety, Visibility & Verification

  1. Intent and women_only drive safety defaults. Per the screen 52 note, intent sets initial visibility, join_type, and verification expectations. Enabling the screen 53 women-only switch (women_only = true) raises the bar — it tightens visibility (away from PUBLIC toward INVITE_ONLY) and favors REQUEST join with stricter ID-verification on join requests.
  2. Publishing requires a verified host. POST/api/v1/events/:id/publish is gated by requireHost + requireVerified and 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

  1. location_type resolves to VENUE or CUSTOM_PIN. Choosing a partner from the list sets location_type = VENUE and venue_id; "Select your own location" sets CUSTOM_PIN with a dropped/draggable pin populating latitude/longitude.
  2. 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 (and address/city when available). The geocoding provider is out-of-scope; a geohash is also computed for proximity queries.
  3. Selectable venues are partner + active + nearby only. GET/api/v1/venues returns venues with is_partner = true and is_active = true within 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

  1. Timestamps are stored in UTC with an explicit timezone. start_at/end_at are persisted as UTC DATETIME while the host's local zone is kept in timezone (default UTC), so "Sun, Jul 6 · 9:00 AM" renders correctly per locale. end_at is optional.
  2. Drafts autosave between wizard steps. Each step persists via PATCH/PUT so a host can leave and resume; the event remains DRAFT until publish. creation_step records progress for resume.
  3. The QR token is generated at publish, not at draft. qr_code_token (UNIQUE) and published_at are populated only by POST /api/v1/events/:id/publish, which transitions DRAFT→PUBLISHED. Re-publishing an already-published event returns 409.
  4. Events use soft deletes. DELETE/api/v1/events/:id sets deleted_at (Sequelize paranoid) and returns 204; rows are excluded from public lists but retained for audit and referential integrity of past attendance.

9.9 Auth & Access Control

  1. JWT access + refresh tokens; host role required to create. authenticate validates the bearer access token and sets req.user = { id, role }; a separate refresh token (longer-lived) is assumed for renewal. All creation/mutation endpoints require requireHost and ownership — a guest gets 403, a missing/invalid token gets 401.
  2. 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 a business_profile_id they own); cross-host access returns 403.
Net effect: a single 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.