feat: agency portfolio with per-client integrations hub
Admin home lists tenants, alerts, and connection health; header selector enables superadmin context; Meta/GA4/GSC/GTM/CRM/email slots live per tenant (manual status until OAuth). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
116
sql/agency_portfolio.sql
Normal file
116
sql/agency_portfolio.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- Agency portfolio: integrations + alerts per tenant
|
||||
-- Idempotent. Run as postgres / service role.
|
||||
|
||||
create table if not exists public.integrations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
provider text not null
|
||||
check (provider in (
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'tag_manager',
|
||||
'clientify',
|
||||
'klaviyo',
|
||||
'mailchimp'
|
||||
)),
|
||||
status text not null default 'disconnected'
|
||||
check (status in ('disconnected', 'pending', 'connected', 'error')),
|
||||
external_account_id text,
|
||||
label text,
|
||||
last_sync_at timestamptz,
|
||||
error_message text,
|
||||
meta jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, provider)
|
||||
);
|
||||
|
||||
create index if not exists integrations_tenant_idx
|
||||
on public.integrations (tenant_id, provider);
|
||||
|
||||
alter table public.integrations enable row level security;
|
||||
|
||||
drop policy if exists integrations_tenant_select on public.integrations;
|
||||
drop policy if exists integrations_tenant_write on public.integrations;
|
||||
|
||||
-- Clients see only their tenant; agency uses service role for cross-tenant.
|
||||
create policy integrations_tenant_select on public.integrations
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy integrations_tenant_write on public.integrations
|
||||
for all using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
)
|
||||
with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create table if not exists public.agency_alerts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid references public.tenants(id) on delete cascade,
|
||||
severity text not null default 'info'
|
||||
check (severity in ('info', 'warning', 'critical')),
|
||||
source text not null default 'system',
|
||||
title text not null,
|
||||
body text,
|
||||
href text,
|
||||
read_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists agency_alerts_unread_idx
|
||||
on public.agency_alerts (created_at desc)
|
||||
where read_at is null;
|
||||
|
||||
alter table public.agency_alerts enable row level security;
|
||||
|
||||
drop policy if exists agency_alerts_tenant_select on public.agency_alerts;
|
||||
|
||||
create policy agency_alerts_tenant_select on public.agency_alerts
|
||||
for select using (
|
||||
tenant_id is null
|
||||
or tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- Seed disconnected integration slots for every existing tenant
|
||||
insert into public.integrations (tenant_id, provider, status, label)
|
||||
select t.id, p.provider, 'disconnected', p.label
|
||||
from public.tenants t
|
||||
cross join (
|
||||
values
|
||||
('meta_ads', 'Meta Ads'),
|
||||
('google_ads', 'Google Ads (MCC)'),
|
||||
('ga4', 'Google Analytics 4'),
|
||||
('search_console', 'Search Console'),
|
||||
('tag_manager', 'Tag Manager'),
|
||||
('clientify', 'Clientify'),
|
||||
('klaviyo', 'Klaviyo'),
|
||||
('mailchimp', 'Mailchimp')
|
||||
) as p(provider, label)
|
||||
on conflict (tenant_id, provider) do nothing;
|
||||
|
||||
-- Bootstrap alert so the UI isn't empty on day one
|
||||
insert into public.agency_alerts (tenant_id, severity, source, title, body, href)
|
||||
select null, 'info', 'system',
|
||||
'Portafolio agencia activo',
|
||||
'Selector de cliente + fichas de integración listas. Conecta Meta/MCC/GA4 por tenant.',
|
||||
'/dashboard/agencia'
|
||||
where not exists (
|
||||
select 1 from public.agency_alerts where title = 'Portafolio agencia activo'
|
||||
);
|
||||
Reference in New Issue
Block a user