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).
{ 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/register-tenant,
/auth/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.Ver backend/prisma/schema.prisma y la migración
backend/prisma/migrations/00000000000000_init/migration.sql (fuente de
verdad, incluye las políticas RLS que Prisma no modela de forma nativa).
Tablas: tenants, users, points_config, customers, interactions,
purchases, 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.
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.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.