crm

Arquitectura

Contexto y decisiones ya tomadas

Aislamiento multi-tenant: Row-Level Security, no solo código

La decisión de seguridad más importante del proyecto: el aislamiento entre negocios no depende de que el código de la aplicación recuerde añadir WHERE tenant_id = ... en cada consulta. Se aplica a nivel de base de datos con Row-Level Security (RLS) de PostgreSQL:

  1. Cada tabla con datos de un negocio (customers, users, interactions, purchases, returns, points_config, audit_log) tiene una columna tenant_id y una política RLS:
    CREATE POLICY tenant_isolation ON customers
      USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
      WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
    
  2. Antes de cada operación, el backend fija esa variable de sesión al tenant del usuario autenticado (ver PrismaService.withTenant() en backend/src/prisma/prisma.service.ts), dentro de una transacción.
  3. Fail-closed: si la variable no está fijada (código con un bug, o un acceso fuera de este mecanismo), Postgres no devuelve ninguna fila — no hay un “modo por defecto” que muestre todo.
  4. Importante: RLS no protege nada si la conexión usa un rol superusuario, porque estos lo saltan (bypass) por defecto. Por eso existe un rol separado, app_role, creado en la propia migración (NOSUPERUSER NOBYPASSRLS), que es el único que debe usar el backend en producción. El rol administrador de Postgres solo se usa para aplicar migraciones.
  5. La tabla tenants es la única excepción parcial: no contiene datos de clientes, solo el registro del negocio en sí (nombre, slug, tipo), así que su lectura es pública — es necesaria para que el login pueda localizar un negocio por su slug antes de que exista una sesión con tenant fijado. La escritura sigue restringida (alta pública permitida, modificación solo del propio tenant).

Esto se verificó con pruebas reales, no solo revisando el código: conectando como app_role (no como superusuario), se comprobó que:

Después, se repitió la misma prueba a través de la API HTTP completa (registro de dos negocios, creación de clientes en cada uno, y verificación de que ninguno puede leer los datos del otro, ni siquiera pidiendo el ID exacto del recurso de otro tenant — responde 404, no 403, para no revelar que el recurso existe).

Bug real encontrado: current_setting devuelve '', no NULL, en una conexión reutilizada

Al construir platform-admin (una consulta que deliberadamente no fija ningún tenant, porque lista negocios de todos), apareció un 500 en vez de la respuesta esperada. La causa, reproducida directamente en psql:

BEGIN;
SELECT set_config('app.tenant_id', '<algún-uuid>', true); -- SET LOCAL
COMMIT;
SELECT current_setting('app.tenant_id', true); -- ¡devuelve '', no NULL!

En una conexión de Postgres que nunca ha tocado app.tenant_id, current_setting(..., true) devuelve NULL limpio (confirmado también en psql). Pero en cuanto esa variable se ha fijado una vez en la conexión — aunque sea con SET LOCAL/set_config(..., true), que revierte al terminar la transacción — Postgres no la “olvida”: el “revertir” es a una cadena vacía '', no a “sin fijar”. Y ''::uuid no es NULL, es un error de casteo que Postgres lanza de inmediato.

Con Prisma reutilizando conexiones de un pool, esto significa que WITH TENANT funciona perfecto siempre, pero cualquier consulta que toque una tabla con RLS fuera de withTenant() podía romperse con un 500 real (no con “0 filas”, que era la intención de diseño) en cuanto esa conexión física ya se hubiera usado antes para algún tenant — es decir, de forma intermitente y dependiente del estado del pool, el tipo de bug más difícil de reproducir y más fácil de no detectar en pruebas manuales rápidas.

Arreglo (migración 20260907001100_fix_rls_empty_string_tenant_id): todas las políticas RLS pasaron de

tenant_id = current_setting('app.tenant_id', true)::uuid

a

tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid

NULLIF(x, '') convierte la cadena vacía en NULL de verdad antes de castear, y tenant_id = NULL vuelve a evaluar a NULL (ninguna fila) — el fail-closed que se buscaba desde el principio, ahora sin el error de casteo intermedio. Verificado reaplicando las 5 migraciones desde una base de datos vacía y repitiendo las pruebas de aislamiento.

Lección para el futuro: cualquier política RLS nueva en este proyecto debe usar NULLIF(current_setting(...), ''), nunca current_setting(...) a secas.

Autenticación y autorización

Administrador de la plataforma

Decisión: no hay auto-registro público de negocios. Solo tú (el operador de la app) decides qué negocios existen, y creas su usuario owner inicial — desde ahí, ese owner ya gestiona sus propios empleados como siempre.

Esto introduce un tercer tipo de identidad, fuera del modelo multi-tenant a propósito:

Modelo de datos

