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:
153
mvp/b2c/src/db/schema.ts
Normal file
153
mvp/b2c/src/db/schema.ts
Normal 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;
|
||||
Reference in New Issue
Block a user