import { pgTable, pgEnum, uuid, text, integer, boolean, numeric, timestamp, jsonb, index, doublePrecision, uniqueIndex, check, type AnyPgColumn, } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; // 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', ]); export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']); // Momento de una foto del lead: el estado antes de la reforma o el render del después. export const fotoMomento = pgEnum('foto_momento', ['antes', 'despues']); export const urgencia = pgEnum('urgencia', ['alta', 'media', 'baja']); export const categoriaMaterial = pgEnum('categoria_material', [ 'suelo', 'pared', 'pintura', 'mobiliario', ]); export const unidadMedida = pgEnum('unidad_medida', ['m2', 'ml', 'ud']); export const userRole = pgEnum('user_role', ['reformista', 'admin']); export const userStatus = pgEnum('user_status', ['activo', 'deshabilitado']); export const subscriptionStatus = pgEnum('subscription_status', [ 'trial', 'activo', 'cancelado', 'vencido', ]); // Cómo entrega el reformista el presupuesto al cliente final. // 'automatico' = el funnel lo envía solo; 'revision' = se para para que el reformista lo revise/edite antes de enviar. export const envioPresupuestoMode = pgEnum('envio_presupuesto_mode', ['automatico', 'revision']); // Estado de moderación de un testimonio. El reformista aprueba antes de publicar. // 'pendiente' = recién enviado por el cliente; 'publicado' = visible en la landing; 'oculto' = retirado. export const testimonioEstado = pgEnum('testimonio_estado', ['pendiente', 'publicado', 'oculto']); // === Estructura del flujo WhatsApp/llamada + workers (esquema "reformix-full" del equipo). // ADITIVO: enums/tablas/columnas nuevas que usará el bot (Luisa) y los workers en la DB única. // NO modifica los enums ni columnas existentes de la app (lead_estado, pipeline_stage, etc.). export const estadoWa = pgEnum('estado_wa', ['sin_enviar', 'enviado', 'entregado', 'leido', 'fallido']); export const canalContacto = pgEnum('canal_contacto', ['formulario', 'whatsapp', 'llamada']); export const canalOrigen = pgEnum('canal_origen', [ 'formulario_web', 'whatsapp', 'llamada', 'referido', 'anuncio', ]); export const resultadoContacto = pgEnum('resultado_contacto', [ 'exitoso', 'no_contesta', 'ocupado', 'rechaza', 'error_tecnico', ]); export const rolMensaje = pgEnum('rol_mensaje', ['user', 'assistant', 'system']); export const jobTipo = pgEnum('job_tipo', ['analisis_fotos', 'render', 'presupuesto_ia']); export const jobEstado = pgEnum('job_estado', ['pendiente', 'procesando', 'completado', 'error']); export const nivelCalificacion = pgEnum('nivel_calificacion', ['A', 'B', 'C', 'D']); export const visitaEstado = pgEnum('visita_estado', [ 'propuesta', 'confirmada', 'realizada', 'cancelada', 'reprogramada', ]); // 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'), // data URI base64 del logo (no hay storage externo aún) provincia: text('provincia'), whatsappBusiness: text('whatsapp_business'), // SEO personalizable de la landing del reformista (RF-A / funnel público). seoTitle: text('seo_title'), seoDescription: text('seo_description'), // Bloque "Quiénes somos" opcional en el funnel del reformista. aboutEnabled: boolean('about_enabled').notNull().default(false), aboutFotoUrl: text('about_foto_url'), // data URI base64 de la foto del reformista aboutTexto: text('about_texto'), aniosExperiencia: integer('anios_experiencia'), // Tema visual de la landing del reformista (personalización del funnel público). // themePreset = id de THEME_PRESETS; themeColor = override hex opcional del color primario. themePreset: text('theme_preset').notNull().default('pizarra'), themeColor: text('theme_color'), // Datos de empresa para la cabecera del presupuesto (RF-D-07). cif: text('cif'), direccion: text('direccion'), telefono: text('telefono'), email: text('email'), web: text('web'), planId: uuid('plan_id').references((): AnyPgColumn => plans.id), subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'), envioPresupuesto: envioPresupuestoMode('envio_presupuesto').notNull().default('automatico'), trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }), stripeCustomerId: text('stripe_customer_id'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); export const plans = pgTable('plans', { id: uuid('id').primaryKey().defaultRandom(), slug: text('slug').notNull().unique(), nombre: text('nombre').notNull(), precioMensual: integer('precio_mensual').notNull(), // céntimos leadsIncluidos: integer('leads_incluidos').notNull(), features: jsonb('features').$type().notNull().default([]), activo: boolean('activo').notNull().default(true), }); export const users = pgTable( 'users', { id: uuid('id').primaryKey().defaultRandom(), email: text('email').notNull().unique(), passwordHash: text('password_hash').notNull(), nombre: text('nombre'), role: userRole('role').notNull().default('reformista'), tenantId: uuid('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }), status: userStatus('status').notNull().default('activo'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [index('users_tenant_idx').on(table.tenantId)] ); export const sessions = pgTable( 'sessions', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), tokenHash: text('token_hash').notNull().unique(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [index('sessions_user_idx').on(table.userId)] ); 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'), // Cuándo el reformista pidió la opinión al cliente (RF: recogida de testimonios). testimonioSolicitadoAt: timestamp('testimonio_solicitado_at', { withTimezone: true }), // Inputs del motor de presupuesto (capturados de menos a más en el funnel) m2Suelo: doublePrecision('m2_suelo'), alturaTecho: doublePrecision('altura_techo'), calidadGlobal: calidad('calidad_global'), estructural: boolean('estructural').notNull().default(false), // Inputs de los extras fijos del presupuesto (no escalan con m²). anteriorA2000: boolean('anterior_a_2000').notNull().default(false), cambioDistribucion: boolean('cambio_distribucion').notNull().default(false), materialSelections: jsonb('material_selections') .$type>() .notNull() .default({}), desgloseSnapshot: jsonb('desglose_snapshot'), // Preferencias del cliente capturadas en la llamada (agente de voz) urgencia: urgencia('urgencia'), presupuestoTarget: integer('presupuesto_target'), // céntimos tasteText: text('taste_text'), preferencesSnapshot: jsonb('preferences_snapshot'), // --- Flujo WhatsApp/llamada (esquema reformix-full; aditivos, los rellena el bot/Luisa). // estadoWa nullable: null = aún sin enviar (el "nuevo" del diagrama). estadoWa: estadoWa('estado_wa'), // Paso actual de la conversación del bot (Luisa). TEXT (no enum) para que el bot evolucione // su vocabulario sin migración. Valores sugeridos: apertura · espacio · tamano · estilo · // urgencia · presupuesto · pide_fotos · fotos_recibidas · completado · no_viable · abandonado. botStep: text('bot_step'), canalOrigen: canalOrigen('canal_origen'), espacio: text('espacio'), // extracción en crudo de Luisa (se normaliza a tipoReforma) rangoM2: text('rango_m2'), // crudo (se normaliza a m2Suelo) estilo: text('estilo'), presupuestoDeclarado: text('presupuesto_declarado'), // crudo (se normaliza a presupuestoTarget) viable: boolean('viable'), fotosSolicitadasAt: timestamp('fotos_solicitadas_at', { withTimezone: true }), }, (table) => [ index('leads_tenant_created_idx').on(table.tenantId, table.createdAt), index('leads_estado_idx').on(table.estado), ] ); // Fotos del lead, etiquetadas por zona y momento. Las "antes" las sube el cliente (funnel o EP); // las "despues" (renders) las devuelve el flujo de generación externo por el mismo EP de ingesta. // zona es nullable por compatibilidad con filas antiguas (fallback al tipoReforma del lead). 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(), momento: fotoMomento('momento').notNull().default('antes'), zona: tipoReforma('zona'), orden: integer('orden').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); // Datos de texto que enriquecen el perfil del lead por zona (ej. "Baño: suelo premium"). // Append-only: cada llamada al EP de ingesta puede añadir notas que el agente externo homologará. export const leadNotas = pgTable('lead_notas', { id: uuid('id').primaryKey().defaultRandom(), leadId: uuid('lead_id') .notNull() .references(() => leads.id, { onDelete: 'cascade' }), zona: tipoReforma('zona'), texto: text('texto').notNull(), origen: text('origen').notNull().default('ep'), // ep | funnel | panel createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); // Opiniones del cliente final, recogidas en el funnel de review (/opinion/[id]). // El reformista las solicita desde el panel y aprueba antes de que salgan en su landing. export const testimonios = pgTable( 'testimonios', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id') .notNull() .references(() => tenants.id, { onDelete: 'cascade' }), leadId: uuid('lead_id').references(() => leads.id, { onDelete: 'set null' }), nombre: text('nombre').notNull(), contexto: text('contexto'), // p.ej. "Reforma de cocina · Madrid" rating: integer('rating').notNull(), // 1-5 estrellas texto: text('texto').notNull(), estado: testimonioEstado('estado').notNull().default('pendiente'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('testimonios_tenant_estado_idx').on(table.tenantId, table.estado), index('testimonios_lead_idx').on(table.leadId), ] ); // Fotos adjuntas a un testimonio (el cliente sube fotos del resultado). export const testimonioFotos = pgTable('testimonio_fotos', { id: uuid('id').primaryKey().defaultRandom(), testimonioId: uuid('testimonio_id') .notNull() .references(() => testimonios.id, { onDelete: 'cascade' }), url: text('url').notNull(), orden: integer('orden').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); // Galería de trabajos del reformista (fotos de reformas hechas), visible en su landing. export const galeriaFotos = pgTable( 'galeria_fotos', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id') .notNull() .references(() => tenants.id, { onDelete: 'cascade' }), url: text('url').notNull(), // data URI base64 (no hay storage externo aún) titulo: text('titulo'), orden: integer('orden').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [index('galeria_tenant_idx').on(table.tenantId)] ); // 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(), }); // Configuración de precios del reformista (1 fila por tenant). RF-D-07. export const pricingConfig = pgTable('pricing_config', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id') .notNull() .references(() => tenants.id, { onDelete: 'cascade' }) .unique(), alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5), factorZona: jsonb('factor_zona').$type>().notNull().default({}), manoObra: jsonb('mano_obra').$type>().notNull().default({}), // Extras fijos en céntimos: { tuberias, boletin, distribucion }. extras: jsonb('extras') .$type<{ tuberias: number; boletin: number; distribucion: number }>() .notNull() .default({ tuberias: 0, boletin: 0, distribucion: 0 }), // Baremo de rentabilidad (céntimos): importe mínimo que el reformista considera rentable. Solo // informativo en el panel (marca en otro color los leads por debajo); los agentes NO lo usan para // decidir nada. Null = sin baremo configurado. baremoMinimo: integer('baremo_minimo'), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); // Catálogo de materiales del reformista. Importable por CSV. export const catalogItems = pgTable( 'catalog_items', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id') .notNull() .references(() => tenants.id, { onDelete: 'cascade' }), categoria: categoriaMaterial('categoria').notNull(), nombre: text('nombre').notNull(), calidad: calidad('calidad').notNull(), precioUnit: integer('precio_unit').notNull(), // céntimos por unidad unidad: unidadMedida('unidad').notNull(), descriptorRender: text('descriptor_render').notNull().default(''), esDefault: boolean('es_default').notNull().default(false), sku: text('sku').notNull(), }, (table) => [ index('catalog_tenant_idx').on(table.tenantId), uniqueIndex('catalog_tenant_sku_idx').on(table.tenantId, table.sku), ] ); // === Tablas del flujo WhatsApp/llamada + workers (esquema reformix-full; aditivas). // Las escribe el bot (Luisa) y los workers; referencian nuestros leads/users/tenants en la DB única. // Historial de la conversación de WhatsApp del bot con el lead. export const conversacionWhatsapp = pgTable( 'conversacion_whatsapp', { id: uuid('id').primaryKey().defaultRandom(), leadId: uuid('lead_id') .notNull() .references(() => leads.id, { onDelete: 'cascade' }), rol: rolMensaje('rol').notNull(), mensaje: text('mensaje').notNull(), mediaType: text('media_type'), mediaUrl: text('media_url'), transcripcionAudio: text('transcripcion_audio'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_conversacion_whatsapp_lead_id').on(table.leadId), index('idx_conversacion_whatsapp_created_at').on(table.createdAt), ] ); // Intentos de contacto multicanal (formulario/whatsapp/llamada). export const intentosContacto = pgTable( 'intentos_contacto', { id: uuid('id').primaryKey().defaultRandom(), leadId: uuid('lead_id') .notNull() .references(() => leads.id, { onDelete: 'cascade' }), canal: canalContacto('canal').notNull(), resultado: resultadoContacto('resultado'), completado: boolean('completado').notNull().default(false), numeroIntento: integer('numero_intento').notNull(), duracionSeg: integer('duracion_seg'), intentadoAt: timestamp('intentado_at', { withTimezone: true }).notNull().defaultNow(), notas: text('notas'), metadata: jsonb('metadata'), }, (table) => [index('idx_intentos_contacto_lead_id').on(table.leadId)] ); // Calificación del lead (score 0-100 + nivel A/B/C/D). Una por lead. export const leadCalificacion = pgTable( 'lead_calificacion', { id: uuid('id').primaryKey().defaultRandom(), leadId: uuid('lead_id') .notNull() .unique() .references(() => leads.id, { onDelete: 'cascade' }), score: integer('score'), nivel: nivelCalificacion('nivel'), criterios: jsonb('criterios'), notasAgente: text('notas_agente'), calificadoPor: uuid('calificado_por').references(() => users.id, { onDelete: 'set null' }), calificadoAt: timestamp('calificado_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_lead_calificacion_lead_id').on(table.leadId), check('lead_calificacion_score_check', sql`${table.score} >= 0 AND ${table.score} <= 100`), ] ); // Visitas agendadas por el reformista. export const visitas = pgTable( 'visitas', { id: uuid('id').primaryKey().defaultRandom(), leadId: uuid('lead_id') .notNull() .references(() => leads.id, { onDelete: 'cascade' }), tenantId: uuid('tenant_id') .notNull() .references(() => tenants.id, { onDelete: 'cascade' }), fechaPropuesta: timestamp('fecha_propuesta', { withTimezone: true }), fechaConfirmada: timestamp('fecha_confirmada', { withTimezone: true }), estado: visitaEstado('estado').notNull().default('propuesta'), direccion: text('direccion'), notas: text('notas'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_visitas_lead_id').on(table.leadId), index('idx_visitas_tenant_id').on(table.tenantId), ] ); // Cola de trabajos async de los workers (análisis de fotos, render, presupuesto IA). export const workerJobs = pgTable( 'worker_jobs', { id: uuid('id').primaryKey().defaultRandom(), leadId: uuid('lead_id') .notNull() .references(() => leads.id, { onDelete: 'cascade' }), tipo: jobTipo('tipo').notNull(), estadoJob: jobEstado('estado_job').notNull().default('pendiente'), payload: jsonb('payload').notNull(), webhookUrl: text('webhook_url'), resultadoUrl: text('resultado_url'), intentos: integer('intentos').notNull().default(0), errorMsg: text('error_msg'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), completedAt: timestamp('completed_at', { withTimezone: true }), }, (table) => [ index('idx_worker_jobs_lead_id').on(table.leadId), index('idx_worker_jobs_estado').on(table.estadoJob), index('idx_worker_jobs_tipo').on(table.tipo), ] ); 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 NewLeadFoto = typeof leadFotos.$inferInsert; export type LeadNota = typeof leadNotas.$inferSelect; export type NewLeadNota = typeof leadNotas.$inferInsert; export type ConversacionWhatsapp = typeof conversacionWhatsapp.$inferSelect; export type IntentoContacto = typeof intentosContacto.$inferSelect; export type LeadCalificacion = typeof leadCalificacion.$inferSelect; export type Visita = typeof visitas.$inferSelect; export type WorkerJob = typeof workerJobs.$inferSelect; export type Testimonio = typeof testimonios.$inferSelect; export type NewTestimonio = typeof testimonios.$inferInsert; export type TestimonioFoto = typeof testimonioFotos.$inferSelect; export type GaleriaFoto = typeof galeriaFotos.$inferSelect; export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect; export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect; export type PrecisionHistory = typeof precisionHistory.$inferSelect; export type PricingConfigRow = typeof pricingConfig.$inferSelect; export type CatalogItemRow = typeof catalogItems.$inferSelect; export type NewCatalogItem = typeof catalogItems.$inferInsert; export type Plan = typeof plans.$inferSelect; export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Session = typeof sessions.$inferSelect;