erp-construccion/docs/02-definicion-modulos/MAI-001-fundamentos/especificaciones/ET-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

42 KiB
Raw Blame History

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

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:

-- 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:

-- 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:

-- 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:

-- 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


🏗️ 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

-- 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)

-- 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)

-- 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

-- 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)

-- 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

-- 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

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

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:

// 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

// 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

// 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

Estándares y Regulaciones


📅 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