Documentation
Database

Schema Reference

Every table you will read or write. Source of truth is supabase/schema.sql plus the additive patch files.

Tables at a glance

TablePurpose
profilesOne row per Supabase user. Holds role and (for clients) the client_id binding.
clientsOne row per agency customer. Holds every per-client integration ID and config field.
integrationsOAuth tokens and per-client integration credentials.
portal_dataEditable JSONB sections that power the Client Portal sub-tabs.
external_metricsCache of pushed metrics from n8n. Keyed on (client_id, key).
seo_rankingsWeekly snapshots from DataForSEO (via n8n).
submit_requestsClient-submitted requests via the Submit Request modal.

profiles

sql
create table public.profiles (
  id uuid primary key references auth.users (id) on delete cascade,
  email text,
  role text check (role in ('admin', 'client')) default 'client',
  client_id uuid references public.clients (id) on delete set null,
  full_name text,
  avatar_url text,
  created_at timestamptz default now()
);
  • Created via the handle_new_user trigger when a Supabase auth user is created.
  • RLS: a user can SELECT and UPDATE their own row. Admin can SELECT/UPDATE any row.

clients

The biggest table by column count. Started lean with integration IDs; Phase 1 Foundation added branding fields and the 29-column Client Info block. Migrations: supabase/add-client-tier.sql + supabase/add-client-info.sql.

sql
create table public.clients (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  url text,
  screenshot_url text,

  -- Google integrations (agency-level OAuth, per-client property IDs)
  ga4_property_id text,
  gsc_site_url text,
  gbp_location_id text,
  gbp_place_id text,
  youtube_channel_id text,
  google_ads_customer_id text,

  -- Other integrations
  ghl_location_id text,
  meta_ad_account_id text,
  dataforseo_domain text,
  sheets_rfms_id text,

  -- Local SEO config
  local_seo_keywords jsonb,    -- { local_pack: { keywords, embed_url }, maps: { keywords, embed_url } }

  -- Phase 1 — branding & program identity
  tier_label text,             -- e.g. 'Highway — Overdrive'
  tier_color text,             -- hex for the badge override
  booking_url text,            -- per-client Calendly / GHL calendar
  welcome_video_url text,      -- Loom / Riverside link
  client_logo_url text,        -- white-label logo shown in HubNav + Client Info header

  -- Phase 1.1 — Client Info hub (shown in the bento grid)
  info_legal_name text,
  info_industry text,
  info_founded_year int,
  info_employee_count text,
  info_about text,

  info_primary_contact_name text,   -- drives the Home greeting first name
  info_primary_contact_role text,
  info_primary_contact_email text,
  info_primary_contact_phone text,

  info_address text,
  info_city text,
  info_state text,
  info_zip text,
  info_country text,

  info_domain text,
  info_domain_registrar text,
  info_domain_expiration date,
  info_hosting_provider text,
  info_ssl_expiration date,

  info_brand_colors text,           -- comma-separated hex strings
  info_brand_fonts text,

  info_social_facebook text,
  info_social_instagram text,
  info_social_linkedin text,
  info_social_youtube text,
  info_social_tiktok text,

  info_billing_email text,
  info_tax_id text,
  info_notes text,

  created_at timestamptz default now(),
  updated_at timestamptz default now()
);
One client, one URL
Multi-location businesses with separate websites are modeled as separate client rows today. Multi-location with shared website is one client with multiple GBP location IDs — pending feature.
Client Info is queried once
The bento grid reads its 29 fields from the same clients SELECT that already runs in /dashboard/clients/[id]/page.tsx. No extra round-trip. Edits via the modal go directly through the Supabase browser client (admin-only via RLS).

integrations

sql
create table public.integrations (
  id uuid primary key default gen_random_uuid(),
  client_id uuid references public.clients (id) on delete cascade,
  provider text not null,    -- 'google_master_agency' | 'meta_ads' | 'ghl' | etc.
  is_active boolean default true,
  access_token text,
  refresh_token text,
  expires_at timestamptz,
  scope text,
  metadata jsonb,            -- provider-specific extras
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

For agency-level integrations like google_master_agency, client_id is null and there is exactly one active row.

For per-client integrations (Meta Ads), there is one row per client.

portal_data

sql
create table public.portal_data (
  id uuid primary key default gen_random_uuid(),
  client_id uuid unique references public.clients (id) on delete cascade,
  snapshot jsonb,
  scorecard jsonb,
  gameplan jsonb,
  deliverables jsonb,
  kpi_reporting jsonb,
  content_tracker jsonb,
  documents jsonb,
  updated_at timestamptz default now()
);

TypeScript shapes for each JSONB column live in src/lib/types/portal.ts. The route PUT /api/portal/[clientId] validates the payload against these shapes before writing.

external_metrics

sql
create table public.external_metrics (
  id uuid primary key default gen_random_uuid(),
  client_id uuid references public.clients (id) on delete cascade,
  key text not null,
  value numeric,
  formatted text,
  change numeric,
  updated_at timestamptz default now(),
  unique (client_id, key)
);

Populated by n8n via POST /api/metrics/external. Read from the dashboard via GET /api/metrics/external/[clientId].

seo_rankings

sql
create table public.seo_rankings (
  id uuid primary key default gen_random_uuid(),
  client_id uuid references public.clients (id) on delete cascade,
  domain text,
  week_date date,
  summary jsonb,        -- { totalKeywords, top3, top5, top10, top20, notFound }
  backlinks jsonb,      -- { totalBacklinks, referringDomains, referringIPs, domainRank }
  rankings jsonb,       -- [{ keyword, rank, url, found }, ...]
  unique (client_id, week_date)
);

Storage

Supabase Storage bucket client-screenshots stores client website screenshots (used in the dashboard hero card and the PDF cover). The URL is stored in clients.screenshot_url.

Row Level Security

All client-scoped tables (clients, integrations, portal_data, external_metrics, seo_rankings, submit_requests) have RLS enabled. The standard policy:

sql
-- read: admin or matching client_id
create policy "read own or admin" on public.clients for select using (
  exists (select 1 from profiles
          where profiles.id = auth.uid()
            and (profiles.role = 'admin' or profiles.client_id = clients.id))
);

-- write: admin only (clients are read-only)
create policy "admin write" on public.clients for all using (
  exists (select 1 from profiles
          where profiles.id = auth.uid() and profiles.role = 'admin')
);

Migrations

We do not have a migration framework. Schema changes ship as additive SQL patches:

  • supabase/schema.sql — full target schema.
  • supabase/add-XXX.sql — additive patches.
  • supabase/update-XXX.sql — patches updating existing rows or constraints.
  • supabase/fix-XXX.sql — corrective patches for bugs.

Apply patches via the Supabase SQL editor. Always add columns, never rename — the codebase reads by name.

Phase 1 migrations to run

If you are setting up a fresh Supabase project, apply these in order after schema.sql:

FileAdds
supabase/add-client-tier.sqltier_label, tier_color, booking_url, welcome_video_url, client_logo_url
supabase/add-client-info.sqlThe 29 info_* columns powering the Client Info hub.
Storage bucket policy
The logo upload uses the existing client-screenshots bucket with the path pattern <clientId>/logo-<ts>.<ext>. Make sure the bucket allows authenticated INSERT and public SELECT — without SELECT the logo will load as 403 in the HubNav.