API Architecture

Designed clean, independent of how the current WordPress/Listar stack shapes its endpoints or fields. The prior recon informed scope (what content types, what scale, what features are actually used) — not the design itself.

Shape: REST, resource-oriented

One API, versioned (/v1), serving the Next.js frontend, the admin portal, and both mobile apps. REST over GraphQL: this is a content-heavy, read-heavy system (public traffic dwarfs write traffic), and REST responses cache trivially at the CDN edge with standard Cache-Control/ETags. GraphQL's flexible-querying benefit matters most when clients have wildly different data shapes per view — not the case here, where mobile and web want essentially the same venue/event/rental/post objects.

GET    /v1/venues                 list + filter (site, region, category, near=lat,lng,radius_km, featured, q)
GET    /v1/venues/{id}
POST   /v1/venues                 [admin]
PATCH  /v1/venues/{id}             [admin]
DELETE /v1/venues/{id}             [admin]

GET    /v1/events                 list + filter (site, venue_id, category, date_from, date_to, featured, q)
GET    /v1/events/{id}
POST   /v1/events                 [admin]
PATCH  /v1/events/{id}             [admin]
DELETE /v1/events/{id}             [admin]

GET    /v1/rentals                list + filter (site, region, price range, near)
GET    /v1/rentals/{id}
POST   /v1/rentals                [admin]
...

GET    /v1/posts                  list + filter (site, category)
GET    /v1/posts/{slug}
POST   /v1/posts                  [admin]
...

GET    /v1/artists                list + filter (site, q)
GET    /v1/artists/{id}           includes upcoming events for that artist
POST   /v1/artists                [admin]
...

GET    /v1/deals                  list + filter (site, venue_id, category, active_now=true)
GET    /v1/deals/{id}
POST   /v1/deals                  [admin]
...

GET    /v1/search?q=...&site=...  hybrid keyword + semantic search, see below

POST   /v1/auth/register          public app users
POST   /v1/auth/login
GET    /v1/me
POST   /v1/me/favorites           { target_type, target_id }
DELETE /v1/me/favorites/{id}
POST   /v1/me/reviews             { target_type, target_id, rating, body }

POST   /v1/newsletter/subscribe   { email }  -- public, no auth required

POST   /v1/events/submit          public "Submit an Event" form; creates an
                                   events row with source='user_submitted',
                                   status='draft' — reviewed like any other
                                   ingested event, never published directly

POST   /v1/admin/ingestion/runs           { venue_id }  -- trigger AI ingestion for one venue
GET    /v1/admin/ingestion/runs/{id}
GET    /v1/admin/ingested-events?status=pending
POST   /v1/admin/ingested-events/{id}/approve
POST   /v1/admin/ingested-events/{id}/reject

active_now=true on /v1/deals filters by days_of_week/start_time/ end_time against the current time in the site's timezone — the one genuinely different query shape deals needs versus events, since a deal has no single timestamp to sort/filter by.

Every list endpoint takes site as a required filter (or is scoped by subdomain/host at the edge) since this is a multi-tenant system — one API, four brands' worth of data.

Auth

Two separate identities, both via Supabase Auth (JWT), never conflated:

Search

GET /v1/search is the one deliberately non-boring endpoint. Flow:

  1. Query hits an LLM to extract structured intent (dates, category, region, price, "kid-friendly", etc.) plus a semantic embedding of the free-text remainder.
  2. Structured filters run as normal SQL predicates against venues/events/ rentals.
  3. The embedding does a cosine-similarity search against search_documents (pgvector, already in the schema) for ranking/fuzzy matches structured filters miss.
  4. Results merge: structured matches first, semantic matches fill in, deduped by (target_type, target_id).

This is what makes "find a rooftop bar with live music this Friday" work, versus keyword search that only matches literal words.

Hosting

Decided: Cloudflare, end to end (already authenticated, one account instead of juggling Vercel + a separate scraping host, and both free tiers comfortably cover dev/test scale — see the pricing check in conversation).

Explicitly not carried over from the legacy stack