Ver backend/prisma/schema.prisma y las migraciones en backend/prisma/migrations/ (fuente de verdad, incluyen las políticas RLS que Prisma no modela de forma nativa). Importante: una vez que una migración se ha aplicado alguna vez (incluida la del propio usuario en su Docker local), no se edita — los cambios de esquema posteriores van en un directorio de migración nuevo, para que prisma migrate deploy los detecte y aplique sobre una base de datos que ya tiene datos.

Tablas: tenants, users, points_config, customers, interactions, products, purchases, purchase_items, returns, audit_log.

customers.custom_fields es JSONB para permitir que cada negocio añada campos propios de su sector sin necesitar una migración de esquema.

Productos y compras itemizadas

Las compras dejaron de ser un importe suelto: cada purchase tiene una o varias purchase_items (producto + cantidad + precio en el momento de la venta). El precio se copia a la línea en el momento de la compra (purchase_items.unit_price) en vez de leerse siempre de products.price, para que un cambio de precio futuro no altere el histórico de ventas ya registradas. purchases.amount se sigue guardando (ahora calculado como la suma de las líneas) para no romper el cálculo de puntos ni las devoluciones, que ya trabajaban sobre el total.

Un producto no se borra nunca físicamente (rompería el historial de purchase_items, que lo referencia); se desactiva (products.active = false), igual que el borrado lógico de clientes.

Llamadas comerciales con resultado

interactions.outcome (sale_closed / interested / not_interested / call_back) y interactions.follow_up_at son columnas nuevas, nulas para visitas — el backend rechaza con 400 cualquier intento de fijar outcome o followUpAt en una interacción que no sea de tipo call.

Reportes

GET /reports/customers y GET /reports/products (solo owner/admin): top clientes por gasto neto (compras menos devoluciones, no gasto bruto) y por puntos acumulados; productos más vendidos por cantidad y por ingresos. Igual que en segmentos, son agregaciones SQL directas sobre purchases/returns/purchase_items, no vistas materializadas — a este volumen de datos no hace falta esa complejidad.

Puntos, compras y devoluciones

Segmentos pre-creados

GET /segments (solo owner/admin) calcula los tres segmentos pedidos al inicio del proyecto con una única consulta SQL (segments.service.ts), comparando dos periodos de 12 meses (el actual y el inmediatamente anterior):

Dos definiciones que son elección nuestra, no un estándar universal, y que quedan documentadas en el propio código para que sean explícitas:

Un cliente sin visitas en el periodo anterior (recién dado de alta) no puede tener una media de gasto “anterior”, así que queda excluido de los segmentos de aumento/disminución de gasto (no se puede comparar contra un dato que no existe) — solo puede aparecer en “visitas disminuidas” si además tenía visitas antes y ahora tiene menos, lo cual por definición no le pasa a un cliente nuevo.

Constructor de filtros personalizados (custom-segments)

Los 3 segmentos de arriba son fijos. Este módulo (backend/src/custom-segments/) es el “constructor acotado” para preguntas ad-hoc tipo “tengo un producto nuevo de categoría X, ¿quién ya ha comprado algo de esa categoría?”.

Simplificaciones deliberadas (lo que lo hace seguro y manejable, no un motor de reglas de negocio completo):

Por qué es seguro pese a construir SQL dinámicamente: el campo y el operador de cada condición se validan contra un enum fijo (@IsIn(FIELD_KEYS) / @IsIn(ALL_OPERATORS) en los DTOs) antes de tocar la base de datos — nunca se interpola texto libre del usuario como nombre de columna u operador SQL, solo como valor parametrizado (vía las plantillas etiquetadas Prisma.sql, que Prisma vincula de forma segura). Prisma.raw(...) solo envuelve strings que ya vienen de ese lookup fijo, nunca del cuerpo de la petición directamente.

Cómo funciona la consulta (custom-segments.service.ts): un CTE customer_facts calcula, para cada cliente, todos los campos numéricos de una vez (visitas/llamadas/gasto del periodo vía subconsultas agregadas, más los campos sin periodo). La condición purchased_category es la excepción: no es una columna precalculada, se traduce a un EXISTS contra purchase_items/products, porque el valor a comparar (la categoría) solo se conoce al ejecutar la consulta. Un cliente que nunca visitó o nunca compró se trata como “hace 999999 días” (no como NULL), a propósito: así aparece correctamente en filtros de inactividad tipo “sin visitar hace más de 90 días”, en vez de quedar excluido por una comparación contra NULL que en SQL nunca es verdadera.

Los filtros se guardan (saved_segments, con RLS desde el principio con el patrón NULLIF correcto) como configuración — nombre + periodo + tipo de coincidencia + condiciones en JSON —, no como resultados: cada vez que se abre un filtro guardado, se recalcula en vivo contra los datos actuales. Verificado con datos sintéticos donde el resultado esperado se conocía de antemano (incluyendo Y vs. O, between, y el caso de un cliente que nunca tuvo actividad).

Leads y contactos (partners / contactos interesantes)

Dos entidades nuevas, deliberadamente separadas de customers en vez de unificadas: un lead o un contacto no tiene historial de compras ni puntos, eso solo lo tiene un cliente real. Mezclarlas habría significado o bien forzar campos de compras/puntos irrelevantes en un lead, o un refactor invasivo de customers (que ya funciona y está integrado con compras/interacciones/puntos) — ninguna opción compensaba.

Ambas tablas con RLS usando ya el patrón NULLIF correcto desde el principio. El frontend reutiliza el listado de /customers ya existente como una pestaña más dentro de “Contactos”, en vez de duplicar esa lectura — la pestaña “Clientes” ahí no es un dato nuevo, es una vista distinta del mismo dato.

Extensión de Chrome (captura de LinkedIn)

Carpeta extension/, independiente del backend/frontend — Manifest V3, sin ningún content_script persistente ni permiso de host amplio (host_permissions). Diseño deliberadamente de mínimo privilegio:

Probado en tres niveles independientes, cada uno con su alcance explícito:

  1. Lógica de extracción (extract-linkedin.js) contra una página HTML local que imita la forma del DOM de LinkedIn (no LinkedIn real) — Playwright + Chromium, verificando que nombre/titular/ubicación/empresa se leen correctamente.
  2. Flujo de guardado (lib/api.js) contra el backend real corriendo en local, simulando solo chrome.storage.local (lo único que no existe en Node) — login, creación de lead, creación de contacto, y rechazo de contraseña incorrecta con datos reales.
  3. Carga de la extensión completa (manifest + popup + options) en un Chromium real vía launchPersistentContext con --load-extension, confirmando que Chrome la acepta y que popup/options se renderizan sin errores de JS — sin abrir LinkedIn real, que es lo que el propio diseño evita hacer de forma automatizada.

Frontend

Cubre todo el backend descrito arriba: registro de negocio, login, clientes (con búsqueda por nombre/teléfono/email), ficha de cliente (interacciones y llamadas con resultado estructurado, compras itemizadas con selector de productos, devoluciones), catálogo de productos, reglas de puntos, segmentos y reportes (clientes y productos). Un botón de acción rápida flotante (+), presente en toda la app vía AppLayout, permite crear un cliente, registrar una llamada, o saltar directo a la ficha de un cliente para una compra, sin tener que navegar primero por el listado — con búsqueda de cliente integrada en el propio modal.

Probado en Chromium con Playwright de punta a punta en dos rondas: la primera cubriendo login/registro/clientes/puntos/devoluciones/segmentos (verificando números exactos: compra de 40€ a 3 puntos/€ → 120 puntos; devolución del 50% → 60 puntos), y la segunda cubriendo productos, compras con varias líneas (verificado que 3×25€ + 1×40€ = 115€ tanto en el carrito como en el reporte de gasto neto), llamadas con resultado, búsqueda de clientes, y el flujo completo del botón de acción rápida.

RGPD: portabilidad y borrado físico definitivo

Sobre la base que ya existía (borrado lógico, consentimiento de marketing, registro de auditoría), tres piezas nuevas en customers.service.ts/customers.controller.ts:

Por qué la purga no necesita borrar nada más a mano: interactions, purchases, purchase_items y returns ya tenían onDelete: Cascade hacia customers desde el modelo de datos original — tx.customer.delete() los arrastra solo. audit_log no tiene una FK real hacia customers (entity_id es un UUID suelto sin relación declarada en Prisma), así que el historial de auditoría sobrevive con normalidad a la purga de un cliente, que es lo que se espera de un registro de auditoría.

La única referencia que sí bloqueaba el DELETE era leads.converted_customer_id (REFERENCES customers(id) sin ON DELETE explícito, por tanto RESTRICT por defecto): un cliente que vino de un lead convertido no debería poder impedir su propia purga. Migración 20260913000100_customer_rgpd_purge: se cambió esa FK a ON DELETE SET NULL — el lead conserva su propio historial (no es el dato que se está purgando), solo pierde el enlace a un cliente que ya no existe.

Verificado con datos reales, no solo revisando el código: una compra con precio unitario editado se exporta con el importe correcto: purgar sin borrado lógico previo devuelve 400; tras purgar, una consulta directa a Postgres confirma 0 filas huérfanas en purchases/purchase_items/interactions/returns; y un lead convertido en el cliente purgado conserva su fila con convertedCustomerId = NULL en vez de bloquear el DELETE.

Edición de clientes y precio unitario editable

Dos huecos que quedaban entre lo que la API ya soportaba y lo que el frontend exponía, cerrados sin tocar el backend: