# ET-AUTH-003: Especificación Técnica - Esquema de Base de Datos

**Version:** 1.0.0
**Fecha:** 2025-12-05
**Estado:** ✅ Implementado
**Épica:** [OQI-001](../_MAP.md)

---

## Resumen

Esta especificación detalla el esquema de base de datos para el sistema de autenticación de OrbiQuant IA.

---

## Diagrama ER

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                            AUTHENTICATION SCHEMA                             │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────┐       ┌─────────────────────┐
│       users         │       │   user_profiles     │
├─────────────────────┤       ├─────────────────────┤
│ id (PK)             │──1:1──│ id (PK)             │
│ email               │       │ user_id (FK)        │
│ password_hash       │       │ display_name        │
│ phone               │       │ bio                 │
│ first_name          │       │ avatar_url          │
│ last_name           │       │ preferred_language  │
│ role                │       │ timezone            │
│ status              │       │ date_format         │
│ email_verified      │       │ notification_prefs  │
│ phone_verified      │       │ created_at          │
│ two_factor_enabled  │       │ updated_at          │
│ two_factor_secret   │       └─────────────────────┘
│ backup_codes        │
│ created_at          │       ┌─────────────────────┐
│ updated_at          │       │  oauth_accounts     │
│ deleted_at          │       ├─────────────────────┤
└─────────────────────┘       │ id (PK)             │
         │                    │ user_id (FK)        │──┐
         │                    │ provider            │  │
         ├──────1:N──────────▶│ provider_user_id    │  │
         │                    │ email               │  │
         │                    │ access_token        │  │
         │                    │ refresh_token       │  │
         │                    │ token_expires_at    │  │
         │                    │ raw_profile         │  │
         │                    │ created_at          │  │
         │                    │ updated_at          │  │
         │                    └─────────────────────┘  │
         │                                             │
         │                    ┌─────────────────────┐  │
         │                    │     sessions        │  │
         │                    ├─────────────────────┤  │
         ├──────1:N──────────▶│ id (PK)             │  │
         │                    │ user_id (FK)        │──┘
         │                    │ refresh_token_hash  │
         │                    │ device_info         │
         │                    │ ip_address          │
         │                    │ location            │
         │                    │ user_agent          │
         │                    │ is_active           │
         │                    │ created_at          │
         │                    │ last_activity       │
         │                    │ expires_at          │
         │                    │ revoked_at          │
         │                    │ revoked_reason      │
         │                    └─────────────────────┘
         │
         │                    ┌─────────────────────┐
         │                    │email_verifications  │
         │                    ├─────────────────────┤
         ├──────1:N──────────▶│ id (PK)             │
         │                    │ user_id (FK)        │
         │                    │ token_hash          │
         │                    │ expires_at          │
         │                    │ verified_at         │
         │                    │ created_at          │
         │                    └─────────────────────┘
         │
         │                    ┌─────────────────────┐
         │                    │phone_verifications  │
         │                    ├─────────────────────┤
         ├──────1:N──────────▶│ id (PK)             │
         │                    │ phone               │
         │                    │ user_id (FK)        │
         │                    │ code_hash           │
         │                    │ channel             │
         │                    │ attempts            │
         │                    │ expires_at          │
         │                    │ verified_at         │
         │                    │ created_at          │
         │                    └─────────────────────┘
         │
         │                    ┌─────────────────────┐
         │                    │password_reset_tokens│
         │                    ├─────────────────────┤
         └──────1:N──────────▶│ id (PK)             │
                              │ user_id (FK)        │
                              │ token_hash          │
                              │ expires_at          │
                              │ used_at             │
                              │ created_at          │
                              └─────────────────────┘
```

---

## ENUMs

```sql
-- Roles de usuario
CREATE TYPE user_role_enum AS ENUM (
    'investor',      -- Usuario inversionista básico
    'trader',        -- Trader activo con más permisos
    'student',       -- Solo acceso a educación
    'admin',         -- Administrador
    'superadmin'     -- Super administrador
);

