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 = 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 | 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); });