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
| Table | Purpose |
|---|---|
| profiles | One row per Supabase user. Holds role and (for clients) the client_id binding. |
| clients | One row per agency customer. Holds every per-client integration ID and config field. |
| integrations | OAuth tokens and per-client integration credentials. |
| portal_data | Editable JSONB sections that power the Client Portal sub-tabs. |
| external_metrics | Cache of pushed metrics from n8n. Keyed on (client_id, key). |
| seo_rankings | Weekly snapshots from DataForSEO (via n8n). |
| submit_requests | Client-submitted requests via the Submit Request modal. |
profiles
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_usertrigger 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.
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()
);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
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
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
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
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:
-- 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:
| File | Adds |
|---|---|
supabase/add-client-tier.sql | tier_label, tier_color, booking_url, welcome_video_url, client_logo_url |
supabase/add-client-info.sql | The 29 info_* columns powering the Client Info hub. |
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.