Media Buyer

Internal command center for tracking Meta ads and GoHighLevel performance across clients.

v1.0.0Updated Aug 25, 2026, 1:41 PM UTC

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

  1. Start the dev server: npm run dev
  2. Navigate to /meta/settings
  3. Click "Generate Mock Data" — this creates 10 clients with 60 days of snapshots
  4. 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:

  1. Set META_ACCESS_TOKEN in your .env
  2. Implement fetchMetaInsights() in src/meta/services/metaService.ts
  3. The function should call the Meta Marketing API /insights endpoint and return MetaDailySnapshot[]
  4. 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/*.json files. Falls back to in-memory if filesystem is read-only.
  • memory — In-memory only. Data lost on restart.
  • postgres — Skeleton only. To implement:
    1. Install a Postgres client (@vercel/postgres, pg, Drizzle, or Prisma)
    2. Create the tables (schemas documented in src/meta/storage/PostgresAdapter.ts)
    3. Implement the adapter methods
    4. Set STORAGE_ADAPTER=postgres and DATABASE_URL in .env

Recommended upgrade path: Supabase Postgres (free tier) or SQLite via better-sqlite3 for local dev.

API Routes

MethodRouteDescription
GET/api/meta/overview?range=7dOverview KPIs + client table
GET/api/meta/client/[clientId]?range=30dClient detail + snapshots
POST/api/meta/health/evaluateRe-evaluate health (all or one)
POST/api/meta/seedGenerate 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

  1. Start the dev server: npm run dev
  2. Navigate to /ghl/settings
  3. Click "Generate Mock Data" — creates 10 locations with 60 days of data
  4. 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:

  1. Create a Marketplace app at https://marketplace.gohighlevel.com/
  2. Implement OAuth 2.0 flow to get access tokens per location
  3. Implement fetchGhlContacts(), fetchGhlAppointments(), fetchGhlOpportunities() in the service file
  4. 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

MethodRouteDescription
GET/api/ghl/overview?range=7dOverview KPIs + location table
GET/api/ghl/location/[locationId]?range=30dLocation detail + snapshots
POST/api/ghl/health/evaluateRe-evaluate health (all or one)
POST/api/ghl/seedGenerate 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:

SourceServes
Meta Marketing API (lib/meta-api.ts)spend, impressions, clicks, reach, CTR/CPM, campaign rows, ad-level rows
openai-wrapper /api/integration/tenant-statsleads, 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

RouteReturns
GET /api/report/summaryExecutive-summary KPIs, period comparison, AI insights
GET /api/report/trendsDaily leads / appointments / revenue / spend
GET /api/report/salespeoplePer-salesperson activity
GET /api/report/sourcesLeads grouped by GHL attribution source
GET /api/report/campaignsMeta campaign rows
GET /api/report/top-adsAd-level rows ranked by lowest CPL
GET /api/report/accountsAccount picker options
GET /api/report/warm-tenant-statsCron: 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 sets estimated: true on 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-stats accepts a calendarIds allow-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:

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.