Skip to content

Venue API โ€‹

The Venue API is a server-side Cloud Run service that handles all venue data operations: importing from OpenStreetMap (OSM), enriching addresses via Nominatim, and maintaining venue data quality. It replaced a previous client-side import flow to improve security, rate limiting, and deduplication.

Source: services/api/venues/Client helper: apps/web/src/lib/venueApiClient.jsShared config: packages/shared/venues/index.js

DevProd
Firebase projectlantern-app-devlantern-app-prod
Cloud Run regionus-central1us-central1
Service URLhttps://venue-api-531553779372.us-central1.run.appSet via VITE_VENUE_API_URL in prod env
RuntimeNode 22 / Express 5Node 22 / Express 5

Table of Contents โ€‹

  1. Architecture Overview
  2. Interactive API Docs
  3. Authentication
  4. Rate Limits
  5. Endpoints
  6. Error Responses
  7. Local Development
  8. Testing
  9. Deploying
  10. Verifying a Deployment
  11. Checking Logs
  12. Frontend Integration
  13. Architecture Deep Dive

Architecture Overview โ€‹

Browser / Admin Panel
        โ”‚  Firebase ID token (Bearer)
        โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   venue-api (Cloud Run) โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚ auth middleware  โ”‚   โ”‚ โ† verifies Firebase token
โ”‚  โ”‚ rbac middleware  โ”‚   โ”‚ โ† checks Firestore users.role
โ”‚  โ”‚ rateLimiter      โ”‚   โ”‚ โ† per-user sliding window
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚ import routes    โ”‚โ—„โ”€โ”€โ”‚โ”€โ”€ Overpass API (OSM)
โ”‚  โ”‚ refresh routes   โ”‚โ—„โ”€โ”€โ”‚โ”€โ”€ Nominatim (geocoding)
โ”‚  โ”‚ utility routes   โ”‚   โ”‚
โ”‚  โ”‚ admin routes     โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚ venue.service    โ”‚โ”€โ”€โ”€โ”‚โ”€โ”€โ–บ Firestore (venues collection)
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Why server-side? Previously, venue imports called Overpass and wrote to Firestore directly from the browser. Moving this server-side lets us:

  • Enforce rate limits against Overpass/Nominatim API policies
  • Prevent clients from writing arbitrary venue documents
  • Run deduplication reliably without client-side race conditions
  • Batch Firestore writes at the 500-doc limit

Interactive API Docs โ€‹

The Venue API exposes its OpenAPI 3.0 spec at /openapi.json. Interactive docs are rendered by the Lantern admin portal under API Reference โ†’ Venues (powered by @lantern/api-docs-renderer); the service itself does not mount a doc UI.

URLEnvironment
http://localhost:8080/openapi.jsonLocal development (raw spec)
https://venue-api-531553779372.us-central1.run.app/openapi.jsonDev (Cloud Run, raw spec)
Admin portal โ†’ API Reference โ†’ VenuesInteractive docs (any env)

To test authenticated endpoints in the admin portal:

  1. Sign in to the admin portal as an admin user
  2. Open API Reference โ†’ Venues
  3. The renderer auto-fills your current Firebase ID token; the Authorization header is sent on every request

The raw spec is also available at /openapi.json for import into Postman, Insomnia, or any OpenAPI-compatible tooling.

Source files:


Authentication โ€‹

All endpoints except GET /health require a Firebase ID token in the Authorization header:

Authorization: Bearer <firebase-id-token>

Getting a token (browser):

js
import { auth } from '../firebase'
const token = await auth.currentUser.getIdToken()

Getting a token (curl โ€” dev only):

bash
# Use the Firebase Auth REST API to exchange email/password for an ID token
curl -X POST \
  "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=<FIREBASE_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"yourpassword","returnSecureToken":true}'
# Copy the "idToken" field from the response

What the middleware does:

  1. Checks for Authorization: Bearer <token> header
  2. Calls firebase-admin.auth().verifyIdToken(token)
  3. Attaches req.user = { uid, email } on success
  4. Returns 401 UNAUTHORIZED if missing or invalid

Admin routes additionally read users/{uid}.role from Firestore and check it against the role hierarchy (user < merchant < admin) before continuing.


Rate Limits โ€‹

Per-User Limits (sliding window, per-minute) โ€‹

