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:
admin_users— curators/editors/owners. JWT carries site + role claims (fromadmin_user_site_roles); every admin write is authorized against that claim, not against a hardcoded role check.app_users— public end users of the mobile apps / website (favorites, reviews, saved searches later). Standard email/password or OAuth via Supabase Auth; no relation to admin roles.
Search
GET /v1/search is the one deliberately non-boring endpoint. Flow:
- Query hits an LLM to extract structured intent (dates, category, region, price, "kid-friendly", etc.) plus a semantic embedding of the free-text remainder.
- Structured filters run as normal SQL predicates against
venues/events/rentals. - The embedding does a cosine-similarity search against
search_documents(pgvector, already in the schema) for ranking/fuzzy matches structured filters miss. - 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).
- API + Next.js frontend: Cloudflare Workers, via the official
@opennextjs/cloudflareadapter (confirmed current: full support for App Router, RSC, SSR, ISR, Server Actions, streaming, Middleware). Scaffolded withcreate-cloudflare@latest --framework=next, deployed withwrangler deploy. Image optimization runs through Cloudflare Images rather than Vercel's image service — the one real config difference to know about. - Database/Auth/Storage: Supabase (unchanged — Cloudflare's D1 is SQLite and doesn't fit the relational/Postgres design here; Supabase stays the right choice regardless of where compute runs).
- Ingestion workers: also Cloudflare now, not a separate Fly.io/Railway
host as originally planned. Browser Run (Cloudflare's Workers-native
headless browser product, renamed from "Browser Rendering") handles the
scraping/rendering step directly — its
/markdownand/jsonquick actions give clean pre-processed input for the LLM extraction step, its/crawlendpoint handles async multi-page discovery, and full Playwright/Puppeteer support covers the harder cases (hotel_vegas_scrapershowed Emo's needs real interaction — clicking through slideout panels and month navigation, not just a page fetch). Workflows orchestrates the multi-step pipeline (fetch → render → extract → validate → dedupe → stage), and Queues manages concurrency/scheduling — no separate always-on server needed at all.
Explicitly not carried over from the legacy stack
- No WP Job Manager-style field names (
job_listing,job_listing_region) — resources are named for what they are (venues,regions). - No 1:1 port of the Listar API's route shape (
place/list,event/save, etc.) — the mobile apps will need a client update to point at the new API regardless, so there's no compatibility constraint forcing us to keep it. - Booking (
author/booking) intentionally omitted — believed dormant/unused (see README recon notes); add back only if you tell me it's actually live.