Añade estructura aditiva del flujo WhatsApp/llamada + workers (DB única)
Integra el esquema "reformix-full" del equipo de forma ADITIVA, sin tocar los enums ni columnas existentes de la app (una sola DB, Drizzle es el dueño): - Enums nuevos: estado_wa, canal_contacto, canal_origen, resultado_contacto, rol_mensaje, job_tipo, job_estado, nivel_calificacion, visita_estado. - Tablas nuevas: conversacion_whatsapp, intentos_contacto, lead_calificacion (score 0-100 + nivel A/B/C/D), visitas, worker_jobs (cola async de los workers de fotos/render/presupuesto). Referencian nuestros leads/users/tenants. - Columnas nuevas en leads (nullable, las rellena el bot/Luisa): estado_wa, canal_origen, espacio, rango_m2, estilo, presupuesto_declarado, viable, fotos_solicitadas_at. - Migración 0010 + db-schema/schema.sql regenerado. El bot/n8n escribe estas tablas en la DB única y usa nuestros leads (creados solo desde el form web). Pendiente: alinear valores de lead_estado/pipeline_stage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,10 @@ import {
|
||||
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', [
|
||||
@@ -78,6 +80,37 @@ export const envioPresupuestoMode = pgEnum('envio_presupuesto_mode', ['automatic
|
||||
// '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', {
|
||||
@@ -213,6 +246,17 @@ export const leads = pgTable(
|
||||
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'),
|
||||
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),
|
||||
@@ -374,6 +418,121 @@ export const catalogItems = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// === 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;
|
||||
@@ -381,6 +540,11 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user