erp-construccion/docs/02-definicion-modulos/MAI-001-fundamentos/requerimientos/RF-AUTH-002-estados-cuenta.md
rckrdmrd 7f422e51db
Some checks failed
CI Pipeline / Lint & Type Check (push) Has been cancelled
CI Pipeline / Validate SSOT Constants (push) Has been cancelled
CI Pipeline / Backend Tests (push) Has been cancelled
CI Pipeline / Frontend Tests (push) Has been cancelled
CI Pipeline / Build (push) Has been cancelled
CI Pipeline / Docker Build (push) Has been cancelled
feat: Documentation and orchestration updates
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:35:28 -06:00

1645 lines
53 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# RF-AUTH-002: Estados de Cuenta de Usuario
## 📋 Metadata
| Campo | Valor |
|-------|-------|
| **ID** | RF-AUTH-002 |
| **Épica** | MAI-001 - Fundamentos |
| **Módulo** | Autenticación y Autorización |
| **Prioridad** | Alta |
| **Estado** | 🚧 Planificado |
| **Versión** | 1.0 |
| **Fecha creación** | 2025-11-17 |
| **Última actualización** | 2025-11-17 |
| **Esfuerzo estimado** | 12h (vs 15h GAMILIT - 20% ahorro por reutilización) |
| **Story Points** | 5 SP |
## 🔗 Referencias
### Especificación Técnica
📐 [ET-AUTH-002: Gestión de Estados de Cuenta](../especificaciones/ET-AUTH-002-estados-cuenta.md) *(Pendiente)*
### Reutilización de Catálogo
♻️ **Reutilización:** 85%
- **Catálogo de referencia:** `shared/catalog/auth/` *(Patrón estados de cuenta)*
- **Diferencias clave:**
- Estados adaptados a contexto de construcción
- Casos de uso específicos para roles de obra
- Integración con sistema de constructoras
### Implementación DDL
🗄️ **ENUM Canónico:**
```sql
-- Location: apps/database/ddl/00-prerequisites.sql
CREATE TYPE auth_management.user_status AS ENUM (
'active', -- Usuario verificado, acceso completo
'inactive', -- Usuario desactivó su cuenta temporalmente
'suspended', -- Admin suspendió cuenta (reversible)
'banned', -- Admin baneó cuenta (permanente)
'pending' -- Email no verificado
);
```
🗄️ **Tablas que usan el ENUM:**
1. `auth_management.profiles`
- Columna: `status auth_management.user_status NOT NULL DEFAULT 'pending'`
2. `auth_management.user_constructoras`
- Columna: `status auth_management.user_status NOT NULL DEFAULT 'active'`
- **Nota:** Permite diferentes estados por constructora
🗄️ **Funciones:**
```sql
-- Validar estado del usuario
CREATE FUNCTION auth_management.verify_user_status(
p_user_id UUID,
p_constructora_id UUID
) RETURNS BOOLEAN;
-- Suspender usuario
CREATE FUNCTION auth_management.suspend_user(
p_user_id UUID,
p_reason TEXT,
p_duration_days INTEGER,
p_suspended_by UUID
) RETURNS VOID;
-- Banear usuario permanentemente
CREATE FUNCTION auth_management.ban_user(
p_user_id UUID,
p_reason TEXT,
p_banned_by UUID
) RETURNS VOID;
-- Reactivar usuario (desde inactive o suspended)
CREATE FUNCTION auth_management.reactivate_user(
p_user_id UUID,
p_reactivated_by UUID
) RETURNS VOID;
```
🗄️ **Triggers:**
```sql
-- Auditar cambios de estado
CREATE TRIGGER trg_profiles_status_change
AFTER UPDATE OF status ON auth_management.profiles
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION audit_logging.log_status_change();
```
### Backend
💻 **Implementación:**
- **Enum:** `apps/backend/src/modules/auth/enums/user-status.enum.ts`
```typescript
export enum UserStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SUSPENDED = 'suspended',
BANNED = 'banned',
PENDING = 'pending',
}
```
- **Middleware:** `apps/backend/src/modules/auth/middleware/user-status.middleware.ts`
- **Service:** `apps/backend/src/modules/auth/services/user-management.service.ts`
- **DTOs:**
- `apps/backend/src/modules/auth/dto/update-user-status.dto.ts`
- `apps/backend/src/modules/auth/dto/suspend-user.dto.ts`
### Frontend
🎨 **Componentes:**
- **Types:** `apps/frontend/src/types/auth.types.ts`
```typescript
interface UserProfile {
id: string;
email: string;
status: UserStatus;
role: ConstructionRole;
constructoraId: string;
// ...otros campos
}
```
- **Componentes:**
- `apps/frontend/src/components/ui/UserStatusBadge.tsx` - Badge visual del estado
- `apps/frontend/src/features/admin/UserManagementPanel.tsx` - Panel de gestión
- `apps/frontend/src/features/admin/SuspendUserModal.tsx` - Modal para suspender
- `apps/frontend/src/features/auth/AccountStatusPage.tsx` - Página de estado de cuenta
### Trazabilidad
📊 [TRACEABILITY.yml](../implementacion/TRACEABILITY.yml#L45-L78)
---
## 📝 Descripción del Requerimiento
### Contexto
En un sistema de gestión de obra, las cuentas de usuario requieren un control riguroso del ciclo de vida completo:
- **Verificación inicial:** Usuario invitado debe verificar su email antes de acceder
- **Seguridad:** Suspender cuentas comprometidas o con comportamiento inadecuado
- **Compliance:** Dar de baja usuarios que ya no trabajan en la constructora
- **Flexibilidad:** Permitir desactivación temporal voluntaria
- **Multi-tenancy:** Un usuario puede tener diferentes estados en diferentes constructoras
### Necesidad del Negocio
**Problema:**
Sin un sistema de estados bien definido:
- ❌ No se puede verificar email antes de dar acceso a datos sensibles de obra
- ❌ No hay forma de suspender temporalmente usuarios problemáticos
- ❌ No se puede diferenciar entre baja temporal (inactivo) y baja permanente (baneado)
- ❌ Empleados que ya no trabajan siguen teniendo acceso a información confidencial
- ❌ No hay auditoría de cambios de estado
**Solución:**
Implementar un sistema de 5 estados que modela el ciclo de vida completo, con transiciones controladas, auditadas y específicas para construcción.
---
## 🎯 Requerimiento Funcional
### RF-AUTH-002.1: Estados Disponibles
El sistema **DEBE** soportar exactamente 5 estados de cuenta:
#### 1. Pendiente (`pending`)
**Descripción:** Cuenta recién creada por invitación, email no verificado
**Características:**
- Estado inicial al ser invitado a una constructora
- Usuario no puede acceder al sistema
- Se envía email de verificación automáticamente
- ⏱️ **Expira después de 7 días** si no se verifica
- 🔒 No tiene acceso a datos de obra
**Acceso Permitido:**
- ❌ Dashboard
- ❌ Proyectos/Obras
- ❌ Módulos del sistema
- ✅ Página de verificación de email
- ✅ Reenviar email de verificación
**Transiciones:**
-`active`: Al verificar email exitosamente
- → ∅ (eliminación automática): Después de 7 días sin verificar
**Caso de Uso Típico:**
> Residente de Obra es invitado por el Director. Recibe email, tiene 7 días para verificar antes de que la invitación expire.
---
#### 2. Activo (`active`)
**Descripción:** Cuenta verificada, acceso completo según rol asignado
**Características:**
- ✅ Email verificado exitosamente
- ✅ Acceso completo según rol (director, engineer, resident, etc.)
- ✅ Puede usar app móvil de asistencias
- ✅ Recibe notificaciones del sistema
- ✅ Aparece en búsquedas de usuarios
**Acceso Permitido:**
- ✅ Todo el sistema según permisos de rol
- ✅ Dashboard personalizado por rol
- ✅ Módulos asignados (proyectos, presupuestos, RRHH, etc.)
- ✅ App móvil (si tiene rol de resident o hr)
**Transiciones:**
-`inactive`: Usuario desactiva su propia cuenta
-`suspended`: Admin suspende la cuenta (reversible)
-`banned`: Admin banea la cuenta (irreversible)
**Caso de Uso Típico:**
> Ingeniero accede diariamente para revisar presupuestos, actualizar programación y aprobar requisiciones.
---
#### 3. Inactivo (`inactive`)
**Descripción:** Usuario desactivó temporalmente su cuenta (voluntario)
**Características:**
- 🔵 Desactivación **voluntaria** por el usuario
- 💾 Datos conservados intactos
- 🔄 **Reversible** por el propio usuario en cualquier momento
- 📧 No recibe notificaciones
- 🚫 No aparece en búsquedas de usuarios
**Acceso Permitido:**
- ❌ Dashboard y funcionalidades principales
- ❌ Módulos del sistema
- ✅ Página de reactivación de cuenta
- ✅ Descargar datos personales (GDPR compliance)
**Transiciones:**
-`active`: Usuario reactiva su cuenta (máximo 3 veces por día)
- → ∅ (eliminación voluntaria): Usuario solicita eliminación de cuenta
**Caso de Uso Típico:**
> Residente que toma vacaciones de 2 semanas desactiva temporalmente su cuenta para no recibir notificaciones.
**Rate Limiting:**
- Máximo **3 reactivaciones por día** (prevenir abuso)
---
#### 4. Suspendido (`suspended`)
**Descripción:** Admin suspendió la cuenta (reversible)
**Características:**
- 🔴 Suspensión por admin debido a comportamiento inapropiado
- 🔄 **REVERSIBLE** (diferencia clave con `banned`)
- 📝 Requiere **razón documentada** obligatoria
- 🔍 Requiere revisión de admin para levantar suspensión
- 📬 Usuario recibe notificación con razón de suspensión
**Acceso Permitido:**
- ❌ Todo el sistema (bloqueado completamente)
- ✅ Ver notificación de suspensión con razón
- ✅ Contactar soporte
**Restricciones:**
- 🚫 No puede iniciar sesión
- 📧 No recibe notificaciones del sistema
- 🔍 No aparece en búsquedas de usuarios
- 📱 App móvil bloqueada
**Transiciones:**
-`active`: Admin levanta suspensión después de revisión
-`banned`: Admin decide hacer baneo permanente
**Duración:**
- Típicamente: **7-30 días**
- Requiere revisión periódica por directores/admins
**Razones Comunes (Construcción):**
- Registro de asistencia fraudulento (check-in sin estar en obra)
- Captura incorrecta de avances de obra
- Modificación no autorizada de presupuestos
- Comportamiento inapropiado en obra
**Caso de Uso Típico:**
> Residente registró asistencias falsas de empleados. Director lo suspende 14 días mientras investiga.
---
#### 5. Baneado (`banned`)
**Descripción:** Admin baneó la cuenta **permanentemente** (irreversible)
**Características:**
- 🔴 Baneo por violación **grave** de términos
-**IRREVERSIBLE** (no hay transición de vuelta)
- 📧 Email y username quedan **bloqueados permanentemente**
- 💾 Datos se conservan por auditoría pero cuenta **inaccesible**
- 📝 Requiere razón grave documentada
**Acceso Permitido:**
- ❌ Todo el sistema
- ✅ Ver notificación de baneo con razón
**Restricciones:**
- 🚫 No puede iniciar sesión
- 🚫 No puede crear nueva cuenta con mismo email
- 🚫 Username queda reservado permanentemente
- 🚫 No puede ser invitado nuevamente a ninguna constructora
**Razones Comunes (Construcción):**
- Fraude financiero (apropiación indebida de recursos)
- Robo de información confidencial de la constructora
- Suplantación de identidad (firmar como otro usuario)
- Alteración de documentos oficiales (contratos, presupuestos)
- Actividad criminal relacionada con la obra
**Caso de Uso Típico:**
> Empleado de compras desvió recursos, realizó órdenes de compra falsas. Director lo banea permanentemente y procede legalmente.
---
### RF-AUTH-002.2: Flujo de Estados
```
┌──────────────────────┐
│ INVITACIÓN A │
│ CONSTRUCTORA │
└──────────┬───────────┘
pending ──────(verificar email)─────> active
│ │
│ ├──(usuario desactiva)──> inactive
│ │ │
│ │ └──(reactiva)──> active
│ │
│ ├──(admin suspende)──> suspended
│ │ │
│ │ ├──(admin levanta)──> active
│ │ └──(admin decide)──> banned
│ │
└────(7 días)───> ∅ └──(admin banea)──> banned
(eliminación) │
└──(permanente)──> ∅
```
**Leyenda:**
- ∅ = Registro eliminado/cuenta no recuperable
- Flechas sólidas = Transiciones permitidas
- Flechas punteadas = Eliminación automática
---
### RF-AUTH-002.3: Reglas de Transición
#### Transiciones Permitidas
| Estado Actual | Puede Transicionar A | Quién Puede Hacerlo | Requiere |
|---------------|---------------------|---------------------|----------|
| `pending` | `active` | Usuario | Verificar email (click en link) |
| `pending` | ∅ (eliminación) | Sistema | 7 días sin verificar |
| `active` | `inactive` | Usuario | Confirmación + password |
| `active` | `suspended` | director, super_admin | Razón documentada obligatoria |
| `active` | `banned` | director, super_admin | Razón grave documentada + evidencia |
| `inactive` | `active` | Usuario | Click en reactivar (máx 3/día) |
| `suspended` | `active` | director, super_admin | Revisión completada + justificación |
| `suspended` | `banned` | director, super_admin | Decisión justificada + evidencia |
| `banned` | ∅ | N/A | **Irreversible** |
#### Transiciones Prohibidas
| Transición | Razón |
|------------|-------|
| ❌ `pending``suspended` | No tiene sentido suspender cuenta no verificada (simplemente eliminar invitación) |
| ❌ `pending``banned` | No tiene sentido banear cuenta no verificada |
| ❌ `inactive``suspended` | Usuario ya desactivó voluntariamente, no hay necesidad de suspender |
| ❌ `inactive``banned` | Si se requiere baneo, reactivar primero y luego banear desde `active` |
| ❌ `suspended``inactive` | Confusión de estados (uno es admin-driven, otro user-driven) |
| ❌ `banned` → cualquier otro | **Irreversible por diseño** |
---
### RF-AUTH-002.4: Validación de Estado en Sistema Multi-tenancy
**Importante:** En este sistema, un usuario puede pertenecer a **múltiples constructoras** con diferentes estados en cada una.
#### Escenario Multi-tenancy
```typescript
// Usuario puede tener diferentes estados en diferentes constructoras
const userConstructoras = [
{ constructoraId: 'A', status: 'active', role: 'director' },
{ constructoraId: 'B', status: 'suspended', role: 'engineer' },
{ constructoraId: 'C', status: 'active', role: 'resident' },
];
// Al hacer login, usuario ve solo constructoras donde status = 'active'
const availableConstructoras = userConstructoras.filter(
uc => uc.status === 'active'
); // ['A', 'C']
```
#### 1. Validación en Login
```typescript
// POST /api/auth/login
// Retorna solo constructoras donde user.status = 'active'
async login(email: string, password: string) {
const user = await this.findByEmail(email);
// Validar estado global del perfil
if (user.profile.status === 'banned') {
throw new UnauthorizedException(
'Tu cuenta ha sido baneada permanentemente. Contacta soporte para más información.'
);
}
if (user.profile.status === 'pending') {
throw new UnauthorizedException(
'Debes verificar tu email antes de acceder. Revisa tu bandeja de entrada.'
);
}
// Obtener constructoras donde el usuario está activo
const activeConstructoras = await this.getActiveConstructoras(user.id);
if (activeConstructoras.length === 0) {
throw new UnauthorizedException(
'No tienes acceso activo a ninguna constructora. Contacta al administrador.'
);
}
return {
user: user,
accessToken: this.generateToken(user, activeConstructoras[0]),
constructoras: activeConstructoras, // Usuario puede elegir
};
}
```
#### 2. Middleware en Cada Request
```typescript
// UserStatusMiddleware valida en CADA request autenticado
@Injectable()
export class UserStatusMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
const user = req.user; // Incluye constructoraId del JWT
// Excepciones: endpoints de reactivación y cambio de constructora
const allowedPaths = [
'/auth/reactivate',
'/auth/status',
'/auth/switch-constructora',
];
if (allowedPaths.some(path => req.path.startsWith(path))) {
return next();
}
// Validar estado global
if (user.profileStatus === 'banned') {
throw new ForbiddenException('Cuenta baneada permanentemente.');
}
if (user.profileStatus === 'pending') {
throw new ForbiddenException('Email no verificado.');
}
// Validar estado en constructora actual
if (user.constructoraStatus !== 'active') {
throw new ForbiddenException(
`Tu acceso a esta constructora está ${user.constructoraStatus}. ` +
`Contacta al administrador o cambia a otra constructora.`
);
}
next();
}
}
```
#### 3. Frontend - Validación Temprana
```typescript
// Redirigir según estado
useEffect(() => {
if (!user) return;
// Estado global del perfil
if (user.profile.status === 'pending') {
navigate('/verify-email');
return;
}
if (user.profile.status === 'banned') {
navigate('/account-banned');
return;
}
// Estado en constructora actual
const currentConstructora = user.constructoras.find(
c => c.id === user.currentConstructoraId
);
if (!currentConstructora) {
navigate('/select-constructora');
return;
}
if (currentConstructora.status === 'suspended') {
navigate('/account-suspended-in-constructora');
return;
}
if (currentConstructora.status === 'inactive') {
navigate('/reactivate-account');
return;
}
// Estado activo, continuar
}, [user, navigate]);
```
---
## 📊 Casos de Uso
### UC-AUTH-003: Usuario verifica email tras invitación
**Actor:** Residente de Obra (nuevo usuario)
**Precondiciones:**
- Usuario invitado a constructora por Director
- Email de invitación enviado
- `profiles.status` = `pending`
**Flujo Principal:**
1. Usuario recibe email de invitación a constructora "Constructora ABC"
2. Usuario hace click en link de verificación
3. Sistema valida token de verificación (válido por 24h)
4. Sistema actualiza `profiles.status` de `pending``active`
5. Sistema actualiza `user_constructoras.status` de `pending``active`
6. Sistema audita cambio en `audit_logs`:
```json
{
"action": "update",
"resource_type": "user_status",
"resource_id": "user_id",
"details": {
"old_status": "pending",
"new_status": "active",
"constructora_id": "abc-123",
"verified_at": "2025-11-17T10:30:00Z"
}
}
```
7. Sistema envía email de bienvenida con instrucciones
8. Usuario redirigido a `/auth/login`
9. Usuario hace login y selecciona constructora "Constructora ABC"
10. Usuario accede a dashboard según su rol (resident)
**Resultado:** Usuario con status `active` puede acceder al sistema
**Variantes:**
- **V1: Token expirado (>24h):**
1. Sistema detecta token expirado
2. Sistema muestra mensaje: "Link expirado"
3. Sistema ofrece botón "Reenviar email de verificación"
4. Usuario hace click
5. Sistema envía nuevo email con nuevo token
6. Volver a flujo principal paso 2
- **V2: Token inválido:**
1. Sistema detecta token inválido
2. Sistema muestra error: "Link inválido"
3. Sistema ofrece contactar soporte
4. Sistema audita intento de verificación inválido
- **V3: Usuario ya verificado:**
1. Sistema detecta que `status` ya es `active`
2. Sistema muestra: "Ya has verificado tu email"
3. Sistema redirige a login
---
### UC-ADMIN-002: Director suspende cuenta de Residente
**Actor:** Director de Construcción
**Precondiciones:**
- Usuario con rol `director`
- Residente con status `active`
- Comportamiento inapropiado detectado (ej: asistencias falsas)
**Flujo Principal:**
1. Director navega a **Panel de Gestión de Usuarios** (`/admin/users`)
2. Director filtra usuarios por constructora actual
3. Director busca residente por nombre: "Juan Pérez"
4. Director hace click en botón "Acciones" → "Suspender cuenta"
5. Sistema muestra **Modal de Suspensión** con formulario:
- **Razón** (textarea obligatorio, min 20 caracteres)
- **Duración sugerida** (select: 7 días, 14 días, 30 días, Indefinido)
- **Evidencia** (opcional: subir screenshots, documentos)
- **Fecha de revisión** (automática según duración)
6. Director completa formulario:
- Razón: "Registró asistencias de empleados que no estaban en obra según GPS"
- Duración: 14 días
- Evidencia: Sube screenshots de GPS
7. Director hace click en "Confirmar Suspensión"
8. Sistema valida:
- Razón tiene mínimo 20 caracteres ✅
- Usuario tiene permisos (rol = director) ✅
- Usuario a suspender no es director ✅ (no puede suspender otro director)
9. Sistema ejecuta transacción:
```sql
BEGIN;
-- Actualizar estado
UPDATE auth_management.user_constructoras
SET status = 'suspended',
suspended_at = NOW(),
suspended_by = :director_id,
suspended_reason = :reason,
suspended_until = NOW() + INTERVAL '14 days'
WHERE user_id = :resident_id
AND constructora_id = :constructora_id;
-- Auditar
INSERT INTO audit_logging.audit_logs (
action, resource_type, resource_id, performed_by, details
) VALUES (
'update', 'user_status', :resident_id, :director_id,
jsonb_build_object(
'old_status', 'active',
'new_status', 'suspended',
'reason', :reason,
'evidence_urls', :evidence_urls,
'suspended_by_name', 'Director López',
'review_date', NOW() + INTERVAL '14 days',
'constructora_id', :constructora_id
)
);
COMMIT;
```
10. Sistema cierra todas las sesiones activas del residente en esa constructora
11. Sistema envía **Notificación Push + Email** al residente:
```
Asunto: Tu cuenta ha sido suspendida temporalmente
Hola Juan,
Tu cuenta en Constructora ABC ha sido suspendida por 14 días.
Razón: Registró asistencias de empleados que no estaban en obra según GPS
Fecha de revisión: 2025-12-01
Si crees que esto es un error, puedes contactar a soporte en soporte@constructora.com
---
Sistema de Gestión de Obra
```
12. Sistema registra en timeline del usuario el evento de suspensión
13. Sistema muestra confirmación al director: "Usuario suspendido exitosamente"
**Resultado:**
- Residente no puede acceder a esa constructora por 14 días
- Director puede revisar suspensión antes de la fecha
- Suspensión queda auditada con evidencia
**Variantes:**
- **V1: Director intenta suspender otro director:**
- Sistema lanza error: "No puedes suspender a otro director"
- Solo super_admin puede suspender directores
- **V2: Residente ya está suspendido:**
- Sistema detecta status = 'suspended'
- Sistema muestra opciones:
- Levantar suspensión
- Extender suspensión
- Convertir en baneo
**Postcondiciones:**
- `user_constructoras.status` = 'suspended'
- Registro en `audit_logs`
- Email enviado
- Sesiones cerradas
---
### UC-AUTH-004: Usuario desactiva su propia cuenta
**Actor:** Ingeniero
**Precondiciones:** Usuario con status `active`
**Flujo Principal:**
1. Ingeniero navega a **Configuración****Mi Cuenta** (`/settings/account`)
2. Ingeniero hace scroll hasta sección "Zona Peligrosa"
3. Ingeniero hace click en botón "Desactivar mi cuenta"
4. Sistema muestra **Modal de Confirmación** con:
- **Título:** "¿Estás seguro que quieres desactivar tu cuenta?"
- **Explicación:**
- ✅ Esta acción es temporal
- ✅ Tus datos NO se eliminarán
- ✅ Puedes reactivar en cualquier momento
- ⚠️ No recibirás notificaciones mientras esté desactivada
- ⚠️ No aparecerás en búsquedas de usuarios
- **Campo:** Contraseña (requerido para confirmar)
5. Ingeniero ingresa su contraseña
6. Ingeniero hace click en "Confirmar Desactivación"
7. Sistema valida contraseña ✅
8. Sistema actualiza `profiles.status` de `active``inactive`
9. Sistema audita cambio:
```json
{
"action": "update",
"resource_type": "user_status",
"resource_id": "user_id",
"details": {
"old_status": "active",
"new_status": "inactive",
"deactivated_by": "self",
"reason": "voluntary_deactivation",
"deactivated_at": "2025-11-17T15:45:00Z"
}
}
```
10. Sistema envía email de confirmación:
```
Asunto: Cuenta desactivada temporalmente
Hola,
Tu cuenta ha sido desactivada exitosamente.
Para reactivarla, simplemente haz login de nuevo en:
https://app.constructora.com/auth/login
Y haz click en "Reactivar cuenta".
---
Sistema de Gestión de Obra
```
11. Sistema cierra sesión del usuario (`logout`)
12. Usuario redirigido a página de login con mensaje:
> "Tu cuenta ha sido desactivada. Puedes reactivarla en cualquier momento haciendo login."
**Resultado:** Cuenta desactivada temporalmente, usuario puede reactivar cuando quiera
**Reactivación (Flujo Secundario):**
1. Usuario navega a `/auth/login`
2. Usuario ingresa email + password
3. Sistema detecta `status` = `inactive`
4. Sistema muestra página de reactivación con:
- Mensaje: "Tu cuenta está desactivada temporalmente"
- Botón grande: "Reactivar mi cuenta"
5. Usuario hace click en "Reactivar mi cuenta"
6. Sistema valida rate limiting (máximo 3 reactivaciones por día)
7. Sistema actualiza `status` de `inactive``active`
8. Sistema audita reactivación
9. Sistema muestra: "Cuenta reactivada exitosamente"
10. Usuario redirigido a dashboard
**Postcondiciones:**
- `profiles.status` = 'inactive'
- Email enviado
- Sesión cerrada
---
### UC-ADMIN-003: Director banea cuenta permanentemente
**Actor:** Director
**Precondiciones:**
- Usuario con rol `director`
- Empleado de compras con status `active` o `suspended`
- Violación grave detectada (fraude)
**Flujo Principal:**
1. Director detecta fraude: Empleado de compras creó órdenes de compra falsas por $500,000 MXN
2. Director navega a **Panel de Gestión de Usuarios** → Usuario "Carlos Ramírez"
3. Director hace click en "Acciones" → "Banear cuenta permanentemente"
4. Sistema muestra **Modal de Baneo** con advertencia:
```
⚠️ ADVERTENCIA: Esta acción es IRREVERSIBLE
El usuario NO podrá:
- Acceder al sistema nunca más
- Crear nueva cuenta con este email
- Ser invitado a ninguna constructora
Esta acción debe reservarse para violaciones GRAVES:
- Fraude financiero
- Robo de información
- Actividad criminal
¿Estás completamente seguro?
```
5. Sistema requiere:
- **Razón grave** (textarea obligatorio, min 50 caracteres)
- **Evidencia** (obligatorio: mínimo 1 archivo)
- **Confirmación:** Usuario debe escribir "BANEAR PERMANENTEMENTE"
6. Director completa:
- Razón: "Empleado creó órdenes de compra falsas por $500,000 MXN a proveedores ficticios. Se detectó desvío de recursos. Se procederá legalmente."
- Evidencia: Sube PDFs de órdenes falsas, capturas de pantalla, reporte de auditoría
- Confirmación: Escribe "BANEAR PERMANENTEMENTE"
7. Director hace click en "Confirmar Baneo"
8. Sistema ejecuta:
```sql
BEGIN;
-- Banear en todas las constructoras
UPDATE auth_management.profiles
SET status = 'banned',
banned_at = NOW(),
banned_by = :director_id,
banned_reason = :reason
WHERE id = :user_id;
-- Marcar email como bloqueado
INSERT INTO auth_management.banned_emails (email, reason, banned_by)
VALUES (:user_email, :reason, :director_id);
-- Auditar con máxima prioridad
INSERT INTO audit_logging.audit_logs (
action, resource_type, resource_id, performed_by, details, priority
) VALUES (
'ban', 'user_status', :user_id, :director_id,
jsonb_build_object(
'old_status', 'active',
'new_status', 'banned',
'reason', :reason,
'evidence_urls', :evidence_urls,
'banned_by_name', 'Director López',
'legal_action', true
),
'critical'
);
-- Cerrar sesiones en TODAS las constructoras
DELETE FROM auth_management.user_sessions
WHERE user_id = :user_id;
COMMIT;
```
9. Sistema envía notificación CRÍTICA a:
- Usuario baneado (email)
- Todos los directores de todas las constructoras donde estaba el usuario
- Super admins del sistema
10. Sistema envía email al usuario baneado:
```
Asunto: Cuenta baneada permanentemente
Tu cuenta ha sido baneada permanentemente.
Razón: [Razón documentada]
Esta acción es irreversible. No podrás acceder al sistema ni crear nueva cuenta.
Si tienes preguntas, contacta a legal@constructora.com
```
**Resultado:**
- Usuario baneado en TODAS las constructoras
- Email bloqueado permanentemente
- Username reservado permanentemente
- No puede ser invitado nunca más
**Postcondiciones:**
- `profiles.status` = 'banned'
- `banned_emails` contiene email
- Sesiones cerradas en todas las constructoras
- Notificaciones críticas enviadas
---
## 🔐 Consideraciones de Seguridad
### 1. Prevención de Bypass de Estado
**Problema:** Usuario suspendido podría intentar acceder vía API directamente, saltándose frontend
**Solución: Middleware en CADA request autenticado**
```typescript
// apps/backend/src/modules/auth/middleware/user-status.middleware.ts
import { Injectable, NestMiddleware, ForbiddenException } from '@nestjs/common';
import { UserStatus } from '../enums/user-status.enum';
@Injectable()
export class UserStatusMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
const user = req.user; // Inyectado por JwtAuthGuard
// Excepciones: endpoints que permiten ciertos estados
const allowedPaths = [
'/auth/reactivate', // inactive puede reactivar
'/auth/status', // consultar estado
'/auth/switch-constructora', // cambiar constructora
'/auth/download-data', // GDPR: inactive puede descargar datos
];
if (allowedPaths.some(path => req.path.startsWith(path))) {
return next();
}
// Validar estado global del perfil
if (user.profileStatus === 'banned') {
throw new ForbiddenException({
statusCode: 403,
message: 'Tu cuenta ha sido baneada permanentemente.',
errorCode: 'ACCOUNT_BANNED',
contactSupport: true,
});
}
if (user.profileStatus === 'pending') {
throw new ForbiddenException({
statusCode: 403,
message: 'Debes verificar tu email antes de acceder.',
errorCode: 'EMAIL_NOT_VERIFIED',
action: 'verify_email',
});
}
// Validar estado en constructora actual
if (user.constructoraStatus !== UserStatus.ACTIVE) {
throw new ForbiddenException({
statusCode: 403,
message: `Tu acceso a esta constructora está ${user.constructoraStatus}.`,
errorCode: 'CONSTRUCTORA_ACCESS_DENIED',
constructoraId: user.constructoraId,
status: user.constructoraStatus,
});
}
next();
}
}
```
**Aplicación global:**
```typescript
// apps/backend/src/main.ts
app.use(UserStatusMiddleware); // Aplica a TODOS los endpoints
```
---
### 2. Auditoría Obligatoria de Cambios de Estado
**Problema:** Cambios de estado sin auditar = no hay trazabilidad
**Solución: Trigger automático en base de datos**
```sql
-- apps/database/ddl/schemas/auth_management/triggers/audit-status-change.sql
CREATE OR REPLACE FUNCTION audit_logging.log_status_change()
RETURNS TRIGGER AS $$
BEGIN
-- Solo auditar si cambió el estado
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO audit_logging.audit_logs (
action,
resource_type,
resource_id,
performed_by,
details,
priority,
ip_address
) VALUES (
CASE NEW.status
WHEN 'suspended' THEN 'suspend'
WHEN 'banned' THEN 'ban'
WHEN 'active' THEN 'reactivate'
WHEN 'inactive' THEN 'deactivate'
ELSE 'update'
END,
'user_status',
NEW.id,
COALESCE(current_setting('app.current_user_id', true)::UUID, NEW.id),
jsonb_build_object(
'old_status', OLD.status,
'new_status', NEW.status,
'reason', NEW.suspended_reason,
'table', TG_TABLE_NAME,
'timestamp', NOW()
),
CASE NEW.status
WHEN 'banned' THEN 'critical'
WHEN 'suspended' THEN 'high'
ELSE 'medium'
END,
inet_client_addr()
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Aplicar a profiles
CREATE TRIGGER trg_audit_status_change_profiles
AFTER UPDATE OF status ON auth_management.profiles
FOR EACH ROW
EXECUTE FUNCTION audit_logging.log_status_change();
-- Aplicar a user_constructoras
CREATE TRIGGER trg_audit_status_change_constructoras
AFTER UPDATE OF status ON auth_management.user_constructoras
FOR EACH ROW
EXECUTE FUNCTION audit_logging.log_status_change();
```
**Resultado:** TODO cambio de estado queda auditado automáticamente
---
### 3. Rate Limiting en Reactivación
**Problema:** Usuario abusa de desactivar/reactivar repetidamente
**Solución: Limitar reactivaciones a 3 por día**
```typescript
// apps/backend/src/modules/auth/services/user-management.service.ts
import { TooManyRequestsException } from '@nestjs/common';
async reactivateAccount(userId: string): Promise<void> {
// Contar reactivaciones hoy
const today = new Date();
today.setHours(0, 0, 0, 0);
const reactivationCount = await this.auditLogRepository.count({
where: {
resourceId: userId,
action: 'reactivate',
createdAt: MoreThan(today),
},
});
// Limitar a 3 reactivaciones por día
if (reactivationCount >= 3) {
throw new TooManyRequestsException({
statusCode: 429,
message: 'Has alcanzado el límite de reactivaciones por hoy (3 máximo).',
errorCode: 'TOO_MANY_REACTIVATIONS',
retryAfter: this.getSecondsUntilMidnight(),
});
}
// Proceder con reactivación
await this.profileRepository.update(
{ id: userId },
{ status: UserStatus.ACTIVE }
);
}
private getSecondsUntilMidnight(): number {
const now = new Date();
const midnight = new Date(now);
midnight.setHours(24, 0, 0, 0);
return Math.floor((midnight.getTime() - now.getTime()) / 1000);
}
```
---
### 4. Notificación Obligatoria al Usuario
**Problema:** Usuario no sabe por qué cambió su estado
**Solución: Notificación automática en todo cambio de estado**
```typescript
// apps/backend/src/modules/auth/services/user-status-notification.service.ts
import { Injectable } from '@nestjs/common';
import { NotificationService } from '../../notifications/notification.service';
import { EmailService } from '../../email/email.service';
@Injectable()
export class UserStatusNotificationService {
constructor(
private readonly notificationService: NotificationService,
private readonly emailService: EmailService,
) {}
async notifyStatusChange(
userId: string,
oldStatus: UserStatus,
newStatus: UserStatus,
reason: string,
changedBy: string,
): Promise<void> {
// Solo notificar si el cambio NO fue iniciado por el propio usuario
if (changedBy !== userId) {
// Notificación in-app (push)
await this.notificationService.send({
userId,
type: 'system_announcement',
priority: newStatus === 'banned' ? 'critical' : 'high',
title: this.getNotificationTitle(newStatus),
body: reason,
icon: this.getNotificationIcon(newStatus),
actions: this.getNotificationActions(newStatus),
});
// Email
await this.emailService.send({
to: await this.getUserEmail(userId),
subject: this.getEmailSubject(newStatus),
template: 'account-status-changed',
data: {
newStatus,
oldStatus,
reason,
changedByName: await this.getUserName(changedBy),
supportEmail: 'soporte@constructora.com',
},
});
}
}
private getNotificationTitle(status: UserStatus): string {
switch (status) {
case UserStatus.SUSPENDED:
return 'Tu cuenta ha sido suspendida temporalmente';
case UserStatus.BANNED:
return 'Tu cuenta ha sido baneada permanentemente';
case UserStatus.ACTIVE:
return 'Tu cuenta ha sido reactivada';
case UserStatus.INACTIVE:
return 'Tu cuenta ha sido desactivada';
default:
return 'Tu estado de cuenta ha cambiado';
}
}
private getNotificationIcon(status: UserStatus): string {
switch (status) {
case UserStatus.SUSPENDED:
return '⚠️';
case UserStatus.BANNED:
return '🚫';
case UserStatus.ACTIVE:
return '✅';
case UserStatus.INACTIVE:
return '';
default:
return '🔔';
}
}
private getNotificationActions(status: UserStatus): any[] {
switch (status) {
case UserStatus.SUSPENDED:
return [{ label: 'Contactar Soporte', action: 'contact_support' }];
case UserStatus.BANNED:
return [{ label: 'Ver Detalles', action: 'view_ban_details' }];
case UserStatus.INACTIVE:
return [{ label: 'Reactivar Cuenta', action: 'reactivate_account' }];
default:
return [];
}
}
}
```
---
### 5. Prevención de Registro con Email Baneado
**Problema:** Usuario baneado intenta crear nueva cuenta con mismo email
**Solución: Validar email contra tabla `banned_emails` en registro**
```typescript
// apps/backend/src/modules/auth/services/auth.service.ts
async registerByInvitation(token: string, password: string): Promise<User> {
const invitation = await this.validateInvitationToken(token);
// Verificar si el email está baneado
const isBanned = await this.bannedEmailRepository.findOne({
where: { email: invitation.email },
});
if (isBanned) {
throw new ForbiddenException({
statusCode: 403,
message: 'Este email está bloqueado y no puede crear una cuenta.',
errorCode: 'EMAIL_BANNED',
contactSupport: true,
bannedReason: isBanned.reason,
});
}
// Continuar con registro...
}
```
```sql
-- apps/database/ddl/schemas/auth_management/tables/banned-emails.sql
CREATE TABLE auth_management.banned_emails (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
reason TEXT NOT NULL,
banned_by UUID REFERENCES auth_management.profiles(id),
banned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_banned_emails_email ON auth_management.banned_emails(email);
```
---
## ✅ Criterios de Aceptación
### AC-001: ENUM Implementado
- [ ] ENUM `user_status` existe en schema `auth_management`
- [ ] ENUM tiene exactamente 5 valores: `active`, `inactive`, `suspended`, `banned`, `pending`
- [ ] Columna `profiles.status` usa el ENUM con default `'pending'`
- [ ] Columna `user_constructoras.status` usa el ENUM con default `'active'`
### AC-002: Transiciones Validadas
- [ ] Solo transiciones permitidas pueden ejecutarse (validación en service)
- [ ] Transiciones prohibidas lanzan exception con mensaje claro
- [ ] Trigger audita TODOS los cambios de estado automáticamente
- [ ] Razón es obligatoria para `suspended` y `banned`
- [ ] Evidencia es obligatoria para `banned`
### AC-003: Middleware Activo
- [ ] `UserStatusMiddleware` aplicado globalmente en backend
- [ ] Middleware valida estado en CADA request autenticado
- [ ] Excepciones configuradas para paths específicos (`/auth/reactivate`, etc.)
- [ ] Frontend redirige según status (pending → verify-email, banned → account-banned)
- [ ] Usuario suspendido no puede acceder ni vía API ni vía UI
### AC-004: Notificaciones Enviadas
- [ ] Usuario recibe notificación push al cambiar status
- [ ] Usuario recibe email con razón y próximos pasos
- [ ] Notificación visible en UI con icono correcto
- [ ] Email incluye información de contacto (soporte, legal)
### AC-005: Admin Panel Funcional
- [ ] Director puede suspender usuarios (excepto otros directores)
- [ ] Director puede banear usuarios permanentemente
- [ ] Razón es campo obligatorio (min 20 caracteres para suspend, 50 para ban)
- [ ] Evidencia es obligatoria para baneo
- [ ] Historial de cambios visible en perfil de usuario
- [ ] Director puede levantar suspensión con justificación
### AC-006: Usuario Puede Desactivar/Reactivar
- [ ] Botón "Desactivar cuenta" visible en `/settings/account`
- [ ] Modal de confirmación requiere contraseña
- [ ] Reactivación simple desde login con botón "Reactivar cuenta"
- [ ] Rate limiting: máximo 3 reactivaciones por día
- [ ] Email de confirmación enviado al desactivar
### AC-007: Multi-tenancy Soportado
- [ ] Usuario puede tener diferentes estados en diferentes constructoras
- [ ] Login muestra solo constructoras donde status = 'active'
- [ ] Suspensión en una constructora no afecta otras constructoras
- [ ] Baneo global afecta TODAS las constructoras
### AC-008: Email Baneado Bloqueado
- [ ] Tabla `banned_emails` creada
- [ ] Registro valida email contra `banned_emails`
- [ ] Email baneado no puede crear nueva cuenta
- [ ] Error claro al intentar registro con email baneado
---
## 🧪 Testing
### Test Suite: User Status Management
#### Test Case 1: Usuario pending no puede acceder
```typescript
// apps/backend/src/modules/auth/tests/user-status.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, HttpStatus } from '@nestjs/common';
import * as request from 'supertest';
describe('UserStatusMiddleware - Pending Users', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('should block pending user from accessing protected endpoints', async () => {
// Arrange: Crear usuario con status pending
const user = await createTestUser({
email: 'pending@test.com',
status: UserStatus.PENDING,
});
const token = await generateAuthToken(user);
// Act: Intentar acceder a dashboard
const response = await request(app.getHttpServer())
.get('/dashboard')
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.FORBIDDEN);
// Assert
expect(response.body.message).toContain('verificar tu email');
expect(response.body.errorCode).toBe('EMAIL_NOT_VERIFIED');
});
it('should allow pending user to access /auth/resend-verification', async () => {
const user = await createTestUser({ status: UserStatus.PENDING });
const token = await generateAuthToken(user);
// Endpoint de excepción debe funcionar
const response = await request(app.getHttpServer())
.post('/auth/resend-verification')
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.OK);
expect(response.body.message).toContain('Email enviado');
});
});
```
---
#### Test Case 2: Director puede suspender usuario
```typescript
describe('UserManagementService - Suspend User', () => {
it('should allow director to suspend resident with reason', async () => {
// Arrange
const director = await createTestUser({
role: ConstructionRole.DIRECTOR,
constructoraId: 'constructora-a',
});
const resident = await createTestUser({
role: ConstructionRole.RESIDENT,
status: UserStatus.ACTIVE,
constructoraId: 'constructora-a',
});
await loginAs(director);
// Act
const response = await request(app.getHttpServer())
.post(`/admin/users/${resident.id}/suspend`)
.send({
reason: 'Registró asistencias falsas de empleados',
durationDays: 14,
evidence: ['https://s3.aws.com/screenshot1.png'],
})
.expect(HttpStatus.OK);
// Assert
expect(response.body.message).toContain('suspendido exitosamente');
// Verificar estado en DB
const updatedResident = await getUserById(resident.id);
expect(updatedResident.status).toBe(UserStatus.SUSPENDED);
expect(updatedResident.suspendedReason).toBe('Registró asistencias falsas de empleados');
// Verificar auditoría
const auditLog = await getLatestAuditLog(resident.id, 'user_status');
expect(auditLog.action).toBe('suspend');
expect(auditLog.performedBy).toBe(director.id);
expect(auditLog.details.reason).toBe('Registró asistencias falsas de empleados');
expect(auditLog.priority).toBe('high');
});
it('should NOT allow director to suspend another director', async () => {
const director1 = await createTestUser({ role: ConstructionRole.DIRECTOR });
const director2 = await createTestUser({ role: ConstructionRole.DIRECTOR });
await loginAs(director1);
const response = await request(app.getHttpServer())
.post(`/admin/users/${director2.id}/suspend`)
.send({ reason: 'Test', durationDays: 7 })
.expect(HttpStatus.FORBIDDEN);
expect(response.body.message).toContain('No puedes suspender a otro director');
});
it('should require reason with minimum length', async () => {
const director = await createTestUser({ role: ConstructionRole.DIRECTOR });
const resident = await createTestUser({ role: ConstructionRole.RESIDENT });
await loginAs(director);
const response = await request(app.getHttpServer())
.post(`/admin/users/${resident.id}/suspend`)
.send({
reason: 'Test', // Muy corto (< 20 caracteres)
durationDays: 7,
})
.expect(HttpStatus.BAD_REQUEST);
expect(response.body.message).toContain('Razón debe tener mínimo 20 caracteres');
});
});
```
---
#### Test Case 3: Usuario puede desactivar y reactivar
```typescript
describe('UserAccountService - Deactivate/Reactivate', () => {
it('should allow user to deactivate their own account', async () => {
// Arrange
const user = await createTestUser({
email: 'engineer@test.com',
password: 'password123',
status: UserStatus.ACTIVE,
});
await loginAs(user);
// Act: Desactivar
const deactivateResponse = await request(app.getHttpServer())
.post('/auth/deactivate')
.send({ password: 'password123' })
.expect(HttpStatus.OK);
expect(deactivateResponse.body.message).toContain('desactivada');
// Assert: Verificar estado
let updatedUser = await getUserById(user.id);
expect(updatedUser.status).toBe(UserStatus.INACTIVE);
// Assert: Verificar auditoría
const auditLog = await getLatestAuditLog(user.id, 'user_status');
expect(auditLog.action).toBe('deactivate');
expect(auditLog.details.deactivatedBy).toBe('self');
});
it('should allow user to reactivate their account', async () => {
// Arrange
const user = await createTestUser({ status: UserStatus.INACTIVE });
await loginAs(user);
// Act: Reactivar
const reactivateResponse = await request(app.getHttpServer())
.post('/auth/reactivate')
.expect(HttpStatus.OK);
expect(reactivateResponse.body.message).toContain('reactivada');
// Assert
const updatedUser = await getUserById(user.id);
expect(updatedUser.status).toBe(UserStatus.ACTIVE);
});
it('should enforce rate limiting on reactivation (max 3/day)', async () => {
const user = await createTestUser({ status: UserStatus.INACTIVE });
await loginAs(user);
// Reactivar 3 veces (máximo permitido)
for (let i = 0; i < 3; i++) {
await request(app.getHttpServer())
.post('/auth/reactivate')
.expect(HttpStatus.OK);
await request(app.getHttpServer())
.post('/auth/deactivate')
.send({ password: user.password })
.expect(HttpStatus.OK);
}
// Intentar 4ta reactivación (debe fallar)
const response = await request(app.getHttpServer())
.post('/auth/reactivate')
.expect(HttpStatus.TOO_MANY_REQUESTS);
expect(response.body.errorCode).toBe('TOO_MANY_REACTIVATIONS');
expect(response.body.message).toContain('límite de reactivaciones');
});
});
```
---
#### Test Case 4: Banned user cannot login
```typescript
describe('AuthService - Banned User', () => {
it('should block banned user from logging in', async () => {
// Arrange
const user = await createTestUser({
email: 'banned@test.com',
password: 'password123',
status: UserStatus.BANNED,
bannedReason: 'Fraude financiero',
});
// Act
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'banned@test.com',
password: 'password123',
})
.expect(HttpStatus.UNAUTHORIZED);
// Assert
expect(response.body.message).toContain('baneada permanentemente');
expect(response.body.errorCode).toBe('ACCOUNT_BANNED');
expect(response.body.contactSupport).toBe(true);
});
it('should block banned email from creating new account', async () => {
// Arrange: Crear email baneado
await createBannedEmail({
email: 'fraud@test.com',
reason: 'Usuario anterior cometió fraude',
});
// Act: Intentar registro con email baneado
const invitationToken = await createInvitation('fraud@test.com');
const response = await request(app.getHttpServer())
.post('/auth/register-by-invitation')
.send({
token: invitationToken,
password: 'newPassword123',
})
.expect(HttpStatus.FORBIDDEN);
// Assert
expect(response.body.errorCode).toBe('EMAIL_BANNED');
expect(response.body.message).toContain('bloqueado');
});
});
```
---
#### Test Case 5: Multi-tenancy status validation
```typescript
describe('Multi-tenancy Status', () => {
it('should allow user active in constructora A but suspended in B', async () => {
// Arrange
const user = await createTestUser({ email: 'multi@test.com' });
await assignUserToConstructora(user.id, 'constructora-a', {
status: UserStatus.ACTIVE,
role: ConstructionRole.DIRECTOR,
});
await assignUserToConstructora(user.id, 'constructora-b', {
status: UserStatus.SUSPENDED,
role: ConstructionRole.ENGINEER,
});
// Act: Login
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'multi@test.com', password: 'password' })
.expect(HttpStatus.OK);
// Assert: Solo debe ver constructora A (activa)
expect(loginResponse.body.constructoras).toHaveLength(1);
expect(loginResponse.body.constructoras[0].id).toBe('constructora-a');
// Act: Acceder a constructora A (debe funcionar)
const tokenA = loginResponse.body.accessToken;
await request(app.getHttpServer())
.get('/dashboard')
.set('Authorization', `Bearer ${tokenA}`)
.expect(HttpStatus.OK);
// Act: Intentar cambiar a constructora B (debe fallar)
const switchResponse = await request(app.getHttpServer())
.post('/auth/switch-constructora')
.set('Authorization', `Bearer ${tokenA}`)
.send({ constructoraId: 'constructora-b' })
.expect(HttpStatus.FORBIDDEN);
expect(switchResponse.body.message).toContain('suspended');
});
});
```
---
#### Test Case 6: Trigger audits all status changes
```typescript
describe('Audit Trigger', () => {
it('should automatically audit status changes via trigger', async () => {
const user = await createTestUser({ status: UserStatus.ACTIVE });
// Cambiar estado directamente en DB (sin pasar por service)
await query(`
UPDATE auth_management.profiles
SET status = 'suspended'
WHERE id = $1
`, [user.id]);
// Verificar que trigger creó audit log
const auditLogs = await query(`
SELECT * FROM audit_logging.audit_logs
WHERE resource_id = $1
AND resource_type = 'user_status'
ORDER BY created_at DESC
LIMIT 1
`, [user.id]);
expect(auditLogs.rows).toHaveLength(1);
expect(auditLogs.rows[0].action).toBe('suspend');
expect(auditLogs.rows[0].details.old_status).toBe('active');
expect(auditLogs.rows[0].details.new_status).toBe('suspended');
});
});
```
---
## 📚 Referencias Adicionales
### Documentos Relacionados
- 📄 [RF-AUTH-001: Sistema de Roles de Construcción](./RF-AUTH-001-roles-construccion.md) - Estados interactúan con roles
- 📄 [RF-AUTH-003: Multi-tenancy por Constructora](./RF-AUTH-003-multi-tenancy.md) *(Pendiente)* - Estados por constructora
- 📄 [US-FUND-001: Autenticación Básica JWT](../historias-usuario/US-FUND-001-autenticacion-basica-jwt.md) - Login valida estado
- 📄 [US-FUND-005: Sistema de Sesiones](../historias-usuario/US-FUND-005-sistema-sesiones.md) *(Pendiente)* - Cerrar sesiones al suspender
### Regulaciones y Compliance
- [GDPR Article 17: Right to Erasure](https://gdpr-info.eu/art-17-gdpr/) - Derecho al olvido
- [GDPR Article 20: Right to Data Portability](https://gdpr-info.eu/art-20-gdpr/) - Exportar datos personales
- [Ley Federal de Protección de Datos Personales (México)](https://www.diputados.gob.mx/LeyesBiblio/pdf/LFPDPPP.pdf) - Protección de datos en México
### Recursos Técnicos
- [PostgreSQL ENUM Types](https://www.postgresql.org/docs/current/datatype-enum.html)
- [NestJS Guards](https://docs.nestjs.com/guards)
- [NestJS Middleware](https://docs.nestjs.com/middleware)
---
## 📅 Historial de Cambios
| Versión | Fecha | Autor | Cambios |
|---------|-------|-------|---------|
| 1.0 | 2025-11-17 | Tech Team | Creación inicial adaptada de GAMILIT con contexto de construcción |
---
**Documento:** `MAI-001-fundamentos/requerimientos/RF-AUTH-002-estados-cuenta.md`
**Ruta absoluta:** `[RUTA-LEGACY-ELIMINADA]/docs/01-fase-alcance-inicial/MAI-001-fundamentos/requerimientos/RF-AUTH-002-estados-cuenta.md`
**Generado:** 2025-11-17
**Mantenedores:** @tech-lead @backend-team @frontend-team