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..2532a4e 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/OPERATOR.md](./docs/OPERATOR.md) — alta `marketing@4ventures.es` +- [docs/PUSH.md](./docs/PUSH.md) — push a Gitea con token + +## 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/OPERATOR.md b/docs/OPERATOR.md new file mode 100644 index 0000000..4ac04ba --- /dev/null +++ b/docs/OPERATOR.md @@ -0,0 +1,55 @@ +# Operador marketing@4ventures.es + +La contraseña del portal legado **no** vale en Supabase Auth (`invalid_credentials`). + +## Alta / reset en Studio + +1. Abre https://supabase.4ventures.es (Studio) con cuenta admin de infra. +2. **Authentication → Users → Add user** + - Email: `marketing@4ventures.es` + - Password: genera una fuerte (guárdala en el gestor de la agencia) + - Auto Confirm: sí +3. En el usuario → **User Metadata** / **App Metadata** (JSON), por ejemplo: + +```json +{ + "role": "admin", + "company": "4Ventures", + "slug": "4ventures-hub" +} +``` + +App metadata (preferible para rol): + +```json +{ + "role": "admin", + "tenant_id": "" +} +``` + +4. Si usáis `provision_client`, también podéis: + +```sql +select public.provision_client( + '4Ventures', + '4ventures-hub', + 'marketing@4ventures.es', + 'CONTRASEÑA_FUERTE_AQUI', + 'admin' +); +``` + +5. Login en https://app.4ventures.es/login con ese email/password. + +## Rotación de API keys (obligatorio) + +Settings → API → Regenerate `anon` y `service_role`. +Actualizar en Dokploy env del contenedor `app` (y n8n si las usa). +Nunca volver a pegar keys en PDFs ni `.env.example`. + +## Verificación + +- Rol `admin` → ve `/dashboard/agencia` +- Rol `client` → redirigido fuera de agencia +- Chat/simulate solo con sesión diff --git a/docs/PUSH.md b/docs/PUSH.md new file mode 100644 index 0000000..8f92880 --- /dev/null +++ b/docs/PUSH.md @@ -0,0 +1,32 @@ +# Push a Gitea (bloqueado sin token) + +HTTPS a `git.4ventures.es` exige autenticación. SSH :22 hace timeout (solo HTTPS público). + +## Crear token (1 vez) + +1. Entra en https://git.4ventures.es (usuario con acceso a `Marketing/4ventures-client-portal`). +2. Settings → Applications → Generate New Token + - Nombre: `cursor-push` + - Scopes: `write:repository` +3. Copia el token (solo se muestra una vez). + +## Push de la rama de seguridad + +```bash +cd /Users/4ventures/4ventures-client-portal +git checkout harden/auth-tenant-security + +# Opción A — URL con usuario (el SO pedirá contraseña = el token) +git push -u origin harden/auth-tenant-security + +# Opción B — token en URL (no dejarlo en historial de shell si puedes evitarlo) +# git push -u https://USER:TOKEN@git.4ventures.es/Marketing/4ventures-client-portal.git HEAD +``` + +Usuario Gitea típico: el de Marcos / marketing (el que pueda escribir en Marketing). + +## Después del push + +1. Dokploy → servicio `app` → Redeploy / pull `harden/auth-tenant-security` o merge a `main`. +2. `curl -sf https://app.4ventures.es/api/health` +3. Login con operador aprovisionado (ver `docs/OPERATOR.md`). 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/creatives.sql b/sql/creatives.sql new file mode 100644 index 0000000..f2c8df5 --- /dev/null +++ b/sql/creatives.sql @@ -0,0 +1,48 @@ +-- Creatives multi-tenant (Supabase) +-- Run in SQL editor after reviewing names against existing schema. + +create table if not exists public.creatives ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + title text not null, + status text not null default 'draft' + check (status in ('draft', 'in_review', 'approved', 'rejected')), + mime_type text, + storage_path text, + bytes int, + sha256 text, + created_by uuid references auth.users(id), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists creatives_tenant_idx on public.creatives (tenant_id, updated_at desc); + +alter table public.creatives enable row level security; + +-- Adjust JWT claim path to match your Auth Hooks +create policy creatives_tenant_select on public.creatives + for select using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy creatives_tenant_insert on public.creatives + for insert with check ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy creatives_tenant_update on public.creatives + for update using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +-- Storage bucket suggestion: creatives (private), path = {tenant_id}/{id} 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/creatives/route.ts b/src/app/api/creatives/route.ts new file mode 100644 index 0000000..ee5e290 --- /dev/null +++ b/src/app/api/creatives/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + 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 } + ); + } + + return NextResponse.json({ creatives: data ?? [] }); +} + +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 }); + } + + 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 body = await req.json().catch(() => ({})); + const title = typeof body.title === 'string' ? body.title.trim() : ''; + if (!title) { + return NextResponse.json({ error: 'title requerido' }, { status: 400 }); + } + + const { data, error } = await supabase + .from('creatives') + .insert({ + tenant_id: tenantId, + title, + status: 'draft', + created_by: user.id, + }) + .select('id,title,status,created_at') + .single(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ creative: data }, { status: 201 }); +} 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..063f85d --- /dev/null +++ b/src/app/dashboard/creatives/page.tsx @@ -0,0 +1,115 @@ +/** + * Creatividades multi-tenant — lista vía /api/creatives (RLS). + */ +'use client'; + +import React, { FormEvent, useEffect, useState } from 'react'; +import { Image as ImageIcon, Plus } from 'lucide-react'; + +type Creative = { + id: string; + title: string; + status: string; + created_at?: string; +}; + +export default function CreativesPage() { + const [creatives, setCreatives] = useState([]); + const [title, setTitle] = useState(''); + const [warning, setWarning] = useState(null); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(true); + + async function load() { + setLoading(true); + const res = await fetch('/api/creatives'); + const data = await res.json(); + setCreatives(data.creatives ?? []); + setWarning(data.warning ?? null); + setLoading(false); + } + + useEffect(() => { + void load(); + }, []); + + async function create(e: FormEvent) { + e.preventDefault(); + setErr(null); + const res = await fetch('/api/creatives', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error ?? 'Error al crear'); + return; + } + setTitle(''); + await load(); + } + + return ( +
+
+

+ Creatividades +

+

+ Borradores por tenant · aplica sql/creatives.sql en Studio + antes del primer uso. +

+
+ + {warning && ( +

+ {warning} +

+ )} + {err &&

{err}

} + +
+
+ + setTitle(e.target.value)} + placeholder="Video Meta · testimonial" + required + /> +
+ +
+ + {loading ? ( +

Cargando…

+ ) : creatives.length === 0 ? ( +
+ +

Sin creatividades aún

+
+ ) : ( +
    + {creatives.map((c) => ( +
  • + {c.title} + + {c.status} + +
  • + ))} +
+ )} +
+ ); +} 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).*)', + ], };