Documentation
Architecture

Data Flow

How a metric travels from a third-party API to a row on the dashboard, end to end.

High-level diagram

text
┌──────────────────┐    ┌────────────────────┐    ┌──────────────────┐
│  Browser         │    │  Next.js server     │    │  External APIs   │
│  ClientDashboard │───▶│  /api/metrics/*    │───▶│  Google / GHL /  │
│  useEffect+fetch │    │  route handler     │    │  Meta / DataForSEO│
└──────────────────┘    └─────────┬──────────┘    └──────────────────┘
        ▲                         │
        │                         │ reads tokens & client config
        │                         ▼
        │              ┌──────────────────────┐
        └──────────────│  Supabase            │
           JSON         │  clients,            │
           response     │  integrations,       │
                        │  external_metrics    │
                        └──────────────────────┘

Pull flow (the default)

For every Google source, GHL, Meta, YouTube and PageSpeed, the flow is:

1
Browser opens client page
The client dashboard component mounts. Its useEffect hooks fire in parallel — one per integration — keyed on (client.id, dateRange.start, dateRange.end).
2
Fetch /api/metrics/<source>
Each effect calls a route handler with the client identifier and date range as query params (e.g. property_id for GA4, location_id for GBP/GHL, customer_id for Google Ads).
3
Route handler authenticates the request
Inside the route, we instantiate a server-side Supabase client. We read the integration row that grants access (typically google_master_agency for Google sources, or a per-client integration for GHL/Meta).
4
Refresh token if expired
For Google integrations, we wrap the token in oauth2Client.setCredentials() and let google-auth-library refresh on demand. The new access_token is written back to integrations.access_token if it changed.
5
Call the upstream API
We hit the platform endpoint with the date range. The shape of the response is platform-specific.
6
Normalise the payload
We transform the raw response into the shape ClientDashboard expects: { value, change, formatted } for each metric. The change field is the percent delta vs the previous period of equal length.
7
Return JSON
The response shape is { metrics: {...}, ...auxiliary }. Auxiliary keys hold things like leadsBySource, weeklyTrend, stageBreakdown.
8
Frontend renders
The setState fires, MetricCard rows populate. Rows with null values hide automatically; cards with no visible rows hide entirely.

Push flow (n8n / external)

Some sources are easier to aggregate in n8n (or another workflow tool) and push into the dashboard than to pull on demand. Email/SMS counts, social media engagement, and certain calculated KPIs flow this way.

  1. n8n workflow runs on a schedule (typically daily or weekly).
  2. It POSTs to POST /api/metrics/external with a bearer token (N8N_WEBHOOK_SECRET) and a payload { client_id, metrics: { key: { value, formatted, change? } } }.
  3. The handler upserts each row into the external_metrics table.
  4. On the frontend, GET /api/metrics/external/[clientId] reads the cache. The dashboard merges this with live-pulled n8n metrics from /api/metrics/n8n.
  5. Inside ClientDashboard.tsx, the helper ext(key) reads from the merged cache.
Why not push everything?
Pull is preferred for sources that change frequently and where the user expects fresh data when changing the date range (GA4, GSC, GHL, Ads). Push is preferred for low-frequency aggregates and any source we cannot OAuth into directly.

Source-by-source: pull vs push

SourceModeToken / config
Google Analytics 4pullintegrations.google_master_agency
Search Consolepullintegrations.google_master_agency
Google Business Profile (Performance)pullintegrations.google_master_agency
Google Business Profile (Reviews)pullGOOGLE_API_KEY env (Places API)
Google Adspullintegrations.google_master_agency + GOOGLE_ADS_DEVELOPER_TOKEN
YouTubepullintegrations.google_master_agency
PageSpeed InsightspullGOOGLE_API_KEY env
GoHighLevelpullGHL_API_KEY env (agency-level)
Meta / Facebook Adspullper-client OAuth in integrations.meta_ads
DataForSEO (rankings, backlinks)pull via n8nExternal n8n workflow proxies the API; we hit /api/metrics/seo-rankings
Email / SMS countspushn8n → /api/metrics/external
Social organic engagementpushn8n → /api/metrics/external
Calculated KPIs (cost-per-X)push or computedSome pushed by n8n, some computed in PipelineSection from live pulls

How the date range propagates

The dashboard has one global date range (state in ClientDashboard.tsx). Every metric fetch includes start_date and end_date as ISO date strings:

ts
const dateQs = `start_date=${dateRange.start}&end_date=${dateRange.end}`
fetch(`/api/metrics/ghl?location_id=${id}&client_id=${client.id}&${dateQs}`)

Each route uses these dates when calling the upstream API. For sources that do not support arbitrary date ranges (GHL contact list), we fetch the full set and filter server-side by dateAdded.

Pipeline KPI bug history
Until 2026-04, the GHL pipeline KPIs (Leads In, Appts Booked, Won) ignored the date range — they returned all-time counts. The fix was to move every counter inside an inRange(ts) check before incrementing. See Pipeline KPIs for the full logic.