Add B2B reformista panel with Postgres/Drizzle data layer

Modela el funnel del lead en dos dimensiones (pipeline_stage técnico
de 7 pasos + estado comercial de 6 estados) y siembra 11 leads demo,
uno por cada momento del funnel, para analizar el siguiente paso.
Incluye panel /panel (lista + detalle RF-D-01/02) y wiring de deploy
(Dockerfile multi-stage + entrypoint migrate+seed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-05-29 15:51:10 +02:00
parent 9020c24e68
commit f09024f753
20 changed files with 3630 additions and 2 deletions

29
mvp/b2c/src/db/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
// Cliente perezoso: solo se conecta en el primer acceso real a la DB.
// Evita que `next build` (que importa los módulos de ruta) falle si no hay
// DATABASE_URL en tiempo de build.
let _db: PostgresJsDatabase<typeof schema> | null = null;
function getDb(): PostgresJsDatabase<typeof schema> {
if (_db) return _db;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL no está definida. Copia .env.example a .env.local y rellénala.');
}
const client = postgres(connectionString, { prepare: false });
_db = drizzle(client, { schema });
return _db;
}
export const db = new Proxy({} as PostgresJsDatabase<typeof schema>, {
get(_target, prop) {
const instance = getDb();
const value = instance[prop as keyof typeof instance];
return typeof value === 'function' ? value.bind(instance) : value;
},
});
export { schema };

66
mvp/b2c/src/db/queries.ts Normal file
View File

@@ -0,0 +1,66 @@
import { and, asc, desc, eq } from 'drizzle-orm';
import { db } from './index';
import {
leads,
leadFotos,
leadEstadoHistory,
leadPipelineEventos,
precisionHistory,
tenants,
} from './schema';
import { TENANT_SLUG } from '@/lib/funnel';
async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
if (!tenant) throw new Error(`Tenant "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`);
return tenant.id;
}
export type LeadFiltro = (typeof leads.estado.enumValues)[number] | 'todos';
export async function getLeads(filtro: LeadFiltro = 'todos') {
const tenantId = await getTenantId();
const where =
filtro === 'todos'
? eq(leads.tenantId, tenantId)
: and(eq(leads.tenantId, tenantId), eq(leads.estado, filtro));
return db.select().from(leads).where(where).orderBy(desc(leads.createdAt));
}
export async function getLead(id: string) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, id), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) return null;
const [fotos, eventos, historial, precision] = await Promise.all([
db.select().from(leadFotos).where(eq(leadFotos.leadId, id)).orderBy(asc(leadFotos.orden)),
db
.select()
.from(leadPipelineEventos)
.where(eq(leadPipelineEventos.leadId, id))
.orderBy(asc(leadPipelineEventos.occurredAt)),
db
.select()
.from(leadEstadoHistory)
.where(eq(leadEstadoHistory.leadId, id))
.orderBy(asc(leadEstadoHistory.changedAt)),
db.select().from(precisionHistory).where(eq(precisionHistory.leadId, id)),
]);
return { lead, fotos, eventos, historial, precision: precision[0] ?? null };
}
export async function getResumen() {
const all = await getLeads('todos');
const porEstado = all.reduce<Record<string, number>>((acc, l) => {
acc[l.estado] = (acc[l.estado] ?? 0) + 1;
return acc;
}, {});
return { total: all.length, porEstado };
}

153
mvp/b2c/src/db/schema.ts Normal file
View File

