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
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1273 lines
42 KiB
Markdown
1273 lines
42 KiB
Markdown
# ET-AUTH-002: Gestión de Estados de Cuenta
|
||
|
||
## 📋 Metadata
|
||
|
||
| Campo | Valor |
|
||
|-------|-------|
|
||
| **ID** | ET-AUTH-002 |
|
||
| **Épica** | MAI-001 - Fundamentos |
|
||
| **Módulo** | Autenticación y Autorización |
|
||
| **Tipo** | Especificación Técnica |
|
||
| **Estado** | 🚧 Planificado |
|
||
| **Versión** | 1.0 |
|
||
| **Fecha creación** | 2025-11-17 |
|
||
| **Última actualización** | 2025-11-17 |
|
||
| **Esfuerzo estimado** | 16h (vs 20h GAMILIT - 20% ahorro por reutilización) |
|
||
|
||
## 🔗 Referencias
|
||
|
||
### Requerimiento Funcional
|
||
📄 [RF-AUTH-002: Estados de Cuenta de Usuario](../requerimientos/RF-AUTH-002-estados-cuenta.md)
|
||
|
||
### Origen (GAMILIT)
|
||
♻️ **Reutilización:** 75%
|
||
- **Catálogo de referencia:** `shared/catalog/auth/` *(Patrón estados de cuenta reutilizado)*
|
||
- **Componentes reutilizables:**
|
||
- Funciones de gestión de estado (suspend_user, ban_user, reactivate_user)
|
||
- Triggers de auditoría
|
||
- Middleware de validación
|
||
- **Adaptaciones:**
|
||
- Estados por constructora (tabla `user_constructoras`)
|
||
- Funciones multi-tenant
|
||
- Tabla `banned_emails` para bloqueo permanente
|
||
|
||
### Implementación DDL
|
||
|
||
🗄️ **ENUM Principal:**
|
||
```sql
|
||
-- apps/database/ddl/00-prerequisites.sql
|
||
DO $$ BEGIN
|
||
CREATE TYPE auth_management.user_status AS ENUM (
|
||
'active', -- Usuario activo, puede acceder
|
||
'inactive', -- Inactivo temporalmente (desactivación voluntaria)
|
||
'suspended', -- Suspendido por admin (reversible)
|
||
'banned', -- Baneado permanentemente (irreversible)
|
||
'pending' -- Registro pendiente de verificación de email
|
||
);
|
||
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||
|
||
COMMENT ON TYPE auth_management.user_status IS
|
||
'Estados de cuenta de usuario: pending → active → (inactive|suspended|banned)';
|
||
```
|
||
|
||
🗄️ **Tablas Principales:**
|
||
|
||
```sql
|
||
-- 1. Perfil global (estado general)
|
||
CREATE TABLE auth_management.profiles (
|
||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||
email VARCHAR(255) UNIQUE NOT NULL,
|
||
password_hash TEXT NOT NULL,
|
||
full_name VARCHAR(255) NOT NULL,
|
||
|
||
-- ESTADO GLOBAL DEL PERFIL
|
||
status auth_management.user_status NOT NULL DEFAULT 'pending',
|
||
|
||
-- Metadata de estado global
|
||
status_changed_at TIMESTAMP WITH TIME ZONE,
|
||
status_changed_by UUID REFERENCES auth_management.profiles(id),
|
||
status_reason TEXT, -- Razón de baneo global
|
||
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- 2. Estado por constructora (multi-tenancy)
|
||
CREATE TABLE auth_management.user_constructoras (
|
||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||
user_id UUID NOT NULL REFERENCES auth_management.profiles(id) ON DELETE CASCADE,
|
||
constructora_id UUID NOT NULL REFERENCES auth_management.constructoras(id) ON DELETE CASCADE,
|
||
role construction_role NOT NULL,
|
||
|
||
-- ESTADO EN ESTA CONSTRUCTORA
|
||
status auth_management.user_status NOT NULL DEFAULT 'active',
|
||
|
||
-- Metadata de estado en constructora
|
||
suspended_at TIMESTAMP WITH TIME ZONE,
|
||
suspended_by UUID REFERENCES auth_management.profiles(id),
|
||
suspended_reason TEXT,
|
||
suspended_until TIMESTAMP WITH TIME ZONE, -- Fecha de revisión
|
||
|
||
is_primary BOOLEAN DEFAULT FALSE,
|
||
invited_by UUID REFERENCES auth_management.profiles(id),
|
||
invited_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||
joined_at TIMESTAMP WITH TIME ZONE,
|
||
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||
|
||
UNIQUE(user_id, constructora_id)
|
||
);
|
||
|
||
-- 3. Emails bloqueados permanentemente
|
||
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);
|
||
```
|
||
|
||
🗄️ **Funciones:**
|
||
|
||
```sql
|
||
-- Verificar si usuario puede acceder
|
||
CREATE FUNCTION auth_management.verify_user_status(
|
||
p_user_id UUID,
|
||
p_constructora_id UUID
|
||
) RETURNS BOOLEAN;
|
||
|
||
-- Suspender usuario en constructora
|
||
CREATE FUNCTION auth_management.suspend_user_in_constructora(
|
||
p_user_id UUID,
|
||
p_constructora_id UUID,
|
||
p_reason TEXT,
|
||
p_duration_days INTEGER,
|
||
p_suspended_by UUID
|
||
) RETURNS VOID;
|
||
|
||
-- Banear usuario globalmente (PERMANENTE)
|
||
CREATE FUNCTION auth_management.ban_user_globally(
|
||
p_user_id UUID,
|
||
p_reason TEXT,
|
||
p_banned_by UUID
|
||
) RETURNS VOID;
|
||
|
||
-- Reactivar usuario
|
||
CREATE FUNCTION auth_management.reactivate_user(
|
||
p_user_id UUID,
|
||
p_constructora_id UUID,
|
||
p_reactivated_by UUID
|
||
) RETURNS VOID;
|
||
|
||
-- Levantar suspensión
|
||
CREATE FUNCTION auth_management.lift_suspension(
|
||
p_user_id UUID,
|
||
p_constructora_id UUID,
|
||
p_lifted_by UUID
|
||
) RETURNS VOID;
|
||
```
|
||
|
||
🗄️ **Triggers:**
|
||
|
||
```sql
|
||
-- Auditar cambios de estado en profiles
|
||
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();
|
||
|
||
-- Auditar cambios de estado en user_constructoras
|
||
CREATE TRIGGER trg_user_constructoras_status_change
|
||
AFTER UPDATE OF status ON auth_management.user_constructoras
|
||
FOR EACH ROW
|
||
WHEN (OLD.status IS DISTINCT FROM NEW.status)
|
||
EXECUTE FUNCTION audit_logging.log_status_change();
|
||
|
||
-- Cambiar pending → active al verificar email
|
||
CREATE TRIGGER trg_verify_email_set_active
|
||
BEFORE UPDATE ON auth_management.profiles
|
||
FOR EACH ROW
|
||
WHEN (OLD.email_verified = FALSE AND NEW.email_verified = TRUE)
|
||
EXECUTE FUNCTION auth_management.set_status_active();
|
||
```
|
||
|
||
### Backend
|
||
|
||
💻 **Archivos de Implementación:**
|
||
- **Service:** `apps/backend/src/modules/auth/services/user-status.service.ts`
|
||
- **DTOs:**
|
||
- `apps/backend/src/modules/auth/dto/suspend-user.dto.ts`
|
||
- `apps/backend/src/modules/auth/dto/ban-user.dto.ts`
|
||
- `apps/backend/src/modules/auth/dto/reactivate-user.dto.ts`
|
||
- **Middleware:** `apps/backend/src/modules/auth/middleware/user-status.middleware.ts`
|
||
- **Controller:** `apps/backend/src/modules/admin/user-management.controller.ts`
|
||
|
||
### Frontend
|
||
|
||
🎨 **Componentes:**
|
||
- **StatusBadge:** `apps/frontend/src/components/ui/UserStatusBadge.tsx`
|
||
- **SuspendModal:** `apps/frontend/src/features/admin/SuspendUserModal.tsx`
|
||
- **BanModal:** `apps/frontend/src/features/admin/BanUserModal.tsx`
|
||
- **ReactivateModal:** `apps/frontend/src/features/auth/ReactivateAccountModal.tsx`
|
||
- **AccountStatusPage:** `apps/frontend/src/features/auth/AccountStatusPage.tsx`
|
||
|
||
### Trazabilidad
|
||
📊 [TRACEABILITY.yml](../implementacion/TRACEABILITY.yml#L45-L78)
|
||
|
||
---
|
||
|
||
## 🏗️ Arquitectura de Estados Multi-tenant
|
||
|
||
### Diagrama de Transiciones
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ CICLO DE VIDA DE CUENTA (Multi-tenant) │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
|
||
[INVITACIÓN A CONSTRUCTORA]
|
||
│
|
||
▼
|
||
┌───────────────────────────────┐
|
||
│ ESTADO GLOBAL: pending │
|
||
│ ESTADO CONSTRUCTORA: pending │
|
||
└───────────────┬───────────────┘
|
||
│
|
||
verify_email() │
|
||
▼
|
||
┌───────────────────────────────┐
|
||
│ ESTADO GLOBAL: active │
|
||
│ ESTADO CONSTRUCTORA: active │
|
||
└───┬───────────┬───────────┬───┘
|
||
│ │ │
|
||
user_deactivates() │ admin_suspends_in_constructora()
|
||
│ │ │
|
||
▼ │ ▼
|
||
┌────────────────┐ │ ┌──────────────────────────┐
|
||
│ GLOBAL: active │ │ │ GLOBAL: active │
|
||
│ CONST-A: inactive │ │ CONST-A: suspended │
|
||
└───────┬────────┘ │ └───────┬──────────────────┘
|
||
│ │ │
|
||
user_reactivates() │ admin_lifts_suspension()
|
||
│ │ │
|
||
└──────────────┼─────────────┘
|
||
│
|
||
admin_bans_globally()
|
||
│
|
||
▼
|
||
┌──────────────────────────────┐
|
||
│ ESTADO GLOBAL: banned │
|
||
│ TODAS CONSTRUCTORAS: banned │
|
||
│ (IRREVERSIBLE - PERMANENTE) │
|
||
└──────────────────────────────┘
|
||
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ ESCENARIO MULTI-CONSTRUCTORA │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
|
||
Usuario "Juan Pérez" trabaja en 2 constructoras:
|
||
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ CONSTRUCTORA A │
|
||
│ - Estado: active │
|
||
│ - Rol: engineer │
|
||
│ - Puede acceder: ✅ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ CONSTRUCTORA B │
|
||
│ - Estado: suspended │
|
||
│ - Rol: resident │
|
||
│ - Razón: "Registró asistencias falsas" │
|
||
│ - Suspendido hasta: 2025-12-01 │
|
||
│ - Puede acceder: ❌ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
|
||
Al hacer login:
|
||
- Ve solo CONSTRUCTORA A en selector
|
||
- NO ve CONSTRUCTORA B (suspendido ahí)
|
||
- Puede trabajar normalmente en CONSTRUCTORA A
|
||
|
||
LEYENDA:
|
||
────► Transición automática/usuario
|
||
- - → Transición admin only
|
||
═══► Transición irreversible global
|
||
```
|
||
|
||
---
|
||
|
||
## 🔧 Implementación Técnica Completa
|
||
|
||
### 1. Funciones de Base de Datos
|
||
|
||
#### verify_user_status()
|
||
**Propósito:** Verificar si usuario puede acceder a una constructora
|
||
|
||
```sql
|
||
-- apps/database/ddl/schemas/auth_management/functions/verify-user-status.sql
|
||
CREATE OR REPLACE FUNCTION auth_management.verify_user_status(
|
||
p_user_id UUID,
|
||
p_constructora_id UUID DEFAULT NULL
|
||
)
|
||
RETURNS BOOLEAN
|
||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||
AS $$
|
||
DECLARE
|
||
v_global_status auth_management.user_status;
|
||
v_constructora_status auth_management.user_status;
|
||
BEGIN
|
||
-- 1. Verificar estado global del perfil
|
||
SELECT status INTO v_global_status
|
||
FROM auth_management.profiles
|
||
WHERE id = p_user_id;
|
||
|
||
-- Usuario no existe
|
||
IF v_global_status IS NULL THEN
|
||
RETURN FALSE;
|
||
END IF;
|
||
|
||
-- Baneado globalmente
|
||
IF v_global_status = 'banned' THEN
|
||
RETURN FALSE;
|
||
END IF;
|
||
|
||
-- Email no verificado
|
||
IF v_global_status = 'pending' THEN
|
||
RETURN FALSE;
|
||
END IF;
|
||
|
||
-- Si no se especifica constructora, solo validar estado global
|
||
IF p_constructora_id IS NULL THEN
|
||
RETURN v_global_status = 'active';
|
||
END IF;
|
||
|
||
-- 2. Verificar estado en constructora específica
|
||
SELECT status INTO v_constructora_status
|
||
FROM auth_management.user_constructoras
|
||
WHERE user_id = p_user_id
|
||
AND constructora_id = p_constructora_id;
|
||
|
||
-- Usuario no está asociado a esa constructora
|
||
IF v_constructora_status IS NULL THEN
|
||
RETURN FALSE;
|
||
END IF;
|
||
|
||
-- Estado debe ser active en constructora
|
||
RETURN v_constructora_status = 'active';
|
||
END;
|
||
$$;
|
||
|
||
COMMENT ON FUNCTION auth_management.verify_user_status(UUID, UUID) IS
|
||
'Verifica si usuario puede acceder (global: active, constructora: active)';
|
||
```
|
||
|
||
---
|
||
|
||
#### suspend_user_in_constructora()
|
||
**Propósito:** Suspender usuario en una constructora específica (no afecta otras)
|
||
|
||
```sql
|
||
-- apps/database/ddl/schemas/auth_management/functions/suspend-user-in-constructora.sql
|
||
CREATE OR REPLACE FUNCTION auth_management.suspend_user_in_constructora(
|
||
p_user_id UUID,
|
||
p_constructora_id UUID,
|
||
p_reason TEXT,
|
||
p_duration_days INTEGER DEFAULT 14,
|
||
p_suspended_by UUID DEFAULT NULL
|
||
)
|
||
RETURNS VOID
|
||
LANGUAGE plpgsql SECURITY DEFINER
|
||
AS $$
|
||
DECLARE
|
||
v_current_status auth_management.user_status;
|
||
v_user_email TEXT;
|
||
v_user_name TEXT;
|
||
v_constructora_name TEXT;
|
||
BEGIN
|
||
-- 1. Validar que usuario esté activo en esta constructora
|
||
SELECT uc.status, p.email, p.full_name, c.nombre
|
||
INTO v_current_status, v_user_email, v_user_name, v_constructora_name
|
||
FROM auth_management.user_constructoras uc
|
||
INNER JOIN auth_management.profiles p ON p.id = uc.user_id
|
||
INNER JOIN auth_management.constructoras c ON c.id = uc.constructora_id
|
||
WHERE uc.user_id = p_user_id
|
||
AND uc.constructora_id = p_constructora_id;
|
||
|
||
-- Usuario no encontrado en constructora
|
||
IF v_current_status IS NULL THEN
|
||
RAISE EXCEPTION 'User % not found in constructora %', p_user_id, p_constructora_id;
|
||
END IF;
|
||
|
||
-- Solo se puede suspender si está active
|
||
IF v_current_status != 'active' THEN
|
||
RAISE EXCEPTION 'User must be active to suspend. Current status: %', v_current_status;
|
||
END IF;
|
||
|
||
-- 2. Validar razón (mínimo 20 caracteres)
|
||
IF p_reason IS NULL OR LENGTH(TRIM(p_reason)) < 20 THEN
|
||
RAISE EXCEPTION 'Suspension reason must be at least 20 characters';
|
||
END IF;
|
||
|
||
-- 3. Validar duración
|
||
IF p_duration_days <= 0 OR p_duration_days > 90 THEN
|
||
RAISE EXCEPTION 'Suspension duration must be between 1 and 90 days';
|
||
END IF;
|
||
|
||
-- 4. Actualizar estado
|
||
UPDATE auth_management.user_constructoras
|
||
SET
|
||
status = 'suspended',
|
||
suspended_at = NOW(),
|
||
suspended_by = p_suspended_by,
|
||
suspended_reason = p_reason,
|
||
suspended_until = NOW() + (p_duration_days || ' days')::INTERVAL,
|
||
updated_at = NOW()
|
||
WHERE user_id = p_user_id
|
||
AND constructora_id = p_constructora_id;
|
||
|
||
-- 5. Cerrar sesiones activas en esta constructora
|
||
DELETE FROM auth_management.user_sessions
|
||
WHERE user_id = p_user_id
|
||
AND constructora_id = p_constructora_id;
|
||
|
||
-- 6. Trigger automático auditará el cambio
|
||
|
||
RAISE NOTICE 'User % (%) suspended in % for % days. Reason: %',
|
||
v_user_name, v_user_email, v_constructora_name, p_duration_days, p_reason;
|
||
END;
|
||
$$;
|
||
|
||
COMMENT ON FUNCTION auth_management.suspend_user_in_constructora IS
|
||
'Suspende usuario en una constructora específica (reversible, no afecta otras constructoras)';
|
||
```
|
||
|
||
---
|
||
|
||
#### ban_user_globally()
|
||
**Propósito:** Banear usuario en TODAS las constructoras (permanente, irreversible)
|
||
|
||
```sql
|
||
-- apps/database/ddl/schemas/auth_management/functions/ban-user-globally.sql
|
||
CREATE OR REPLACE FUNCTION auth_management.ban_user_globally(
|
||
p_user_id UUID,
|
||
p_reason TEXT,
|
||
p_banned_by UUID DEFAULT NULL
|
||
)
|
||
RETURNS VOID
|
||
LANGUAGE plpgsql SECURITY DEFINER
|
||
AS $$
|
||
DECLARE
|
||
v_current_status auth_management.user_status;
|
||
v_email TEXT;
|
||
v_full_name TEXT;
|
||
v_affected_constructoras INTEGER;
|
||
BEGIN
|
||
-- 1. Obtener estado y email
|
||
SELECT status, email, full_name
|
||
INTO v_current_status, v_email, v_full_name
|
||
FROM auth_management.profiles
|
||
WHERE id = p_user_id;
|
||
|
||
-- Usuario no existe
|
||
IF v_current_status IS NULL THEN
|
||
RAISE EXCEPTION 'User % not found', p_user_id;
|
||
END IF;
|
||
|
||
-- Ya está baneado
|
||
IF v_current_status = 'banned' THEN
|
||
RAISE NOTICE 'User % is already banned', p_user_id;
|
||
RETURN;
|
||
END IF;
|
||
|
||
-- 2. Validar razón (mínimo 50 caracteres para acción permanente)
|
||
IF p_reason IS NULL OR LENGTH(TRIM(p_reason)) < 50 THEN
|
||
RAISE EXCEPTION 'Ban reason must be at least 50 characters (PERMANENT action requires detailed justification)';
|
||
END IF;
|
||
|
||
-- 3. Banear en perfil global (IRREVERSIBLE)
|
||
UPDATE auth_management.profiles
|
||
SET
|
||
status = 'banned',
|
||
status_changed_at = NOW(),
|
||
status_changed_by = p_banned_by,
|
||
status_reason = p_reason,
|
||
updated_at = NOW()
|
||
WHERE id = p_user_id;
|
||
|
||
-- 4. Banear en TODAS las constructoras
|
||
UPDATE auth_management.user_constructoras
|
||
SET
|
||
status = 'banned',
|
||
suspended_at = NOW(),
|
||
suspended_by = p_banned_by,
|
||
suspended_reason = p_reason,
|
||
updated_at = NOW()
|
||
WHERE user_id = p_user_id
|
||
AND status != 'banned'; -- Solo actualizar las que no estén baneadas
|
||
|
||
GET DIAGNOSTICS v_affected_constructoras = ROW_COUNT;
|
||
|
||
-- 5. Bloquear email permanentemente
|
||
INSERT INTO auth_management.banned_emails (
|
||
email, reason, banned_by, banned_at
|
||
) VALUES (
|
||
v_email, p_reason, p_banned_by, NOW()
|
||
) ON CONFLICT (email) DO UPDATE
|
||
SET
|
||
reason = EXCLUDED.reason,
|
||
banned_by = EXCLUDED.banned_by,
|
||
banned_at = EXCLUDED.banned_at;
|
||
|
||
-- 6. Cerrar TODAS las sesiones del usuario
|
||
DELETE FROM auth_management.user_sessions
|
||
WHERE user_id = p_user_id;
|
||
|
||
-- 7. Trigger auditará el cambio
|
||
|
||
RAISE WARNING 'User % (%) BANNED GLOBALLY (PERMANENT). Affected % constructoras. Reason: %',
|
||
v_full_name, v_email, v_affected_constructoras, p_reason;
|
||
END;
|
||
$$;
|
||
|
||
COMMENT ON FUNCTION auth_management.ban_user_globally IS
|
||
'Banea usuario en TODAS las constructoras (PERMANENTE e IRREVERSIBLE)';
|
||
```
|
||
|
||
---
|
||
|
||
#### lift_suspension()
|
||
**Propósito:** Levantar suspensión en constructora
|
||
|
||
```sql
|
||
-- apps/database/ddl/schemas/auth_management/functions/lift-suspension.sql
|
||
CREATE OR REPLACE FUNCTION auth_management.lift_suspension(
|
||
p_user_id UUID,
|
||
p_constructora_id UUID,
|
||
p_lifted_by UUID DEFAULT NULL
|
||
)
|
||
RETURNS VOID
|
||
LANGUAGE plpgsql SECURITY DEFINER
|
||
AS $$
|
||
DECLARE
|
||
v_current_status auth_management.user_status;
|
||
v_user_name TEXT;
|
||
BEGIN
|
||
-- Obtener estado actual
|
||
SELECT uc.status, p.full_name
|
||
INTO v_current_status, v_user_name
|
||
FROM auth_management.user_constructoras uc
|
||
INNER JOIN auth_management.profiles p ON p.id = uc.user_id
|
||
WHERE uc.user_id = p_user_id
|
||
AND uc.constructora_id = p_constructora_id;
|
||
|
||
-- Solo se puede levantar suspensión si está suspended
|
||
IF v_current_status != 'suspended' THEN
|
||
RAISE EXCEPTION 'User must be suspended to lift. Current status: %', v_current_status;
|
||
END IF;
|
||
|
||
-- Reactivar
|
||
UPDATE auth_management.user_constructoras
|
||
SET
|
||
status = 'active',
|
||
suspended_at = NULL,
|
||
suspended_by = NULL,
|
||
suspended_reason = NULL,
|
||
suspended_until = NULL,
|
||
updated_at = NOW()
|
||
WHERE user_id = p_user_id
|
||
AND constructora_id = p_constructora_id;
|
||
|
||
-- Trigger auditará
|
||
|
||
RAISE NOTICE 'Suspension lifted for user % in constructora %', v_user_name, p_constructora_id;
|
||
END;
|
||
$$;
|
||
|
||
COMMENT ON FUNCTION auth_management.lift_suspension IS
|
||
'Levanta suspensión de usuario en constructora (suspended → active)';
|
||
```
|
||
|
||
---
|
||
|
||
#### reactivate_user()
|
||
**Propósito:** Usuario reactiva su propia cuenta (desde inactive)
|
||
|
||
```sql
|
||
-- apps/database/ddl/schemas/auth_management/functions/reactivate-user.sql
|
||
CREATE OR REPLACE FUNCTION auth_management.reactivate_user(
|
||
p_user_id UUID
|
||
)
|
||
RETURNS VOID
|
||
LANGUAGE plpgsql SECURITY DEFINER
|
||
AS $$
|
||
DECLARE
|
||
v_current_status auth_management.user_status;
|
||
v_reactivations_today INTEGER;
|
||
BEGIN
|
||
-- Obtener estado global
|
||
SELECT status INTO v_current_status
|
||
FROM auth_management.profiles
|
||
WHERE id = p_user_id;
|
||
|
||
-- Solo se puede reactivar desde inactive
|
||
IF v_current_status != 'inactive' THEN
|
||
RAISE EXCEPTION 'Can only reactivate inactive users. Current status: %', v_current_status;
|
||
END IF;
|
||
|
||
-- Rate limiting: máximo 3 reactivaciones por día
|
||
SELECT COUNT(*)
|
||
INTO v_reactivations_today
|
||
FROM audit_logging.audit_logs
|
||
WHERE resource_id = p_user_id::TEXT
|
||
AND action = 'reactivate'
|
||
AND created_at >= CURRENT_DATE;
|
||
|
||
IF v_reactivations_today >= 3 THEN
|
||
RAISE EXCEPTION 'Maximum reactivations per day reached (3). Try again tomorrow.';
|
||
END IF;
|
||
|
||
-- Reactivar
|
||
UPDATE auth_management.profiles
|
||
SET
|
||
status = 'active',
|
||
status_changed_at = NOW(),
|
||
status_changed_by = p_user_id, -- Usuario se reactiva a sí mismo
|
||
status_reason = NULL,
|
||
updated_at = NOW()
|
||
WHERE id = p_user_id;
|
||
|
||
-- Trigger auditará
|
||
|
||
RAISE NOTICE 'User % reactivated (inactive → active)', p_user_id;
|
||
END;
|
||
$$;
|
||
|
||
COMMENT ON FUNCTION auth_management.reactivate_user IS
|
||
'Usuario reactiva su propia cuenta (inactive → active, máx 3/día)';
|
||
```
|
||
|
||
---
|
||
|
||
### 2. Triggers de Auditoría
|
||
|
||
```sql
|
||
-- apps/database/ddl/schemas/audit_logging/functions/log-status-change.sql
|
||
CREATE OR REPLACE FUNCTION audit_logging.log_status_change()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql SECURITY DEFINER
|
||
AS $$
|
||
DECLARE
|
||
v_current_user_id UUID;
|
||
v_priority TEXT;
|
||
BEGIN
|
||
-- Obtener usuario que ejecuta la acción
|
||
v_current_user_id := NULLIF(current_setting('app.current_user_id', true), '')::UUID;
|
||
|
||
-- Determinar prioridad según nuevo estado
|
||
v_priority := CASE NEW.status
|
||
WHEN 'banned' THEN 'critical'
|
||
WHEN 'suspended' THEN 'high'
|
||
WHEN 'inactive' THEN 'medium'
|
||
ELSE 'low'
|
||
END;
|
||
|
||
-- Insertar en audit_logs
|
||
INSERT INTO audit_logging.audit_logs (
|
||
action,
|
||
resource_type,
|
||
resource_id,
|
||
performed_by,
|
||
details,
|
||
priority,
|
||
created_at
|
||
) VALUES (
|
||
CASE NEW.status
|
||
WHEN 'suspended' THEN 'suspend'
|
||
WHEN 'banned' THEN 'ban'
|
||
WHEN 'active' THEN 'reactivate'
|
||
WHEN 'inactive' THEN 'deactivate'
|
||
ELSE 'update_status'
|
||
END,
|
||
'user_status',
|
||
COALESCE(NEW.user_id::TEXT, NEW.id::TEXT), -- user_constructoras vs profiles
|
||
COALESCE(v_current_user_id, NEW.id), -- Si es NULL, asumir self-action
|
||
jsonb_build_object(
|
||
'old_status', OLD.status,
|
||
'new_status', NEW.status,
|
||
'reason', COALESCE(NEW.suspended_reason, NEW.status_reason),
|
||
'table', TG_TABLE_NAME,
|
||
'constructora_id', CASE
|
||
WHEN TG_TABLE_NAME = 'user_constructoras' THEN NEW.constructora_id::TEXT
|
||
ELSE NULL
|
||
END,
|
||
'timestamp', NOW()
|
||
),
|
||
v_priority,
|
||
NOW()
|
||
);
|
||
|
||
RETURN NEW;
|
||
END;
|
||
$$;
|
||
|
||
-- Aplicar a ambas tablas
|
||
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();
|
||
|
||
CREATE TRIGGER trg_user_constructoras_status_change
|
||
AFTER UPDATE OF status ON auth_management.user_constructoras
|
||
FOR EACH ROW
|
||
WHEN (OLD.status IS DISTINCT FROM NEW.status)
|
||
EXECUTE FUNCTION audit_logging.log_status_change();
|
||
```
|
||
|
||
---
|
||
|
||
### 3. Backend - Service de Gestión de Estados
|
||
|
||
**Ubicación:** `apps/backend/src/modules/auth/services/user-status.service.ts`
|
||
|
||
```typescript
|
||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository, DataSource } from 'typeorm';
|
||
import { Profile } from '../entities/profile.entity';
|
||
import { UserConstructora } from '../entities/user-constructora.entity';
|
||
import { BannedEmail } from '../entities/banned-email.entity';
|
||
import { UserStatus } from '../enums/user-status.enum';
|
||
import { NotificationService } from '@modules/notifications/notification.service';
|
||
import { EmailService } from '@modules/email/email.service';
|
||
|
||
@Injectable()
|
||
export class UserStatusService {
|
||
constructor(
|
||
@InjectRepository(Profile)
|
||
private readonly profileRepo: Repository<Profile>,
|
||
|
||
@InjectRepository(UserConstructora)
|
||
private readonly userConstructoraRepo: Repository<UserConstructora>,
|
||
|
||
@InjectRepository(BannedEmail)
|
||
private readonly bannedEmailRepo: Repository<BannedEmail>,
|
||
|
||
private readonly dataSource: DataSource,
|
||
private readonly notificationService: NotificationService,
|
||
private readonly emailService: EmailService,
|
||
) {}
|
||
|
||
/**
|
||
* Suspender usuario en una constructora específica
|
||
*/
|
||
async suspendUserInConstructora(
|
||
userId: string,
|
||
constructoraId: string,
|
||
reason: string,
|
||
durationDays: number,
|
||
suspendedBy: string,
|
||
): Promise<void> {
|
||
// Validaciones
|
||
if (!reason || reason.trim().length < 20) {
|
||
throw new BadRequestException('Razón debe tener mínimo 20 caracteres');
|
||
}
|
||
|
||
if (durationDays < 1 || durationDays > 90) {
|
||
throw new BadRequestException('Duración debe estar entre 1 y 90 días');
|
||
}
|
||
|
||
// Ejecutar función de base de datos
|
||
await this.dataSource.query(`
|
||
SELECT auth_management.suspend_user_in_constructora($1, $2, $3, $4, $5)
|
||
`, [userId, constructoraId, reason, durationDays, suspendedBy]);
|
||
|
||
// Enviar notificación
|
||
await this.notifyStatusChange(
|
||
userId,
|
||
UserStatus.ACTIVE,
|
||
UserStatus.SUSPENDED,
|
||
reason,
|
||
suspendedBy,
|
||
constructoraId,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Banear usuario globalmente (todas las constructoras)
|
||
*/
|
||
async banUserGlobally(
|
||
userId: string,
|
||
reason: string,
|
||
bannedBy: string,
|
||
): Promise<void> {
|
||
// Validación estricta para acción permanente
|
||
if (!reason || reason.trim().length < 50) {
|
||
throw new BadRequestException(
|
||
'Razón debe tener mínimo 50 caracteres (acción PERMANENTE requiere justificación detallada)'
|
||
);
|
||
}
|
||
|
||
// Ejecutar función de base de datos
|
||
await this.dataSource.query(`
|
||
SELECT auth_management.ban_user_globally($1, $2, $3)
|
||
`, [userId, reason, bannedBy]);
|
||
|
||
// Enviar notificación crítica
|
||
await this.notifyStatusChange(
|
||
userId,
|
||
UserStatus.ACTIVE,
|
||
UserStatus.BANNED,
|
||
reason,
|
||
bannedBy,
|
||
null, // Global ban
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Levantar suspensión en constructora
|
||
*/
|
||
async liftSuspension(
|
||
userId: string,
|
||
constructoraId: string,
|
||
liftedBy: string,
|
||
): Promise<void> {
|
||
await this.dataSource.query(`
|
||
SELECT auth_management.lift_suspension($1, $2, $3)
|
||
`, [userId, constructoraId, liftedBy]);
|
||
|
||
await this.notifyStatusChange(
|
||
userId,
|
||
UserStatus.SUSPENDED,
|
||
UserStatus.ACTIVE,
|
||
'Suspensión levantada por administrador',
|
||
liftedBy,
|
||
constructoraId,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Usuario reactiva su propia cuenta
|
||
*/
|
||
async reactivateAccount(userId: string): Promise<void> {
|
||
try {
|
||
await this.dataSource.query(`
|
||
SELECT auth_management.reactivate_user($1)
|
||
`, [userId]);
|
||
|
||
await this.notifyStatusChange(
|
||
userId,
|
||
UserStatus.INACTIVE,
|
||
UserStatus.ACTIVE,
|
||
'Cuenta reactivada por el usuario',
|
||
userId,
|
||
null,
|
||
);
|
||
} catch (error) {
|
||
if (error.message.includes('Maximum reactivations')) {
|
||
throw new BadRequestException(
|
||
'Has alcanzado el límite de reactivaciones por hoy (3 máximo). Intenta mañana.'
|
||
);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Usuario desactiva su propia cuenta
|
||
*/
|
||
async deactivateAccount(userId: string, password: string): Promise<void> {
|
||
// Validar contraseña primero
|
||
const user = await this.profileRepo.findOne({ where: { id: userId } });
|
||
const isPasswordValid = await this.verifyPassword(password, user.passwordHash);
|
||
|
||
if (!isPasswordValid) {
|
||
throw new BadRequestException('Contraseña incorrecta');
|
||
}
|
||
|
||
// Desactivar
|
||
await this.profileRepo.update(
|
||
{ id: userId },
|
||
{
|
||
status: UserStatus.INACTIVE,
|
||
statusChangedAt: new Date(),
|
||
statusChangedBy: userId, // Self-deactivation
|
||
}
|
||
);
|
||
|
||
// Enviar email de confirmación
|
||
await this.emailService.send({
|
||
to: user.email,
|
||
subject: 'Cuenta desactivada temporalmente',
|
||
template: 'account-deactivated',
|
||
data: {
|
||
userName: user.fullName,
|
||
reactivationUrl: `${process.env.FRONTEND_URL}/auth/reactivate`,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Verificar si email está baneado
|
||
*/
|
||
async isEmailBanned(email: string): Promise<boolean> {
|
||
const banned = await this.bannedEmailRepo.findOne({ where: { email } });
|
||
return !!banned;
|
||
}
|
||
|
||
/**
|
||
* Obtener razón de baneo de email
|
||
*/
|
||
async getBannedEmailReason(email: string): Promise<string | null> {
|
||
const banned = await this.bannedEmailRepo.findOne({ where: { email } });
|
||
return banned?.reason || null;
|
||
}
|
||
|
||
/**
|
||
* Notificar cambio de estado
|
||
*/
|
||
private async notifyStatusChange(
|
||
userId: string,
|
||
oldStatus: UserStatus,
|
||
newStatus: UserStatus,
|
||
reason: string,
|
||
changedBy: string,
|
||
constructoraId: string | null,
|
||
): Promise<void> {
|
||
// Solo notificar si el cambio NO fue iniciado por el propio usuario
|
||
if (changedBy !== userId) {
|
||
// Notificación push
|
||
await this.notificationService.send({
|
||
userId,
|
||
type: 'system_announcement',
|
||
priority: newStatus === UserStatus.BANNED ? 'critical' : 'high',
|
||
title: this.getNotificationTitle(newStatus, constructoraId),
|
||
body: reason,
|
||
icon: this.getStatusIcon(newStatus),
|
||
});
|
||
|
||
// Email
|
||
const user = await this.profileRepo.findOne({ where: { id: userId } });
|
||
await this.emailService.send({
|
||
to: user.email,
|
||
subject: this.getEmailSubject(newStatus),
|
||
template: 'account-status-changed',
|
||
data: {
|
||
userName: user.fullName,
|
||
newStatus,
|
||
oldStatus,
|
||
reason,
|
||
constructoraId,
|
||
supportEmail: process.env.SUPPORT_EMAIL,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
private getNotificationTitle(status: UserStatus, constructoraId: string | null): string {
|
||
const scope = constructoraId ? 'en esta constructora' : 'globalmente';
|
||
|
||
switch (status) {
|
||
case UserStatus.SUSPENDED:
|
||
return `Tu cuenta ha sido suspendida ${scope}`;
|
||
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 getStatusIcon(status: UserStatus): string {
|
||
const icons = {
|
||
[UserStatus.SUSPENDED]: '⚠️',
|
||
[UserStatus.BANNED]: '🚫',
|
||
[UserStatus.ACTIVE]: '✅',
|
||
[UserStatus.INACTIVE]: 'ℹ️',
|
||
[UserStatus.PENDING]: '⏳',
|
||
};
|
||
return icons[status] || '🔔';
|
||
}
|
||
|
||
private getEmailSubject(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 'Cuenta desactivada temporalmente';
|
||
default:
|
||
return 'Cambio en tu estado de cuenta';
|
||
}
|
||
}
|
||
|
||
private async verifyPassword(password: string, hash: string): Promise<boolean> {
|
||
const bcrypt = require('bcrypt');
|
||
return bcrypt.compare(password, hash);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4. Backend - Middleware de Validación de Estado
|
||
|
||
**Ubicación:** `apps/backend/src/modules/auth/middleware/user-status.middleware.ts`
|
||
|
||
```typescript
|
||
import { Injectable, NestMiddleware, ForbiddenException } from '@nestjs/common';
|
||
import { Request, Response, NextFunction } from 'express';
|
||
import { UserStatus } from '../enums/user-status.enum';
|
||
|
||
/**
|
||
* Middleware que valida estado de usuario en CADA request autenticado
|
||
*
|
||
* Valida:
|
||
* 1. Estado global del perfil (no banned, no pending)
|
||
* 2. Estado en constructora actual (active)
|
||
*/
|
||
@Injectable()
|
||
export class UserStatusMiddleware implements NestMiddleware {
|
||
use(req: Request, res: Response, next: NextFunction) {
|
||
const user = req.user as any;
|
||
|
||
// Si no hay usuario, continuar (otros guards manejarán autenticación)
|
||
if (!user) {
|
||
return next();
|
||
}
|
||
|
||
// Excepciones: endpoints que permiten ciertos estados
|
||
const allowedPaths = [
|
||
'/auth/reactivate', // inactive puede reactivar
|
||
'/auth/status', // consultar estado
|
||
'/auth/switch-constructora', // cambiar constructora
|
||
'/auth/deactivate', // active puede desactivar
|
||
'/auth/logout', // cualquiera puede cerrar sesión
|
||
];
|
||
|
||
if (allowedPaths.some(path => req.path.startsWith(path))) {
|
||
return next();
|
||
}
|
||
|
||
// Validar estado global del perfil
|
||
if (user.profileStatus === UserStatus.BANNED) {
|
||
throw new ForbiddenException({
|
||
statusCode: 403,
|
||
message: 'Tu cuenta ha sido baneada permanentemente.',
|
||
errorCode: 'ACCOUNT_BANNED',
|
||
contactSupport: true,
|
||
});
|
||
}
|
||
|
||
if (user.profileStatus === UserStatus.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.constructoraId && 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
|
||
import { UserStatusMiddleware } from './modules/auth/middleware/user-status.middleware';
|
||
|
||
async function bootstrap() {
|
||
const app = await NestFactory.create(AppModule);
|
||
|
||
// Aplicar middleware globalmente
|
||
app.use(UserStatusMiddleware);
|
||
|
||
await app.listen(3000);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🧪 Testing
|
||
|
||
### Test Suite 1: Funciones de Base de Datos
|
||
|
||
```typescript
|
||
// apps/backend/test/database/user-status-functions.spec.ts
|
||
describe('User Status Database Functions', () => {
|
||
describe('suspend_user_in_constructora()', () => {
|
||
it('should suspend active user in constructora', async () => {
|
||
const user = await createUser({ status: UserStatus.ACTIVE });
|
||
const constructora = await createConstructora();
|
||
await assignToConstructora(user.id, constructora.id, 'engineer');
|
||
|
||
await db.query(`
|
||
SELECT auth_management.suspend_user_in_constructora($1, $2, $3, $4, $5)
|
||
`, [user.id, constructora.id, 'Test suspension', 14, adminUser.id]);
|
||
|
||
const userConstructora = await getUserConstructora(user.id, constructora.id);
|
||
expect(userConstructora.status).toBe(UserStatus.SUSPENDED);
|
||
expect(userConstructora.suspendedReason).toBe('Test suspension');
|
||
});
|
||
|
||
it('should reject suspension with short reason', async () => {
|
||
await expect(
|
||
db.query(`
|
||
SELECT auth_management.suspend_user_in_constructora($1, $2, $3, $4, $5)
|
||
`, [user.id, constructora.id, 'Short', 14, adminUser.id])
|
||
).rejects.toThrow('at least 20 characters');
|
||
});
|
||
});
|
||
|
||
describe('ban_user_globally()', () => {
|
||
it('should ban user in all constructoras', async () => {
|
||
const user = await createUser();
|
||
const constructoraA = await createConstructora();
|
||
const constructoraB = await createConstructora();
|
||
|
||
await assignToConstructora(user.id, constructoraA.id, 'engineer');
|
||
await assignToConstructora(user.id, constructoraB.id, 'director');
|
||
|
||
const reason = 'Fraude financiero comprobado con evidencia documental y testimonio de testigos';
|
||
await db.query(`
|
||
SELECT auth_management.ban_user_globally($1, $2, $3)
|
||
`, [user.id, reason, adminUser.id]);
|
||
|
||
// Verificar perfil global
|
||
const profile = await getProfile(user.id);
|
||
expect(profile.status).toBe(UserStatus.BANNED);
|
||
|
||
// Verificar ambas constructoras
|
||
const ucA = await getUserConstructora(user.id, constructoraA.id);
|
||
const ucB = await getUserConstructora(user.id, constructoraB.id);
|
||
expect(ucA.status).toBe(UserStatus.BANNED);
|
||
expect(ucB.status).toBe(UserStatus.BANNED);
|
||
|
||
// Verificar email bloqueado
|
||
const bannedEmail = await getBannedEmail(user.email);
|
||
expect(bannedEmail).toBeDefined();
|
||
expect(bannedEmail.reason).toBe(reason);
|
||
});
|
||
|
||
it('should reject ban with insufficient reason', async () => {
|
||
await expect(
|
||
db.query(`
|
||
SELECT auth_management.ban_user_globally($1, $2, $3)
|
||
`, [user.id, 'Too short', adminUser.id])
|
||
).rejects.toThrow('at least 50 characters');
|
||
});
|
||
});
|
||
|
||
describe('reactivate_user()', () => {
|
||
it('should enforce rate limiting (max 3/day)', async () => {
|
||
const user = await createUser({ status: UserStatus.INACTIVE });
|
||
|
||
// Primera reactivación: OK
|
||
await db.query(`SELECT auth_management.reactivate_user($1)`, [user.id]);
|
||
await db.query(`UPDATE auth_management.profiles SET status = 'inactive' WHERE id = $1`, [user.id]);
|
||
|
||
// Segunda: OK
|
||
await db.query(`SELECT auth_management.reactivate_user($1)`, [user.id]);
|
||
await db.query(`UPDATE auth_management.profiles SET status = 'inactive' WHERE id = $1`, [user.id]);
|
||
|
||
// Tercera: OK
|
||
await db.query(`SELECT auth_management.reactivate_user($1)`, [user.id]);
|
||
await db.query(`UPDATE auth_management.profiles SET status = 'inactive' WHERE id = $1`, [user.id]);
|
||
|
||
// Cuarta: DEBE FALLAR
|
||
await expect(
|
||
db.query(`SELECT auth_management.reactivate_user($1)`, [user.id])
|
||
).rejects.toThrow('Maximum reactivations per day reached');
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### Test Suite 2: Service Integration
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/services/user-status.service.spec.ts
|
||
describe('UserStatusService', () => {
|
||
let service: UserStatusService;
|
||
|
||
beforeEach(async () => {
|
||
const module = await Test.createTestingModule({
|
||
providers: [UserStatusService, ...mockProviders],
|
||
}).compile();
|
||
|
||
service = module.get<UserStatusService>(UserStatusService);
|
||
});
|
||
|
||
describe('suspendUserInConstructora', () => {
|
||
it('should send notification to suspended user', async () => {
|
||
const user = await createUser();
|
||
const constructora = await createConstructora();
|
||
const notificationSpy = jest.spyOn(service['notificationService'], 'send');
|
||
|
||
await service.suspendUserInConstructora(
|
||
user.id,
|
||
constructora.id,
|
||
'Comportamiento inapropiado en obra',
|
||
14,
|
||
adminUser.id,
|
||
);
|
||
|
||
expect(notificationSpy).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
userId: user.id,
|
||
type: 'system_announcement',
|
||
priority: 'high',
|
||
})
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('isEmailBanned', () => {
|
||
it('should return true for banned email', async () => {
|
||
await createBannedEmail('banned@test.com');
|
||
|
||
const isBanned = await service.isEmailBanned('banned@test.com');
|
||
expect(isBanned).toBe(true);
|
||
});
|
||
|
||
it('should return false for non-banned email', async () => {
|
||
const isBanned = await service.isEmailBanned('clean@test.com');
|
||
expect(isBanned).toBe(false);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 📚 Referencias Adicionales
|
||
|
||
### Documentos Relacionados
|
||
- 📄 [RF-AUTH-002: Estados de Cuenta](../requerimientos/RF-AUTH-002-estados-cuenta.md)
|
||
- 📄 [RF-AUTH-003: Multi-tenancy](../requerimientos/RF-AUTH-003-multi-tenancy.md)
|
||
- 📄 [ET-AUTH-001: RBAC](./ET-AUTH-001-rbac.md)
|
||
|
||
### Estándares y Regulaciones
|
||
- [GDPR Article 17: Right to Erasure](https://gdpr-info.eu/art-17-gdpr/)
|
||
- [Ley Federal de Protección de Datos Personales (México)](https://www.diputados.gob.mx/LeyesBiblio/pdf/LFPDPPP.pdf)
|
||
|
||
---
|
||
|
||
## 📅 Historial de Cambios
|
||
|
||
| Versión | Fecha | Autor | Cambios |
|
||
|---------|-------|-------|---------|
|
||
| 1.0 | 2025-11-17 | Tech Team | Creación inicial adaptada de GAMILIT con multi-tenancy |
|
||
|
||
---
|
||
|
||
**Documento:** `MAI-001-fundamentos/especificaciones/ET-AUTH-002-estados-cuenta.md`
|
||
**Ruta absoluta:** `[RUTA-LEGACY-ELIMINADA]/docs/01-fase-alcance-inicial/MAI-001-fundamentos/especificaciones/ET-AUTH-002-estados-cuenta.md`
|
||
**Generado:** 2025-11-17
|
||
**Mantenedores:** @tech-lead @backend-team @database-team
|