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:
Carlos Narro
2026-06-04 16:45:14 +02:00
parent cd38fe6233
commit f2b19ab719
5 changed files with 2813 additions and 1 deletions

View File

@@ -1,9 +1,17 @@
CREATE TYPE "public"."calidad" AS ENUM('basica', 'media', 'premium'); CREATE TYPE "public"."calidad" AS ENUM('basica', 'media', 'premium');
CREATE TYPE "public"."canal_contacto" AS ENUM('formulario', 'whatsapp', 'llamada');
CREATE TYPE "public"."canal_origen" AS ENUM('formulario_web', 'whatsapp', 'llamada', 'referido', 'anuncio');
CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario'); CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario');
CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision'); CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision');
CREATE TYPE "public"."estado_wa" AS ENUM('sin_enviar', 'enviado', 'entregado', 'leido', 'fallido');
CREATE TYPE "public"."foto_momento" AS ENUM('antes', 'despues'); CREATE TYPE "public"."foto_momento" AS ENUM('antes', 'despues');
CREATE TYPE "public"."job_estado" AS ENUM('pendiente', 'procesando', 'completado', 'error');
CREATE TYPE "public"."job_tipo" AS ENUM('analisis_fotos', 'render', 'presupuesto_ia');
CREATE TYPE "public"."lead_estado" AS ENUM('nuevo', 'contactado', 'visita_agendada', 'presupuesto_enviado', 'ganado', 'perdido'); CREATE TYPE "public"."lead_estado" AS ENUM('nuevo', 'contactado', 'visita_agendada', 'presupuesto_enviado', 'ganado', 'perdido');
CREATE TYPE "public"."nivel_calificacion" AS ENUM('A', 'B', 'C', 'D');
CREATE TYPE "public"."pipeline_stage" AS ENUM('form_completado', 'fotos_subidas', 'prellamada_enviada', 'llamada_completada', 'render_generado', 'presupuesto_generado', 'whatsapp_entregado'); CREATE TYPE "public"."pipeline_stage" AS ENUM('form_completado', 'fotos_subidas', 'prellamada_enviada', 'llamada_completada', 'render_generado', 'presupuesto_generado', 'whatsapp_entregado');
CREATE TYPE "public"."resultado_contacto" AS ENUM('exitoso', 'no_contesta', 'ocupado', 'rechaza', 'error_tecnico');
CREATE TYPE "public"."rol_mensaje" AS ENUM('user', 'assistant', 'system');
CREATE TYPE "public"."subscription_status" AS ENUM('trial', 'activo', 'cancelado', 'vencido'); CREATE TYPE "public"."subscription_status" AS ENUM('trial', 'activo', 'cancelado', 'vencido');
CREATE TYPE "public"."testimonio_estado" AS ENUM('pendiente', 'publicado', 'oculto'); CREATE TYPE "public"."testimonio_estado" AS ENUM('pendiente', 'publicado', 'oculto');
CREATE TYPE "public"."tipo_reforma" AS ENUM('cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'); CREATE TYPE "public"."tipo_reforma" AS ENUM('cocina', 'bano', 'salon', 'comedor', 'integral', 'otro');
@@ -11,6 +19,7 @@ CREATE TYPE "public"."unidad_medida" AS ENUM('m2', 'ml', 'ud');
CREATE TYPE "public"."urgencia" AS ENUM('alta', 'media', 'baja'); CREATE TYPE "public"."urgencia" AS ENUM('alta', 'media', 'baja');
CREATE TYPE "public"."user_role" AS ENUM('reformista', 'admin'); CREATE TYPE "public"."user_role" AS ENUM('reformista', 'admin');
CREATE TYPE "public"."user_status" AS ENUM('activo', 'deshabilitado'); CREATE TYPE "public"."user_status" AS ENUM('activo', 'deshabilitado');
CREATE TYPE "public"."visita_estado" AS ENUM('propuesta', 'confirmada', 'realizada', 'cancelada', 'reprogramada');
CREATE TABLE "catalog_items" ( CREATE TABLE "catalog_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL, "tenant_id" uuid NOT NULL,
@@ -24,6 +33,17 @@ CREATE TABLE "catalog_items" (
"sku" text NOT NULL "sku" text NOT NULL
); );
CREATE TABLE "conversacion_whatsapp" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"rol" "rol_mensaje" NOT NULL,
"mensaje" text NOT NULL,
"media_type" text,
"media_url" text,
"transcripcion_audio" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "galeria_fotos" ( CREATE TABLE "galeria_fotos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL, "tenant_id" uuid NOT NULL,
@@ -33,6 +53,32 @@ CREATE TABLE "galeria_fotos" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL "created_at" timestamp with time zone DEFAULT now() NOT NULL
); );
CREATE TABLE "intentos_contacto" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"canal" "canal_contacto" NOT NULL,
"resultado" "resultado_contacto",
"completado" boolean DEFAULT false NOT NULL,
"numero_intento" integer NOT NULL,
"duracion_seg" integer,
"intentado_at" timestamp with time zone DEFAULT now() NOT NULL,
"notas" text,
"metadata" jsonb
);
CREATE TABLE "lead_calificacion" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"score" integer,
"nivel" "nivel_calificacion",
"criterios" jsonb,
"notas_agente" text,
"calificado_por" uuid,
"calificado_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "lead_calificacion_lead_id_unique" UNIQUE("lead_id"),
CONSTRAINT "lead_calificacion_score_check" CHECK ("lead_calificacion"."score" >= 0 AND "lead_calificacion"."score" <= 100)
);
CREATE TABLE "lead_estado_history" ( CREATE TABLE "lead_estado_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL, "lead_id" uuid NOT NULL,
@@ -94,12 +140,22 @@ CREATE TABLE "leads" (
"altura_techo" double precision, "altura_techo" double precision,
"calidad_global" "calidad", "calidad_global" "calidad",
"estructural" boolean DEFAULT false NOT NULL, "estructural" boolean DEFAULT false NOT NULL,
"anterior_a_2000" boolean DEFAULT false NOT NULL,
"cambio_distribucion" boolean DEFAULT false NOT NULL,
"material_selections" jsonb DEFAULT '{}'::jsonb NOT NULL, "material_selections" jsonb DEFAULT '{}'::jsonb NOT NULL,
"desglose_snapshot" jsonb, "desglose_snapshot" jsonb,
"urgencia" "urgencia", "urgencia" "urgencia",
"presupuesto_target" integer, "presupuesto_target" integer,
"taste_text" text, "taste_text" text,
"preferences_snapshot" jsonb "preferences_snapshot" jsonb,
"estado_wa" "estado_wa",
"canal_origen" "canal_origen",
"espacio" text,
"rango_m2" text,
"estilo" text,
"presupuesto_declarado" text,
"viable" boolean,
"fotos_solicitadas_at" timestamp with time zone
); );
CREATE TABLE "plans" ( CREATE TABLE "plans" (
@@ -128,6 +184,7 @@ CREATE TABLE "pricing_config" (
"altura_techo_default" double precision DEFAULT 2.5 NOT NULL, "altura_techo_default" double precision DEFAULT 2.5 NOT NULL,
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL, "factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL, "mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
"extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id") CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
); );
@@ -203,8 +260,38 @@ CREATE TABLE "users" (
CONSTRAINT "users_email_unique" UNIQUE("email") CONSTRAINT "users_email_unique" UNIQUE("email")
); );
CREATE TABLE "visitas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"tenant_id" uuid NOT NULL,
"fecha_propuesta" timestamp with time zone,
"fecha_confirmada" timestamp with time zone,
"estado" "visita_estado" DEFAULT 'propuesta' NOT NULL,
"direccion" text,
"notas" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "worker_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"tipo" "job_tipo" NOT NULL,
"estado_job" "job_estado" DEFAULT 'pendiente' NOT NULL,
"payload" jsonb NOT NULL,
"webhook_url" text,
"resultado_url" text,
"intentos" integer DEFAULT 0 NOT NULL,
"error_msg" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);
ALTER TABLE "catalog_items" ADD CONSTRAINT "catalog_items_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "catalog_items" ADD CONSTRAINT "catalog_items_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "conversacion_whatsapp" ADD CONSTRAINT "conversacion_whatsapp_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "galeria_fotos" ADD CONSTRAINT "galeria_fotos_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "galeria_fotos" ADD CONSTRAINT "galeria_fotos_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "intentos_contacto" ADD CONSTRAINT "intentos_contacto_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_calificado_por_users_id_fk" FOREIGN KEY ("calificado_por") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "lead_estado_history" ADD CONSTRAINT "lead_estado_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "lead_estado_history" ADD CONSTRAINT "lead_estado_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_fotos" ADD CONSTRAINT "lead_fotos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "lead_fotos" ADD CONSTRAINT "lead_fotos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_notas" ADD CONSTRAINT "lead_notas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "lead_notas" ADD CONSTRAINT "lead_notas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
@@ -218,12 +305,24 @@ ALTER TABLE "testimonio_fotos" ADD CONSTRAINT "testimonio_fotos_testimonio_id_te
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE set null ON UPDATE no action; ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "worker_jobs" ADD CONSTRAINT "worker_jobs_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "catalog_tenant_idx" ON "catalog_items" USING btree ("tenant_id"); CREATE INDEX "catalog_tenant_idx" ON "catalog_items" USING btree ("tenant_id");
CREATE UNIQUE INDEX "catalog_tenant_sku_idx" ON "catalog_items" USING btree ("tenant_id","sku"); CREATE UNIQUE INDEX "catalog_tenant_sku_idx" ON "catalog_items" USING btree ("tenant_id","sku");
CREATE INDEX "idx_conversacion_whatsapp_lead_id" ON "conversacion_whatsapp" USING btree ("lead_id");
CREATE INDEX "idx_conversacion_whatsapp_created_at" ON "conversacion_whatsapp" USING btree ("created_at");
CREATE INDEX "galeria_tenant_idx" ON "galeria_fotos" USING btree ("tenant_id"); CREATE INDEX "galeria_tenant_idx" ON "galeria_fotos" USING btree ("tenant_id");
CREATE INDEX "idx_intentos_contacto_lead_id" ON "intentos_contacto" USING btree ("lead_id");
CREATE INDEX "idx_lead_calificacion_lead_id" ON "lead_calificacion" USING btree ("lead_id");
CREATE INDEX "leads_tenant_created_idx" ON "leads" USING btree ("tenant_id","created_at"); CREATE INDEX "leads_tenant_created_idx" ON "leads" USING btree ("tenant_id","created_at");
CREATE INDEX "leads_estado_idx" ON "leads" USING btree ("estado"); CREATE INDEX "leads_estado_idx" ON "leads" USING btree ("estado");
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id"); CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");
CREATE INDEX "testimonios_tenant_estado_idx" ON "testimonios" USING btree ("tenant_id","estado"); CREATE INDEX "testimonios_tenant_estado_idx" ON "testimonios" USING btree ("tenant_id","estado");
CREATE INDEX "testimonios_lead_idx" ON "testimonios" USING btree ("lead_id"); CREATE INDEX "testimonios_lead_idx" ON "testimonios" USING btree ("lead_id");
CREATE INDEX "users_tenant_idx" ON "users" USING btree ("tenant_id"); CREATE INDEX "users_tenant_idx" ON "users" USING btree ("tenant_id");
CREATE INDEX "idx_visitas_lead_id" ON "visitas" USING btree ("lead_id");
CREATE INDEX "idx_visitas_tenant_id" ON "visitas" USING btree ("tenant_id");
CREATE INDEX "idx_worker_jobs_lead_id" ON "worker_jobs" USING btree ("lead_id");
CREATE INDEX "idx_worker_jobs_estado" ON "worker_jobs" USING btree ("estado_job");
CREATE INDEX "idx_worker_jobs_tipo" ON "worker_jobs" USING btree ("tipo");

