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:
4Ventures
2026-09-22 01:31:47 +02:00
parent 9d1609c88b
commit 791976a3d8
19 changed files with 1503 additions and 20 deletions

View File

@@ -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

View File

@@ -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())

116
sql/agency_portfolio.sql Normal file
View 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'
);

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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);

View File

@@ -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<string, unknown> = {
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 });
}

View File

@@ -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<string, Integ[]>();
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,
});
}

View File

@@ -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<string, unknown>;
user_metadata?: Record<string, unknown>;
}): 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();

View File

@@ -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 <ClientIntegrationsPanel tenantId={id} tenantName={name} />;
}

View File

@@ -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() {
</div>
<h2 className="text-2xl font-bold tracking-tight text-white">Centro de Mando 4Ventures</h2>
<p className="text-sm text-gray-400 mt-1">
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.
</p>
</div>
<AgencyPortfolioBoard />
{/* Global Agency KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">

View File

@@ -57,7 +57,8 @@ export default function CreativesPage() {
Creatividades
</h2>
<p className="text-sm text-gray-400 mt-1">
Borradores por tenant con RLS · visibles solo para tu <code>tenant_id</code>.
Borradores del cliente en contexto (selector arriba). Con superadmin
activo, ves/creas en ese tenant.
</p>
</div>

View File

@@ -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 (
<div className="flex min-h-screen bg-[#0B0F19]">
@@ -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"
>
<Layers className="w-4 h-4 text-amber-400" />
Cartera Cross-Tenant
Portafolio Agencia
</Link>
</div>
)}
@@ -139,10 +144,11 @@ export default async function DashboardLayout({
<div className="flex-1 flex flex-col min-w-0">
<header className="h-16 border-b border-gray-800 bg-[#0D121F]/50 backdrop-blur px-8 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span>Cliente / Portal:</span>
<span className="bg-brand-500/10 text-brand-400 font-semibold px-2.5 py-1 rounded-md border border-brand-500/20">
{company}
</span>
<span>{isAgency ? 'Contexto:' : 'Cliente / Portal:'}</span>
<TenantSwitcher
isAgency={isAgency}
initialName={viewName || company}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-xs bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2.5 py-1 rounded-full">

View File

@@ -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');
}

View File

@@ -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 (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-emerald-400">
<CheckCircle2 className="w-3 h-3" /> OK
</span>
);
if (h === 'critical')
return (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-red-400">
<XCircle className="w-3 h-3" /> Error
</span>
);
if (h === 'warning')
return (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-amber-400">
<AlertTriangle className="w-3 h-3" /> Pendiente
</span>
);
return (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-gray-500">
<CircleDashed className="w-3 h-3" /> Sin conectar
</span>
);
}
export default function AgencyPortfolioBoard() {
const [portfolio, setPortfolio] = useState<PortfolioRow[]>([]);
const [alerts, setAlerts] = useState<AlertRow[]>([]);
const [warning, setWarning] = useState<string | null>(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 <p className="text-sm text-gray-500">Cargando portafolio…</p>;
}
return (
<div className="space-y-6">
{warning && (
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
{warning}
</p>
)}
{alerts.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-white">
<Bell className="w-4 h-4 text-amber-400" />
Alertas ({alerts.length})
</div>
<ul className="space-y-2">
{alerts.map((a) => (
<li
key={a.id}
className={`flex items-start justify-between gap-3 rounded-lg border px-4 py-3 ${
a.severity === 'critical'
? 'border-red-500/30 bg-red-500/5'
: a.severity === 'warning'
? 'border-amber-500/30 bg-amber-500/5'
: 'border-gray-800 bg-[#111827]'
}`}
>
<div>
<p className="text-sm text-white font-medium">{a.title}</p>
{a.body && (
<p className="text-xs text-gray-400 mt-0.5">{a.body}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{a.href && (
<Link
href={a.href}
className="text-xs text-brand-400 hover:underline"
>
Ver
</Link>
)}
<button
type="button"
onClick={() => dismiss(a.id)}
className="text-[10px] uppercase tracking-wider text-gray-500 hover:text-gray-300"
>
OK
</button>
</div>
</li>
))}
</ul>
</section>
)}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Plug className="w-4 h-4 text-brand-400" />
Portafolio · estado de conexiones
</h3>
<p className="text-[11px] text-gray-500">
Meta · Google Ads · GA4 · GSC · GTM · CRM · Email
</p>
</div>
{portfolio.length === 0 ? (
<p className="text-sm text-gray-500">
No hay clientes. Usa «Dar de Alta Cliente» abajo.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{portfolio.map((t) => (
<div
key={t.id}
className="rounded-xl border border-gray-800 bg-[#111827] p-4 flex flex-col gap-3"
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-white">{t.name}</p>
<p className="text-[11px] text-gray-500">{t.slug}</p>
</div>
{healthBadge(t.stats.health)}
</div>
<p className="text-xs text-gray-400">
{t.stats.connected}/{t.stats.totalProviders} conectadas
{t.stats.errors > 0 && (
<span className="text-red-400">
{' '}
· {t.stats.errors} error
</span>
)}
</p>
<div className="flex flex-wrap gap-2 mt-auto">
<button
type="button"
onClick={() => enterTenant(t.id)}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brand-500/90 hover:bg-brand-500 text-white"
>
Entrar (superadmin)
</button>
<Link
href={`/dashboard/agencia/cliente/${t.id}`}
className="inline-flex items-center gap-1 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-700 text-gray-300 hover:border-gray-500 hover:text-white"
>
Integraciones
<ExternalLink className="w-3 h-3" />
</Link>
</div>
</div>
))}
</div>
)}
</section>
</div>
);
}

View File

@@ -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<Integ[]>([]);
const [providers, setProviders] = useState<Provider[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const [accountDraft, setAccountDraft] = useState<Record<string, string>>({});
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<string, string> = {};
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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link
href="/dashboard/agencia"
className="text-gray-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h2 className="text-2xl font-bold text-white tracking-tight">
{tenantName}
</h2>
<p className="text-sm text-gray-400 mt-0.5">
Integraciones del cliente · marca estado y account id (OAuth live en
siguiente fase)
</p>
</div>
</div>
{warning && (
<p className="text-xs text-amber-400 border border-amber-500/20 rounded-lg px-3 py-2">
{warning}
</p>
)}
{groups.map((g) => {
const list = providers.filter((p) => p.group === g.id);
if (!list.length) return null;
return (
<section key={g.id} className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-500">
{g.title}
</h3>
<ul className="space-y-2">
{list.map((p) => {
const row = byProvider.get(p.id);
const status = row?.status || 'disconnected';
return (
<li
key={p.id}
className="rounded-xl border border-gray-800 bg-[#111827] p-4 space-y-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-white">
{p.label}
</p>
{row?.last_sync_at && (
<p className="text-[10px] text-gray-500">
Último sync{' '}
{new Date(row.last_sync_at).toLocaleString('es-ES')}
</p>
)}
{row?.error_message && (
<p className="text-[11px] text-red-400 mt-1">
{row.error_message}
</p>
)}
</div>
<select
className="text-xs rounded-lg bg-gray-900 border border-gray-700 text-gray-200 px-2 py-1.5"
value={status}
disabled={saving === p.id}
onChange={(e) => save(p.id, e.target.value)}
>
{STATUS_OPTS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="flex gap-2 items-center">
<input
className="flex-1 text-xs rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-white placeholder:text-gray-600"
placeholder="Account / Property ID (act_…, G-…, UA…)"
value={accountDraft[p.id] || ''}
onChange={(e) =>
setAccountDraft((d) => ({
...d,
[p.id]: e.target.value,
}))
}
/>
<button
type="button"
disabled={saving === p.id}
onClick={() => save(p.id, status)}
className="text-xs px-3 py-2 rounded-lg border border-gray-700 text-gray-300 hover:text-white hover:border-gray-500 inline-flex items-center gap-1"
>
{saving === p.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
'Guardar'
)}
</button>
</div>
</li>
);
})}
</ul>
</section>
);
})}
</div>
);
}

View File

@@ -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<TenantOpt[]>([]);
const [activeName, setActiveName] = useState(initialName);
const [activeId, setActiveId] = useState<string | null>(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 (
<span className="bg-brand-500/10 text-brand-400 font-semibold px-2.5 py-1 rounded-md border border-brand-500/20">
{initialName}
</span>
);
}
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 (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-2 bg-brand-500/10 text-brand-400 font-semibold px-2.5 py-1 rounded-md border border-brand-500/20 hover:bg-brand-500/20 transition-colors"
>
<Eye className="w-3.5 h-3.5" />
<span className="max-w-[200px] truncate">
{activeId ? activeName : 'Vista global agencia'}
</span>
<ChevronDown className="w-3.5 h-3.5 opacity-70" />
</button>
{open && (
<div className="absolute left-0 top-full mt-2 z-50 w-72 rounded-xl border border-gray-700 bg-[#111827] shadow-xl py-1 max-h-80 overflow-y-auto">
<button
type="button"
disabled={pending}
onClick={() => select(null)}
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-gray-800 flex items-center gap-2"
>
<Building2 className="w-3.5 h-3.5 text-amber-400" />
Vista global agencia
</button>
<div className="border-t border-gray-800 my-1" />
{tenants.length === 0 ? (
<p className="px-3 py-2 text-xs text-gray-500">
Sin tenants · da de alta en Agencia OS
</p>
) : (
tenants.map((t) => (
<button
key={t.id}
type="button"
disabled={pending}
onClick={() => select(t)}
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-800 ${
activeId === t.id ? 'text-brand-400' : 'text-gray-200'
}`}
>
<span className="font-medium">{t.name}</span>
<span className="block text-[10px] text-gray-500">{t.slug}</span>
</button>
))
)}
</div>
)}
</div>
);
}

66
src/lib/agency.ts Normal file
View File

@@ -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<string, unknown>;
user_metadata?: Record<string, unknown>;
} | null): string {
return (
(user?.app_metadata?.role as string) ||
(user?.user_metadata?.role as string) ||
'client'
);
}
export function homeTenantId(user: {
app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>;
} | 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';

View File

@@ -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));
}