@@ -0,0 +1,153 @@
import {
pgTable,
pgEnum,
uuid,
text,
integer,
boolean,
numeric,
timestamp,
jsonb,
index,
} from 'drizzle-orm/pg-core';
// Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano.
export const leadEstado = pgEnum('lead_estado', [
'nuevo',
'contactado',
'visita_agendada',
'presupuesto_enviado',
'ganado',
'perdido',
]);
// Avance técnico en el funnel B2C (docs/funnel.md, superficie C).
// Cada valor = un momento del pipeline; el "siguiente paso" se deriva de aquí.
export const pipelineStage = pgEnum('pipeline_stage', [
'form_completado', // 2. dejó nombre+tel+email+opt-in
'fotos_subidas', // 3. subió 2-4 fotos
'prellamada_enviada', // 4. SMS + WhatsApp pre-llamada
'llamada_completada', // 5. agente IA terminó la cualificación
'render_generado', // 6. Nano Banana generó el render
'presupuesto_generado', // 6. motor de presupuesto + PDF listos
'whatsapp_entregado', // 7. entregado al cliente + lead caliente al panel
]);
export const tipoReforma = pgEnum('tipo_reforma', [
'cocina',
'bano',
'salon',
'comedor',
'integral',
'otro',
]);
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull().unique(),
nombreEmpresa: text('nombre_empresa').notNull(),
logoUrl: text('logo_url'),
provincia: text('provincia'),
whatsappBusiness: text('whatsapp_business'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const leads = pgTable(
'leads',
{
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
// Datos del cliente final (paso 2 del funnel)
nombre: text('nombre').notNull(),
telefono: text('telefono').notNull(),
email: text('email').notNull(),
provincia: text('provincia'),
tipoReforma: tipoReforma('tipo_reforma'),
// Consentimientos LSSI-CE + RGPD (RF-LEG-01)
consentPrivacidad: boolean('consent_privacidad').notNull().default(false),
consentContratacion: boolean('consent_contratacion').notNull().default(false),
// Posición en el funnel y estado comercial
pipelineStage: pipelineStage('pipeline_stage').notNull().default('form_completado'),
estado: leadEstado('estado').notNull().default('nuevo'),
// Presupuesto orientativo en céntimos de euro (evita floats)
presupuestoEstimado: integer('presupuesto_estimado'),
// Artefactos generados por el pipeline (RF-D-02)
transcripcion: text('transcripcion'),
entidades: jsonb('entidades'),
renderUrl: text('render_url'),
pdfUrl: text('pdf_url'),
audioUrl: text('audio_url'),
notas: text('notas'),
},
(table) => [
index('leads_tenant_created_idx').on(table.tenantId, table.createdAt),
index('leads_estado_idx').on(table.estado),
]
);
// Fotos subidas por el cliente (paso 3, 2-4 fotos)
export const leadFotos = pgTable('lead_fotos', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
url: text('url').notNull(),
orden: integer('orden').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// Histórico de cambios de estado comercial (RF-D-03: persistir y reflejar)
export const leadEstadoHistory = pgTable('lead_estado_history', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
estado: leadEstado('estado').notNull(),
changedAt: timestamp('changed_at', { withTimezone: true }).notNull().defaultNow(),
changedBy: text('changed_by'),
});
// Timeline del funnel: una fila por cada paso alcanzado.
// Permite ver dónde está el lead y cuál es el siguiente paso.
export const leadPipelineEventos = pgTable('lead_pipeline_eventos', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
stage: pipelineStage('stage').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
metadata: jsonb('metadata'),
});
// Bucle de precisión (RF-D-04): precio final firmado vs estimado.
export const precisionHistory = pgTable('precision_history', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
estimated: integer('estimated').notNull(), // céntimos
final: integer('final').notNull(), // céntimos
deltaPct: numeric('delta_pct', { precision: 6, scale: 2 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export type Tenant = typeof tenants.$inferSelect;
export type Lead = typeof leads.$inferSelect;
export type NewLead = typeof leads.$inferInsert;
export type LeadFoto = typeof leadFotos.$inferSelect;
export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
export type PrecisionHistory = typeof precisionHistory.$inferSelect;

373
mvp/b2c/src/db/seed.ts Normal file
View File

@@ -0,0 +1,373 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
import { eq, sql } from 'drizzle-orm';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL no está definida.');
}
const client = postgres(connectionString, { prepare: false });
const db = drizzle(client, { schema });
const euros = (n: number) => Math.round(n * 100); // a céntimos
// Cada lead vive en un momento distinto del funnel para poder analizar
// cuál es el siguiente paso de cada uno. days = hace cuántos días entró.
type SeedLead = {
nombre: string;
telefono: string;
email: string;
provincia: string;
tipoReforma: (typeof schema.tipoReforma.enumValues)[number];
pipelineStage: (typeof schema.pipelineStage.enumValues)[number];
estado: (typeof schema.leadEstado.enumValues)[number];
presupuestoEstimado: number | null;
transcripcion: string | null;
entidades: Record<string, unknown> | null;
renderUrl: string | null;
pdfUrl: string | null;
audioUrl: string | null;
fotos: string[];
daysAgo: number;
precioFinal?: number; // solo para ganados
};
const SEED_LEADS: SeedLead[] = [
{
// 1. Acaba de dejar sus datos. Siguiente paso: subir fotos.
nombre: 'Lucía Fernández',
telefono: '+34 612 003 451',
email: 'lucia.fernandez@example.com',
provincia: 'Madrid',
tipoReforma: 'cocina',
pipelineStage: 'form_completado',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion: null,
entidades: null,
renderUrl: null,
pdfUrl: null,
audioUrl: null,
fotos: [],
daysAgo: 0,
},
{
// 2. Subió fotos. Siguiente paso: pre-llamada SMS+WhatsApp.
nombre: 'Javier Ortega',
telefono: '+34 633 118 902',
email: 'javier.ortega@example.com',
provincia: 'Valencia',
tipoReforma: 'bano',
pipelineStage: 'fotos_subidas',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion: null,
entidades: null,
renderUrl: null,
pdfUrl: null,
audioUrl: null,
fotos: ['/antes-bano.webp'],
daysAgo: 0,
},
{
// 3. Pre-llamada enviada, pendiente de descolgar. Siguiente: llamada agente.
nombre: 'Marta Ruiz',
telefono: '+34 655 740 213',
email: 'marta.ruiz@example.com',
provincia: 'Sevilla',
tipoReforma: 'comedor',
pipelineStage: 'prellamada_enviada',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion: null,
entidades: null,
renderUrl: null,
pdfUrl: null,
audioUrl: null,
fotos: ['/antes-comedor.webp'],
daysAgo: 1,
},
{
// 4. Llamada completada. Siguiente: render IA.
nombre: 'Andrés Gil',
telefono: '+34 677 552 008',
email: 'andres.gil@example.com',
provincia: 'Málaga',
tipoReforma: 'cocina',
pipelineStage: 'llamada_completada',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion:
'Agente: Hola Andrés, te llamo de Reformas Ejemplo. Te aviso de que soy un asistente con IA y de que la llamada se graba. ¿Quieres reformar la cocina entera? Andrés: Sí, son unos 12 metros, quiero quitar un tabique...',
entidades: {
espacio: 'cocina',
m2_aprox: 12,
tirar_tabique: true,
calidad: 'media',
licencia_urbanistica: 'posible',
},
renderUrl: null,
pdfUrl: null,
audioUrl: '/demo/audio-andres.mp3',
fotos: ['/antes.webp'],
daysAgo: 1,
},
{
// 5. Render generado, falta presupuesto+PDF. Siguiente: motor presupuesto.
nombre: 'Patricia Núñez',
telefono: '+34 688 410 776',
email: 'patricia.nunez@example.com',
provincia: 'Zaragoza',
tipoReforma: 'bano',
pipelineStage: 'render_generado',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion:
'Agente: Hola Patricia... Patricia: Quiero cambiar la bañera por un plato de ducha y alicatar todo.',
entidades: {
espacio: 'bano',
m2_aprox: 6,
banera_por_ducha: true,
alicatado_completo: true,
calidad: 'media',
},
renderUrl: '/despues-bano.webp',
pdfUrl: null,
audioUrl: '/demo/audio-patricia.mp3',
fotos: ['/antes-bano.webp'],
daysAgo: 2,
},
{
// 6. Presupuesto + PDF listos, sin entregar aún. Siguiente: WhatsApp.
nombre: 'Roberto Salas',
telefono: '+34 699 320 145',
email: 'roberto.salas@example.com',
provincia: 'Barcelona',
tipoReforma: 'integral',
pipelineStage: 'presupuesto_generado',
estado: 'nuevo',
presupuestoEstimado: euros(28400),
transcripcion:
'Agente: Hola Roberto... Roberto: Es un piso de 70 metros, lo quiero reformar entero, suelo, baño, cocina y pintura.',
entidades: {
espacio: 'integral',
m2_aprox: 70,
incluye: ['suelo', 'bano', 'cocina', 'pintura'],
calidad: 'media',
},
renderUrl: '/despues.webp',
pdfUrl: '/demo/presupuesto-roberto.pdf',
audioUrl: '/demo/audio-roberto.mp3',
fotos: ['/antes.webp', '/antes-comedor.webp'],
daysAgo: 2,
},
{
// 7. Entregado al cliente por WhatsApp. Lead caliente recién llegado al panel.
nombre: 'Elena Castro',
telefono: '+34 600 781 459',
email: 'elena.castro@example.com',
provincia: 'Madrid',
tipoReforma: 'cocina',
pipelineStage: 'whatsapp_entregado',
estado: 'nuevo',
presupuestoEstimado: euros(15600),
transcripcion:
'Agente: Hola Elena... Elena: Quiero renovar la cocina, muebles nuevos y encimera de cuarzo.',
entidades: {
espacio: 'cocina',
m2_aprox: 10,
muebles_nuevos: true,
encimera: 'cuarzo',
calidad: 'media-alta',
},
renderUrl: '/despues.webp',
pdfUrl: '/demo/presupuesto-elena.pdf',
audioUrl: '/demo/audio-elena.mp3',
fotos: ['/antes.webp'],
daysAgo: 3,
},
{
// Entregado y ya contactado por el reformista. Siguiente: agendar visita.
nombre: 'Tomás Herrero',
telefono: '+34 611 902 334',
email: 'tomas.herrero@example.com',
provincia: 'Bilbao',
tipoReforma: 'bano',
pipelineStage: 'whatsapp_entregado',
estado: 'contactado',
presupuestoEstimado: euros(9800),
transcripcion: 'Agente: Hola Tomás... Tomás: Solo el baño, cambiar todo el alicatado y sanitarios.',
entidades: { espacio: 'bano', m2_aprox: 5, sanitarios_nuevos: true, calidad: 'media' },
renderUrl: '/despues-bano.webp',
pdfUrl: '/demo/presupuesto-tomas.pdf',
audioUrl: '/demo/audio-tomas.mp3',
fotos: ['/antes-bano.webp'],
daysAgo: 5,
},
{
// Visita agendada. Siguiente: hacer la visita y enviar presupuesto firmado.
nombre: 'Carmen Ibáñez',
telefono: '+34 622 145 870',
email: 'carmen.ibanez@example.com',
provincia: 'Madrid',
tipoReforma: 'comedor',
pipelineStage: 'whatsapp_entregado',
estado: 'visita_agendada',
presupuestoEstimado: euros(7200),
transcripcion: 'Agente: Hola Carmen... Carmen: Quiero abrir el comedor al salón y poner tarima.',
entidades: { espacio: 'comedor', m2_aprox: 20, tarima: true, calidad: 'media' },
renderUrl: '/despues-comedor.webp',
pdfUrl: '/demo/presupuesto-carmen.pdf',
audioUrl: '/demo/audio-carmen.mp3',
fotos: ['/antes-comedor.webp'],
daysAgo: 8,
},
{
// Ganado: precio final firmado, alimenta precision_history. RF-D-04.
nombre: 'Diego Romero',
telefono: '+34 633 770 218',
email: 'diego.romero@example.com',
provincia: 'Valencia',
tipoReforma: 'integral',
pipelineStage: 'whatsapp_entregado',
estado: 'ganado',
presupuestoEstimado: euros(31000),
transcripcion: 'Agente: Hola Diego... Diego: Reforma integral de un piso de 85 metros heredado.',
entidades: { espacio: 'integral', m2_aprox: 85, calidad: 'alta' },
renderUrl: '/despues.webp',
pdfUrl: '/demo/presupuesto-diego.pdf',
audioUrl: '/demo/audio-diego.mp3',
fotos: ['/antes.webp'],
daysAgo: 25,
precioFinal: euros(33500),
},
{
// Perdido: el cliente no siguió adelante.
nombre: 'Sara Blanco',
telefono: '+34 644 318 092',
email: 'sara.blanco@example.com',
provincia: 'Sevilla',
tipoReforma: 'cocina',
pipelineStage: 'whatsapp_entregado',
estado: 'perdido',
presupuestoEstimado: euros(14200),
transcripcion: 'Agente: Hola Sara... Sara: Quería una idea de precio para la cocina.',
entidades: { espacio: 'cocina', m2_aprox: 9, calidad: 'media' },
renderUrl: '/despues.webp',
pdfUrl: '/demo/presupuesto-sara.pdf',
audioUrl: '/demo/audio-sara.mp3',
fotos: ['/antes.webp'],
daysAgo: 18,
},
];
// Orden cronológico del funnel para reconstruir el timeline de cada lead.
const STAGE_ORDER = schema.pipelineStage.enumValues;
async function main() {
const [existing] = await db
.select()
.from(schema.tenants)
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
.limit(1);
if (existing && !process.env.SEED_FORCE) {
console.log('Ya hay datos (tenant "reformas-ejemplo"). Saltando seed. Usa SEED_FORCE=1 para forzar.');
await client.end();
return;
}
console.log('Limpiando datos previos...');
await db.execute(
sql`TRUNCATE TABLE ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.tenants} RESTART IDENTITY CASCADE`
);
console.log('Creando tenant "Reformas Ejemplo"...');
const [tenant] = await db
.insert(schema.tenants)
.values({
slug: 'reformas-ejemplo',
nombreEmpresa: 'Reformas Ejemplo',
provincia: 'Madrid',
whatsappBusiness: '+34 600 000 000',
})
.returning();
console.log(`Insertando ${SEED_LEADS.length} leads...`);
for (const l of SEED_LEADS) {
const createdAt = new Date(Date.now() - l.daysAgo * 24 * 60 * 60 * 1000);
const [lead] = await db
.insert(schema.leads)
.values({
tenantId: tenant.id,
createdAt,
updatedAt: createdAt,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
provincia: l.provincia,
tipoReforma: l.tipoReforma,
consentPrivacidad: true,
consentContratacion: true,
pipelineStage: l.pipelineStage,
estado: l.estado,
presupuestoEstimado: l.presupuestoEstimado,
transcripcion: l.transcripcion,
entidades: l.entidades,
renderUrl: l.renderUrl,
pdfUrl: l.pdfUrl,
audioUrl: l.audioUrl,
})
.returning();
// Fotos
if (l.fotos.length) {
await db.insert(schema.leadFotos).values(
l.fotos.map((url, orden) => ({ leadId: lead.id, url, orden }))
);
}
// Timeline del funnel: un evento por cada paso alcanzado hasta el actual.
const reached = STAGE_ORDER.slice(0, STAGE_ORDER.indexOf(l.pipelineStage) + 1);
await db.insert(schema.leadPipelineEventos).values(
reached.map((stage, i) => ({
leadId: lead.id,
stage,
occurredAt: new Date(createdAt.getTime() + i * 5 * 60 * 1000),
}))
);
// Histórico de estado (siempre nace en 'nuevo')
const estados: (typeof schema.leadEstado.enumValues)[number][] =
l.estado === 'nuevo' ? ['nuevo'] : ['nuevo', l.estado];
await db.insert(schema.leadEstadoHistory).values(
estados.map((estado, i) => ({
leadId: lead.id,
estado,
changedAt: new Date(createdAt.getTime() + i * 60 * 60 * 1000),
}))
);
// Precisión para ganados (RF-D-04)
if (l.estado === 'ganado' && l.precioFinal && l.presupuestoEstimado) {
const deltaPct = ((l.precioFinal - l.presupuestoEstimado) / l.presupuestoEstimado) * 100;
await db.insert(schema.precisionHistory).values({
leadId: lead.id,
estimated: l.presupuestoEstimado,
final: l.precioFinal,
deltaPct: deltaPct.toFixed(2),
});
}
}
console.log('Seed completado.');
await client.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});