From 791976a3d8a3875c2ff04623974cbd71e5001aaa Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 22 Sep 2026 01:31:47 +0200 Subject: [PATCH] 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 --- docs/RUNBOOK.md | 11 +- scripts/dokploy_apply_agency_sql.py | 107 +++++++++ sql/agency_portfolio.sql | 116 +++++++++ src/app/api/admin/active-tenant/route.ts | 91 +++++++ src/app/api/admin/alerts/route.ts | 104 ++++++++ src/app/api/admin/create-client/route.ts | 39 +++ src/app/api/admin/integrations/route.ts | 180 ++++++++++++++ src/app/api/admin/portfolio/route.ts | 105 +++++++++ src/app/api/creatives/route.ts | 94 +++++++- .../dashboard/agencia/cliente/[id]/page.tsx | 41 ++++ src/app/dashboard/agencia/page.tsx | 5 +- src/app/dashboard/creatives/page.tsx | 3 +- src/app/dashboard/layout.tsx | 16 +- src/app/dashboard/page.tsx | 11 +- src/components/AgencyPortfolioBoard.tsx | 223 ++++++++++++++++++ src/components/ClientIntegrationsPanel.tsx | 190 +++++++++++++++ src/components/TenantSwitcher.tsx | 115 +++++++++ src/lib/agency.ts | 66 ++++++ src/middleware.ts | 6 +- 19 files changed, 1503 insertions(+), 20 deletions(-) create mode 100644 scripts/dokploy_apply_agency_sql.py create mode 100644 sql/agency_portfolio.sql create mode 100644 src/app/api/admin/active-tenant/route.ts create mode 100644 src/app/api/admin/alerts/route.ts create mode 100644 src/app/api/admin/integrations/route.ts create mode 100644 src/app/api/admin/portfolio/route.ts create mode 100644 src/app/dashboard/agencia/cliente/[id]/page.tsx create mode 100644 src/components/AgencyPortfolioBoard.tsx create mode 100644 src/components/ClientIntegrationsPanel.tsx create mode 100644 src/components/TenantSwitcher.tsx create mode 100644 src/lib/agency.ts diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index aa4726d..292c2fb 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -26,9 +26,16 @@ python3 scripts/smoke_chat_auth.py 4. Traefik: `portal.yml` → `http://web4ventures-clientportal-jntjhs:3000` 5. Smoke: `/login` → KPIs → creatividades → chat. -## Alta cliente +## Portafolio agencia (admin) + +1. Login `marketing@` → redirige a `/dashboard/agencia`. +2. Cabecera **Contexto**: selector de cliente (modo superadmin) o vista global. +3. Tarjetas de portafolio: salud de integraciones + **Entrar** / **Integraciones**. +4. Ficha `/dashboard/agencia/cliente/[id]`: Meta, Google Ads, GA4, GSC, GTM, Clientify, Klaviyo, Mailchimp (estado manual por ahora). +5. SQL: `sql/agency_portfolio.sql` (tablas `integrations` + `agency_alerts`). + +OAuth live Meta/MCC/GA4 = siguiente fase; el esqueleto ya escala por tenant. -UI `/dashboard/agencia` (roles admin/team) o RPC `provision_client`. ## Atribución diff --git a/scripts/dokploy_apply_agency_sql.py b/scripts/dokploy_apply_agency_sql.py new file mode 100644 index 0000000..b8e1f1b --- /dev/null +++ b/scripts/dokploy_apply_agency_sql.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Apply sql/agency_portfolio.sql via Dokploy supabase-db terminal.""" +from __future__ import annotations + +import base64 +import os +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SQL = ROOT / "sql" / "agency_portfolio.sql" +SHOT = ROOT / "scripts" / "dokploy-shots" +BASE = "https://dokploy.4ventures.es" + + +def env(key: str) -> str: + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text().splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + if not password: + print("NO_PASSWORD", flush=True) + return 2 + SHOT.mkdir(parents=True, exist_ok=True) + b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode() + chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1500, "height": 1100}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(500) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + + page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000) + page.wait_for_timeout(800) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill( + "supabase-db" + ) + page.wait_for_timeout(1200) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(400) + page.get_by_text("Terminal", exact=True).click() + page.wait_for_timeout(1200) + # db image usually has bash + if page.locator("button", has_text="Bash").count(): + page.locator("button", has_text="Bash").first.click() + page.wait_for_timeout(3000) + if page.locator("textarea.xterm-helper-textarea").count(): + page.locator("textarea.xterm-helper-textarea").first.click() + + def run(cmd: str, wait: int = 900) -> None: + page.keyboard.type(cmd, delay=1) + page.keyboard.press("Enter") + page.wait_for_timeout(wait) + + run("rm -f /tmp/agency.b64 /tmp/agency.sql; echo READY", 1500) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + run(f"printf '%s' '{ch}' {op} /tmp/agency.b64", 500) + run( + "base64 -d /tmp/agency.b64 > /tmp/agency.sql && wc -c /tmp/agency.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/agency.sql; echo EXIT:$?", + 10000, + ) + run( + "psql -U postgres -d postgres -c \"select count(*) as integrations from public.integrations;\" ; echo DONE", + 4000, + ) + text = page.evaluate( + "() => (document.querySelector('.xterm-rows')||{}).innerText||''" + ) + page.screenshot(path=str(SHOT / "agency_sql.png"), full_page=True) + browser.close() + + ok = "EXIT:0" in text + print("EXIT0", ok, flush=True) + for ln in text.splitlines(): + if re.search(r"EXIT:|ERROR|integrations|DONE|READY", ln, re.I): + print(">", ln.strip()[:200], flush=True) + return 0 if ok else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/agency_portfolio.sql b/sql/agency_portfolio.sql new file mode 100644 index 0000000..8a5fce5 --- /dev/null +++ b/sql/agency_portfolio.sql @@ -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' +); diff --git a/src/app/api/admin/active-tenant/route.ts b/src/app/api/admin/active-tenant/route.ts new file mode 100644 index 0000000..6730909 --- /dev/null +++ b/src/app/api/admin/active-tenant/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + VIEW_TENANT_COOKIE, + VIEW_TENANT_NAME_COOKIE, + VIEW_TENANT_SLUG_COOKIE, + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +/** POST { tenantId } — set superadmin view context cookie. null clears. */ +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + const tenantId = + typeof body.tenantId === 'string' && body.tenantId.trim() + ? body.tenantId.trim() + : null; + + const cookieOpts = { + path: '/', + maxAge: 60 * 60 * 24 * 30, + sameSite: 'lax' as const, + secure: process.env.NODE_ENV === 'production', + httpOnly: false, + }; + + if (!tenantId) { + const res = NextResponse.json({ ok: true, cleared: true }); + res.cookies.set(VIEW_TENANT_COOKIE, '', { ...cookieOpts, maxAge: 0 }); + res.cookies.set(VIEW_TENANT_SLUG_COOKIE, '', { ...cookieOpts, maxAge: 0 }); + res.cookies.set(VIEW_TENANT_NAME_COOKIE, '', { ...cookieOpts, maxAge: 0 }); + return res; + } + + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json( + { error: 'Falta SUPABASE_SERVICE_ROLE_KEY' }, + { status: 503 } + ); + } + + const r = await fetch( + `${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=id,name,slug`, + { headers, cache: 'no-store' } + ); + if (!r.ok) { + return NextResponse.json({ error: 'Tenant lookup failed' }, { status: 500 }); + } + const rows = await r.json(); + const tenant = Array.isArray(rows) ? rows[0] : null; + if (!tenant) { + return NextResponse.json({ error: 'Tenant no encontrado' }, { status: 404 }); + } + + const res = NextResponse.json({ + ok: true, + tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug }, + }); + res.cookies.set(VIEW_TENANT_COOKIE, tenant.id, cookieOpts); + res.cookies.set(VIEW_TENANT_SLUG_COOKIE, tenant.slug || '', cookieOpts); + res.cookies.set(VIEW_TENANT_NAME_COOKIE, tenant.name || '', cookieOpts); + return res; +} + +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + // readable from client via document.cookie too; this is for SSR helpers + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/alerts/route.ts b/src/app/api/admin/alerts/route.ts new file mode 100644 index 0000000..2b2cd6c --- /dev/null +++ b/src/app/api/admin/alerts/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json({ alerts: [], warning: 'no service role' }); + } + + const r = await fetch( + `${supabaseUrl()}/rest/v1/agency_alerts?select=*&order=created_at.desc&limit=50`, + { headers, cache: 'no-store' } + ); + if (!r.ok) { + return NextResponse.json({ + alerts: [], + warning: 'Aplicar sql/agency_portfolio.sql', + }); + } + return NextResponse.json({ alerts: await r.json() }); +} + +/** POST { id } mark read — or { title, body, severity, tenantId } create */ +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json({ error: 'no service role' }, { status: 503 }); + } + + const body = await req.json().catch(() => ({})); + const base = supabaseUrl(); + + if (typeof body.id === 'string' && body.markRead) { + const r = await fetch( + `${base}/rest/v1/agency_alerts?id=eq.${encodeURIComponent(body.id)}`, + { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ read_at: new Date().toISOString() }), + } + ); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + return NextResponse.json({ ok: true }); + } + + const title = typeof body.title === 'string' ? body.title.trim() : ''; + if (!title) { + return NextResponse.json({ error: 'title requerido' }, { status: 400 }); + } + + const row = { + title, + body: typeof body.body === 'string' ? body.body : null, + severity: body.severity || 'info', + tenant_id: body.tenantId || null, + source: body.source || 'manual', + href: body.href || null, + }; + + const r = await fetch(`${base}/rest/v1/agency_alerts`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(row), + }); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const created = await r.json(); + return NextResponse.json({ alert: created[0] || created }, { status: 201 }); +} diff --git a/src/app/api/admin/create-client/route.ts b/src/app/api/admin/create-client/route.ts index a4727c3..23bc8e1 100644 --- a/src/app/api/admin/create-client/route.ts +++ b/src/app/api/admin/create-client/route.ts @@ -38,6 +38,45 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: error.message || 'Error al provisionar cliente en base de datos.' }, { status: 500 }); } + // Seed integration slots (best-effort; ignore if table missing) + try { + const tenantId = + data?.tenant_id || data?.id || data?.client?.tenant_id || null; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; + if (tenantId && serviceKey) { + const providers = [ + ['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'], + ]; + await fetch(`${url}/rest/v1/integrations`, { + method: 'POST', + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + 'Content-Type': 'application/json', + Prefer: 'resolution=ignore-duplicates', + }, + body: JSON.stringify( + providers.map(([provider, label]) => ({ + tenant_id: tenantId, + provider, + status: 'disconnected', + label, + })) + ), + }); + } + } catch (e) { + console.warn('seed integrations skipped', e); + } + return NextResponse.json({ success: true, client: data }); } catch (err: any) { console.error('Server error creating client:', err); diff --git a/src/app/api/admin/integrations/route.ts b/src/app/api/admin/integrations/route.ts new file mode 100644 index 0000000..78ae97b --- /dev/null +++ b/src/app/api/admin/integrations/route.ts @@ -0,0 +1,180 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + INTEGRATION_PROVIDERS, + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +async function requireAgency() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; + if (!isAgencyRole(userRole(user))) { + return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }; + } + const headers = serviceHeaders(); + if (!headers) { + return { + error: NextResponse.json( + { error: 'Falta SUPABASE_SERVICE_ROLE_KEY' }, + { status: 503 } + ), + }; + } + return { user, headers }; +} + +/** GET ?tenantId= — integrations for one tenant (seed missing providers). */ +export async function GET(req: NextRequest) { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + const { headers } = gate as { headers: { apikey: string; Authorization: string } }; + + const tenantId = req.nextUrl.searchParams.get('tenantId'); + if (!tenantId) { + return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 }); + } + + const base = supabaseUrl(); + const listRes = await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=*`, + { headers, cache: 'no-store' } + ); + + if (!listRes.ok) { + const msg = await listRes.text(); + return NextResponse.json( + { + integrations: [], + providers: INTEGRATION_PROVIDERS, + warning: msg.includes('does not exist') + ? 'Aplicar sql/agency_portfolio.sql' + : msg.slice(0, 200), + }, + { status: 200 } + ); + } + + let integrations = await listRes.json(); + const have = new Set( + (integrations as { provider: string }[]).map((i) => i.provider) + ); + const missing = INTEGRATION_PROVIDERS.filter((p) => !have.has(p.id)); + + if (missing.length) { + const rows = missing.map((p) => ({ + tenant_id: tenantId, + provider: p.id, + status: 'disconnected', + label: p.label, + })); + await fetch(`${base}/rest/v1/integrations`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' }, + body: JSON.stringify(rows), + }); + const again = await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=*`, + { headers, cache: 'no-store' } + ); + if (again.ok) integrations = await again.json(); + } + + return NextResponse.json({ + integrations, + providers: INTEGRATION_PROVIDERS, + }); +} + +/** PATCH — update one integration status/account (manual connect for now). */ +export async function PATCH(req: NextRequest) { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + const { headers } = gate as { headers: { apikey: string; Authorization: string } }; + + const body = await req.json().catch(() => ({})); + const tenantId = typeof body.tenantId === 'string' ? body.tenantId.trim() : ''; + const provider = typeof body.provider === 'string' ? body.provider.trim() : ''; + const status = typeof body.status === 'string' ? body.status.trim() : ''; + const externalAccountId = + typeof body.externalAccountId === 'string' + ? body.externalAccountId.trim() + : undefined; + const label = + typeof body.label === 'string' ? body.label.trim() : undefined; + const errorMessage = + typeof body.errorMessage === 'string' ? body.errorMessage.trim() : undefined; + + if (!tenantId || !provider || !status) { + return NextResponse.json( + { error: 'tenantId, provider y status requeridos' }, + { status: 400 } + ); + } + if ( + !['disconnected', 'pending', 'connected', 'error'].includes(status) + ) { + return NextResponse.json({ error: 'status inválido' }, { status: 400 }); + } + + const patch: Record = { + status, + updated_at: new Date().toISOString(), + }; + if (externalAccountId !== undefined) { + patch.external_account_id = externalAccountId || null; + } + if (label !== undefined) patch.label = label || null; + if (errorMessage !== undefined) patch.error_message = errorMessage || null; + if (status === 'connected') { + patch.last_sync_at = new Date().toISOString(); + patch.error_message = null; + } + + const base = supabaseUrl(); + const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}`; + const r = await fetch(url, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(patch), + }); + + if (!r.ok) { + // upsert if missing + const ins = await fetch(`${base}/rest/v1/integrations`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation,resolution=merge-duplicates', + }, + body: JSON.stringify({ + tenant_id: tenantId, + provider, + ...patch, + }), + }); + if (!ins.ok) { + return NextResponse.json( + { error: await ins.text() }, + { status: 500 } + ); + } + const created = await ins.json(); + return NextResponse.json({ integration: created[0] || created }); + } + + const updated = await r.json(); + return NextResponse.json({ integration: updated[0] || updated }); +} diff --git a/src/app/api/admin/portfolio/route.ts b/src/app/api/admin/portfolio/route.ts new file mode 100644 index 0000000..8a028dd --- /dev/null +++ b/src/app/api/admin/portfolio/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + INTEGRATION_PROVIDERS, + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +/** GET portfolio: tenants + integration summary + unread alerts (agency only). */ +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json( + { error: 'Falta SUPABASE_SERVICE_ROLE_KEY' }, + { status: 503 } + ); + } + + const base = supabaseUrl(); + const [tenantsRes, integRes, alertsRes] = await Promise.all([ + fetch(`${base}/rest/v1/tenants?select=id,name,slug,created_at&order=name.asc`, { + headers, + cache: 'no-store', + }), + fetch( + `${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message`, + { headers, cache: 'no-store' } + ), + fetch( + `${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`, + { headers, cache: 'no-store' } + ), + ]); + + const tenants = tenantsRes.ok ? await tenantsRes.json() : []; + const integrations = integRes.ok ? await integRes.json() : []; + const alerts = alertsRes.ok ? await alertsRes.json() : []; + const integMissing = + !integRes.ok && + (await integRes.text().catch(() => '')).toLowerCase().includes('does not exist'); + + type Integ = { + tenant_id: string; + provider: string; + status: string; + }; + const byTenant = new Map(); + for (const row of integrations as Integ[]) { + const list = byTenant.get(row.tenant_id) || []; + list.push(row); + byTenant.set(row.tenant_id, list); + } + + const portfolio = (tenants as { id: string; name: string; slug: string; created_at: string }[]).map( + (t) => { + const rows = byTenant.get(t.id) || []; + const connected = rows.filter((r) => r.status === 'connected').length; + const errors = rows.filter((r) => r.status === 'error').length; + const pending = rows.filter((r) => r.status === 'pending').length; + return { + ...t, + integrations: rows, + stats: { + connected, + errors, + pending, + totalProviders: INTEGRATION_PROVIDERS.length, + health: + errors > 0 + ? 'critical' + : connected === 0 + ? 'empty' + : pending > 0 + ? 'warning' + : 'ok', + }, + }; + } + ); + + return NextResponse.json({ + portfolio, + alerts, + providers: INTEGRATION_PROVIDERS, + schemaReady: !integMissing, + warning: integMissing + ? 'Falta aplicar sql/agency_portfolio.sql en Supabase' + : null, + }); +} diff --git a/src/app/api/creatives/route.ts b/src/app/api/creatives/route.ts index ee5e290..5f70376 100644 --- a/src/app/api/creatives/route.ts +++ b/src/app/api/creatives/route.ts @@ -1,5 +1,27 @@ import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; import { createClient } from '@/lib/supabase/server'; +import { + VIEW_TENANT_COOKIE, + homeTenantId, + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; + +async function resolveTenantId(user: { + app_metadata?: Record; + user_metadata?: Record; +}): Promise<{ tenantId: string | null; viaView: boolean }> { + const role = userRole(user); + const cookieStore = await cookies(); + const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null; + if (isAgencyRole(role) && viewId) { + return { tenantId: viewId, viaView: true }; + } + return { tenantId: homeTenantId(user), viaView: false }; +} export async function GET() { const supabase = await createClient(); @@ -10,13 +32,39 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const { tenantId, viaView } = await resolveTenantId(user); + + if (viaView && tenantId) { + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json( + { creatives: [], warning: 'Service role required for superadmin view' }, + { status: 200 } + ); + } + const r = await fetch( + `${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=id,title,status,mime_type,bytes,created_at,updated_at&order=updated_at.desc`, + { headers, cache: 'no-store' } + ); + if (!r.ok) { + return NextResponse.json({ + creatives: [], + warning: await r.text(), + viewTenantId: tenantId, + }); + } + return NextResponse.json({ + creatives: await r.json(), + viewTenantId: tenantId, + }); + } + const { data, error } = await supabase .from('creatives') .select('id,title,status,mime_type,bytes,created_at,updated_at') .order('updated_at', { ascending: false }); if (error) { - // Table may not exist yet — soft message for pre-migration return NextResponse.json( { creatives: [], warning: error.message }, { status: 200 } @@ -35,8 +83,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const tenantId = - user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null; + const { tenantId, viaView } = await resolveTenantId(user); if (!tenantId) { return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 }); } @@ -47,14 +94,43 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'title requerido' }, { status: 400 }); } + const row = { + tenant_id: tenantId, + title, + status: 'draft', + created_by: user.id, + }; + + if (viaView) { + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json( + { error: 'Service role required' }, + { status: 503 } + ); + } + const r = await fetch(`${supabaseUrl()}/rest/v1/creatives`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(row), + }); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const data = await r.json(); + return NextResponse.json( + { creative: Array.isArray(data) ? data[0] : data }, + { status: 201 } + ); + } + const { data, error } = await supabase .from('creatives') - .insert({ - tenant_id: tenantId, - title, - status: 'draft', - created_by: user.id, - }) + .insert(row) .select('id,title,status,created_at') .single(); diff --git a/src/app/dashboard/agencia/cliente/[id]/page.tsx b/src/app/dashboard/agencia/cliente/[id]/page.tsx new file mode 100644 index 0000000..a9a199d --- /dev/null +++ b/src/app/dashboard/agencia/cliente/[id]/page.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel'; +import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +export default async function ClienteIntegrationsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + if (!isAgencyRole(userRole(user))) redirect('/dashboard/kpis'); + + const headers = serviceHeaders(); + let name = 'Cliente'; + if (headers) { + const r = await fetch( + `${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(id)}&select=name`, + { headers, cache: 'no-store' } + ); + if (r.ok) { + const rows = await r.json(); + if (rows[0]?.name) name = rows[0].name; + } + } + + return ; +} diff --git a/src/app/dashboard/agencia/page.tsx b/src/app/dashboard/agencia/page.tsx index 0148abd..00975fe 100644 --- a/src/app/dashboard/agencia/page.tsx +++ b/src/app/dashboard/agencia/page.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Building2, DollarSign, Users, TrendingUp, Layers } from 'lucide-react'; import ClientManagement from '@/components/ClientManagement'; +import AgencyPortfolioBoard from '@/components/AgencyPortfolioBoard'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; @@ -76,10 +77,12 @@ export default async function AgencyPage() {

Centro de Mando 4Ventures

- Métricas consolidadas de infraestructura, inversión publicitaria y cartera de clientes. + Portafolio multi-cliente · integraciones (Meta, MCC, GA4, GSC, GTM, CRM, email) · alertas · modo superadmin.

+ + {/* Global Agency KPI Cards */}
diff --git a/src/app/dashboard/creatives/page.tsx b/src/app/dashboard/creatives/page.tsx index 03625e3..d3f07b0 100644 --- a/src/app/dashboard/creatives/page.tsx +++ b/src/app/dashboard/creatives/page.tsx @@ -57,7 +57,8 @@ export default function CreativesPage() { Creatividades

- Borradores por tenant con RLS · visibles solo para tu tenant_id. + Borradores del cliente en contexto (selector arriba). Con superadmin + activo, ves/creas en ese tenant.

diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index b0ae316..947cd14 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -13,6 +13,9 @@ import { } from 'lucide-react'; import { createClient } from '@/lib/supabase/server'; import { logoutAction } from '@/app/auth/actions'; +import TenantSwitcher from '@/components/TenantSwitcher'; +import { cookies } from 'next/headers'; +import { VIEW_TENANT_NAME_COOKIE } from '@/lib/agency'; export default async function DashboardLayout({ children, @@ -26,6 +29,8 @@ export default async function DashboardLayout({ const company = user?.user_metadata?.company || '4Ventures Central'; const email = user?.email || 'usuario@4ventures.es'; const isAgency = role === 'admin' || role === 'team'; + const cookieStore = await cookies(); + const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value; return (
@@ -94,7 +99,7 @@ export default async function DashboardLayout({ className="flex items-center gap-3 px-3 py-2.5 mt-1 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all" > - Cartera Cross-Tenant + Portafolio Agencia
)} @@ -139,10 +144,11 @@ export default async function DashboardLayout({
- Cliente / Portal: - - {company} - + {isAgency ? 'Contexto:' : 'Cliente / Portal:'} +
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 22f0e2f..aae3dc4 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,5 +1,14 @@ import { redirect } from 'next/navigation'; +import { createClient } from '@/lib/supabase/server'; +import { isAgencyRole, userRole } from '@/lib/agency'; -export default function DashboardIndex() { +export default async function DashboardIndex() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (user && isAgencyRole(userRole(user))) { + redirect('/dashboard/agencia'); + } redirect('/dashboard/kpis'); } diff --git a/src/components/AgencyPortfolioBoard.tsx b/src/components/AgencyPortfolioBoard.tsx new file mode 100644 index 0000000..3bdffb9 --- /dev/null +++ b/src/components/AgencyPortfolioBoard.tsx @@ -0,0 +1,223 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { + AlertTriangle, + Bell, + CheckCircle2, + CircleDashed, + ExternalLink, + Plug, + XCircle, +} from 'lucide-react'; + +type PortfolioRow = { + id: string; + name: string; + slug: string; + stats: { + connected: number; + errors: number; + pending: number; + totalProviders: number; + health: string; + }; +}; + +type AlertRow = { + id: string; + title: string; + body?: string | null; + severity: string; + tenant_id?: string | null; + href?: string | null; + created_at: string; + read_at?: string | null; +}; + +function healthBadge(h: string) { + if (h === 'ok') + return ( + + OK + + ); + if (h === 'critical') + return ( + + Error + + ); + if (h === 'warning') + return ( + + Pendiente + + ); + return ( + + Sin conectar + + ); +} + +export default function AgencyPortfolioBoard() { + const [portfolio, setPortfolio] = useState([]); + const [alerts, setAlerts] = useState([]); + const [warning, setWarning] = useState(null); + const [loading, setLoading] = useState(true); + + async function load() { + setLoading(true); + const res = await fetch('/api/admin/portfolio'); + const data = await res.json(); + setPortfolio(data.portfolio || []); + setAlerts((data.alerts || []).filter((a: AlertRow) => !a.read_at)); + setWarning(data.warning || null); + setLoading(false); + } + + useEffect(() => { + void load(); + }, []); + + async function enterTenant(id: string) { + await fetch('/api/admin/active-tenant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tenantId: id }), + }); + window.location.href = '/dashboard/kpis'; + } + + async function dismiss(id: string) { + await fetch('/api/admin/alerts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, markRead: true }), + }); + setAlerts((prev) => prev.filter((a) => a.id !== id)); + } + + if (loading) { + return

Cargando portafolio…

; + } + + return ( +
+ {warning && ( +

+ {warning} +

+ )} + + {alerts.length > 0 && ( +
+
+ + Alertas ({alerts.length}) +
+
    + {alerts.map((a) => ( +
  • +
    +

    {a.title}

    + {a.body && ( +

    {a.body}

    + )} +
    +
    + {a.href && ( + + Ver + + )} + +
    +
  • + ))} +
+
+ )} + +
+
+

+ + Portafolio · estado de conexiones +

+

+ Meta · Google Ads · GA4 · GSC · GTM · CRM · Email +

+
+ + {portfolio.length === 0 ? ( +

+ No hay clientes. Usa «Dar de Alta Cliente» abajo. +

+ ) : ( +
+ {portfolio.map((t) => ( +
+
+
+

{t.name}

+

{t.slug}

+
+ {healthBadge(t.stats.health)} +
+

+ {t.stats.connected}/{t.stats.totalProviders} conectadas + {t.stats.errors > 0 && ( + + {' '} + · {t.stats.errors} error + + )} +

+
+ + + Integraciones + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/ClientIntegrationsPanel.tsx b/src/components/ClientIntegrationsPanel.tsx new file mode 100644 index 0000000..5b4d549 --- /dev/null +++ b/src/components/ClientIntegrationsPanel.tsx @@ -0,0 +1,190 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { ArrowLeft, Loader2 } from 'lucide-react'; + +type Integ = { + id?: string; + provider: string; + status: string; + label?: string | null; + external_account_id?: string | null; + error_message?: string | null; + last_sync_at?: string | null; +}; + +type Provider = { id: string; label: string; group: string }; + +const STATUS_OPTS = [ + { value: 'disconnected', label: 'Desconectado' }, + { value: 'pending', label: 'Pendiente' }, + { value: 'connected', label: 'Conectado' }, + { value: 'error', label: 'Error' }, +] as const; + +export default function ClientIntegrationsPanel({ + tenantId, + tenantName, +}: { + tenantId: string; + tenantName: string; +}) { + const [integrations, setIntegrations] = useState([]); + const [providers, setProviders] = useState([]); + const [warning, setWarning] = useState(null); + const [saving, setSaving] = useState(null); + const [accountDraft, setAccountDraft] = useState>({}); + + async function load() { + const res = await fetch( + `/api/admin/integrations?tenantId=${encodeURIComponent(tenantId)}` + ); + const data = await res.json(); + setIntegrations(data.integrations || []); + setProviders(data.providers || []); + setWarning(data.warning || null); + const drafts: Record = {}; + for (const i of data.integrations || []) { + drafts[i.provider] = i.external_account_id || ''; + } + setAccountDraft(drafts); + } + + useEffect(() => { + void load(); + }, [tenantId]); + + async function save(provider: string, status: string) { + setSaving(provider); + await fetch('/api/admin/integrations', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tenantId, + provider, + status, + externalAccountId: accountDraft[provider] || '', + }), + }); + await load(); + setSaving(null); + } + + const byProvider = new Map(integrations.map((i) => [i.provider, i])); + + const groups = [ + { id: 'paid', title: 'Paid media' }, + { id: 'analytics', title: 'Analítica y medición' }, + { id: 'crm', title: 'CRM' }, + { id: 'email', title: 'Email / lifecycle' }, + ]; + + return ( +
+
+ + + +
+

+ {tenantName} +

+

+ Integraciones del cliente · marca estado y account id (OAuth live en + siguiente fase) +

+
+
+ + {warning && ( +

+ {warning} +

+ )} + + {groups.map((g) => { + const list = providers.filter((p) => p.group === g.id); + if (!list.length) return null; + return ( +
+

+ {g.title} +

+
    + {list.map((p) => { + const row = byProvider.get(p.id); + const status = row?.status || 'disconnected'; + return ( +
  • +
    +
    +

    + {p.label} +

    + {row?.last_sync_at && ( +

    + Último sync{' '} + {new Date(row.last_sync_at).toLocaleString('es-ES')} +

    + )} + {row?.error_message && ( +

    + {row.error_message} +

    + )} +
    + +
    +
    + + setAccountDraft((d) => ({ + ...d, + [p.id]: e.target.value, + })) + } + /> + +
    +
  • + ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/TenantSwitcher.tsx b/src/components/TenantSwitcher.tsx new file mode 100644 index 0000000..ffcb836 --- /dev/null +++ b/src/components/TenantSwitcher.tsx @@ -0,0 +1,115 @@ +'use client'; + +import React, { useEffect, useState, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import { ChevronDown, Eye, Building2 } from 'lucide-react'; + +type TenantOpt = { id: string; name: string; slug: string }; + +export default function TenantSwitcher({ + initialName, + isAgency, +}: { + initialName: string; + isAgency: boolean; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [tenants, setTenants] = useState([]); + const [activeName, setActiveName] = useState(initialName); + const [activeId, setActiveId] = useState(null); + const [pending, startTransition] = useTransition(); + + useEffect(() => { + if (!isAgency) return; + // read cookies + const match = document.cookie.match(/(?:^|; )4v_view_tenant_name=([^;]*)/); + const idMatch = document.cookie.match(/(?:^|; )4v_view_tenant_id=([^;]*)/); + if (match?.[1]) setActiveName(decodeURIComponent(match[1])); + if (idMatch?.[1]) setActiveId(decodeURIComponent(idMatch[1])); + + fetch('/api/admin/portfolio') + .then((r) => r.json()) + .then((d) => { + const list = (d.portfolio || []).map( + (t: { id: string; name: string; slug: string }) => ({ + id: t.id, + name: t.name, + slug: t.slug, + }) + ); + setTenants(list); + }) + .catch(() => {}); + }, [isAgency]); + + if (!isAgency) { + return ( + + {initialName} + + ); + } + + async function select(tenant: TenantOpt | null) { + setOpen(false); + await fetch('/api/admin/active-tenant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tenantId: tenant?.id ?? null }), + }); + setActiveId(tenant?.id ?? null); + setActiveName(tenant?.name ?? 'Vista global agencia'); + startTransition(() => router.refresh()); + } + + return ( +
+ + {open && ( +
+ +
+ {tenants.length === 0 ? ( +

+ Sin tenants · da de alta en Agencia OS +

+ ) : ( + tenants.map((t) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/src/lib/agency.ts b/src/lib/agency.ts new file mode 100644 index 0000000..3643e5d --- /dev/null +++ b/src/lib/agency.ts @@ -0,0 +1,66 @@ +/** Agency portfolio helpers — providers, role gates, service-role REST. */ + +export const INTEGRATION_PROVIDERS = [ + { id: 'meta_ads', label: 'Meta Ads', group: 'paid' }, + { id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid' }, + { id: 'ga4', label: 'Google Analytics 4', group: 'analytics' }, + { id: 'search_console', label: 'Search Console', group: 'analytics' }, + { id: 'tag_manager', label: 'Tag Manager', group: 'analytics' }, + { id: 'clientify', label: 'Clientify', group: 'crm' }, + { id: 'klaviyo', label: 'Klaviyo', group: 'email' }, + { id: 'mailchimp', label: 'Mailchimp', group: 'email' }, +] as const; + +export type IntegrationProviderId = + (typeof INTEGRATION_PROVIDERS)[number]['id']; + +export type IntegrationStatus = + | 'disconnected' + | 'pending' + | 'connected' + | 'error'; + +export function isAgencyRole(role: string | undefined | null): boolean { + return role === 'admin' || role === 'team'; +} + +export function userRole(user: { + app_metadata?: Record; + user_metadata?: Record; +} | null): string { + return ( + (user?.app_metadata?.role as string) || + (user?.user_metadata?.role as string) || + 'client' + ); +} + +export function homeTenantId(user: { + app_metadata?: Record; + user_metadata?: Record; +} | null): string | null { + return ( + (user?.app_metadata?.tenant_id as string) || + (user?.user_metadata?.tenant_id as string) || + null + ); +} + +export function serviceHeaders(): + | { apikey: string; Authorization: string } + | null { + const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!key) return null; + return { apikey: key, Authorization: `Bearer ${key}` }; +} + +export function supabaseUrl(): string { + return ( + process.env.NEXT_PUBLIC_SUPABASE_URL?.trim() || + 'https://supabase.4ventures.es' + ); +} + +export const VIEW_TENANT_COOKIE = '4v_view_tenant_id'; +export const VIEW_TENANT_SLUG_COOKIE = '4v_view_tenant_slug'; +export const VIEW_TENANT_NAME_COOKIE = '4v_view_tenant_name'; diff --git a/src/middleware.ts b/src/middleware.ts index 25630ef..df02bc1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -77,8 +77,12 @@ export async function middleware(req: NextRequest) { } if (user && path === '/login') { + const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client'; const dashboardUrl = req.nextUrl.clone(); - dashboardUrl.pathname = '/dashboard/kpis'; + dashboardUrl.pathname = + role === 'admin' || role === 'team' + ? '/dashboard/agencia' + : '/dashboard/kpis'; return withSecurityHeaders(NextResponse.redirect(dashboardUrl)); }