View File

@@ -0,0 +1,96 @@
CREATE TYPE "public"."canal_contacto" AS ENUM('formulario', 'whatsapp', 'llamada');--> statement-breakpoint
CREATE TYPE "public"."canal_origen" AS ENUM('formulario_web', 'whatsapp', 'llamada', 'referido', 'anuncio');--> statement-breakpoint
CREATE TYPE "public"."estado_wa" AS ENUM('sin_enviar', 'enviado', 'entregado', 'leido', 'fallido');--> statement-breakpoint
CREATE TYPE "public"."job_estado" AS ENUM('pendiente', 'procesando', 'completado', 'error');--> statement-breakpoint
CREATE TYPE "public"."job_tipo" AS ENUM('analisis_fotos', 'render', 'presupuesto_ia');--> statement-breakpoint
CREATE TYPE "public"."nivel_calificacion" AS ENUM('A', 'B', 'C', 'D');--> statement-breakpoint
CREATE TYPE "public"."resultado_contacto" AS ENUM('exitoso', 'no_contesta', 'ocupado', 'rechaza', 'error_tecnico');--> statement-breakpoint
CREATE TYPE "public"."rol_mensaje" AS ENUM('user', 'assistant', 'system');--> statement-breakpoint
CREATE TYPE "public"."visita_estado" AS ENUM('propuesta', 'confirmada', 'realizada', 'cancelada', 'reprogramada');--> statement-breakpoint
CREATE TABLE "conversacion_whatsapp" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"rol" "rol_mensaje" NOT NULL,
"mensaje" text NOT NULL,
"media_type" text,
"media_url" text,
"transcripcion_audio" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "intentos_contacto" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"canal" "canal_contacto" NOT NULL,
"resultado" "resultado_contacto",
"completado" boolean DEFAULT false NOT NULL,
"numero_intento" integer NOT NULL,
"duracion_seg" integer,
"intentado_at" timestamp with time zone DEFAULT now() NOT NULL,
"notas" text,
"metadata" jsonb
);
--> statement-breakpoint
CREATE TABLE "lead_calificacion" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"score" integer,
"nivel" "nivel_calificacion",
"criterios" jsonb,
"notas_agente" text,
"calificado_por" uuid,
"calificado_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "lead_calificacion_lead_id_unique" UNIQUE("lead_id"),
CONSTRAINT "lead_calificacion_score_check" CHECK ("lead_calificacion"."score" >= 0 AND "lead_calificacion"."score" <= 100)
);
--> statement-breakpoint
CREATE TABLE "visitas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"tenant_id" uuid NOT NULL,
"fecha_propuesta" timestamp with time zone,
"fecha_confirmada" timestamp with time zone,
"estado" "visita_estado" DEFAULT 'propuesta' NOT NULL,
"direccion" text,
"notas" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "worker_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"tipo" "job_tipo" NOT NULL,
"estado_job" "job_estado" DEFAULT 'pendiente' NOT NULL,
"payload" jsonb NOT NULL,
"webhook_url" text,
"resultado_url" text,
"intentos" integer DEFAULT 0 NOT NULL,
"error_msg" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "estado_wa" "estado_wa";--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "canal_origen" "canal_origen";--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "espacio" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "rango_m2" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "estilo" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "presupuesto_declarado" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "viable" boolean;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "fotos_solicitadas_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "conversacion_whatsapp" ADD CONSTRAINT "conversacion_whatsapp_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "intentos_contacto" ADD CONSTRAINT "intentos_contacto_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_calificado_por_users_id_fk" FOREIGN KEY ("calificado_por") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "worker_jobs" ADD CONSTRAINT "worker_jobs_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_conversacion_whatsapp_lead_id" ON "conversacion_whatsapp" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_conversacion_whatsapp_created_at" ON "conversacion_whatsapp" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_intentos_contacto_lead_id" ON "intentos_contacto" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_lead_calificacion_lead_id" ON "lead_calificacion" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_visitas_lead_id" ON "visitas" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_visitas_tenant_id" ON "visitas" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "idx_worker_jobs_lead_id" ON "worker_jobs" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_worker_jobs_estado" ON "worker_jobs" USING btree ("estado_job");--> statement-breakpoint
CREATE INDEX "idx_worker_jobs_tipo" ON "worker_jobs" USING btree ("tipo");

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1780569557328, "when": 1780569557328,
"tag": "0009_white_agent_brand", "tag": "0009_white_agent_brand",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780584271011,
"tag": "0010_square_vulture",
"breakpoints": true
} }
] ]
} }