ActionLimitApplies To
import5 req / minPOST /venues/import/osm
refresh10 req / minPOST /venues/refresh/batch
enrich20 req / minPOST /venues/refresh/enrich/:id, POST /venues/geocode

External API Source Limits (per-second, server-side) โ€‹

SourceLimitReason
OSM Overpass5 req / secOverpass fair-use policy
Nominatim1 req / 1.1 secNominatim usage policy
Google Places10 req / sec(future)

When a per-user limit is hit, the API responds:

json
{
  "error": "RATE_LIMITED",
  "message": "Too many import requests. Please try again later.",
  "retryAfterMs": 42000
}

Endpoints โ€‹

Health Check โ€‹


GET /health โ€‹

No authentication required. Returns service status.

Response 200:

json
{
  "status": "ok",
  "service": "venue-api",
  "timestamp": "2026-02-10T12:00:00.000Z"
}

curl example:

bash
curl https://venue-api-531553779372.us-central1.run.app/health

Import โ€‹

All import routes require Authorization: Bearer <token> and are rate-limited to 5 requests per minute per user.


POST /venues/import/osm โ€‹

Import venues from OpenStreetMap's Overpass API for a geographic area. Deduplicates against existing venues by osmId. Updates geohash-based refresh metadata.

Request body:

FieldTypeRequiredDefaultConstraints
latnumberโœ“โ€”-90 to 90
lngnumberโœ“โ€”-180 to 180
radiusnumber5000100โ€“50000 (meters)
includeGoldbooleantrueBars, cafes, pubs, libraries
includeSilverbooleantrueGyms, arcades, dance halls, pools, etc.
includeBronzebooleantrueRestaurants, fast food, ice cream

Tier definitions (from @lantern/shared/venues):

  • Gold: bar, biergarten, cafe, pub, library
  • Silver: gyms, arcades, dance halls, sports centres, pools, saunas, etc.
  • Bronze: restaurants, fast food, ice cream shops
  • Coal (always excluded): parks, playgrounds, dog parks, schools, colleges

Response 200:

json
{
  "success": true,
  "imported": 42,
  "skipped": 8,
  "total": 50
}

skipped = venues already in Firestore (matched by osmId). total = all venues fetched from Overpass.

Response 400 (validation error):

json
{
  "error": "VALIDATION_ERROR",
  "message": "Invalid request data",
  "details": [{ "path": ["lat"], "message": "Number must be less than or equal to 90" }]
}

curl example:

bash
TOKEN="your-firebase-id-token"
curl -X POST https://venue-api-531553779372.us-central1.run.app/venues/import/osm \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"lat": 32.7649, "lng": -117.1246, "radius": 5000}'

POST /venues/import/hybrid โ€‹

Reserved for a future combined OSM + Google Places import. Currently returns 501 Not Implemented.

Response 501:

json
{
  "error": "NOT_IMPLEMENTED",
  "message": "Hybrid import is not yet available. Use /venues/import/osm."
}

Refresh / Enrichment โ€‹

All refresh routes require Authorization: Bearer <token>.


POST /venues/refresh/enrich/:venueId โ€‹

Enrich a single venue with a reverse-geocoded address from Nominatim. Skips if the venue already has addressComponents.

Rate limit: 20 req / min (enrich bucket).

Path param: venueId โ€” Firestore document ID

Response 200 (enriched):

json
{
  "success": true,
  "venueId": "abc123",
  "address": "123 Main St, San Diego, CA 92101, USA",
  "addressComponents": {
    "houseNumber": "123",
    "road": "Main St",
    "city": "San Diego",
    "state": "California",
    "postcode": "92101",
    "country": "United States",
    "countryCode": "us"
  }
}

Response 200 (skipped):

json
{
  "success": true,
  "skipped": true,
  "reason": "already_enriched",
  "venueId": "abc123"
}

Possible reason values: already_enriched, no_coordinates, nominatim_error.

Response 404:

json
{ "error": "NOT_FOUND", "message": "Venue not found" }

curl example:

bash
curl -X POST https://venue-api-531553779372.us-central1.run.app/venues/refresh/enrich/abc123 \
  -H "Authorization: Bearer $TOKEN"

POST /venues/refresh/batch โ€‹