-- Estados de usuario
CREATE TYPE user_status_enum AS ENUM (
    'pending_verification',  -- Email no verificado
    'active',                -- Cuenta activa
    'suspended',             -- Suspendida temporalmente
    'banned',                -- Baneada permanentemente
    'deleted'                -- Eliminada (soft delete)
);

-- Proveedores OAuth
CREATE TYPE auth_provider_enum AS ENUM (
    'google',
    'facebook',
    'twitter',
    'apple',
    'github'
);

-- Canales de verificación telefónica
CREATE TYPE phone_channel_enum AS ENUM (
    'sms',
    'whatsapp'
);

-- Eventos de autenticación (audit)
CREATE TYPE auth_event_enum AS ENUM (
    'login_success',
    'login_failed',
    'logout',
    'register',
    'email_verified',
    'phone_verified',
    'password_changed',
    'password_reset_requested',
    'password_reset_completed',
    '2fa_enabled',
    '2fa_disabled',
    '2fa_verified',
    'backup_code_used',
    'oauth_linked',
    'oauth_unlinked',
    'session_revoked',
    'account_suspended',
    'account_reactivated'
);
```

---

## Tablas

### users

```sql
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

    -- Identificadores
    email VARCHAR(255) UNIQUE,
    phone VARCHAR(20) UNIQUE,

    -- Credenciales
    password_hash VARCHAR(255),

    -- Datos personales
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,

    -- Permisos
    role user_role_enum NOT NULL DEFAULT 'investor',
    status user_status_enum NOT NULL DEFAULT 'pending_verification',

    -- Verificación
    email_verified BOOLEAN NOT NULL DEFAULT FALSE,
    phone_verified BOOLEAN NOT NULL DEFAULT FALSE,
    email_verified_at TIMESTAMPTZ,
    phone_verified_at TIMESTAMPTZ,

    -- 2FA
    two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
    two_factor_secret VARCHAR(255),  -- Encriptado
    backup_codes JSONB DEFAULT '[]', -- Array de hashes

    -- Metadata
    last_login_at TIMESTAMPTZ,
    login_count INTEGER DEFAULT 0,
    failed_login_attempts INTEGER DEFAULT 0,
    locked_until TIMESTAMPTZ,

    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ,

    -- Constraints
    CONSTRAINT email_or_phone_required CHECK (
        email IS NOT NULL OR phone IS NOT NULL
    ),
    CONSTRAINT password_required_for_email CHECK (
        email IS NULL OR password_hash IS NOT NULL OR
        EXISTS (SELECT 1 FROM oauth_accounts WHERE user_id = id)
    )
);

-- Índices
CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_phone ON users(phone) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_status ON users(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_role ON users(role) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_created_at ON users(created_at);

-- Trigger para updated_at
CREATE TRIGGER update_users_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
```

### user_profiles

```sql
CREATE TABLE user_profiles (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,

    -- Display
    display_name VARCHAR(50),
    bio TEXT,
    avatar_url VARCHAR(500),

    -- Preferencias regionales
    preferred_language VARCHAR(5) DEFAULT 'es',
    timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
    date_format VARCHAR(20) DEFAULT 'DD/MM/YYYY',
    currency VARCHAR(3) DEFAULT 'MXN',

    -- Preferencias de notificación
    notification_prefs JSONB DEFAULT '{
        "email": {
            "marketing": true,
            "security": true,
            "trading": true,
            "education": true
        },
        "push": {
            "trading_signals": true,
            "price_alerts": true,
            "course_updates": true
        },
        "sms": {
            "security": true,
            "trading": false
        }
    }',

    -- Trading preferences
    trading_prefs JSONB DEFAULT '{
        "default_timeframe": "1h",
        "chart_theme": "dark",
        "confirmations": true
    }',

    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_user_profiles_user_id ON user_profiles(user_id);
```

### oauth_accounts

```sql
CREATE TABLE oauth_accounts (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    -- Proveedor
    provider auth_provider_enum NOT NULL,
    provider_user_id VARCHAR(255) NOT NULL,

    -- Datos del proveedor
    email VARCHAR(255),

    -- Tokens (encriptados)
    access_token TEXT,
    refresh_token TEXT,
    token_expires_at TIMESTAMPTZ,

    -- Perfil raw del proveedor
    raw_profile JSONB,

    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Constraints
    UNIQUE(provider, provider_user_id),
    UNIQUE(user_id, provider)
);