View File

@@ -11,8 +11,10 @@ import {
index, index,
doublePrecision, doublePrecision,
uniqueIndex, uniqueIndex,
check,
type AnyPgColumn, type AnyPgColumn,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
// Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano. // Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano.
export const leadEstado = pgEnum('lead_estado', [ 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. // 'pendiente' = recién enviado por el cliente; 'publicado' = visible en la landing; 'oculto' = retirado.
export const testimonioEstado = pgEnum('testimonio_estado', ['pendiente', 'publicado', 'oculto']); 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. // Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
// Multi-tenant real es F1.5; la tabla ya queda lista para ello. // Multi-tenant real es F1.5; la tabla ya queda lista para ello.
export const tenants = pgTable('tenants', { export const tenants = pgTable('tenants', {
@@ -213,6 +246,17 @@ export const leads = pgTable(
presupuestoTarget: integer('presupuesto_target'), // céntimos presupuestoTarget: integer('presupuesto_target'), // céntimos
tasteText: text('taste_text'), tasteText: text('taste_text'),
preferencesSnapshot: jsonb('preferences_snapshot'), 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) => [ (table) => [
index('leads_tenant_created_idx').on(table.tenantId, table.createdAt), 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 Tenant = typeof tenants.$inferSelect;
export type Lead = typeof leads.$inferSelect; export type Lead = typeof leads.$inferSelect;
export type NewLead = typeof leads.$inferInsert; export type NewLead = typeof leads.$inferInsert;
@@ -381,6 +540,11 @@ export type LeadFoto = typeof leadFotos.$inferSelect;
export type NewLeadFoto = typeof leadFotos.$inferInsert; export type NewLeadFoto = typeof leadFotos.$inferInsert;
export type LeadNota = typeof leadNotas.$inferSelect; export type LeadNota = typeof leadNotas.$inferSelect;
export type NewLeadNota = typeof leadNotas.$inferInsert; 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 Testimonio = typeof testimonios.$inferSelect;
export type NewTestimonio = typeof testimonios.$inferInsert; export type NewTestimonio = typeof testimonios.$inferInsert;
export type TestimonioFoto = typeof testimonioFotos.$inferSelect; export type TestimonioFoto = typeof testimonioFotos.$inferSelect;