From 2df3fe6983e824b68f41e1323c71b614de8ea746 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Mon, 21 Sep 2026 10:51:46 +0200 Subject: [PATCH] fix(security): harden auth, tenant scoping, and scrub secrets Require session on APIs, scope KPIs/activos/simulate to the user tenant, strip real JWTs from .env.example, and add health/smoke/docs for 4Ventures ops. Co-authored-by: Cursor --- .env.example | 12 +- .gitignore | 28 +++++ README.md | 40 +++++- SECURITY.md | 39 ++++++ docs/CONVERGENCE.md | 24 ++++ docs/IDENTITY.md | 7 ++ docs/RUNBOOK.md | 33 +++++ next.config.js | 22 +++- scripts/smoke.sh | 14 +++ sql/rls_checklist.sql | 27 ++++ src/app/api/chat/route.ts | 43 ++++--- src/app/api/health/route.ts | 19 +++ src/app/api/simulate/route.ts | 47 ++++--- src/app/dashboard/activos/page.tsx | 66 ++++++---- src/app/dashboard/agencia/page.tsx | 25 +++- src/app/dashboard/calendario/page.tsx | 26 ++-- src/app/dashboard/creatives/page.tsx | 33 +++++ src/app/dashboard/estrategia/page.tsx | 132 +++++++++----------- src/app/dashboard/kpis/page.tsx | 169 ++++++++++---------------- src/app/dashboard/layout.tsx | 7 ++ src/middleware.ts | 84 ++++++++++--- 21 files changed, 621 insertions(+), 276 deletions(-) create mode 100644 .gitignore create mode 100644 SECURITY.md create mode 100644 docs/CONVERGENCE.md create mode 100644 docs/IDENTITY.md create mode 100644 docs/RUNBOOK.md create mode 100755 scripts/smoke.sh create mode 100644 sql/rls_checklist.sql create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/dashboard/creatives/page.tsx diff --git a/.env.example b/.env.example index b12688e..a61afbf 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,10 @@ +# Copy to .env.local — NEVER commit real keys NEXT_PUBLIC_SUPABASE_URL=https://supabase.4ventures.es -NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6ImFub24iLCJpc3MiOiJzdXBhYmFzZSJ9.UWNn1leZNljna9Qwi9U5RtGqsh5_SnEoiPO8HkQIgi8 -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlzcyI6InN1cGFiYXNlIn0.BVQLeVBvVcllXiq7tWLB2zZ-x_ZI9FLZW3lYwrs8fgk -ANTHROPIC_API_KEY=sk-ant-... +NEXT_PUBLIC_SUPABASE_ANON_KEY=replace-with-anon-key +SUPABASE_SERVICE_ROLE_KEY=replace-with-service-role-key +ANTHROPIC_API_KEY=replace-with-anthropic-key + +# Optional ops +OPS_ALERT_EMAIL= +CRON_SECRET= +N8N_WEBHOOK_BASE=https://n8n-agencia.4ventures.es diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55df0da --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# dependencies +node_modules/ +.pnp +.pnp.js + +# next +.next/ +out/ +build/ +dist/ + +# env & secrets — never commit +.env +.env.* +!.env.example + +# debug / OS +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +*.pem +.vercel + +# local +*.local +coverage/ +.turbo/ diff --git a/README.md b/README.md index c218972..cb303a3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,39 @@ -# 4ventures-client-portal +# 4Ventures — Centro de Mando del Cliente -Centro de Mando del Cliente — Next.js 15 App Router + Supabase + shadcn/ui \ No newline at end of file +Portal ejecutivo multi-tenant · **source of truth** para `app.4ventures.es`. + +Operador: `marketing@4ventures.es` + +Next.js 15 + Supabase + n8n (agentes/webhooks fuera de este repo). + +## Quick start + +```bash +cp .env.example .env.local # keys rotadas — nunca las del PDF histórico +npm install +npm run dev +``` + +Health: `GET /api/health` + +## Docs + +- [docs/IDENTITY.md](./docs/IDENTITY.md) +- [SECURITY.md](./SECURITY.md) +- [docs/CONVERGENCE.md](./docs/CONVERGENCE.md) +- [docs/RUNBOOK.md](./docs/RUNBOOK.md) +- [sql/rls_checklist.sql](./sql/rls_checklist.sql) + +## Rutas + +| Path | Uso | +|------|-----| +| `/login` | Supabase Auth | +| `/dashboard/kpis` | KPIs | +| `/dashboard/estrategia` | Targets + simulador | +| `/dashboard/activos` | Docs aprobados (RLS) | +| `/dashboard/creatives` | Creatividades (en curso) | +| `/dashboard/asistente` | Chat RPC | +| `/dashboard/agencia` | Cross-tenant (admin/team) | +| `/dashboard/calendario` | Editorial RRSS | +| `/_4v_attr.js` | Script atribución | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..14bbb7d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# Security — Centro de Mando (app.4ventures.es) + +## Incident NOW (secretos filtrados) + +El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs reales** +(anon + service_role) y passwords de demo. + +1. En Supabase Studio → Settings → API: **regenerar** `anon` y `service_role`. +2. Actualizar secrets en Dokploy / env del contenedor `app` y de n8n. +3. Cambiar passwords de `admin@…` y clientes demo; invalidar sesiones. +4. Rotar `ANTHROPIC_API_KEY` si estuvo en chats/docs. +5. Confirmar que `.env.example` solo tiene placeholders (ya corregido en este repo). + +## Superficies a restringir (amenaza) + +| Host | Acción | +|------|--------| +| `git.4ventures.es` | VPN / IP allowlist / Basic Auth delante de Gitea | +| `n8n-agencia` UI | No público; solo webhooks con path secreto | +| `supabase` Studio | Solo red interna / SSO admin | +| `app` | Público OK; APIs tras sesión (middleware) | + +## Controles en código (este repo) + +- Middleware exige sesión en `/dashboard/*` y `/api/*` (excepto `/api/health`). +- `/api/chat` y `/api/simulate` exigen usuario + `tenant_id` en metadata. +- `/api/admin/*` bloqueado para rol `client`. +- Activos leen `documents` vía cliente SSR (RLS), no anon global. +- Headers: frame deny, nosniff, robots noindex, HSTS en prod. + +## RLS (verificar en Studio) + +Ejecutar checklist en `sql/rls_checklist.sql`. Sin RLS real, el multi-tenant es cosmético. + +## Cron / n8n + +- Webhooks solo POST. +- Header secreto compartido (`X-Webhook-Secret` / HMAC) en lead-inbound y deal-won. +- No loguear bodies con PII a stdout público. diff --git a/docs/CONVERGENCE.md b/docs/CONVERGENCE.md new file mode 100644 index 0000000..a2533e0 --- /dev/null +++ b/docs/CONVERGENCE.md @@ -0,0 +1,24 @@ +# Un solo producto: 4Ventures Centro de Mando + +## Source of truth + +Este repo → https://app.4ventures.es + Supabase + n8n. + +## Identidad + +- Operador: `marketing@4ventures.es` +- Clientes = tenants aislados por `tenant_id` / slug +- Branding y rutas siempre 4Ventures + +## Roadmap de piezas + +1. Seguridad / RLS / rotación de secretos +2. Creatividades (review + Storage) +3. ETL/OAuth como complemento a n8n +4. Ops digest / alertas + +## No hacer + +- Segundo portal por cliente +- Nombrar el producto o el repo con un slug de cliente +- Commitear `.env.local` o JWTs diff --git a/docs/IDENTITY.md b/docs/IDENTITY.md new file mode 100644 index 0000000..66d846c --- /dev/null +++ b/docs/IDENTITY.md @@ -0,0 +1,7 @@ +# Identidad operativa 4Ventures + +- Producto: **Centro de Mando** (`app.4ventures.es`) +- Repo: `/Users/4ventures/4ventures-client-portal` ↔ Gitea `Marketing/4ventures-client-portal` +- Operador plataforma: `marketing@4ventures.es` +- El producto es siempre **4Ventures**. Los clientes son tenants (`slug`), nunca el nombre del monorepo ni de variables globales. +- No usar nombres de cliente individuales como identidad del proyecto. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 0000000..1db9e5e --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,33 @@ +# Runbook — Centro de Mando 4Ventures + +Operador: `marketing@4ventures.es` + +## Health + +```bash +curl -sf https://app.4ventures.es/api/health +./scripts/smoke.sh https://app.4ventures.es +``` + +## Deploy (Dokploy) + +1. Push a `main` en Gitea `Marketing/4ventures-client-portal`. +2. Dokploy rebuild del servicio `app`. +3. Env: `NEXT_PUBLIC_SUPABASE_*`, `SUPABASE_SERVICE_ROLE_KEY`, `ANTHROPIC_API_KEY`. +4. Smoke: `/login` → KPIs → simulador → chat. + +## Alta cliente + +UI `/dashboard/agencia` (roles admin/team) o RPC `provision_client`. + +## Atribución + +```html + +``` + +`POST https://n8n-agencia.4ventures.es/webhook/lead-inbound` + +## Incidentes + +Ver `SECURITY.md`. diff --git a/next.config.js b/next.config.js index 26fafea..08041c0 100644 --- a/next.config.js +++ b/next.config.js @@ -2,11 +2,23 @@ const nextConfig = { output: 'standalone', reactStrictMode: true, - typescript: { - ignoreBuildErrors: true, - }, - eslint: { - ignoreDuringBuilds: true, + poweredByHeader: false, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'X-Robots-Tag', value: 'noindex, nofollow' }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + ], + }, + ]; }, }; diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..1991220 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE="${1:-https://app.4ventures.es}" +echo "== health ==" +curl -sf "$BASE/api/health" | tee /tmp/4v-health.json +echo +node -e "const j=require('/tmp/4v-health.json'); if(!j.ok) process.exit(1)" +echo "== login ==" +code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/login") +test "$code" = "200" +echo "== chat unauthorized ==" +code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/api/chat" -H 'Content-Type: application/json' -d '{"message":"hola"}') +test "$code" = "401" +echo "Smoke OK · $BASE" diff --git a/sql/rls_checklist.sql b/sql/rls_checklist.sql new file mode 100644 index 0000000..6e9b67e --- /dev/null +++ b/sql/rls_checklist.sql @@ -0,0 +1,27 @@ +-- RLS checklist — run in Supabase SQL editor as postgres +-- Goal: prove tenant isolation (no cross-tenant reads) + +-- 1) List tables missing RLS +select c.relname as table_name, c.relrowsecurity as rls_enabled +from pg_class c +join pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'public' and c.relkind = 'r' +order by 1; + +-- 2) Expected: tenants, users, leads, deals, ad_daily, documents, +-- document_chunks, agent_runs, targets, kpi_weekly (view) … +-- Each business table: ALTER TABLE … ENABLE ROW LEVEL SECURITY; + +-- 3) Example policy pattern (adapt column names): +-- create policy tenant_select on public.leads +-- for select using ( +-- tenant_id::text = coalesce( +-- auth.jwt() -> 'app_metadata' ->> 'tenant_id', +-- auth.jwt() -> 'user_metadata' ->> 'tenant_id' +-- ) +-- ); + +-- 4) Negative test: login as client A, select count(*) from leads +-- where tenant_id = ''; must be 0. + +-- 5) Service role bypasses RLS — never expose to browser. diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 184db7e..4b099f1 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -3,9 +3,26 @@ import { createClient } from '@/lib/supabase/server'; export async function POST(req: NextRequest) { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; + // Prefer user JWT for tenant-scoped reads; service role only if explicitly set (server) + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || ''; - // 1. Extraer mensaje del usuario con compatibilidad para cualquier estructura de body + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const tenantId = + user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null; + const companyName = user.user_metadata?.company || 'Cliente'; + if (!tenantId) { + return NextResponse.json( + { error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' }, + { status: 403 } + ); + } + + // 1. Extraer mensaje del usuario let userMessage = ''; try { const body = await req.json(); @@ -23,7 +40,6 @@ export async function POST(req: NextRequest) { console.error('Error parsing chat body:', e); } - // Normalizar consulta eliminando tildes, signos de interrogación y puntuación const normQuery = userMessage .toLowerCase() .normalize('NFD') @@ -32,24 +48,11 @@ export async function POST(req: NextRequest) { .replace(/\s+/g, ' ') .trim(); - // 2. Obtener tenant_id dinámico del usuario autenticado - let tenantId = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541'; - let companyName = '4Ventures Central'; - - try { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - if (user) { - tenantId = user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || tenantId; - companyName = user.user_metadata?.company || companyName; - } - } catch (e) { - console.error('Error resolving user tenant in chat:', e); - } - + // Use service role for RPC only after authn; still filter by tenant_id in every call + const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; const headers = { - apikey: serviceKey, - Authorization: `Bearer ${serviceKey}`, + apikey: keyForRpc, + Authorization: `Bearer ${keyForRpc}`, 'Content-Type': 'application/json' }; diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..6bb688b --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; + +/** Liveness for Dokploy / uptime — no secrets. */ +export async function GET() { + const hasAnon = Boolean(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim()); + const hasUrl = Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()); + const hasService = Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY?.trim()); + const ok = hasAnon && hasUrl; + return NextResponse.json( + { + ok, + service: '4ventures-client-portal', + supabaseConfigured: hasUrl && hasAnon, + serviceRoleConfigured: hasService, + ts: new Date().toISOString(), + }, + { status: ok ? 200 : 503 } + ); +} diff --git a/src/app/api/simulate/route.ts b/src/app/api/simulate/route.ts index df69e5a..ac455a6 100644 --- a/src/app/api/simulate/route.ts +++ b/src/app/api/simulate/route.ts @@ -1,9 +1,28 @@ -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; export async function POST(req: NextRequest) { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; - const tenantId = process.env.DEFAULT_TENANT_ID || '0b3c5fc0-6901-4caf-b26a-b6a0201d8541'; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || ''; + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const tenantId = + user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null; + if (!tenantId) { + return NextResponse.json( + { error: 'Usuario sin tenant_id' }, + { status: 403 } + ); + } + + const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; try { const body = await req.json(); @@ -13,25 +32,25 @@ export async function POST(req: NextRequest) { const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, { method: 'POST', headers: { - apikey: serviceKey, - Authorization: `Bearer ${serviceKey}`, - 'Content-Type': 'application/json' + apikey: keyForRpc, + Authorization: `Bearer ${keyForRpc}`, + 'Content-Type': 'application/json', }, body: JSON.stringify({ p_tenant_id: tenantId, p_additional_spend: spend, - p_target_channel: channel === 'blended' ? null : channel - }) + p_target_channel: channel === 'blended' ? null : channel, + }), }); if (res.ok) { const data = await res.json(); - return Response.json(data); - } else { - const err = await res.text(); - return Response.json({ error: err }, { status: 500 }); + return NextResponse.json(data); } - } catch (e: any) { - return Response.json({ error: e.message }, { status: 500 }); + const err = await res.text(); + return NextResponse.json({ error: err }, { status: 500 }); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'Error'; + return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/src/app/dashboard/activos/page.tsx b/src/app/dashboard/activos/page.tsx index db5abe4..15ab22b 100644 --- a/src/app/dashboard/activos/page.tsx +++ b/src/app/dashboard/activos/page.tsx @@ -1,34 +1,48 @@ import React from 'react'; import { FileText, CheckCircle2 } from 'lucide-react'; +import { createClient } from '@/lib/supabase/server'; export const revalidate = 0; export default async function ActivosPage() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; - const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); - let documents: any[] = []; - try { - const res = await fetch(`${supabaseUrl}/rest/v1/documents?select=*&order=created_at.desc`, { - headers: { - apikey: anonKey, - Authorization: `Bearer ${anonKey}` - }, - cache: 'no-store' - }); - if (res.ok) { - documents = await res.json(); + let documents: Array<{ + id: string; + type?: string; + title?: string; + content?: string; + version?: number; + created_at?: string; + status?: string; + }> = []; + + if (user) { + // Relies on RLS: only documents for the caller's tenant + const { data, error } = await supabase + .from('documents') + .select('id,type,title,content,version,created_at,status') + .eq('status', 'approved') + .order('created_at', { ascending: false }); + if (error) { + console.error('Error fetching documents:', error.message); + } else { + documents = data ?? []; } - } catch (e) { - console.error('Error fetching documents:', e); } return (
-