CREATE INDEX idx_oauth_accounts_user_id ON oauth_accounts(user_id);
CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider);
CREATE INDEX idx_oauth_accounts_email ON oauth_accounts(email);
```

### sessions

```sql
CREATE TABLE sessions (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    -- Token
    refresh_token_hash VARCHAR(255) NOT NULL,

    -- Device info
    device_info JSONB NOT NULL DEFAULT '{}',
    -- {
    --   "type": "desktop",
    --   "browser": "Chrome",
    --   "browserVersion": "120.0.0",
    --   "os": "Windows",
    --   "osVersion": "11"
    -- }

    -- Ubicación
    ip_address INET,
    location JSONB,
    -- {
    --   "country": "México",
    --   "countryCode": "MX",
    --   "region": "CDMX",
    --   "city": "Ciudad de México",
    --   "timezone": "America/Mexico_City"
    -- }

    user_agent TEXT,

    -- Estado
    is_active BOOLEAN NOT NULL DEFAULT TRUE,

    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL,

    -- Revocación
    revoked_at TIMESTAMPTZ,
    revoked_reason VARCHAR(100)
);

CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_token_hash ON sessions(refresh_token_hash);
CREATE INDEX idx_sessions_active ON sessions(is_active) WHERE is_active = TRUE;
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE;
```

### email_verifications

```sql
CREATE TABLE email_verifications (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    -- Token
    token_hash VARCHAR(255) NOT NULL,

    -- Estado
    expires_at TIMESTAMPTZ NOT NULL,
    verified_at TIMESTAMPTZ,

    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_email_verifications_user_id ON email_verifications(user_id);
CREATE INDEX idx_email_verifications_token ON email_verifications(token_hash);
```

### phone_verifications

```sql
CREATE TABLE phone_verifications (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

    -- Teléfono (puede ser nuevo, no vinculado aún)
    phone VARCHAR(20) NOT NULL,
    user_id UUID REFERENCES users(id) ON DELETE CASCADE,

    -- OTP
    code_hash VARCHAR(255) NOT NULL,
    channel phone_channel_enum NOT NULL,

    -- Intentos
    attempts INTEGER NOT NULL DEFAULT 0,

    -- Estado
    expires_at TIMESTAMPTZ NOT NULL,
    verified_at TIMESTAMPTZ,

    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_phone_verifications_phone ON phone_verifications(phone);
CREATE INDEX idx_phone_verifications_user_id ON phone_verifications(user_id);
```

### password_reset_tokens

```sql
CREATE TABLE password_reset_tokens (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    -- Token
    token_hash VARCHAR(255) NOT NULL,

    -- Estado
    expires_at TIMESTAMPTZ NOT NULL,
    used_at TIMESTAMPTZ,

    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token_hash);
```

### auth_logs (Audit)

```sql
CREATE TABLE auth_logs (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID REFERENCES users(id) ON DELETE SET NULL,

    -- Evento
    event auth_event_enum NOT NULL,

    -- Contexto
    ip_address INET,
    user_agent TEXT,
    device_info JSONB,
    location JSONB,

    -- Detalles adicionales
    metadata JSONB DEFAULT '{}',
    -- Ejemplo: { "provider": "google", "reason": "invalid_password" }

    -- Resultado
    success BOOLEAN NOT NULL,
    error_message TEXT,

    -- Timestamp
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_auth_logs_user_id ON auth_logs(user_id);
CREATE INDEX idx_auth_logs_event ON auth_logs(event);
CREATE INDEX idx_auth_logs_created_at ON auth_logs(created_at);
CREATE INDEX idx_auth_logs_ip ON auth_logs(ip_address);

-- Particionar por fecha para mejor performance
-- (considerar para producción con alto volumen)
```

---

## Funciones

### update_updated_at_column

```sql
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
```

### log_auth_event

```sql
CREATE OR REPLACE FUNCTION log_auth_event(
    p_user_id UUID,
    p_event auth_event_enum,
    p_ip_address INET,
    p_user_agent TEXT,
    p_device_info JSONB,
    p_location JSONB,
    p_success BOOLEAN,
    p_metadata JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
    v_log_id UUID;
BEGIN
    INSERT INTO auth_logs (
        user_id, event, ip_address, user_agent,
        device_info, location, success, metadata
    ) VALUES (
        p_user_id, p_event, p_ip_address, p_user_agent,
        p_device_info, p_location, p_success, p_metadata
    ) RETURNING id INTO v_log_id;

    RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
```

### cleanup_expired_sessions

```sql
CREATE OR REPLACE FUNCTION cleanup_expired_sessions()
RETURNS INTEGER AS $$
DECLARE
    v_count INTEGER;
BEGIN
    UPDATE sessions
    SET is_active = FALSE,
        revoked_at = NOW(),
        revoked_reason = 'expired'
    WHERE is_active = TRUE
      AND expires_at < NOW();

    GET DIAGNOSTICS v_count = ROW_COUNT;
    RETURN v_count;
END;
$$ LANGUAGE plpgsql;
```

---

## Triggers

### Crear perfil al crear usuario

```sql
CREATE OR REPLACE FUNCTION create_user_profile()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO user_profiles (user_id)
    VALUES (NEW.id);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_create_user_profile
    AFTER INSERT ON users
    FOR EACH ROW
    EXECUTE FUNCTION create_user_profile();
```

### Log de eventos de autenticación

```sql
CREATE OR REPLACE FUNCTION log_user_status_change()
RETURNS TRIGGER AS $$
BEGIN
    IF OLD.status IS DISTINCT FROM NEW.status THEN
        IF NEW.status = 'suspended' THEN
            PERFORM log_auth_event(
                NEW.id, 'account_suspended', NULL, NULL, NULL, NULL, TRUE
            );
        ELSIF NEW.status = 'active' AND OLD.status = 'suspended' THEN
            PERFORM log_auth_event(
                NEW.id, 'account_reactivated', NULL, NULL, NULL, NULL, TRUE
            );
        END IF;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_log_status_change
    AFTER UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION log_user_status_change();
```

---

## Migraciones

### Archivo: `001_create_auth_schema.sql`

```sql
-- Extensiones requeridas
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- ENUMs
-- (copiar definiciones de ENUMs)

-- Tablas
-- (copiar definiciones de tablas en orden)

-- Funciones
-- (copiar funciones)

-- Triggers
-- (copiar triggers)

-- Datos iniciales
INSERT INTO users (
    id,
    email,
    password_hash,
    first_name,
    last_name,
    role,
    status,
    email_verified
) VALUES (
    'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
    'admin@orbiquant.com',
    crypt('AdminPassword123!', gen_salt('bf', 12)),
    'Admin',
    'OrbiQuant',
    'superadmin',
    'active',
    TRUE
);
```

---

## Consideraciones de Performance

### Índices Parciales

```sql
-- Solo usuarios activos
CREATE INDEX idx_users_active_email
    ON users(email)
    WHERE status = 'active' AND deleted_at IS NULL;

-- Solo sesiones activas no expiradas
CREATE INDEX idx_sessions_valid
    ON sessions(user_id, expires_at)
    WHERE is_active = TRUE;
```

### Particionamiento (Producción)

```sql
-- Particionar auth_logs por mes
CREATE TABLE auth_logs (
    -- ... columnas ...
) PARTITION BY RANGE (created_at);

CREATE TABLE auth_logs_2025_01 PARTITION OF auth_logs
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
-- ... más particiones ...
```

---

## Referencias

- [PostgreSQL UUID](https://www.postgresql.org/docs/current/uuid-ossp.html)
- [PostgreSQL JSONB](https://www.postgresql.org/docs/current/datatype-json.html)
- [PostgreSQL Partitioning](https://www.postgresql.org/docs/current/ddl-partitioning.html)
