Files
reformix-hackaton/mvp/b2c/src/db/seed.ts
Carlos Narro 5afda5af05 Arregla pérdida de datos al desplegar: el seed solo siembra si la DB está vacía
El docker-entrypoint corre db:seed en cada arranque. El guard comprobaba si
existía un tenant CONCRETO ("reformas-ejemplo"); si ese no estaba pero había
otros (una empresa creada por el reformista), el seed ejecutaba TRUNCATE de
todas las tablas y resembraba el demo → borraba los datos reales en cada deploy.

Ahora el guard salta si existe CUALQUIER tenant, así que con datos reales el seed
nunca toca la DB. SEED_FORCE=1 sigue forzando el reseed (borra todo) a propósito.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:38:38 +02:00

620 lines
22 KiB
TypeScript

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';
import { computeBudget } from '../budget';
import type { BudgetInputs } from '../budget/types';
import { hashPassword } from '../lib/auth/password';
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
// Factor de zona geográfica por provincia/ciudad. Las no listadas valen 1.0 (media nacional).
// Tramos: Madrid/Barcelona 1.40, islas 1.30, capitales grandes 1.20, rural/interior 0.85.
const ZONA_FACTORES: Record<string, number> = Object.fromEntries(
[
[['Madrid', 'Barcelona'], 1.4],
[
['Baleares', 'Islas Baleares', 'Palma', 'Mallorca', 'Las Palmas', 'Tenerife', 'Santa Cruz de Tenerife', 'Canarias'],
1.3,
],
[
['Valencia', 'Sevilla', 'Málaga', 'Bilbao', 'Vizcaya', 'Bizkaia', 'Zaragoza', 'Alicante', 'Murcia', 'San Sebastián', 'Gipuzkoa', 'Vitoria', 'Granada', 'Valladolid'],
1.2,
],
[
['Cuenca', 'Teruel', 'Soria', 'Zamora', 'Ávila', 'Palencia', 'Ourense', 'Lugo', 'Cáceres', 'Badajoz', 'Ciudad Real', 'Albacete', 'Jaén', 'Huesca', 'Segovia', 'Guadalajara'],
0.85,
],
].flatMap(([nombres, factor]) => (nombres as string[]).map((n) => [n, factor as number])),
);
// 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() {
// Guard de seguridad: solo sembramos si la base de datos está VACÍA (sin ningún tenant). Antes se
// comprobaba un slug concreto ("reformas-ejemplo"); si ese tenant no estaba pero había otros
// (p. ej. una empresa creada por el reformista), el seed los TRUNCABA en cada deploy → pérdida de
// datos. Ahora cualquier tenant existente protege toda la DB. SEED_FORCE=1 fuerza el reseed (BORRA TODO).
const [existing] = await db.select({ id: schema.tenants.id }).from(schema.tenants).limit(1);
if (existing && !process.env.SEED_FORCE) {
console.log('La base de datos ya tiene datos (existe al menos un tenant). Saltando seed para no borrar nada. Usa SEED_FORCE=1 para forzar (¡BORRA TODO!).');
await client.end();
return;
}
console.log('Limpiando datos previos...');
await db.execute(
sql`TRUNCATE TABLE ${schema.testimonioFotos}, ${schema.testimonios}, ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.sessions}, ${schema.users}, ${schema.plans}, ${schema.tenants} RESTART IDENTITY CASCADE`
);
console.log('Sembrando planes...');
const [, pro] = await db
.insert(schema.plans)
.values([
{
slug: 'starter',
nombre: 'Starter',
precioMensual: 2900,
leadsIncluidos: 5,
features: ['5 leads procesados / mes', '3 €/lead extra', 'Hasta 100 contactos', 'Branding básico'],
},
{
slug: 'pro',
nombre: 'Pro',
precioMensual: 7900,
leadsIncluidos: 15,
features: [
'15 leads procesados / mes',
'2,50 €/lead extra',
'White-label completo',
'Sub-flujo licencia urbanística',
'Integraciones Holded/Stel',
'Soporte prioritario',
],
},
{
slug: 'business',
nombre: 'Business',
precioMensual: 19900,
leadsIncluidos: 50,
features: [
'50 leads procesados / mes',
'2 €/lead extra',
'Usuarios ilimitados',
'API',
'Multi-zona',
'Custom price book',
'Dashboard analytics',
],
},
])
.returning();
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',
planId: pro.id,
subscriptionStatus: 'trial',
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
seoTitle: 'Reformas Ejemplo · Reformas integrales en Madrid',
seoDescription:
'Pide tu presupuesto de reforma con render IA en minutos. Reformas de cocina, baño y vivienda completa en Madrid.',
aboutEnabled: true,
aboutTexto:
'Somos un equipo de Madrid especializado en reformas integrales de cocinas, baños y viviendas completas. Cuidamos cada detalle y te acompañamos desde la primera idea hasta la entrega de llaves, con presupuestos claros y sin sorpresas.',
aniosExperiencia: 15,
})
.returning();
console.log('Creando usuarios demo (admin + owner)...');
await db.insert(schema.users).values([
{
email: 'admin@reformix.es',
passwordHash: await hashPassword('AdminReformix2026!'),
nombre: 'Admin Reformix',
role: 'admin',
tenantId: null,
},
{
email: 'demo@reformas-ejemplo.es',
passwordHash: await hashPassword('DemoReformix2026!'),
nombre: 'Reformas Ejemplo',
role: 'reformista',
tenantId: tenant.id,
},
]);
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),
});
}
}
// --- Opiniones demo (recogidas en el funnel de review, ya moderadas) ---
console.log('Sembrando opiniones demo...');
const leadsPorEmail = await db
.select({ id: schema.leads.id, email: schema.leads.email })
.from(schema.leads)
.where(eq(schema.leads.tenantId, tenant.id));
const leadIdPorEmail = new Map(leadsPorEmail.map((l) => [l.email, l.id]));
// Diego (ganado) ya tiene la opinión solicitada desde el panel.
const diegoId = leadIdPorEmail.get('diego.romero@example.com');
if (diegoId) {
await db
.update(schema.leads)
.set({ testimonioSolicitadoAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000) })
.where(eq(schema.leads.id, diegoId));
}
const [testiDiego] = await db
.insert(schema.testimonios)
.values({
tenantId: tenant.id,
leadId: diegoId ?? null,
nombre: 'Diego Romero',
contexto: 'Reforma integral · Valencia',
rating: 5,
texto:
'Reformaron un piso heredado de 85 metros de arriba a abajo. El presupuesto inicial cuadró casi al detalle con el final y los plazos se cumplieron. Repetiría sin dudarlo.',
estado: 'publicado',
})
.returning();
if (testiDiego) {
await db
.insert(schema.testimonioFotos)
.values({ testimonioId: testiDiego.id, url: '/despues.webp', orden: 0 });
}
await db.insert(schema.testimonios).values([
{
tenantId: tenant.id,
leadId: leadIdPorEmail.get('carmen.ibanez@example.com') ?? null,
nombre: 'Carmen Ibáñez',
contexto: 'Comedor · Madrid',
rating: 5,
texto:
'Abrieron el comedor al salón y pusieron tarima nueva. El render que me enseñaron al principio era casi idéntico al resultado final. Trato impecable.',
estado: 'publicado',
},
{
// Pendiente de aprobar: aparece en el panel pero aún no en la landing.
tenantId: tenant.id,
leadId: leadIdPorEmail.get('tomas.herrero@example.com') ?? null,
nombre: 'Tomás Herrero',
contexto: 'Baño · Bilbao',
rating: 4,
texto:
'Cambiaron todo el alicatado y los sanitarios del baño. Buen acabado y limpios. Solo se retrasaron un par de días por los materiales.',
estado: 'pendiente',
},
]);
// --- Precios + catálogo demo (motor de presupuesto) ---
const [tenantRow] = await db
.select()
.from(schema.tenants)
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
.limit(1);
if (tenantRow) {
await db.delete(schema.catalogItems).where(eq(schema.catalogItems.tenantId, tenantRow.id));
await db.delete(schema.pricingConfig).where(eq(schema.pricingConfig.tenantId, tenantRow.id));
const [config] = await db
.insert(schema.pricingConfig)
.values({
tenantId: tenantRow.id,
alturaTechoDefault: 2.5,
factorZona: ZONA_FACTORES,
manoObra: {
demolicion: 5000,
impermeabilizacion: 4500,
fontaneria: 14600,
electricidad: 5400,
mano_de_obra: 7500,
},
extras: { tuberias: 115000, boletin: 17500, distribucion: 90000 },
})
.returning();
const cat = (
categoria: 'suelo' | 'pared' | 'pintura' | 'mobiliario',
nombre: string,
calidad: 'basica' | 'media' | 'premium',
precioEuros: number,
unidad: 'm2' | 'ml' | 'ud',
descriptorRender: string,
sku: string,
) => ({
tenantId: tenantRow.id,
categoria,
nombre,
calidad,
precioUnit: Math.round(precioEuros * 100),
unidad,
descriptorRender,
esDefault: true,
sku,
});
const catalog = await db.insert(schema.catalogItems).values([
cat('suelo', 'Gres cerámico básico', 'basica', 40, 'm2', 'suelo gres beige liso', 'SUE-B'),
cat('suelo', 'Porcelánico símil madera', 'media', 70, 'm2', 'porcelánico símil roble claro', 'SUE-M'),
cat('suelo', 'Porcelánico gran formato', 'premium', 170, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'),
cat('pared', 'Azulejo blanco brillo', 'basica', 32, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'),
cat('pared', 'Azulejo rectificado', 'media', 60, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'),
cat('pared', 'Porcelánico decorativo', 'premium', 140, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'),
cat('pintura', 'Plástica mate', 'basica', 6, 'm2', 'pintura plástica blanca mate', 'PIN-B'),
cat('pintura', 'Plástica lavable', 'media', 9, 'm2', 'pintura lavable blanco roto', 'PIN-M'),
cat('pintura', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'),
cat('mobiliario', 'Muebles melamina', 'basica', 180, 'ml', 'muebles cocina melamina blanca', 'MOB-B'),
cat('mobiliario', 'Muebles laminado', 'media', 320, 'ml', 'muebles cocina laminado roble con tirador integrado', 'MOB-M'),
cat('mobiliario', 'Muebles lacado', 'premium', 550, 'ml', 'muebles cocina lacado mate antracita y encimera porcelánica', 'MOB-P'),
]).returning();
// Inputs demo en un lead ya avanzado + presupuesto ya calculado para que el
// desglose sea visible al abrir el detalle (Roberto: integral 70 m², Barcelona).
const [roberto] = await db
.update(schema.leads)
.set({ m2Suelo: 70, calidadGlobal: 'media', estructural: false })
.where(eq(schema.leads.email, 'roberto.salas@example.com'))
.returning();
if (roberto) {
const inputs: BudgetInputs = {
tipoReforma: roberto.tipoReforma ?? 'integral',
m2Suelo: roberto.m2Suelo,
alturaTecho: roberto.alturaTecho ?? null,
calidadGlobal: roberto.calidadGlobal ?? 'media',
estructural: roberto.estructural,
provincia: roberto.provincia ?? null,
materialSelections: {},
};
const result = computeBudget(inputs, config, catalog);
await db
.update(schema.leads)
.set({
presupuestoEstimado: result.total,
desgloseSnapshot: { stage: roberto.pipelineStage, result },
})
.where(eq(schema.leads.id, roberto.id));
}
}
console.log('Seed completado.');
await client.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});