Biblioteca de Activos & Entregables

+

+ Biblioteca de Activos & Entregables +

- Documentos, copys, calendarios y briefs producidos por los agentes de IA y aprobados por el equipo en Slack. + Documentos, copys, calendarios y briefs producidos por los agentes de IA + y aprobados por el equipo en Slack. Filtrado por tenant (RLS).

@@ -36,14 +50,20 @@ export default async function ActivosPage() { {documents.length === 0 ? (
-

No hay activos aprobados todavía.

+

+ No hay activos aprobados todavía. +

- Los activos generados por los agentes aparecerán aquí tras ser validados en Slack. + Los activos generados por los agentes aparecerán aquí tras ser + validados en Slack.

) : ( documents.map((doc) => ( -
+
@@ -58,7 +78,11 @@ export default async function ActivosPage() {
Versión v{doc.version} - {new Date(doc.created_at).toLocaleDateString('es-ES')} + + {doc.created_at + ? new Date(doc.created_at).toLocaleDateString('es-ES') + : '—'} +
)) diff --git a/src/app/dashboard/agencia/page.tsx b/src/app/dashboard/agencia/page.tsx index 3cdc4c3..0148abd 100644 --- a/src/app/dashboard/agencia/page.tsx +++ b/src/app/dashboard/agencia/page.tsx @@ -1,16 +1,37 @@ import React from 'react'; import { Building2, DollarSign, Users, TrendingUp, Layers } from 'lucide-react'; import ClientManagement from '@/components/ClientManagement'; +import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; export const revalidate = 0; export default async function AgencyPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + + const role = user.app_metadata?.role || user.user_metadata?.role || 'client'; + if (role !== 'admin' && role !== 'team') { + redirect('/dashboard/kpis'); + } + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; + // Cross-tenant agency view requires service role — only after role gate above + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!serviceKey) { + return ( +
+ Falta SUPABASE_SERVICE_ROLE_KEY en el servidor para la vista agencia. +
+ ); + } const headers = { apikey: serviceKey, - Authorization: `Bearer ${serviceKey}` + Authorization: `Bearer ${serviceKey}`, }; let tenants: any[] = []; diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 2e14fd3..e76fb3a 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -9,23 +9,25 @@ export default async function CalendarioPage() { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); - const tenantId = user?.app_metadata?.tenant_id || user?.user_metadata?.tenant_id || '0b3c5fc0-6901-4caf-b26a-b6a0201d8541'; + const tenantId = user?.app_metadata?.tenant_id || user?.user_metadata?.tenant_id || null; const companyName = user?.user_metadata?.company || '4Ventures'; let calendarDocs: any[] = []; - try { - const { data, error } = await supabase - .from('documents') - .select('*') - .eq('type', 'calendar') - .eq('tenant_id', tenantId) - .order('created_at', { ascending: false }); + if (tenantId) { + try { + const { data, error } = await supabase + .from('documents') + .select('*') + .eq('type', 'calendar') + .eq('tenant_id', tenantId) + .order('created_at', { ascending: false }); - if (!error && data) { - calendarDocs = data; + if (!error && data) { + calendarDocs = data; + } + } catch (e) { + console.error('Error fetching calendar documents:', e); } - } catch (e) { - console.error('Error fetching calendar documents:', e); } // Parse posts from calendar document diff --git a/src/app/dashboard/creatives/page.tsx b/src/app/dashboard/creatives/page.tsx new file mode 100644 index 0000000..00b9068 --- /dev/null +++ b/src/app/dashboard/creatives/page.tsx @@ -0,0 +1,33 @@ +/** + * Stub: workflow de creatividades multi-tenant (upload, review, storage). + */ +import React from 'react'; +import { Image as ImageIcon, Upload } from 'lucide-react'; + +export const revalidate = 0; + +export default function CreativesPage() { + return ( +
+
+

+ Creatividades +

+

+ Revisión cliente/agencia · roadmap 4Ventures Centro de Mando. +

+
+
+ +

Próximo sprint

+

+ Upload (JPEG/PNG/WebP/MP4/PDF · máx 25MB), estados draft → in_review → + approved/rejected, comentarios y storage por tenant (Supabase Storage). +

+
+ docs/CONVERGENCE.md +
+
+
+ ); +} diff --git a/src/app/dashboard/estrategia/page.tsx b/src/app/dashboard/estrategia/page.tsx index 85251eb..42f6cdc 100644 --- a/src/app/dashboard/estrategia/page.tsx +++ b/src/app/dashboard/estrategia/page.tsx @@ -1,108 +1,92 @@ import React from 'react'; -import { Target, CheckCircle2, TrendingUp } from 'lucide-react'; +import { Target } from 'lucide-react'; import BudgetSimulator from '@/components/BudgetSimulator'; +import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; export const revalidate = 0; export default async function EstrategiaPage() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + + const tenantId = + user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null; const now = new Date(); const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; - let targetData: any = { - revenue_target: 10000, + let targetData: { + revenue_target: number; + revenue_actual: number; + revenue_achievement_pct: number; + leads_target: number; + leads_actual: number; + clients_target: number; + clients_actual: number; + } = { + revenue_target: 0, revenue_actual: 0, revenue_achievement_pct: 0, - leads_target: 20, + leads_target: 0, leads_actual: 0, - clients_target: 4, - clients_actual: 0 + clients_target: 0, + clients_actual: 0, }; - try { - const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, { - method: 'POST', - headers: { - apikey: serviceKey, - Authorization: `Bearer ${serviceKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ p_month: currentMonth }), - cache: 'no-store' + if (tenantId) { + const { data, error } = await supabase.rpc('targets_vs_actual', { + p_month: currentMonth, + p_tenant_id: tenantId, }); - if (res.ok) { - const data = await res.json(); - if (Array.isArray(data) && data.length > 0) { - targetData = data[0]; + if (error) { + // Some deployments only take p_month (tenant from JWT) + const fallback = await supabase.rpc('targets_vs_actual', { + p_month: currentMonth, + }); + if (!fallback.error && Array.isArray(fallback.data) && fallback.data[0]) { + targetData = { ...targetData, ...fallback.data[0] }; + } else { + console.error('targets_vs_actual', error.message); } + } else if (Array.isArray(data) && data[0]) { + targetData = { ...targetData, ...data[0] }; } - } catch (e) { - console.error('Error fetching targets_vs_actual:', e); } const revenuePct = Math.min(100, Math.round(targetData.revenue_achievement_pct || 0)); - const leadsPct = targetData.leads_target > 0 - ? Math.min(100, Math.round((Number(targetData.leads_actual) / Number(targetData.leads_target)) * 100)) - : 0; - const clientsPct = targetData.clients_target > 0 - ? Math.min(100, Math.round((Number(targetData.clients_actual) / Number(targetData.clients_target)) * 100)) - : 0; return (
-

Estrategia de Crecimiento & Objetivos

+

+ Estrategia de Crecimiento & Objetivos +

- Monitoreo mensual en tiempo real de metas fijadas frente al rendimiento real consolidado en Supabase. + Metas del mes vs real · scoped al tenant del usuario.

-
-
-
-

Objetivo de Facturación

- -
-

- {Number(targetData.revenue_actual || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € -

-
-
-
-

- Meta: {Number(targetData.revenue_target || 0).toLocaleString('es-ES')} € ({revenuePct}% completado) -

+
+
+ + Facturación {revenuePct}%
- -
-
-

Meta de Leads Cualificados

- -
-

{targetData.leads_actual || 0}

-
-
-
-

- {targetData.leads_actual || 0} de {targetData.leads_target || 0} leads captados ({leadsPct}%) -

-
- -
-
-

Nuevos Clientes (Won)

- -
-

{targetData.clients_actual || 0}

-
-
-
-

- {targetData.clients_actual || 0} de {targetData.clients_target || 0} clientes ganados ({clientsPct}%) -

+
+
+

+ {Number(targetData.revenue_actual).toLocaleString('es-ES')} € /{' '} + {Number(targetData.revenue_target).toLocaleString('es-ES')} € · leads{' '} + {targetData.leads_actual}/{targetData.leads_target} · clientes{' '} + {targetData.clients_actual}/{targetData.clients_target} +

diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index dccdb0b..175da7a 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -1,148 +1,109 @@ import React from 'react'; -import { - TrendingUp, - Users, - DollarSign, - Target, - Layers +import { + TrendingUp, + Users, + DollarSign, + Target, } from 'lucide-react'; +import { createClient } from '@/lib/supabase/server'; export const revalidate = 0; -export default async function KpisPage() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; - const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; +type KpiRow = { + total_spend?: number; + total_leads?: number; + cohort_revenue_won?: number; + cohort_deals_won?: number; + cash_cac?: number; + cohort_cac?: number; + channel?: string; + week?: string; +}; - let kpis: any[] = []; - try { - const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?select=*`, { - headers: { - apikey: anonKey, - Authorization: `Bearer ${anonKey}` - }, - cache: 'no-store' - }); - if (res.ok) { - kpis = await res.json(); - } - } catch (e) { - console.error('Error fetching kpis:', e); +export default async function KpisPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + let kpis: KpiRow[] = []; + if (user) { + // RLS must scope by tenant; never use service role here + const { data, error } = await supabase.from('kpi_weekly').select('*'); + if (error) console.error('kpi_weekly', error.message); + else kpis = (data as KpiRow[]) ?? []; } const totalSpend = kpis.reduce((acc, row) => acc + Number(row.total_spend || 0), 0); const totalLeads = kpis.reduce((acc, row) => acc + Number(row.total_leads || 0), 0); - const totalRevenue = kpis.reduce((acc, row) => acc + Number(row.cohort_revenue_won || 0), 0); - const dealsWon = kpis.reduce((acc, row) => acc + Number(row.cohort_deals_won || 0), 0); + const totalRevenue = kpis.reduce( + (acc, row) => acc + Number(row.cohort_revenue_won || 0), + 0 + ); + const dealsWon = kpis.reduce( + (acc, row) => acc + Number(row.cohort_deals_won || 0), + 0 + ); const avgCac = dealsWon > 0 ? (totalSpend / dealsWon).toFixed(2) : '0.00'; const overallRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0.00'; return (
-

Rendimiento & Métricas de Adquisición

+

+ Rendimiento & Métricas de Adquisición +

- Modelo de Atribución Oficial: Cohorte a 60 días con desglose comparativo de Caja Semanal. + Modelo: Cohorte vs caja · datos + filtrados por tenant (RLS).

-
+
- Inversión en Ads + + Inversión en Ads +
-

{totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €

- Windsor.ai (Meta + Google) +

+ {totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} € +

- -
+
- Leads Captados + Leads

{totalLeads}

- Atribución UTM 100% verificada
- -
+
- CAC Cohorte + + CAC Cohorte +

{avgCac} €

- Coste por cliente cerrado
- -
+
- ROAS Cohorte + + ROAS Cohorte +
-

{overallRoas}x

- Retorno sobre inversión de ads +

{overallRoas}x

-
-
-
- -

Desglose por Canal y Semana (kpi_weekly)

-
- Refresco automático: cada hora (pg_cron) -
- -
- - - - - - - - - - - - - - {kpis.length === 0 ? ( - - - - ) : ( - kpis.map((row, idx) => ( - - - - - - - - - - )) - )} - -
SemanaCanalLeadsInversiónVentas CohorteCAC CohorteROAS Cohorte
- No hay datos registrados aún. Los leads ingresados por el Webhook de n8n o Windsor.ai se consolidarán aquí. -
- {row.week ? new Date(row.week).toLocaleDateString('es-ES') : '-'} - - - {row.channel} - - {row.total_leads} - {Number(row.total_spend || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € - - {Number(row.cohort_revenue_won || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € - - {row.cac_cohort} € - - {row.roas_cohort}x -
-
-
+ {kpis.length === 0 && ( +

+ Sin filas en kpi_weekly para este tenant. Revisar pipeline + n8n/Windsor y políticas RLS. +

+ )}
); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 57716e8..b0ae316 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -71,6 +71,13 @@ export default async function DashboardLayout({ Biblioteca de Activos + + + Creatividades + req.cookies.set(name, value)); res = NextResponse.next({ - request: { - headers: req.headers, - }, + request: { headers: req.headers }, }); cookiesToSet.forEach(({ name, value, options }) => res.cookies.set(name, value, options) @@ -38,39 +52,64 @@ export async function middleware(req: NextRequest) { const url = req.nextUrl.clone(); const path = url.pathname; - // 1. Redirigir usuarios no autenticados que intenten acceder al dashboard - if (!user && (path === '/' || path.startsWith('/dashboard'))) { + // Public: login, attribution script, health + const isPublic = + path === '/login' || + path === '/api/health' || + path === '/_4v_attr.js' || + path.startsWith('/_next') || + path === '/favicon.ico' || + path === '/favicon.png' || + path === '/icon.png'; + + if (!user && !isPublic && (path === '/' || path.startsWith('/dashboard') || path.startsWith('/api/'))) { + if (path.startsWith('/api/')) { + return withSecurityHeaders( + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ); + } const loginUrl = req.nextUrl.clone(); loginUrl.pathname = '/login'; if (path.startsWith('/dashboard')) { loginUrl.searchParams.set('next', path); } - return NextResponse.redirect(loginUrl); + return withSecurityHeaders(NextResponse.redirect(loginUrl)); } - // 2. Redirigir usuarios autenticados que intenten acceder a /login if (user && path === '/login') { const dashboardUrl = req.nextUrl.clone(); dashboardUrl.pathname = '/dashboard/kpis'; - return NextResponse.redirect(dashboardUrl); + return withSecurityHeaders(NextResponse.redirect(dashboardUrl)); } - // 3. Proteger la vista directiva de agencia contra clientes const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client'; if (user && role === 'client' && path.startsWith('/dashboard/agencia')) { const forbiddenUrl = req.nextUrl.clone(); forbiddenUrl.pathname = '/dashboard/kpis'; - return NextResponse.redirect(forbiddenUrl); + return withSecurityHeaders(NextResponse.redirect(forbiddenUrl)); } - // 4. Resolver Tenant Slug (desde subdominio, metadata de usuario o cookie) - let tenantSlug = user?.user_metadata?.slug || user?.app_metadata?.slug || '4ventures-hub'; + if (user && role === 'client' && path.startsWith('/api/admin/')) { + return withSecurityHeaders( + NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + ); + } + + let tenantSlug = + user?.user_metadata?.slug || user?.app_metadata?.slug || '4ventures-hub'; const hostname = req.headers.get('host') || ''; const parts = hostname.split('.'); if (parts.length > 2) { const sub = parts[0].toLowerCase(); - if (sub !== 'app' && sub !== 'git' && sub !== 'supabase' && sub !== 'n8n' && sub !== 'www') { + if ( + sub !== 'app' && + sub !== 'git' && + sub !== 'supabase' && + sub !== 'n8n' && + sub !== 'n8n-agencia' && + sub !== 'www' + ) { tenantSlug = sub; } } @@ -90,11 +129,18 @@ export async function middleware(req: NextRequest) { if (user?.email) { res.headers.set('x-user-email', user.email); } - res.cookies.set('4v_tenant', tenantSlug, { path: '/', maxAge: 60 * 60 * 24 * 30 }); + res.cookies.set('4v_tenant', tenantSlug, { + path: '/', + maxAge: 60 * 60 * 24 * 30, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }); - return res; + return withSecurityHeaders(res); } export const config = { - matcher: ['/((?!_next/static|_next/image|favicon.ico|favicon.png|icon.png|_4v_attr.js|api/chat|api/simulate).*)'] + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|favicon.png|icon.png|_4v_attr.js).*)', + ], };