Add B2B reformista panel with Postgres/Drizzle data layer

Modela el funnel del lead en dos dimensiones (pipeline_stage técnico
de 7 pasos + estado comercial de 6 estados) y siembra 11 leads demo,
uno por cada momento del funnel, para analizar el siguiente paso.
Incluye panel /panel (lista + detalle RF-D-01/02) y wiring de deploy
(Dockerfile multi-stage + entrypoint migrate+seed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-05-29 15:51:10 +02:00
parent 9020c24e68
commit f09024f753
20 changed files with 3630 additions and 2 deletions

153
mvp/b2c/src/db/schema.ts Normal file
View File

@@ -0,0 +1,153 @@
import {
pgTable,
pgEnum,
uuid,
text,
integer,
boolean,
numeric,
timestamp,
jsonb,
index,
} from 'drizzle-orm/pg-core';
// 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',
]);
// 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'),
provincia: text('provincia'),
whatsappBusiness: text('whatsapp_business'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
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'),
},
(table) => [
index('leads_tenant_created_idx').on(table.tenantId, table.createdAt),
index('leads_estado_idx').on(table.estado),
]
);
// Fotos subidas por el cliente (paso 3, 2-4 fotos)
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(),
orden: integer('orden').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// 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(),
});
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 LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
export type PrecisionHistory = typeof precisionHistory.$inferSelect;