From f728c33533f29884c8bdf3cb78bc0eef1f3e8baa Mon Sep 17 00:00:00 2001
From: Marcos Fargas
Date: Mon, 21 Sep 2026 01:51:50 +0200
Subject: [PATCH] feat(portal): complete masterplan with deterministic budget
simulator, RAG assistant, agency cross-tenant view and multi-tenant
middleware
---
src/app/api/chat/route.ts | 73 ++++++++++
src/app/api/simulate/route.ts | 37 +++++
src/app/dashboard/agencia/page.tsx | 150 +++++++++++++++++++
src/app/dashboard/estrategia/page.tsx | 3 +
src/app/dashboard/layout.tsx | 13 +-
src/components/BudgetSimulator.tsx | 198 ++++++++++++++++++++++++++
src/middleware.ts | 43 ++++++
7 files changed, 516 insertions(+), 1 deletion(-)
create mode 100644 src/app/api/simulate/route.ts
create mode 100644 src/app/dashboard/agencia/page.tsx
create mode 100644 src/components/BudgetSimulator.tsx
create mode 100644 src/middleware.ts
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index ab4d968..f02a311 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -19,6 +19,79 @@ export async function POST(req: NextRequest) {
'Content-Type': 'application/json'
};
+ // 0. Intención: Simulación Determinista "¿Qué pasa si...?"
+ if (userMessage.includes('simul') || userMessage.includes('invierto') || userMessage.includes('pasa si') || userMessage.includes('proyec')) {
+ try {
+ // Extraer importe numérico
+ const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
+ let amount = 1000;
+ if (matchNumber) {
+ amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
+ }
+
+ let channel: string | null = null;
+ if (userMessage.includes('meta') || userMessage.includes('facebook') || userMessage.includes('instagram')) channel = 'meta';
+ else if (userMessage.includes('google')) channel = 'google';
+ else if (userMessage.includes('tiktok')) channel = 'tiktok';
+
+ const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ p_tenant_id: tenantId,
+ p_additional_spend: amount,
+ p_target_channel: channel
+ })
+ });
+
+ if (res.ok) {
+ const sim = await res.json();
+ const p10 = sim.conservative_p10 || {};
+ const p50 = sim.expected_p50 || {};
+ const p90 = sim.optimistic_p90 || {};
+ const base = sim.baseline || {};
+
+ const text = `🔮 **Simulación de Inversión Adicional (${amount.toLocaleString('es-ES')} € en ${sim.target_channel.toUpperCase()}):**\n\n` +
+ `*Métricas Base Reales:* CPL ${base.historical_cpl} €, Tasa de Cierre ${base.conversion_rate_pct}%, Ticket Medio ${Number(base.avg_ticket || 0).toLocaleString('es-ES')} €.\n\n` +
+ `- **Escenario Esperado (p50):** +${p50.leads} leads, +${p50.deals} clientes, **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p50.roas}x)\n` +
+ `- **Escenario Conservador (p10):** +${p10.leads} leads, +${p10.deals} clientes, **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p10.roas}x)\n` +
+ `- **Escenario Optimista (p90):** +${p90.leads} leads, +${p90.deals} clientes, **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p90.roas}x)\n\n` +
+ `*Proyección calculada matemáticamente sin alucinaciones con la RPC simulate_budget.*`;
+ return Response.json([{ text }]);
+ }
+ } catch (e) {
+ console.error('Error in simulate_budget chat:', e);
+ }
+ }
+
+ // 0.5 Intención: RAG Documental Híbrido (Documentos / Copys / Informes)
+ if (userMessage.includes('document') || userMessage.includes('copy') || userMessage.includes('informe') || userMessage.includes('brief') || userMessage.includes('contenido') || userMessage.includes('activo')) {
+ try {
+ const res = await fetch(`${supabaseUrl}/rest/v1/rpc/match_documents`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ p_tenant_id: tenantId,
+ p_query_text: userMessage,
+ p_match_count: 3
+ })
+ });
+ if (res.ok) {
+ const matches = await res.json();
+ if (Array.isArray(matches) && matches.length > 0) {
+ const excerpts = matches.map((m: any) =>
+ `📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
+ ).join('\n\n');
+
+ const text = `📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Fuente: Búsqueda vectorial y de texto completo en document_chunks.*`;
+ return Response.json([{ text }]);
+ }
+ }
+ } catch (e) {
+ console.error('Error in match_documents chat:', e);
+ }
+ }
+
// 1. Intención: Objetivos vs Real / Estrategia
if (userMessage.includes('objetivo') || userMessage.includes('meta') || userMessage.includes('factura') || userMessage.includes('estrategia')) {
try {
diff --git a/src/app/api/simulate/route.ts b/src/app/api/simulate/route.ts
new file mode 100644
index 0000000..df69e5a
--- /dev/null
+++ b/src/app/api/simulate/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest } from 'next/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';
+
+ try {
+ const body = await req.json();
+ const spend = parseFloat(body.spend || 1000);
+ const channel = body.channel || 'meta';
+
+ const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
+ method: 'POST',
+ headers: {
+ apikey: serviceKey,
+ Authorization: `Bearer ${serviceKey}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ p_tenant_id: tenantId,
+ p_additional_spend: spend,
+ 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 });
+ }
+ } catch (e: any) {
+ return Response.json({ error: e.message }, { status: 500 });
+ }
+}
diff --git a/src/app/dashboard/agencia/page.tsx b/src/app/dashboard/agencia/page.tsx
new file mode 100644
index 0000000..2df4899
--- /dev/null
+++ b/src/app/dashboard/agencia/page.tsx
@@ -0,0 +1,150 @@
+import React from 'react';
+import { Building2, DollarSign, Users, TrendingUp, Layers } from 'lucide-react';
+
+export const revalidate = 0;
+
+export default async function AgencyPage() {
+ 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 headers = {
+ apikey: serviceKey,
+ Authorization: `Bearer ${serviceKey}`
+ };
+
+ let tenants: any[] = [];
+ let totalSpend = 0;
+ let totalLeads = 0;
+ let totalWonDeals = 0;
+ let totalRevenue = 0;
+
+ try {
+ const [tenantsRes, adRes, leadsRes, dealsRes] = await Promise.all([
+ fetch(`${supabaseUrl}/rest/v1/tenants?select=*&order=created_at.asc`, { headers, cache: 'no-store' }),
+ fetch(`${supabaseUrl}/rest/v1/ad_daily?select=spend`, { headers, cache: 'no-store' }),
+ fetch(`${supabaseUrl}/rest/v1/leads?select=id`, { headers, cache: 'no-store' }),
+ fetch(`${supabaseUrl}/rest/v1/deals?select=amount&stage=eq.won`, { headers, cache: 'no-store' })
+ ]);
+
+ if (tenantsRes.ok) tenants = await tenantsRes.json();
+ if (adRes.ok) {
+ const ads = await adRes.json();
+ totalSpend = ads.reduce((acc: number, curr: any) => acc + Number(curr.spend || 0), 0);
+ }
+ if (leadsRes.ok) {
+ const leads = await leadsRes.json();
+ totalLeads = leads.length;
+ }
+ if (dealsRes.ok) {
+ const deals = await dealsRes.json();
+ totalWonDeals = deals.length;
+ totalRevenue = deals.reduce((acc: number, curr: any) => acc + Number(curr.amount || 0), 0);
+ }
+ } catch (e) {
+ console.error('Error in AgencyPage:', e);
+ }
+
+ const blendedRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0';
+
+ return (
+
+
+
+ Vista Global de Agencia (Cross-Tenant)
+
+
Centro de Mando 4Ventures
+
+ Métricas consolidadas de infraestructura, inversión publicitaria y cartera de clientes.
+
+
+
+ {/* Global Agency KPI Cards */}
+
+
+
+ Clientes / Tenants
+
+
+
{tenants.length}
+
100% aislados vía RLS
+
+
+
+
+ Inversión Gestionada
+
+
+
+ {totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
+
+
Vía Windsor.ai pipeline
+
+
+
+
+ Leads Captados
+
+
+
{totalLeads}
+
{totalWonDeals} cerrados ganados
+
+
+
+
+ ROAS Blended Agencia
+
+
+
{blendedRoas}x
+
+ {totalRevenue.toLocaleString('es-ES', { minimumFractionDigits: 2 })} € generados
+
+
+
+
+ {/* Tenants Table */}
+
+
+
Cartera de Clientes Activos
+
+ Cada cliente cuenta con aislamiento nativo Row Level Security (RLS) en Postgres.
+
+
+
+
+
+
+ | Nombre del Cliente |
+ Tenant Slug |
+ UUID |
+ Acceso Portal |
+ Estado |
+
+
+
+ {tenants.map((t) => (
+
+ | {t.name} |
+ {t.slug} |
+ {t.id} |
+
+
+ Abrir Portal ↗
+
+ |
+
+
+ Activo
+
+ |
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/app/dashboard/estrategia/page.tsx b/src/app/dashboard/estrategia/page.tsx
index fdb9dd7..85251eb 100644
--- a/src/app/dashboard/estrategia/page.tsx
+++ b/src/app/dashboard/estrategia/page.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { Target, CheckCircle2, TrendingUp } from 'lucide-react';
+import BudgetSimulator from '@/components/BudgetSimulator';
export const revalidate = 0;
@@ -103,6 +104,8 @@ export default async function EstrategiaPage() {
+
+
);
}
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index 404fa84..6abe019 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -5,7 +5,8 @@ import {
Target,
FolderGit2,
Bot,
- ShieldCheck
+ ShieldCheck,
+ Layers
} from 'lucide-react';
export default function DashboardLayout({
@@ -57,6 +58,16 @@ export default function DashboardLayout({
Asistente IA Financiero
+
+ Agencia OS
+
+
+ Cartera Cross-Tenant
+
+
diff --git a/src/components/BudgetSimulator.tsx b/src/components/BudgetSimulator.tsx
new file mode 100644
index 0000000..1b56449
--- /dev/null
+++ b/src/components/BudgetSimulator.tsx
@@ -0,0 +1,198 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Calculator, TrendingUp, ShieldCheck, Sparkles } from 'lucide-react';
+
+export default function BudgetSimulator() {
+ const [spend, setSpend] = useState(1000);
+ const [channel, setChannel] = useState('meta');
+ const [simulation, setSimulation] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ fetchSimulation(spend, channel);
+ }, 200);
+ return () => clearTimeout(timer);
+ }, [spend, channel]);
+
+ async function fetchSimulation(amount: number, ch: string) {
+ setLoading(true);
+ try {
+ const res = await fetch('/api/simulate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ spend: amount, channel: ch })
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setSimulation(data);
+ }
+ } catch (e) {
+ console.error('Error simulating budget:', e);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const p10 = simulation?.conservative_p10 || {};
+ const p50 = simulation?.expected_p50 || {};
+ const p90 = simulation?.optimistic_p90 || {};
+ const baseline = simulation?.baseline || {};
+
+ return (
+
+
+
+
+
+
Simulador Determinista "¿Qué pasa si...?"
+
+
+ Proyección matemática basada en el histórico de CPL, tasa de cierre y ticket medio en Supabase.
+
+
+
+ {['meta', 'google', 'tiktok', 'blended'].map((ch) => (
+
+ ))}
+
+
+
+ {/* Control del presupuesto */}
+
+
+
+
+ {spend.toLocaleString('es-ES')} €
+
+
+
setSpend(Number(e.target.value))}
+ className="w-full accent-brand-500 bg-gray-800 rounded-lg cursor-pointer h-2"
+ />
+
+ 200 €
+ 2.500 €
+ 5.000 €
+ 10.000 €
+
+
+
+ {/* Baseline Info */}
+
+ Parámetros Base Reales:
+
+ CPL Histórico: {Number(baseline.historical_cpl || 0).toFixed(2)} €
+
+
+ Tasa Cierre: {baseline.conversion_rate_pct || 0}%
+
+
+ Ticket Medio: {Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} €
+
+
+
+ {/* Las 3 Bandas de Confianza */}
+
+ {/* Conservador p10 */}
+
+
+
+ Conservador (p10)
+
+
+
+ +{Number(p10.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
+
+
+ ROAS {p10.roas || 0}x
+
+
+
+
+ Leads estimados:
+ {p10.leads || 0}
+
+
+ Ventas cerradas:
+ {p10.deals || 0}
+
+
+
+
+ {/* Esperado p50 (Destacado) */}
+
+
+ Más Probable
+
+
+
+ Esperado (p50)
+
+
+
+ +{Number(p50.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
+
+
+ ROAS {p50.roas || 0}x
+
+
+
+
+ Leads estimados:
+ {p50.leads || 0}
+
+
+ Ventas cerradas:
+ {p50.deals || 0}
+
+
+
+
+ {/* Optimista p90 */}
+
+
+
+ Optimista (p90)
+
+
+
+ +{Number(p90.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
+
+
+ ROAS {p90.roas || 0}x
+
+
+
+
+ Leads estimados:
+ {p90.leads || 0}
+
+
+ Ventas cerradas:
+ {p90.deals || 0}
+
+
+
+
+
+ );
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..750bb02
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+export function middleware(req: NextRequest) {
+ const url = req.nextUrl.clone();
+ const hostname = req.headers.get('host') || '';
+
+ // 1. Extraer slug desde subdominio (ej: acme.4ventures.es)
+ let tenantSlug = '4ventures-hub';
+ const parts = hostname.split('.');
+
+ if (parts.length > 2) {
+ const sub = parts[0].toLowerCase();
+ if (sub !== 'app' && sub !== 'git' && sub !== 'supabase' && sub !== 'n8n' && sub !== 'www') {
+ tenantSlug = sub;
+ }
+ }
+
+ // 2. Override opcional vía query param ?tenant=slug
+ const queryTenant = url.searchParams.get('tenant');
+ if (queryTenant) {
+ tenantSlug = queryTenant.toLowerCase();
+ } else {
+ const cookieTenant = req.cookies.get('4v_tenant')?.value;
+ if (cookieTenant) {
+ tenantSlug = cookieTenant;
+ }
+ }
+
+ const response = NextResponse.next({
+ request: {
+ headers: new Headers(req.headers)
+ }
+ });
+
+ response.headers.set('x-tenant-slug', tenantSlug);
+ response.cookies.set('4v_tenant', tenantSlug, { path: '/', maxAge: 60 * 60 * 24 * 30 });
+
+ return response;
+}
+
+export const config = {
+ matcher: ['/((?!_next/static|_next/image|favicon.ico|_4v_attr.js).*)']
+};