Batch-enrich multiple venues. Processes sequentially (one per ~1.1 sec) to respect Nominatim's rate limit. Max 100 venues per request.

Rate limit: 10 req / min (refresh bucket).

Request body:

FieldTypeRequiredDefaultConstraints
venueIdsstring[]โœ“โ€”1โ€“100 items
priority"high" | "normal""normal"Future use

Response 200:

json
{
  "success": true,
  "enriched": 10,
  "skipped": 3,
  "errors": 1,
  "details": [
    { "venueId": "abc123", "status": "enriched", "address": "123 Main St..." },
    { "venueId": "def456", "status": "already_enriched" },
    { "venueId": "ghi789", "status": "enrichment_failed" }
  ]
}

Possible status values per venue: enriched, not_found, already_enriched, no_coordinates, enrichment_failed, error.

curl example:

bash
curl -X POST https://venue-api-531553779372.us-central1.run.app/venues/refresh/batch \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"venueIds": ["abc123", "def456", "ghi789"]}'

POST /venues/refresh/scheduled โ€‹

Automated enrichment endpoint intended for Cloud Scheduler. Finds venues missing addressComponents and enriches up to limit of them.

In production: requires the X-CloudScheduler-JobName header โ€” this is automatically set by Google Cloud Scheduler and cannot be spoofed. Omitting it returns 403 FORBIDDEN.

In development: the header check is skipped.

Query params:

ParamDefaultMax
limit5050

Response 200:

json
{
  "success": true,
  "enriched": 45,
  "errors": 2
}

Utility โ€‹

All utility routes require Authorization: Bearer <token>.


POST /venues/geocode โ€‹

Reverse geocode coordinates to a formatted address via Nominatim. Forward geocoding (address โ†’ lat/lng) is not yet implemented.

Rate limit: 20 req / min (enrich bucket).

Request body:

FieldTypeRequiredNotes
latnumberโœ“ (for reverse)-90 to 90
lngnumberโœ“ (for reverse)-180 to 180
addressstring(for forward)Not yet implemented

Either lat + lng or address must be provided.

Response 200 (reverse geocode):

json
{
  "success": true,
  "type": "reverse",
  "address": "123 Main St, San Diego, CA 92101, USA",
  "displayName": "Balboa Park, San Diego, San Diego County, California, United States"
}

Response 501 (forward geocoding attempt):

json
{
  "error": "NOT_IMPLEMENTED",
  "message": "Forward geocoding is not yet available. Provide lat and lng for reverse geocoding."
}

curl example:

bash
curl -X POST https://venue-api-531553779372.us-central1.run.app/venues/geocode \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"lat": 32.7649, "lng": -117.1246}'

GET /venues/:venueId/metadata โ€‹

Fetch a venue document with source provenance and data quality metrics.

Response 200:

json
{
  "venue": { "name": "The Pub", "lat": 32.76, "lng": -117.12, "category": "bar", "..." : "..." },
  "id": "abc123",
  "sources": ["osm"],
  "lastUpdated": "2026-01-15T08:00:00Z",
  "quality": {
    "hasAddress": true,
    "hasOpeningHours": false,
    "hasContact": true,
    "searchTermCount": 6
  }
}

Response 404:

json
{ "error": "NOT_FOUND", "message": "Venue not found" }

curl example:

bash
curl https://venue-api-531553779372.us-central1.run.app/venues/abc123/metadata \
  -H "Authorization: Bearer $TOKEN"

POST /venues/consolidate โ€‹

Find and merge duplicate venues that share the same osmId. Dry-run by default โ€” set dryRun: false to execute.

Request body:

FieldTypeDefault
dryRunbooleantrue

Response 200 (dry run):

json
{
  "success": true,
  "dryRun": true,
  "duplicatesFound": 3,
  "totalDuplicateDocuments": 5,
  "duplicates": [
    { "osmId": "12345", "count": 3, "ids": ["abc", "def", "ghi"] }
  ]
}

Response 200 (executed):

json
{
  "success": true,
  "dryRun": false,
  "duplicatesFound": 3,
  "deleted": 5
}

Admin โ€‹

All admin routes require Authorization: Bearer <token> and the authenticated user must have role: "admin" in Firestore.


GET /venues/admin/stats โ€‹

Venue collection statistics including duplicate count.

Response 200:

json
{
  "success": true,
  "total": 4821,
  "duplicateOsmIds": 2,
  "totalDuplicateDocuments": 3
}

curl example:

bash
curl https://venue-api-531553779372.us-central1.run.app/venues/admin/stats \
  -H "Authorization: Bearer $ADMIN_TOKEN"

POST /venues/admin/cleanup/orphaned โ€‹

Remove venues with zero active lanterns and no activity for a configurable number of days (default: 90). Dry-run by default.

Request body:

FieldTypeDefaultNotes
dryRunbooleantrueSet false to delete
daysInactivenumber90Days since creation with no lanterns

Response 200 (dry run):

json
{
  "success": true,
  "dryRun": true,
  "orphanedCount": 12,
  "orphaned": [
    { "id": "abc123", "name": "Closed Bar" }
  ]
}

Response 200 (executed):

json
{
  "success": true,
  "deleted": 12,
  "errors": []
}

POST /venues/admin/validate/all โ€‹

Run data quality checks across all venues. Can optionally auto-fix missing geohashes. Max 5000 venues per call.

Request body:

FieldTypeDefaultNotes
autoFixbooleanfalseRebuilds missing geohashes (max 500)
limitnumber1000Max venues to check (hard cap: 5000)

Response 200:

json
{
  "success": true,
  "total": 1000,
  "totalIssues": 7,
  "fixed": 3,
  "issues": {
    "missingName": 1,
    "missingCoordinates": 0,
    "missingGeohash": 3,
    "missingCategory": 2,
    "invalidCoordinates": 1
  }
}

POST /venues/admin/deduplicate โ€‹

Find and remove duplicate venues grouped by osmId. Keeps the oldest document, deletes newer duplicates. Dry-run by default.

Request body:

FieldTypeDefault
dryRunbooleantrue

Response 200 (dry run):

json
{
  "success": true,
  "dryRun": true,
  "duplicatesFound": 4,
  "totalToDelete": 7,
  "sample": [
    { "osmId": "12345", "count": 3, "ids": ["a", "b", "c"] }
  ]
}

Response 200 (executed):

json
{
  "success": true,
  "dryRun": false,
  "duplicatesFound": 4,
  "deleted": 7
}

Error Responses โ€‹

All errors follow a consistent shape:

json
{
  "error": "ERROR_CODE",
  "message": "Human-readable description",
  "details": []
}
HTTP StatusError CodeCause
400VALIDATION_ERRORZod schema failure โ€” details array has per-field errors
401UNAUTHORIZEDMissing, malformed, or expired Bearer token
403FORBIDDENValid token but insufficient role for this endpoint
403FORBIDDENPOST /venues/refresh/scheduled called without Scheduler header in prod
404NOT_FOUNDVenue document doesn't exist in Firestore
429RATE_LIMITEDPer-user rate limit exceeded โ€” retryAfterMs tells you when to retry
500INTERNAL_ERRORUnexpected server error โ€” check Cloud Run logs
501NOT_IMPLEMENTEDFeature exists in the API design but isn't implemented yet

Local Development โ€‹

Prerequisites โ€‹

  • Node 22+
  • Firebase project credentials (copy .env.local.example โ†’ .env.local)
  • FIREBASE_PROJECT_ID set in your environment

Running the service โ€‹

bash
# From the monorepo root:
npm run dev -w services/api/venues

# Or from within the service directory:
cd services/api/venues
npm run dev

The dev script uses node --env-file=../../../.env.local --watch, which:

  • Loads your .env.local credentials
  • Auto-restarts on file changes

The service listens on http://localhost:8080 by default.

Verifying it's running โ€‹

bash
curl http://localhost:8080/health
# {"status":"ok","service":"venue-api","timestamp":"..."}

Making authenticated calls locally โ€‹

Get a Firebase ID token for a test user. The easiest way is from the browser console on the dev app:

js
// In browser devtools on https://dev.ourlantern.app
const token = await firebase.auth().currentUser.getIdToken()
console.log(token)

Then use it in curl:

bash
TOKEN="<paste token here>"

# Import venues around San Diego downtown
curl -X POST http://localhost:8080/venues/import/osm \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"lat": 32.7157, "lng": -117.1611, "radius": 2000, "includeBronze": false}'

Environment variables โ€‹

The service reads these environment variables:

VariableRequiredDescription
FIREBASE_PROJECT_IDโœ“Firebase project (lantern-app-dev or lantern-app-prod)
NODE_ENVproduction enables stricter scheduler header check; defaults to development
PORTPort to listen on; defaults to 8080

In .env.local, set:

bash
FIREBASE_PROJECT_ID=lantern-app-dev

The frontend also needs:

bash
VITE_VENUE_API_URL=http://localhost:8080

Testing โ€‹

Tests live in services/api/venues/test/ and run with Vitest.

Running tests โ€‹

bash
# From monorepo root:
npm run validate -w services/api/venues

# Watch mode during development:
cd services/api/venues
npm test

# Single run:
npm run test:run

Test files โ€‹

FileWhat it tests
routes.integration.test.jsFull route pipeline: auth, RBAC, validation, response shapes
osm.service.test.jsbuildVenueFromOSM, lifecycle detection, category normalization
nominatim.service.test.jsReverse geocoding, address field extraction
rateLimiter.test.jsSliding window accuracy, per-user and per-source limits

How the integration tests work โ€‹

The integration tests mount Express route handlers directly (no network) with Firebase Admin mocked:

js
// Firebase Admin is mocked before imports:
vi.mock('firebase-admin', () => ({ ... }))

// A lightweight fetch-based request helper replaces supertest:
async function request(app, method, path, { body, headers }) {
  // Spins up the app on a random port, makes a fetch, shuts down
}

// Fixture tokens map to fixture UIDs:
const ADMIN_TOKEN = 'admin-test-token'  // โ†’ uid: 'admin-uid-123'
const USER_TOKEN  = 'user-test-token'   // โ†’ uid: 'user-uid-456'

Adding a test for a new endpoint โ€‹

js
describe('POST /venues/my-new-endpoint', () => {
  let app

  beforeEach(() => {
    app = createApp()
    setupAuth()              // registers token โ†’ UID mapping
    setupFirestoreUser(USER_UID, 'user')  // sets role in mock
  })

  it('returns 400 for missing required field', async () => {
    const res = await request(app, 'POST', '/venues/my-new-endpoint', {
      body: {},
      headers: { Authorization: `Bearer ${USER_TOKEN}` },
    })
    expect(res.status).toBe(400)
    expect(res.body.error).toBe('VALIDATION_ERROR')
  })
})

Deploying โ€‹

Never deploy manually. All deployments are triggered automatically by GitHub Actions when code merges to dev (โ†’ dev environment) or main (โ†’ prod).

How deployment works โ€‹

The deploy-dev.yml workflow includes a deploy-venue-api job that:

  1. Authenticates to GCP using the GCP_SA_KEY_DEV repository secret
  2. Runs gcloud run deploy venue-api --source services/api/venues ...
  3. Cloud Build builds the Docker image from services/api/venues/Dockerfile
  4. Cloud Run replaces the running revision with the new image
  5. Verifies the deployment by calling GET /health and checking for HTTP 200

Dockerfile โ€‹

The service uses a minimal multi-stage image:

dockerfile
FROM node:22-slim
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev      # production deps only
COPY src/ ./src/
ENV PORT=8080
USER node                        # non-root for security
CMD ["node", "src/index.js"]

Manual deploy (emergency only) โ€‹

If you need to deploy manually outside of CI (e.g. to fix a critical prod issue), use the script defined in package.json:

bash
cd services/api/venues
npm run deploy:dev
# Runs: gcloud run deploy venue-api --source . --region us-central1 --project lantern-app-dev --allow-unauthenticated

This requires the gcloud CLI to be authenticated with an account that has Cloud Run Developer permissions on the project.


Verifying a Deployment โ€‹

After a deployment (CI or manual), confirm the service is healthy:

1. Health check โ€‹

bash
curl -s https://venue-api-531553779372.us-central1.run.app/health | jq
# Expected: {"status":"ok","service":"venue-api","timestamp":"..."}

2. Check the deployed revision โ€‹

bash
gcloud run services describe venue-api \
  --region us-central1 \
  --project lantern-app-dev \
  --format='value(status.url,status.latestReadyRevisionName)'

3. Smoke test an authenticated endpoint โ€‹

