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 <cursoragent@cursor.com>
This commit is contained in:
12
.env.example
12
.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_URL=https://supabase.4ventures.es
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6ImFub24iLCJpc3MiOiJzdXBhYmFzZSJ9.UWNn1leZNljna9Qwi9U5RtGqsh5_SnEoiPO8HkQIgi8
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=replace-with-anon-key
|
||||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlzcyI6InN1cGFiYXNlIn0.BVQLeVBvVcllXiq7tWLB2zZ-x_ZI9FLZW3lYwrs8fgk
|
SUPABASE_SERVICE_ROLE_KEY=replace-with-service-role-key
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
ANTHROPIC_API_KEY=replace-with-anthropic-key
|
||||||
|
|
||||||
|
# Optional ops
|
||||||
|
OPS_ALERT_EMAIL=
|
||||||
|
CRON_SECRET=
|
||||||
|
N8N_WEBHOOK_BASE=https://n8n-agencia.4ventures.es
|
||||||
|
|||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -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/
|
||||||
40
README.md
40
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
|
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 |
|
||||||
|
|||||||
39
SECURITY.md
Normal file
39
SECURITY.md
Normal file
@@ -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.
|
||||||
24
docs/CONVERGENCE.md
Normal file
24
docs/CONVERGENCE.md
Normal file
@@ -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
|
||||||
7
docs/IDENTITY.md
Normal file
7
docs/IDENTITY.md
Normal file
@@ -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.
|
||||||
33
docs/RUNBOOK.md
Normal file
33
docs/RUNBOOK.md
Normal file
@@ -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
|
||||||
|
<script src="https://app.4ventures.es/_4v_attr.js" defer></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST https://n8n-agencia.4ventures.es/webhook/lead-inbound`
|
||||||
|
|
||||||
|
## Incidentes
|
||||||
|
|
||||||
|
Ver `SECURITY.md`.
|
||||||
@@ -2,11 +2,23 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
typescript: {
|
poweredByHeader: false,
|
||||||
ignoreBuildErrors: true,
|
async headers() {
|
||||||
},
|
return [
|
||||||
eslint: {
|
{
|
||||||
ignoreDuringBuilds: true,
|
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=()',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
14
scripts/smoke.sh
Executable file
14
scripts/smoke.sh
Executable file
@@ -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"
|
||||||
27
sql/rls_checklist.sql
Normal file
27
sql/rls_checklist.sql
Normal file
@@ -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 = '<tenant_B>'; must be 0.
|
||||||
|
|
||||||
|
-- 5) Service role bypasses RLS — never expose to browser.
|
||||||
@@ -3,9 +3,26 @@ import { createClient } from '@/lib/supabase/server';
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
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 = '';
|
let userMessage = '';
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
@@ -23,7 +40,6 @@ export async function POST(req: NextRequest) {
|
|||||||
console.error('Error parsing chat body:', e);
|
console.error('Error parsing chat body:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalizar consulta eliminando tildes, signos de interrogación y puntuación
|
|
||||||
const normQuery = userMessage
|
const normQuery = userMessage
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
@@ -32,24 +48,11 @@ export async function POST(req: NextRequest) {
|
|||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// 2. Obtener tenant_id dinámico del usuario autenticado
|
// Use service role for RPC only after authn; still filter by tenant_id in every call
|
||||||
let tenantId = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
|
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
apikey: serviceKey,
|
apikey: keyForRpc,
|
||||||
Authorization: `Bearer ${serviceKey}`,
|
Authorization: `Bearer ${keyForRpc}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
19
src/app/api/health/route.ts
Normal file
19
src/app/api/health/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
export async function POST(req: NextRequest) {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
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 serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
|
||||||
const tenantId = process.env.DEFAULT_TENANT_ID || '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
|
|
||||||
|
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 {
|
try {
|
||||||
const body = await req.json();
|
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`, {
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
apikey: serviceKey,
|
apikey: keyForRpc,
|
||||||
Authorization: `Bearer ${serviceKey}`,
|
Authorization: `Bearer ${keyForRpc}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_tenant_id: tenantId,
|
p_tenant_id: tenantId,
|
||||||
p_additional_spend: spend,
|
p_additional_spend: spend,
|
||||||
p_target_channel: channel === 'blended' ? null : channel
|
p_target_channel: channel === 'blended' ? null : channel,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return Response.json(data);
|
return NextResponse.json(data);
|
||||||
} else {
|
|
||||||
const err = await res.text();
|
|
||||||
return Response.json({ error: err }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
const err = await res.text();
|
||||||
return Response.json({ error: e.message }, { status: 500 });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,48 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, CheckCircle2 } from 'lucide-react';
|
import { FileText, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function ActivosPage() {
|
export default async function ActivosPage() {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
const supabase = await createClient();
|
||||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
let documents: any[] = [];
|
let documents: Array<{
|
||||||
try {
|
id: string;
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/documents?select=*&order=created_at.desc`, {
|
type?: string;
|
||||||
headers: {
|
title?: string;
|
||||||
apikey: anonKey,
|
content?: string;
|
||||||
Authorization: `Bearer ${anonKey}`
|
version?: number;
|
||||||
},
|
created_at?: string;
|
||||||
cache: 'no-store'
|
status?: string;
|
||||||
});
|
}> = [];
|
||||||
if (res.ok) {
|
|
||||||
documents = await res.json();
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">Biblioteca de Activos & Entregables</h2>
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
|
Biblioteca de Activos & Entregables
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
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).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,14 +50,20 @@ export default async function ActivosPage() {
|
|||||||
{documents.length === 0 ? (
|
{documents.length === 0 ? (
|
||||||
<div className="col-span-3 bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
<div className="col-span-3 bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
||||||
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||||
<p className="text-base font-medium text-gray-300">No hay activos aprobados todavía.</p>
|
<p className="text-base font-medium text-gray-300">
|
||||||
|
No hay activos aprobados todavía.
|
||||||
|
</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
documents.map((doc) => (
|
documents.map((doc) => (
|
||||||
<div key={doc.id} className="bg-[#111827] border border-gray-800 rounded-xl p-5 flex flex-col justify-between">
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="bg-[#111827] border border-gray-800 rounded-xl p-5 flex flex-col justify-between"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="uppercase text-[10px] font-bold px-2 py-0.5 rounded bg-gray-800 text-gray-300">
|
<span className="uppercase text-[10px] font-bold px-2 py-0.5 rounded bg-gray-800 text-gray-300">
|
||||||
@@ -58,7 +78,11 @@ export default async function ActivosPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pt-4 border-t border-gray-800/80 mt-4 text-[11px] text-gray-400 flex justify-between">
|
<div className="pt-4 border-t border-gray-800/80 mt-4 text-[11px] text-gray-400 flex justify-between">
|
||||||
<span>Versión v{doc.version}</span>
|
<span>Versión v{doc.version}</span>
|
||||||
<span>{new Date(doc.created_at).toLocaleDateString('es-ES')}</span>
|
<span>
|
||||||
|
{doc.created_at
|
||||||
|
? new Date(doc.created_at).toLocaleDateString('es-ES')
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Building2, DollarSign, Users, TrendingUp, Layers } from 'lucide-react';
|
import { Building2, DollarSign, Users, TrendingUp, Layers } from 'lucide-react';
|
||||||
import ClientManagement from '@/components/ClientManagement';
|
import ClientManagement from '@/components/ClientManagement';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function AgencyPage() {
|
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 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 (
|
||||||
|
<div className="text-amber-400 text-sm">
|
||||||
|
Falta SUPABASE_SERVICE_ROLE_KEY en el servidor para la vista agencia.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
apikey: serviceKey,
|
apikey: serviceKey,
|
||||||
Authorization: `Bearer ${serviceKey}`
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
let tenants: any[] = [];
|
let tenants: any[] = [];
|
||||||
|
|||||||
@@ -9,23 +9,25 @@ export default async function CalendarioPage() {
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
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';
|
const companyName = user?.user_metadata?.company || '4Ventures';
|
||||||
|
|
||||||
let calendarDocs: any[] = [];
|
let calendarDocs: any[] = [];
|
||||||
try {
|
if (tenantId) {
|
||||||
const { data, error } = await supabase
|
try {
|
||||||
.from('documents')
|
const { data, error } = await supabase
|
||||||
.select('*')
|
.from('documents')
|
||||||
.eq('type', 'calendar')
|
.select('*')
|
||||||
.eq('tenant_id', tenantId)
|
.eq('type', 'calendar')
|
||||||
.order('created_at', { ascending: false });
|
.eq('tenant_id', tenantId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
calendarDocs = 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
|
// Parse posts from calendar document
|
||||||
|
|||||||
33
src/app/dashboard/creatives/page.tsx
Normal file
33
src/app/dashboard/creatives/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
|
Creatividades
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Revisión cliente/agencia · roadmap 4Ventures Centro de Mando.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#111827] border border-dashed border-gray-700 rounded-xl p-12 text-center">
|
||||||
|
<ImageIcon className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-300 font-medium">Próximo sprint</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-2 max-w-md mx-auto">
|
||||||
|
Upload (JPEG/PNG/WebP/MP4/PDF · máx 25MB), estados draft → in_review →
|
||||||
|
approved/rejected, comentarios y storage por tenant (Supabase Storage).
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 inline-flex items-center gap-2 text-amber-400/80 text-xs">
|
||||||
|
<Upload className="w-3.5 h-3.5" /> docs/CONVERGENCE.md
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,108 +1,92 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Target, CheckCircle2, TrendingUp } from 'lucide-react';
|
import { Target } from 'lucide-react';
|
||||||
import BudgetSimulator from '@/components/BudgetSimulator';
|
import BudgetSimulator from '@/components/BudgetSimulator';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function EstrategiaPage() {
|
export default async function EstrategiaPage() {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
const supabase = await createClient();
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
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 now = new Date();
|
||||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
|
||||||
let targetData: any = {
|
let targetData: {
|
||||||
revenue_target: 10000,
|
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_actual: 0,
|
||||||
revenue_achievement_pct: 0,
|
revenue_achievement_pct: 0,
|
||||||
leads_target: 20,
|
leads_target: 0,
|
||||||
leads_actual: 0,
|
leads_actual: 0,
|
||||||
clients_target: 4,
|
clients_target: 0,
|
||||||
clients_actual: 0
|
clients_actual: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
if (tenantId) {
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, {
|
const { data, error } = await supabase.rpc('targets_vs_actual', {
|
||||||
method: 'POST',
|
p_month: currentMonth,
|
||||||
headers: {
|
p_tenant_id: tenantId,
|
||||||
apikey: serviceKey,
|
|
||||||
Authorization: `Bearer ${serviceKey}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ p_month: currentMonth }),
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (error) {
|
||||||
const data = await res.json();
|
// Some deployments only take p_month (tenant from JWT)
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
const fallback = await supabase.rpc('targets_vs_actual', {
|
||||||
targetData = data[0];
|
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 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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">Estrategia de Crecimiento & Objetivos</h2>
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
|
Estrategia de Crecimiento & Objetivos
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
<div className="flex items-center gap-2 text-brand-400 mb-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<Target className="w-4 h-4" />
|
||||||
<h3 className="font-semibold text-white text-base">Objetivo de Facturación</h3>
|
<span className="text-sm font-semibold">Facturación {revenuePct}%</span>
|
||||||
<Target className="w-5 h-5 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-3xl font-extrabold text-white mb-2">
|
|
||||||
{Number(targetData.revenue_actual || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
|
||||||
</p>
|
|
||||||
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
|
|
||||||
<div className="bg-brand-500 h-2 rounded-full" style={{ width: `${revenuePct}%` }}></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Meta: {Number(targetData.revenue_target || 0).toLocaleString('es-ES')} € ({revenuePct}% completado)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
<div
|
||||||
<div className="flex items-center justify-between mb-4">
|
className="h-full bg-brand-500"
|
||||||
<h3 className="font-semibold text-white text-base">Meta de Leads Cualificados</h3>
|
style={{ width: `${revenuePct}%` }}
|
||||||
<TrendingUp className="w-5 h-5 text-blue-400" />
|
/>
|
||||||
</div>
|
|
||||||
<p className="text-3xl font-extrabold text-white mb-2">{targetData.leads_actual || 0}</p>
|
|
||||||
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
|
|
||||||
<div className="bg-blue-400 h-2 rounded-full" style={{ width: `${leadsPct}%` }}></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
{targetData.leads_actual || 0} de {targetData.leads_target || 0} leads captados ({leadsPct}%)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="font-semibold text-white text-base">Nuevos Clientes (Won)</h3>
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-3xl font-extrabold text-white mb-2">{targetData.clients_actual || 0}</p>
|
|
||||||
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
|
|
||||||
<div className="bg-emerald-400 h-2 rounded-full" style={{ width: `${clientsPct}%` }}></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
{targetData.clients_actual || 0} de {targetData.clients_target || 0} clientes ganados ({clientsPct}%)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-3">
|
||||||
|
{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}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BudgetSimulator />
|
<BudgetSimulator />
|
||||||
|
|||||||
@@ -4,145 +4,106 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Target,
|
Target,
|
||||||
Layers
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function KpisPage() {
|
type KpiRow = {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
total_spend?: number;
|
||||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
total_leads?: number;
|
||||||
|
cohort_revenue_won?: number;
|
||||||
|
cohort_deals_won?: number;
|
||||||
|
cash_cac?: number;
|
||||||
|
cohort_cac?: number;
|
||||||
|
channel?: string;
|
||||||
|
week?: string;
|
||||||
|
};
|
||||||
|
|
||||||
let kpis: any[] = [];
|
export default async function KpisPage() {
|
||||||
try {
|
const supabase = await createClient();
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?select=*`, {
|
const {
|
||||||
headers: {
|
data: { user },
|
||||||
apikey: anonKey,
|
} = await supabase.auth.getUser();
|
||||||
Authorization: `Bearer ${anonKey}`
|
|
||||||
},
|
let kpis: KpiRow[] = [];
|
||||||
cache: 'no-store'
|
if (user) {
|
||||||
});
|
// RLS must scope by tenant; never use service role here
|
||||||
if (res.ok) {
|
const { data, error } = await supabase.from('kpi_weekly').select('*');
|
||||||
kpis = await res.json();
|
if (error) console.error('kpi_weekly', error.message);
|
||||||
}
|
else kpis = (data as KpiRow[]) ?? [];
|
||||||
} catch (e) {
|
|
||||||
console.error('Error fetching kpis:', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalSpend = kpis.reduce((acc, row) => acc + Number(row.total_spend || 0), 0);
|
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 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 totalRevenue = kpis.reduce(
|
||||||
const dealsWon = kpis.reduce((acc, row) => acc + Number(row.cohort_deals_won || 0), 0);
|
(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 avgCac = dealsWon > 0 ? (totalSpend / dealsWon).toFixed(2) : '0.00';
|
||||||
const overallRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0.00';
|
const overallRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0.00';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">Rendimiento & Métricas de Adquisición</h2>
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
|
Rendimiento & Métricas de Adquisición
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
Modelo de Atribución Oficial: <strong className="text-gray-200">Cohorte a 60 días</strong> con desglose comparativo de Caja Semanal.
|
Modelo: <strong className="text-gray-200">Cohorte</strong> vs caja · datos
|
||||||
|
filtrados por tenant (RLS).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||||
<div className="flex items-center justify-between text-gray-400 mb-2">
|
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wider">Inversión en Ads</span>
|
<span className="text-xs font-medium uppercase tracking-wider">
|
||||||
|
Inversión en Ads
|
||||||
|
</span>
|
||||||
<DollarSign className="w-4 h-4 text-brand-500" />
|
<DollarSign className="w-4 h-4 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-white">{totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €</p>
|
<p className="text-2xl font-bold text-white">
|
||||||
<span className="text-[11px] text-gray-400 mt-1 inline-block">Windsor.ai (Meta + Google)</span>
|
{totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between text-gray-400 mb-2">
|
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wider">Leads Captados</span>
|
<span className="text-xs font-medium uppercase tracking-wider">Leads</span>
|
||||||
<Users className="w-4 h-4 text-blue-400" />
|
<Users className="w-4 h-4 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-white">{totalLeads}</p>
|
<p className="text-2xl font-bold text-white">{totalLeads}</p>
|
||||||
<span className="text-[11px] text-emerald-400 mt-1 inline-block">Atribución UTM 100% verificada</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between text-gray-400 mb-2">
|
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wider">CAC Cohorte</span>
|
<span className="text-xs font-medium uppercase tracking-wider">
|
||||||
|
CAC Cohorte
|
||||||
|
</span>
|
||||||
<Target className="w-4 h-4 text-amber-400" />
|
<Target className="w-4 h-4 text-amber-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-white">{avgCac} €</p>
|
<p className="text-2xl font-bold text-white">{avgCac} €</p>
|
||||||
<span className="text-[11px] text-gray-400 mt-1 inline-block">Coste por cliente cerrado</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between text-gray-400 mb-2">
|
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wider">ROAS Cohorte</span>
|
<span className="text-xs font-medium uppercase tracking-wider">
|
||||||
|
ROAS Cohorte
|
||||||
|
</span>
|
||||||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-emerald-400">{overallRoas}x</p>
|
<p className="text-2xl font-bold text-white">{overallRoas}x</p>
|
||||||
<span className="text-[11px] text-gray-400 mt-1 inline-block">Retorno sobre inversión de ads</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl overflow-hidden shadow-sm">
|
{kpis.length === 0 && (
|
||||||
<div className="p-5 border-b border-gray-800 flex items-center justify-between">
|
<p className="text-sm text-gray-500">
|
||||||
<div className="flex items-center gap-2">
|
Sin filas en <code>kpi_weekly</code> para este tenant. Revisar pipeline
|
||||||
<Layers className="w-4 h-4 text-brand-500" />
|
n8n/Windsor y políticas RLS.
|
||||||
<h3 className="font-semibold text-white text-sm">Desglose por Canal y Semana (kpi_weekly)</h3>
|
</p>
|
||||||
</div>
|
)}
|
||||||
<span className="text-xs text-gray-400">Refresco automático: cada hora (pg_cron)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left text-sm">
|
|
||||||
<thead className="bg-[#0D121F] text-gray-400 text-xs uppercase tracking-wider border-b border-gray-800">
|
|
||||||
<tr>
|
|
||||||
<th className="py-3.5 px-5 font-semibold">Semana</th>
|
|
||||||
<th className="py-3.5 px-5 font-semibold">Canal</th>
|
|
||||||
<th className="py-3.5 px-5 font-semibold text-right">Leads</th>
|
|
||||||
<th className="py-3.5 px-5 font-semibold text-right">Inversión</th>
|
|
||||||
<th className="py-3.5 px-5 font-semibold text-right">Ventas Cohorte</th>
|
|
||||||
<th className="py-3.5 px-5 font-semibold text-right">CAC Cohorte</th>
|
|
||||||
<th className="py-3.5 px-5 font-semibold text-right">ROAS Cohorte</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-800/80 text-gray-200">
|
|
||||||
{kpis.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="py-8 text-center text-gray-400">
|
|
||||||
No hay datos registrados aún. Los leads ingresados por el Webhook de n8n o Windsor.ai se consolidarán aquí.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
kpis.map((row, idx) => (
|
|
||||||
<tr key={idx} className="hover:bg-gray-800/30 transition-colors">
|
|
||||||
<td className="py-3.5 px-5 font-mono text-xs text-gray-400">
|
|
||||||
{row.week ? new Date(row.week).toLocaleDateString('es-ES') : '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3.5 px-5">
|
|
||||||
<span className="capitalize font-medium text-white px-2 py-0.5 rounded bg-gray-800/80 text-xs border border-gray-700/60">
|
|
||||||
{row.channel}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3.5 px-5 text-right font-medium">{row.total_leads}</td>
|
|
||||||
<td className="py-3.5 px-5 text-right font-mono">
|
|
||||||
{Number(row.total_spend || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
|
||||||
</td>
|
|
||||||
<td className="py-3.5 px-5 text-right font-mono text-emerald-400">
|
|
||||||
{Number(row.cohort_revenue_won || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
|
||||||
</td>
|
|
||||||
<td className="py-3.5 px-5 text-right font-mono font-medium text-amber-400">
|
|
||||||
{row.cac_cohort} €
|
|
||||||
</td>
|
|
||||||
<td className="py-3.5 px-5 text-right font-mono font-bold text-emerald-400">
|
|
||||||
{row.roas_cohort}x
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export default async function DashboardLayout({
|
|||||||
<FolderGit2 className="w-4 h-4 text-blue-400" />
|
<FolderGit2 className="w-4 h-4 text-blue-400" />
|
||||||
Biblioteca de Activos
|
Biblioteca de Activos
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/creatives"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
>
|
||||||
|
<FolderGit2 className="w-4 h-4 text-orange-400" />
|
||||||
|
Creatividades
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/asistente"
|
href="/dashboard/asistente"
|
||||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import { createServerClient } from '@supabase/ssr';
|
import { createServerClient } from '@supabase/ssr';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
function withSecurityHeaders(res: NextResponse) {
|
||||||
|
res.headers.set('X-Frame-Options', 'DENY');
|
||||||
|
res.headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
res.headers.set('X-Robots-Tag', 'noindex, nofollow');
|
||||||
|
res.headers.set(
|
||||||
|
'Permissions-Policy',
|
||||||
|
'camera=(), microphone=(), geolocation=()'
|
||||||
|
);
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
res.headers.set(
|
||||||
|
'Strict-Transport-Security',
|
||||||
|
'max-age=63072000; includeSubDomains; preload'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
let res = NextResponse.next({
|
let res = NextResponse.next({
|
||||||
request: {
|
request: { headers: req.headers },
|
||||||
headers: req.headers,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const supabase = createServerClient(
|
const supabase = createServerClient(
|
||||||
@@ -19,9 +35,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
setAll(cookiesToSet) {
|
setAll(cookiesToSet) {
|
||||||
cookiesToSet.forEach(({ name, value }) => req.cookies.set(name, value));
|
cookiesToSet.forEach(({ name, value }) => req.cookies.set(name, value));
|
||||||
res = NextResponse.next({
|
res = NextResponse.next({
|
||||||
request: {
|
request: { headers: req.headers },
|
||||||
headers: req.headers,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
res.cookies.set(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 url = req.nextUrl.clone();
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
|
|
||||||
// 1. Redirigir usuarios no autenticados que intenten acceder al dashboard
|
// Public: login, attribution script, health
|
||||||
if (!user && (path === '/' || path.startsWith('/dashboard'))) {
|
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();
|
const loginUrl = req.nextUrl.clone();
|
||||||
loginUrl.pathname = '/login';
|
loginUrl.pathname = '/login';
|
||||||
if (path.startsWith('/dashboard')) {
|
if (path.startsWith('/dashboard')) {
|
||||||
loginUrl.searchParams.set('next', path);
|
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') {
|
if (user && path === '/login') {
|
||||||
const dashboardUrl = req.nextUrl.clone();
|
const dashboardUrl = req.nextUrl.clone();
|
||||||
dashboardUrl.pathname = '/dashboard/kpis';
|
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';
|
const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client';
|
||||||
if (user && role === 'client' && path.startsWith('/dashboard/agencia')) {
|
if (user && role === 'client' && path.startsWith('/dashboard/agencia')) {
|
||||||
const forbiddenUrl = req.nextUrl.clone();
|
const forbiddenUrl = req.nextUrl.clone();
|
||||||
forbiddenUrl.pathname = '/dashboard/kpis';
|
forbiddenUrl.pathname = '/dashboard/kpis';
|
||||||
return NextResponse.redirect(forbiddenUrl);
|
return withSecurityHeaders(NextResponse.redirect(forbiddenUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Resolver Tenant Slug (desde subdominio, metadata de usuario o cookie)
|
if (user && role === 'client' && path.startsWith('/api/admin/')) {
|
||||||
let tenantSlug = user?.user_metadata?.slug || user?.app_metadata?.slug || '4ventures-hub';
|
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 hostname = req.headers.get('host') || '';
|
||||||
const parts = hostname.split('.');
|
const parts = hostname.split('.');
|
||||||
|
|
||||||
if (parts.length > 2) {
|
if (parts.length > 2) {
|
||||||
const sub = parts[0].toLowerCase();
|
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;
|
tenantSlug = sub;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,11 +129,18 @@ export async function middleware(req: NextRequest) {
|
|||||||
if (user?.email) {
|
if (user?.email) {
|
||||||
res.headers.set('x-user-email', 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 = {
|
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).*)',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user