From 1a70ab2eaaa0f614740ef71e130cea783e05979b Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Wed, 3 Jun 2026 18:07:46 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20esquema=20SQL=20consolidado=20de?= =?UTF-8?q?=20la=20base=20de=20datos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Genera db-schema/schema.sql (DDL completo: 12 enums, 14 tablas, FKs e índices) a partir del schema Drizzle, para que el equipo pueda consultar y proponer cambios sobre el modelo de datos sin leer las migraciones una a una. - db-schema/schema.sql: foto del esquema actual (drizzle-kit export) - db-schema/README.md: qué es cada tabla, cómo cambiar el modelo y cómo regenerar/levantar la BD desde este SQL - package.json: script db:export para regenerar la foto Co-Authored-By: Claude Opus 4.8 --- mvp/b2c/db-schema/README.md | 53 +++++++++ mvp/b2c/db-schema/schema.sql | 216 +++++++++++++++++++++++++++++++++++ mvp/b2c/package.json | 1 + 3 files changed, 270 insertions(+) create mode 100644 mvp/b2c/db-schema/README.md create mode 100644 mvp/b2c/db-schema/schema.sql diff --git a/mvp/b2c/db-schema/README.md b/mvp/b2c/db-schema/README.md new file mode 100644 index 0000000..a3d8f3e --- /dev/null +++ b/mvp/b2c/db-schema/README.md @@ -0,0 +1,53 @@ +# Esquema de la base de datos — Reformix B2C + +`schema.sql` es el **DDL consolidado** de toda la base de datos en su estado actual: +12 enums, 14 tablas, sus claves foráneas e índices. Sirve para que el equipo entienda, +consulte y proponga cambios sobre el modelo de datos sin tener que leer las migraciones +una a una. + +## Tablas + +| Tabla | Para qué | +| --- | --- | +| `tenants` | Reformistas (multi-tenant; en el MVP solo "Reformas Ejemplo") | +| `plans` | Planes de suscripción | +| `users` / `sessions` | Auth del panel del reformista | +| `leads` | Lead del cliente final + estado del funnel + resultado (presupuesto, render, transcripción) | +| `lead_fotos` | Fotos que sube el cliente del espacio a reformar | +| `lead_pipeline_eventos` | Traza de cada paso del pipeline (prellamada, llamada, render, presupuesto, WhatsApp) | +| `lead_estado_history` | Historial de cambios de estado comercial del lead | +| `catalog_items` | Catálogo de materiales del reformista (precio, calidad, unidad) — entrada del motor de presupuesto | +| `pricing_config` | Config de precios del reformista (mano de obra, márgenes…) | +| `precision_history` | Histórico de precisión de las estimaciones | +| `galeria_fotos` | Galería de trabajos del reformista | +| `testimonios` / `testimonio_fotos` | Reseñas con fotos | + +## Importante: la fuente de la verdad es `src/db/schema.ts` + +**No edites `schema.sql` a mano.** El esquema real vive en [`src/db/schema.ts`](../src/db/schema.ts) +(Drizzle ORM) y los cambios se aplican con migraciones en [`drizzle/`](../drizzle/). +Este archivo es una **foto** generada a partir de ese schema. + +### Para cambiar el modelo de datos + +1. Edita `src/db/schema.ts`. +2. Genera la migración: `npx drizzle-kit generate` +3. Aplícala: `npx drizzle-kit migrate` +4. Regenera esta foto (ver abajo). + +### Regenerar `schema.sql` + +```bash +cd mvp/b2c +npm run db:export +``` + +### Levantar una base de datos local desde cero con este SQL + +```bash +docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17 +psql "postgresql://postgres:reformix@localhost:5432/reformix" -f db-schema/schema.sql +``` + +> El `export` no incluye datos semilla (tenant de ejemplo, catálogo, planes). Para eso usa +> el seed del proyecto si existe, o inserta los registros base manualmente. diff --git a/mvp/b2c/db-schema/schema.sql b/mvp/b2c/db-schema/schema.sql new file mode 100644 index 0000000..f5a4a8e --- /dev/null +++ b/mvp/b2c/db-schema/schema.sql @@ -0,0 +1,216 @@ +CREATE TYPE "public"."calidad" AS ENUM('basica', 'media', 'premium'); +CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario'); +CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision'); +CREATE TYPE "public"."lead_estado" AS ENUM('nuevo', 'contactado', 'visita_agendada', 'presupuesto_enviado', 'ganado', 'perdido'); +CREATE TYPE "public"."pipeline_stage" AS ENUM('form_completado', 'fotos_subidas', 'prellamada_enviada', 'llamada_completada', 'render_generado', 'presupuesto_generado', 'whatsapp_entregado'); +CREATE TYPE "public"."subscription_status" AS ENUM('trial', 'activo', 'cancelado', 'vencido'); +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"."unidad_medida" AS ENUM('m2', 'ml', 'ud'); +CREATE TYPE "public"."urgencia" AS ENUM('alta', 'media', 'baja'); +CREATE TYPE "public"."user_role" AS ENUM('reformista', 'admin'); +CREATE TYPE "public"."user_status" AS ENUM('activo', 'deshabilitado'); +CREATE TABLE "catalog_items" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" uuid NOT NULL, + "categoria" "categoria_material" NOT NULL, + "nombre" text NOT NULL, + "calidad" "calidad" NOT NULL, + "precio_unit" integer NOT NULL, + "unidad" "unidad_medida" NOT NULL, + "descriptor_render" text DEFAULT '' NOT NULL, + "es_default" boolean DEFAULT false NOT NULL, + "sku" text NOT NULL +); + +CREATE TABLE "galeria_fotos" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" uuid NOT NULL, + "url" text NOT NULL, + "titulo" text, + "orden" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "lead_estado_history" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "lead_id" uuid NOT NULL, + "estado" "lead_estado" NOT NULL, + "changed_at" timestamp with time zone DEFAULT now() NOT NULL, + "changed_by" text +); + +CREATE TABLE "lead_fotos" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "lead_id" uuid NOT NULL, + "url" text NOT NULL, + "orden" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "lead_pipeline_eventos" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "lead_id" uuid NOT NULL, + "stage" "pipeline_stage" NOT NULL, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL, + "metadata" jsonb +); + +CREATE TABLE "leads" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "nombre" text NOT NULL, + "telefono" text NOT NULL, + "email" text NOT NULL, + "provincia" text, + "tipo_reforma" "tipo_reforma", + "consent_privacidad" boolean DEFAULT false NOT NULL, + "consent_contratacion" boolean DEFAULT false NOT NULL, + "pipeline_stage" "pipeline_stage" DEFAULT 'form_completado' NOT NULL, + "estado" "lead_estado" DEFAULT 'nuevo' NOT NULL, + "presupuesto_estimado" integer, + "transcripcion" text, + "entidades" jsonb, + "render_url" text, + "pdf_url" text, + "audio_url" text, + "notas" text, + "testimonio_solicitado_at" timestamp with time zone, + "m2_suelo" double precision, + "altura_techo" double precision, + "calidad_global" "calidad", + "estructural" boolean DEFAULT false NOT NULL, + "material_selections" jsonb DEFAULT '{}'::jsonb NOT NULL, + "desglose_snapshot" jsonb, + "urgencia" "urgencia", + "presupuesto_target" integer, + "taste_text" text, + "preferences_snapshot" jsonb +); + +CREATE TABLE "plans" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "nombre" text NOT NULL, + "precio_mensual" integer NOT NULL, + "leads_incluidos" integer NOT NULL, + "features" jsonb DEFAULT '[]'::jsonb NOT NULL, + "activo" boolean DEFAULT true NOT NULL, + CONSTRAINT "plans_slug_unique" UNIQUE("slug") +); + +CREATE TABLE "precision_history" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "lead_id" uuid NOT NULL, + "estimated" integer NOT NULL, + "final" integer NOT NULL, + "delta_pct" numeric(6, 2) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "pricing_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" uuid NOT NULL, + "altura_techo_default" double precision DEFAULT 2.5 NOT NULL, + "factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL, + "mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id") +); + +CREATE TABLE "sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash") +); + +CREATE TABLE "tenants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "nombre_empresa" text NOT NULL, + "logo_url" text, + "provincia" text, + "whatsapp_business" text, + "seo_title" text, + "seo_description" text, + "about_enabled" boolean DEFAULT false NOT NULL, + "about_foto_url" text, + "about_texto" text, + "anios_experiencia" integer, + "theme_preset" text DEFAULT 'pizarra' NOT NULL, + "theme_color" text, + "cif" text, + "direccion" text, + "telefono" text, + "email" text, + "web" text, + "plan_id" uuid, + "subscription_status" "subscription_status" DEFAULT 'trial' NOT NULL, + "envio_presupuesto" "envio_presupuesto_mode" DEFAULT 'automatico' NOT NULL, + "trial_ends_at" timestamp with time zone, + "stripe_customer_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "tenants_slug_unique" UNIQUE("slug") +); + +CREATE TABLE "testimonio_fotos" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "testimonio_id" uuid NOT NULL, + "url" text NOT NULL, + "orden" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "testimonios" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" uuid NOT NULL, + "lead_id" uuid, + "nombre" text NOT NULL, + "contexto" text, + "rating" integer NOT NULL, + "texto" text NOT NULL, + "estado" "testimonio_estado" DEFAULT 'pendiente' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "password_hash" text NOT NULL, + "nombre" text, + "role" "user_role" DEFAULT 'reformista' NOT NULL, + "tenant_id" uuid, + "status" "user_status" DEFAULT 'activo' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); + +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 "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 "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_pipeline_eventos" ADD CONSTRAINT "lead_pipeline_eventos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "leads" ADD CONSTRAINT "leads_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "precision_history" ADD CONSTRAINT "precision_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "pricing_config" ADD CONSTRAINT "pricing_config_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE no action ON UPDATE no action; +ALTER TABLE "testimonio_fotos" ADD CONSTRAINT "testimonio_fotos_testimonio_id_testimonios_id_fk" FOREIGN KEY ("testimonio_id") REFERENCES "public"."testimonios"("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 "users" ADD CONSTRAINT "users_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action; +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 INDEX "galeria_tenant_idx" ON "galeria_fotos" USING btree ("tenant_id"); +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 "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_lead_idx" ON "testimonios" USING btree ("lead_id"); +CREATE INDEX "users_tenant_idx" ON "users" USING btree ("tenant_id"); diff --git a/mvp/b2c/package.json b/mvp/b2c/package.json index 3ae9eb1..d9fe1cc 100644 --- a/mvp/b2c/package.json +++ b/mvp/b2c/package.json @@ -12,6 +12,7 @@ "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:seed": "tsx src/db/seed.ts", + "db:export": "drizzle-kit export --dialect=postgresql --schema=./src/db/schema.ts > db-schema/schema.sql", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage"