owner, admin, employee).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:
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);
PrismaService.withTenant() en
backend/src/prisma/prisma.service.ts), dentro de una transacción.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.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:
tenant_id de otro negocio es
rechazado por Postgres (new row violates row-level security policy);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).
current_setting devuelve '', no NULL, en una conexión reutilizadaAl 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.
{ slug, email, password } → el slug identifica el negocio
(ej. peluqueria-marisa), porque un mismo email puede repetirse entre
negocios distintos.JWT_SECRET, payload con { sub: userId, tenantId,
email, role }, expira a las 8 horas.JwtAuthGuard) y, cuando así se declara con @Roles(...), un rol
concreto (RolesGuard). Las rutas públicas (/auth/login,
/platform-admin/login) se marcan explícitamente con @Public() — el
valor por defecto es “cerrado”, no “abierto”.bcryptjs (12 rondas). Se usa la variante
pura en JavaScript (en vez de argon2 o bcrypt nativos) para evitar
depender de compilación de módulos nativos en este proyecto de práctica;
en un uso real conviene evaluar argon2.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:
platform_admins, sin Row-Level Security. RLS aísla datos
entre tenants; esto no es un dato de tenant, es el nivel por encima
de todos ellos — no hay “tenant activo” que aplicarle. Se protege por
diseño de aplicación (solo el módulo platform-admin la toca), no por
RLS.PLATFORM_ADMIN_EMAIL / PLATFORM_ADMIN_PASSWORD (upsert en
PlatformAdminService.onModuleInit) — no existe ningún endpoint para
crear administradores de plataforma. Cambiar la contraseña es cambiar
la variable de entorno y reiniciar.POST /platform-admin/login) que emite un JWT con
un secreto de firma distinto (PLATFORM_ADMIN_JWT_SECRET, no
JWT_SECRET) y un payload con forma distinta ({ sub, email, scope:
'platform_admin' }, sin tenantId ni role). Es defensa en
profundidad real, no solo cosmética: aunque hubiera un bug en cómo se
aplican los guards, un token de un tipo no puede validar contra el
secreto del otro — la verificación de firma falla antes de mirar el
contenido. Verificado con pruebas: un token de owner de negocio da 401
en rutas de platform-admin, y viceversa.POST /platform-admin/tenants crea el tenant + su usuario owner (la
misma lógica que antes vivía en el registro público), protegido por
PlatformAdminGuard. GET /platform-admin/tenants lista todos los
negocios con su recuento de usuarios/clientes (ver el bug de RLS más
abajo, relevante aquí).RootApp.tsx): sesión propia en localStorage bajo otra clave,
sin relación con el AuthContext de los usuarios de negocio.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.
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.
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.
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.
POST /customers/:id/purchases calcula los puntos dentro de la misma
transacción que crea la compra y actualiza customers.points_balance
(usando increment atómico de Prisma, no una lectura-y-escritura
separada que podría perder una actualización concurrente).amount >= points_config.minPurchaseAmount, se otorgan
amount * points_config.pointsPerCurrencyUnit puntos; si no, 0. El
amount es ahora la suma de las líneas de producto de la compra.POST /customers/:id/purchases/:purchaseId/returns sigue dos reglas:
Decimal de
Prisma a number de JavaScript y redondeando a 2 decimales. Es una
simplificación deliberada aceptable para un CRM de práctica; para un
sistema con más volumen o más precisión monetaria requerida, convendría
operar con una librería decimal (ej. decimal.js, que Prisma ya usa
internamente) en vez de number.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):
visit en los últimos 12 meses es menor que en los 12 meses anteriores.(total comprado en el periodo) / (visitas en el periodo) entre ambos
periodos.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.
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):
points_balance, days_since_last_visit,
days_since_last_purchase, customer_age_days) simplemente lo ignoran.matchType: 'all' | 'any'), sin grupos anidados mezclando ambas.field-catalog.ts), no cualquier columna: visitas,
llamadas, gasto total, gasto medio por visita, saldo de puntos, días
desde la última visita/compra, antigüedad del cliente, y categoría de
producto comprada.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).
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.
leads: pipeline con 6 fases fijas (new → contacted → qualified →
proposal → won/lost), no configurables por tenant — misma lógica de
“constructor acotado” que en los filtros personalizados: cubre el caso
común sin la complejidad de un editor de pipelines. PATCH
/leads/:id/stage es el endpoint que usa el drag-and-drop del frontend.POST /leads/:id/convert-to-customer):
crea un Customer de verdad a partir de los datos del lead, marca el
lead como won y lo enlaza (convertedCustomerId) sin borrarlo — así
se conserva de dónde vino ese cliente. Bloqueado convertir el mismo
lead dos veces (comprobación de convertedCustomerId ya establecido).contacts: partners y “contactos interesantes” son la misma tabla
con una columna category distinta, no dos tablas separadas — son
estructuralmente idénticos (nombre, empresa, contacto, notas), así que
duplicar la tabla solo habría sido repetir código sin ganar nada.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.
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:
chrome.scripting.executeScript para
inyectar la función de extracción solo cuando el usuario, viendo un
perfil, hace clic en “Extraer de esta página” — apoyado en el permiso
temporal activeTab que concede ese mismo clic en el icono de la
extensión, no en un permiso permanente sobre linkedin.com.lib/api.js la usa una sola vez
contra POST /auth/login y solo guarda en chrome.storage.local el
JWT resultante y su expiración (decodificada del propio JWT, sin
verificar firma en el cliente — la verificación real la hace el
backend en cada petición).lib/extract-linkedin.js lee el
DOM con selectores (h1, .text-body-medium, etc.) porque LinkedIn no
publica una estructura de HTML estable ni una API pública para esto.
El formulario del popup siempre se rellena para revisar/editar antes
de guardar — nunca se envía nada a la API sin que el usuario lo vea
primero.extension/README.md.POST /leads, POST /contacts)
con source: "linkedin_extension", sin ningún endpoint nuevo en el
backend — la extensión es un cliente más de la misma API, con las
mismas políticas RLS y de roles que el frontend web.Probado en tres niveles independientes, cada uno con su alcance explícito:
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.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.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.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.
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:
GET /customers/:id/export (owner/admin): derecho a la
portabilidad. Devuelve un único JSON con el perfil del cliente,
todas sus interacciones, compras (con líneas de producto) y
devoluciones. Deliberadamente no excluye clientes ya borrados
lógicamente — una solicitud de portabilidad puede llegar antes de
pedir el borrado, o justo para quedarse con una copia antes de
purgar.GET /customers/deleted (owner/admin): la “papelera” — clientes
con deletedAt no nulo. Necesaria porque el listado normal
(GET /customers) los excluye a propósito, así que sin este
endpoint no habría forma de llegar a un cliente ya borrado para
exportarlo o purgarlo.DELETE /customers/:id/purge (owner/admin): derecho al olvido
real, borrado físico con tx.customer.delete(). Exige que el
cliente ya esté borrado lógicamente (responde 400 si no) — dos
pasos deliberados, para que una operación irreversible nunca sea el
primer clic posible. Antes de borrar, escribe una entrada de
auditoría (customer.purged) con el entityId pero sin copiar
ningún dato personal del cliente en el metadata — la propia idea
de “borrado definitivo” pierde sentido si el nombre sigue viviendo
en otra tabla.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.
Dos huecos que quedaban entre lo que la API ya soportaba y lo que el frontend exponía, cerrados sin tocar el backend:
CustomerDetailPage.tsx)
gana un botón “Editar” que abre un formulario inline (nombre,
teléfono, email, consentimiento de marketing) sobre el PATCH
/customers/:id que ya existía desde el módulo de clientes original.<input> numérico en vez de texto estático; al
confirmar, se envía explícitamente unitPrice por línea (la API ya
lo aceptaba como override opcional del precio del catálogo, pensado
para descuentos puntuales, pero la UI hasta ahora nunca lo usaba).