Media Buyer
Internal command center for tracking Meta ads and GoHighLevel performance across clients.
Media Buyer
📘 Operator SOP — docs/SOP.md
If you're using this tool, start here. The SOP covers first-time onboarding plus daily, weekly, and monthly routines in plain English. The rest of this README is the developer reference (env vars, storage adapters, API routes).
This is a Next.js project bootstrapped with create-next-app.
Getting Started
First, run the development server:
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
Open http://localhost:3000 with your browser to see the result.
You can start editing the page by modifying app/page.tsx. The page auto-updates as you edit the file.
This project uses next/font to automatically optimize and load Geist, a new font family for Vercel.
Meta Command Center
Internal tool for tracking Meta ads performance across all clients.
Pages
/meta/overview— All clients, KPIs, health status, anomalies/meta/clients/[clientId]— Client detail with charts and trends/meta/anomalies— Red/yellow accounts with reasons/meta/settings— Client mapping, connection status, mock data generation
Enabling
Add to your .env:
META_ENABLED=true # Enable/disable (default: true)
STORAGE_ADAPTER=local # local | memory | postgres (default: local)
If META_ENABLED=false, all Meta pages show a "Meta not connected" empty state.
Seeding Mock Data
- Start the dev server:
npm run dev - Navigate to
/meta/settings - Click "Generate Mock Data" — this creates 10 clients with 60 days of snapshots
- Or call the API directly:
curl -X POST http://localhost:3000/api/meta/seed
Connecting the Meta API
The Meta API integration is stubbed in src/meta/services/metaService.ts. To connect:
- Set
META_ACCESS_TOKENin your.env - Implement
fetchMetaInsights()insrc/meta/services/metaService.ts - The function should call the Meta Marketing API
/insightsendpoint and returnMetaDailySnapshot[] - Call
storage.bulkUpsertSnapshots()to persist the data
Switching Storage Adapters
The storage layer uses an adapter pattern (src/meta/storage/StorageAdapter.ts):
local(default) — Persists to/data/*.jsonfiles. Falls back to in-memory if filesystem is read-only.memory— In-memory only. Data lost on restart.postgres— Skeleton only. To implement:- Install a Postgres client (
@vercel/postgres,pg, Drizzle, or Prisma) - Create the tables (schemas documented in
src/meta/storage/PostgresAdapter.ts) - Implement the adapter methods
- Set
STORAGE_ADAPTER=postgresandDATABASE_URLin.env
- Install a Postgres client (
Recommended upgrade path: Supabase Postgres (free tier) or SQLite via better-sqlite3 for local dev.
API Routes
| Method | Route | Description |
|---|---|---|
| GET | /api/meta/overview?range=7d | Overview KPIs + client table |
| GET | /api/meta/client/[clientId]?range=30d | Client detail + snapshots |
| POST | /api/meta/health/evaluate | Re-evaluate health (all or one) |
| POST | /api/meta/seed | Generate mock data (dev only) |
| PATCH | /api/meta/clients/[clientId] | Update client fields |
GHL Command Center
Internal tool for tracking GoHighLevel leads, appointments, and pipeline across all locations (sub-accounts).
Pages
/ghl/overview— All locations, KPIs (leads, appointments, show rate, booking rate, pipeline)/ghl/locations/[locationId]— Location detail with 6 charts (leads, booked, showed, show rate, opps won, pipeline)/ghl/anomalies— Red/yellow locations with reasons/ghl/settings— OAuth connection status, location mapping, mock data generation
Enabling
Add to your .env:
GHL_ENABLED=true # Enable/disable (default: true)
GHL_CLIENT_ID=xxx # From your GHL Marketplace app
GHL_CLIENT_SECRET=xxx
GHL_ACCESS_TOKEN=xxx # Obtained via OAuth flow
DATABASE_URL=xxx # Postgres (Neon) — REQUIRED in production; without it
# snapshots fall back to ephemeral /tmp JSON files
CRON_SECRET=xxx # Protects the scheduled GET /api/ghl/sync endpoint
Scheduled Sync
vercel.json defines an hourly cron that calls GET /api/ghl/sync (30 days).
Each run syncs as many locations as fit in one serverless invocation
(stalest first, skipping locations synced within the last 55 minutes), so a
large fleet is walked incrementally across consecutive runs. The cron uses
the database-stored OAuth token, so connect once via /ghl/settings on a
deployment with DATABASE_URL set. Manual "Sync Data" on /ghl/overview
loops until all locations are covered and lists any per-location errors.
Seeding Mock Data
- Start the dev server:
npm run dev - Navigate to
/ghl/settings - Click "Generate Mock Data" — creates 10 locations with 60 days of data
- Or:
curl -X POST http://localhost:3000/api/ghl/seed
Connecting the GHL API
The GHL API integration is stubbed in src/ghl/services/ghlService.ts. To connect:
- Create a Marketplace app at
https://marketplace.gohighlevel.com/ - Implement OAuth 2.0 flow to get access tokens per location
- Implement
fetchGhlContacts(),fetchGhlAppointments(),fetchGhlOpportunities()in the service file - API base:
https://services.leadconnectorhq.com
Health Rules
Compares last 7 days to previous 7 days:
- RED: 0 leads in 7d, show rate < 30%, 0 appointments despite having leads
- YELLOW: Leads down 25%+, show rate < 50%, booking rate down 20%+
- GREEN: Everything else
GHL API Routes
| Method | Route | Description |
|---|---|---|
| GET | /api/ghl/overview?range=7d | Overview KPIs + location table |
| GET | /api/ghl/location/[locationId]?range=30d | Location detail + snapshots |
| POST | /api/ghl/health/evaluate | Re-evaluate health (all or one) |
| POST | /api/ghl/seed | Generate mock data (dev only) |
| PATCH | /api/ghl/locations/[locationId] | Update location fields |
Client Report
/report (screen) and /report/print (PDF) render the client-facing
monthly report. Data comes from three places:
| Source | Serves |
|---|---|
Meta Marketing API (lib/meta-api.ts) | spend, impressions, clicks, reach, CTR/CPM, campaign rows, ad-level rows |
openai-wrapper /api/integration/tenant-stats | leads, land-owner leads, appointments, show rate, calls, unread messages, closed-won revenue, AI conversation insights |
GHL direct (src/ghl/services/ghlService.ts) | snapshot sync, and the fallback when the wrapper doesn't know a location |
Why the wrapper is the primary CRM source
It holds a location-scoped OAuth token per tenant, mirrors messages locally
(so call counts are a SQL query rather than a conversation walk), and has
retry + circuit breakers around GHL. Calling GHL directly from here fights
Cloudflare rate limits — hence the concurrency limiter and blocked-location
tracking in ghlService.ts, and the 100-conversation cap on
fetchGhlCallCounts that makes it undercount on busy locations. Direct GHL
is the fallback, not the default.
Report API routes
| Route | Returns |
|---|---|
GET /api/report/summary | Executive-summary KPIs, period comparison, AI insights |
GET /api/report/trends | Daily leads / appointments / revenue / spend |
GET /api/report/salespeople | Per-salesperson activity |
GET /api/report/sources | Leads grouped by GHL attribution source |
GET /api/report/campaigns | Meta campaign rows |
GET /api/report/top-ads | Ad-level rows ranked by lowest CPL |
GET /api/report/accounts | Account picker options |
GET /api/report/warm-tenant-stats | Cron: fills the tenant-stats cache |
All take range=7d|14d|30d|90d (or explicit since/until) plus an
optional accountId.
Caching
ghl_tenant_stats_cache_v2 stores wrapper responses keyed by (location,
window). The wrapper takes 3–10s per location and caches nothing, so a
report over ~20 tenants can't fetch them all inside one request. The hourly
warm-tenant-stats cron pre-fetches the default 30-day window — the one the
report opens on — and prunes rows past the 24h stale horizon. A user-picked
custom range is fetched on the request path, and only for a single selected
account; the "All accounts" rollup is cache-only by design.
Known approximations
These are flagged in the payload rather than hidden:
- Source attribution reads each contact's GHL
attributionSource, but only for a single selected account — the per-contact walk is too expensive to fan out across the fleet. The rollup falls back to inferring Facebook leads by differencing Meta-reported against CRM leads, and setsestimated: trueon every row. - Appointments and revenue by source are always apportioned by lead
share (
apportioned: true). GHL's snapshot layer doesn't carry the originating source on appointments or opportunities. - Google spend is absent — there is no Google Ads integration. A Google source row shows leads with zero spend and a null CPL.
- Sales calendars:
tenant-statsaccepts acalendarIdsallow-list, but nothing configures it yet, so appointment counts include every calendar on the location (staff 1:1s, install scheduling).
Learn More
To learn more about Next.js, take a look at the following resources:
- Next.js Documentation - learn about Next.js features and API.
- Learn Next.js - an interactive Next.js tutorial.
You can check out the Next.js GitHub repository - your feedback and contributions are welcome!
Deploy on Vercel
The easiest way to deploy your Next.js app is to use the Vercel Platform from the creators of Next.js.
Check out our Next.js deployment documentation for more details.