bash
TOKEN="<your-admin-token>"
curl -s https://venue-api-531553779372.us-central1.run.app/venues/admin/stats \
  -H "Authorization: Bearer $TOKEN" | jq

4. Check Cloud Run console โ€‹

Navigate to Cloud Run โ†’ venue-api (dev) to see request counts, error rates, and latency graphs.


Checking Logs โ€‹

Via gcloud CLI โ€‹

bash
# Tail live logs
gcloud logs tail "resource.type=cloud_run_revision AND resource.labels.service_name=venue-api" \
  --project lantern-app-dev

# Last 50 entries
gcloud logging read \
  "resource.type=cloud_run_revision AND resource.labels.service_name=venue-api" \
  --project lantern-app-dev \
  --limit=50 \
  --format="table(timestamp,severity,textPayload)"

Via Google Cloud Console โ€‹

  1. Open Cloud Logging
  2. Filter: resource.type="cloud_run_revision" resource.labels.service_name="venue-api"
  3. Use the severity dropdown to filter by ERROR or WARNING

What to look for โ€‹

The API uses Pino structured logging. Every request is logged automatically by pino-http. Key log fields:

FieldDescription
req.method, req.urlHTTP method and path
res.statusCodeResponse status
responseTimems to respond
userIdFirebase UID (logged explicitly in import/enrich handlers)
err.messageError message (production sanitizes stack traces)

Example: Finding all failed imports

resource.type="cloud_run_revision"
resource.labels.service_name="venue-api"
httpRequest.status>=400
httpRequest.requestUrl=~"/venues/import"

Log levels:

  • Dev environment: debug (verbose, includes all request details)
  • Prod environment: info (request summaries and explicit log calls only)

Frontend Integration โ€‹

The client-side helper venueApiClient.js wraps all API calls:

js
import {
  importVenuesFromApi,
  enrichVenue,
  batchRefreshVenues,
  geocodeVenue,
  getVenueMetadata,
  // Admin:
  getVenueStats,
  deduplicateVenues,
  cleanupVenues,
  validateVenues,
} from '../lib/venueApiClient'

All functions automatically:

  1. Read the Firebase ID token from auth.currentUser
  2. Set Authorization: Bearer <token>
  3. Point at VITE_VENUE_API_URL (from .env.local)
  4. Throw typed errors with .status and .data on non-2xx responses

Required environment variable โ€‹

bash
# .env.local
VITE_VENUE_API_URL=https://venue-api-531553779372.us-central1.run.app
# or for local dev against a local instance:
VITE_VENUE_API_URL=http://localhost:8080

Usage examples โ€‹

js
// Import venues near the user's location
const result = await importVenuesFromApi(lat, lng, 5000, {
  includeGold: true,
  includeSilver: true,
  includeBronze: false,
})
console.log(`Imported ${result.imported}, skipped ${result.skipped}`)

// Enrich a venue after it's been imported
const enriched = await enrichVenue(venueId)
if (enriched.skipped) {
  console.log('Already enriched or no coordinates')
} else {
  console.log('Address:', enriched.address)
}

// Admin: get stats
const stats = await getVenueStats()
console.log(`Total venues: ${stats.total}`)

Architecture Deep Dive โ€‹

Service files โ€‹

services/api/venues/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ index.js                   # App setup, CORS, route mounting
โ”‚   โ”œโ”€โ”€ middleware/
โ”‚   โ”‚   โ”œโ”€โ”€ auth.js                # Firebase token verification
โ”‚   โ”‚   โ”œโ”€โ”€ rbac.js                # Role-based access (checks Firestore users.role)
โ”‚   โ”‚   โ”œโ”€โ”€ rateLimiter.js         # Sliding window rate limiter
โ”‚   โ”‚   โ””โ”€โ”€ errorHandler.js        # Global error handler
โ”‚   โ”œโ”€โ”€ routes/
โ”‚   โ”‚   โ”œโ”€โ”€ health.js
โ”‚   โ”‚   โ”œโ”€โ”€ import.js              # /venues/import/*
โ”‚   โ”‚   โ”œโ”€โ”€ refresh.js             # /venues/refresh/*
โ”‚   โ”‚   โ”œโ”€โ”€ utility.js             # /venues/geocode, /venues/:id/metadata, /venues/consolidate
โ”‚   โ”‚   โ””โ”€โ”€ admin.js               # /venues/admin/*
โ”‚   โ””โ”€โ”€ services/
โ”‚       โ”œโ”€โ”€ osm.service.js         # Overpass API client, venue building, tier filtering
โ”‚       โ”œโ”€โ”€ nominatim.service.js   # Nominatim reverse geocoding
โ”‚       โ””โ”€โ”€ venue.service.js       # Firestore CRUD, dedup, batch writes
โ”œโ”€โ”€ test/
โ”‚   โ”œโ”€โ”€ routes.integration.test.js
โ”‚   โ”œโ”€โ”€ osm.service.test.js
โ”‚   โ”œโ”€โ”€ nominatim.service.test.js
โ”‚   โ””โ”€โ”€ rateLimiter.test.js
โ”œโ”€โ”€ Dockerfile
โ”œโ”€โ”€ package.json
โ””โ”€โ”€ vitest.config.js

