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:
4Ventures
2026-09-21 10:51:46 +02:00
parent e7b6ab71ca
commit 2df3fe6983
21 changed files with 621 additions and 276 deletions

View File

@@ -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 (
<div className="space-y-8">
<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">
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>
</div>
<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">
<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" />
</div>
<p className="text-2xl font-bold text-white">{totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €</p>
<span className="text-[11px] text-gray-400 mt-1 inline-block">Windsor.ai (Meta + Google)</span>
<p className="text-2xl font-bold text-white">
{totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
</p>
</div>
<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">
<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" />
</div>
<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 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">
<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" />
</div>
<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 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">
<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" />
</div>
<p className="text-2xl font-bold text-emerald-400">{overallRoas}x</p>
<span className="text-[11px] text-gray-400 mt-1 inline-block">Retorno sobre inversión de ads</span>
<p className="text-2xl font-bold text-white">{overallRoas}x</p>
</div>
</div>
<div className="bg-[#111827] border border-gray-800 rounded-xl overflow-hidden shadow-sm">
<div className="p-5 border-b border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-brand-500" />
<h3 className="font-semibold text-white text-sm">Desglose por Canal y Semana (kpi_weekly)</h3>
</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>
{kpis.length === 0 && (
<p className="text-sm text-gray-500">
Sin filas en <code>kpi_weekly</code> para este tenant. Revisar pipeline
n8n/Windsor y políticas RLS.
</p>
)}
</div>
);
}