Shared configuration โ€‹

packages/shared/venues/index.js is the single source of truth for constants used by both the API and the frontend. This includes:

  • Tier filter definitions (Gold/Silver/Bronze/Coal OSM tags)
  • Category mapping (48 OSM amenity/shop/leisure tags โ†’ Lantern categories)
  • Overpass API endpoints (3 fallback URLs with retry settings)
  • Search term expansions (e.g., cafe โ†’ ["coffee", "espresso", "latte"])
  • Lifecycle detection patterns (detecting disused:, abandoned:, was: prefixed tags)
  • Import defaults (default lat/lng, radius, limits)
  • Refresh thresholds (fresh < 14 days, stale 14-30 days, very stale 30+ days)

Import pipeline โ€‹

1. Validate request (Zod)
2. rateLimitMiddleware('import') โ†’ check 5/min per user
3. fetchOSMVenues(lat, lng, radius, options)
   a. buildOverpassQuery() โ†’ Overpass QL
   b. fetchOverpassWithRetry() โ†’ tries 3 endpoints with exponential backoff
   c. Filter results: isLikelyClosed(), isCoalTierTags()
   d. buildVenueFromOSM() โ†’ normalizes OSM element to Lantern venue shape
4. importVenuesToFirestore(venues)
   a. getExistingOsmIds() โ†’ batch Firestore 'in' query (max 30/batch)
   b. Skip venues with known osmIds
   c. WriteBatch.set() for new venues (500/batch max)
5. updateRefreshMetadata(lat, lng, result) โ†’ geohash prefix โ†’ lastRefreshedAt

CORS โ€‹

The API uses a whitelist of allowed origins:

http://localhost:3001, 3002, 5173, 5174   (local dev)
https://dev.ourlantern.app                 (dev app)
https://ourlantern.app                     (prod app)
https://admin.dev.ourlantern.app           (dev admin)
https://admin.ourlantern.app               (prod admin)

Preflight OPTIONS requests return 204 No Content immediately. CORS max-age is 86400 seconds (24 hours).

Request flow through middleware โ€‹

Incoming request
     โ”‚
     โ”œโ”€โ”€ CORS middleware (all routes)
     โ”œโ”€โ”€ express.json({ limit: '1mb' }) โ€” body parsing
     โ”œโ”€โ”€ pino-http โ€” request/response logging
     โ”‚
     โ”œโ”€โ”€ GET /health โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ No auth โ”€โ”€โ–บ healthRoutes
     โ”‚
     โ”œโ”€โ”€ POST /venues/import/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ verifyFirebaseToken
     โ”‚                                            rateLimitMiddleware('import')
     โ”‚                                            โ”€โ”€โ–บ importRoutes
     โ”‚
     โ”œโ”€โ”€ POST /venues/refresh/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ verifyFirebaseToken
     โ”‚                                            rateLimitMiddleware('refresh'|'enrich')
     โ”‚                                            โ”€โ”€โ–บ refreshRoutes
     โ”‚
     โ”œโ”€โ”€ GET|POST /venues/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ verifyFirebaseToken
     โ”‚                                            โ”€โ”€โ–บ utilityRoutes
     โ”‚
     โ””โ”€โ”€ GET|POST /venues/admin/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ verifyFirebaseToken
                                                  requireRole('admin')
                                                  โ”€โ”€โ–บ adminRoutes
                                                       โ”‚
                                                  errorHandler (last)

Built with VitePress