# DDL-SPEC: Schema core_users

## Identificacion

| Campo | Valor |
|-------|-------|
| **Schema** | core_users |
| **Modulo** | MGN-002 |
| **Version** | 1.0 |
| **Estado** | En Diseño |
| **Autor** | System |
| **Fecha** | 2025-12-05 |

---

## Descripcion General

El schema `core_users` contiene la tabla principal de usuarios y todas las tablas relacionadas con la gestion de perfiles, preferencias y cambios de identidad. Es el schema central de identidad del sistema.

### Alcance

- Tabla principal de usuarios
- Avatares e historial de imagenes
- Solicitudes de cambio de email
- Preferencias de usuario
- Activacion de cuentas

> **Nota:** El historial de passwords reside en `core_auth.password_history` por coherencia con el modulo de autenticacion.

---

## Diagrama Entidad-Relacion

```mermaid
erDiagram
    tenants ||--o{ users : "contains"
    users ||--o| user_preferences : "has"
    users ||--o{ user_avatars : "uploads"
    users ||--o{ email_change_requests : "requests"
    users ||--o{ user_activation_tokens : "receives"
    users }o--o{ user_roles : "has"
    roles ||--o{ user_roles : "assigned to"

    users {
        uuid id PK
        uuid tenant_id FK
        varchar email UK
        varchar password_hash
        varchar first_name
        varchar last_name
        varchar phone
        varchar avatar_url
        varchar avatar_thumbnail_url
        enum status
        boolean is_active
        timestamptz email_verified_at
        timestamptz last_login_at
        integer failed_login_attempts
        timestamptz locked_until
        jsonb metadata
        timestamptz created_at
        uuid created_by FK
        timestamptz updated_at
        uuid updated_by FK
        timestamptz deleted_at
        uuid deleted_by FK
    }

    user_preferences {
        uuid id PK
        uuid user_id FK
        uuid tenant_id FK
        varchar language
        varchar timezone
        varchar date_format
        varchar time_format
        varchar theme
        boolean sidebar_collapsed
        boolean compact_mode
        varchar font_size
        jsonb notifications
        jsonb dashboard
        jsonb metadata
        timestamptz created_at
        timestamptz updated_at
    }

    user_avatars {
        uuid id PK
        uuid user_id FK
        uuid tenant_id FK
        varchar original_url
        varchar main_url
        varchar thumbnail_url
        varchar mime_type
        integer file_size
        boolean is_current
        timestamptz created_at
    }

    email_change_requests {
        uuid id PK
        uuid user_id FK
        uuid tenant_id FK
        varchar current_email
        varchar new_email
        varchar token_hash
        timestamptz expires_at
        timestamptz completed_at
        timestamptz created_at
    }

    user_activation_tokens {
        uuid id PK
        uuid user_id FK
        uuid tenant_id FK
        varchar token_hash
        timestamptz expires_at
        timestamptz used_at
        timestamptz created_at
    }

    user_roles {
        uuid id PK
        uuid user_id FK
        uuid role_id FK
        uuid tenant_id FK
        timestamptz created_at
        uuid created_by FK
    }

    roles {
        uuid id PK
        uuid tenant_id FK
        varchar name
        varchar description
        boolean is_system
        timestamptz created_at
    }
```

---

## Tablas

### 1. users

Tabla principal que almacena la informacion de todos los usuarios del sistema.

#### Estructura

| Columna | Tipo | Nullable | Default | Descripcion |
|---------|------|----------|---------|-------------|
| `id` | UUID | NOT NULL | gen_random_uuid() | PK |
| `tenant_id` | UUID | NOT NULL | - | FK → core_tenants.tenants |
| `email` | VARCHAR(255) | NOT NULL | - | Email unico por tenant |
| `password_hash` | VARCHAR(255) | NOT NULL | - | Hash bcrypt del password |
| `first_name` | VARCHAR(100) | NOT NULL | - | Nombre |
| `last_name` | VARCHAR(100) | NOT NULL | - | Apellido |
| `phone` | VARCHAR(20) | NULL | - | Telefono E.164 |
| `avatar_url` | VARCHAR(500) | NULL | - | URL avatar principal |
| `avatar_thumbnail_url` | VARCHAR(500) | NULL | - | URL thumbnail 50x50 |
| `status` | user_status | NOT NULL | 'pending_activation' | Estado del usuario |
| `is_active` | BOOLEAN | NOT NULL | false | Si puede operar |
| `email_verified_at` | TIMESTAMPTZ | NULL | - | Cuando verifico email |
| `last_login_at` | TIMESTAMPTZ | NULL | - | Ultimo login |
| `failed_login_attempts` | INTEGER | NOT NULL | 0 | Intentos fallidos |
| `locked_until` | TIMESTAMPTZ | NULL | - | Bloqueado hasta |
| `metadata` | JSONB | NULL | '{}' | Datos adicionales |
| `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion |
| `created_by` | UUID | NULL | - | Usuario que creo |
| `updated_at` | TIMESTAMPTZ | NOT NULL | NOW() | Ultima actualizacion |
| `updated_by` | UUID | NULL | - | Usuario que actualizo |
| `deleted_at` | TIMESTAMPTZ | NULL | - | Soft delete timestamp |
| `deleted_by` | UUID | NULL | - | Usuario que elimino |

#### Enum user_status

```sql
CREATE TYPE core_users.user_status AS ENUM (
    'pending_activation',
    'active',
    'inactive',
    'locked'
);
```

#### Constraints

```sql
-- Primary Key
CONSTRAINT pk_users PRIMARY KEY (id),

-- Unique (email unico por tenant, excluyendo eliminados)
CONSTRAINT uk_users_tenant_email UNIQUE (tenant_id, email) WHERE deleted_at IS NULL,

-- Foreign Keys
CONSTRAINT fk_users_tenant
    FOREIGN KEY (tenant_id) REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
CONSTRAINT fk_users_created_by
    FOREIGN KEY (created_by) REFERENCES core_users.users(id) ON DELETE SET NULL,
CONSTRAINT fk_users_updated_by
    FOREIGN KEY (updated_by) REFERENCES core_users.users(id) ON DELETE SET NULL,
CONSTRAINT fk_users_deleted_by
    FOREIGN KEY (deleted_by) REFERENCES core_users.users(id) ON DELETE SET NULL,

-- Check
CONSTRAINT chk_users_email_format
    CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
CONSTRAINT chk_users_phone_format
    CHECK (phone IS NULL OR phone ~* '^\+[0-9]{10,15}$'),
CONSTRAINT chk_users_failed_attempts
    CHECK (failed_login_attempts >= 0)
```

#### Indices

```sql
CREATE INDEX idx_users_tenant_id ON core_users.users(tenant_id);
CREATE INDEX idx_users_email ON core_users.users(email);
CREATE INDEX idx_users_status ON core_users.users(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_is_active ON core_users.users(is_active) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_created_at ON core_users.users(created_at DESC);
CREATE INDEX idx_users_full_name ON core_users.users(tenant_id, first_name, last_name) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_not_deleted ON core_users.users(tenant_id) WHERE deleted_at IS NULL;
```

---

### 2. user_preferences

Almacena las preferencias personalizadas de cada usuario.

#### Estructura

| Columna | Tipo | Nullable | Default | Descripcion |
|---------|------|----------|---------|-------------|
| `id` | UUID | NOT NULL | gen_random_uuid() | PK |
| `user_id` | UUID | NOT NULL | - | FK → users (UNIQUE) |
| `tenant_id` | UUID | NOT NULL | - | FK → tenants |
| `language` | VARCHAR(5) | NOT NULL | 'es' | Codigo idioma ISO |
| `timezone` | VARCHAR(50) | NOT NULL | 'America/Mexico_City' | IANA timezone |
| `date_format` | VARCHAR(20) | NOT NULL | 'DD/MM/YYYY' | Formato fecha |
| `time_format` | VARCHAR(5) | NOT NULL | '24h' | 12h o 24h |
| `currency` | VARCHAR(3) | NOT NULL | 'MXN' | Moneda ISO 4217 |
| `number_format` | VARCHAR(10) | NOT NULL | 'es-MX' | Formato numeros |
| `theme` | VARCHAR(10) | NOT NULL | 'system' | light, dark, system |
| `sidebar_collapsed` | BOOLEAN | NOT NULL | false | Sidebar colapsado |
| `compact_mode` | BOOLEAN | NOT NULL | false | Modo compacto |
| `font_size` | VARCHAR(10) | NOT NULL | 'medium' | small, medium, large |
| `notifications` | JSONB | NOT NULL | '{}' | Config notificaciones |
| `dashboard` | JSONB | NOT NULL | '{}' | Config dashboard |
| `metadata` | JSONB | NULL | '{}' | Datos adicionales |
| `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion |
| `updated_at` | TIMESTAMPTZ | NOT NULL | NOW() | Ultima actualizacion |

#### Constraints

```sql
CONSTRAINT pk_user_preferences PRIMARY KEY (id),
CONSTRAINT uk_user_preferences_user UNIQUE (user_id),
CONSTRAINT fk_user_preferences_user
    FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_preferences_tenant
    FOREIGN KEY (tenant_id) REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
CONSTRAINT chk_user_preferences_language
    CHECK (language IN ('es', 'en', 'pt')),
CONSTRAINT chk_user_preferences_theme
    CHECK (theme IN ('light', 'dark', 'system')),
CONSTRAINT chk_user_preferences_font_size
    CHECK (font_size IN ('small', 'medium', 'large')),
CONSTRAINT chk_user_preferences_time_format
    CHECK (time_format IN ('12h', '24h'))
```

#### Indices

```sql
CREATE INDEX idx_user_preferences_user_id ON core_users.user_preferences(user_id);
CREATE INDEX idx_user_preferences_tenant_id ON core_users.user_preferences(tenant_id);
```

---

### 3. user_avatars

Historial de avatares subidos por usuarios.

#### Estructura

| Columna | Tipo | Nullable | Default | Descripcion |
|---------|------|----------|---------|-------------|
| `id` | UUID | NOT NULL | gen_random_uuid() | PK |
| `user_id` | UUID | NOT NULL | - | FK → users |
| `tenant_id` | UUID | NOT NULL | - | FK → tenants |
| `original_url` | VARCHAR(500) | NOT NULL | - | URL original |
| `main_url` | VARCHAR(500) | NOT NULL | - | URL 200x200 |
| `thumbnail_url` | VARCHAR(500) | NOT NULL | - | URL 50x50 |
| `mime_type` | VARCHAR(50) | NOT NULL | - | image/jpeg, etc |
| `file_size` | INTEGER | NOT NULL | - | Tamano en bytes |
| `is_current` | BOOLEAN | NOT NULL | true | Si es el actual |
| `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha subida |

#### Constraints

```sql
CONSTRAINT pk_user_avatars PRIMARY KEY (id),
CONSTRAINT fk_user_avatars_user
    FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_avatars_tenant
    FOREIGN KEY (tenant_id) REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
CONSTRAINT chk_user_avatars_mime_type
    CHECK (mime_type IN ('image/jpeg', 'image/png', 'image/webp')),
CONSTRAINT chk_user_avatars_file_size
    CHECK (file_size > 0 AND file_size <= 10485760) -- 10MB max
```

#### Indices

```sql
CREATE INDEX idx_user_avatars_user_id ON core_users.user_avatars(user_id);
CREATE INDEX idx_user_avatars_current ON core_users.user_avatars(user_id) WHERE is_current = true;
```

---

### 4. email_change_requests

Solicitudes de cambio de email pendientes.

#### Estructura

| Columna | Tipo | Nullable | Default | Descripcion |
|---------|------|----------|---------|-------------|
| `id` | UUID | NOT NULL | gen_random_uuid() | PK |
| `user_id` | UUID | NOT NULL | - | FK → users |
| `tenant_id` | UUID | NOT NULL | - | FK → tenants |
| `current_email` | VARCHAR(255) | NOT NULL | - | Email actual |
| `new_email` | VARCHAR(255) | NOT NULL | - | Nuevo email |
| `token_hash` | VARCHAR(255) | NOT NULL | - | Hash del token |
| `expires_at` | TIMESTAMPTZ | NOT NULL | - | Expiracion (24h) |
| `completed_at` | TIMESTAMPTZ | NULL | - | Cuando se completo |
| `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha solicitud |

#### Constraints

```sql
CONSTRAINT pk_email_change_requests PRIMARY KEY (id),
CONSTRAINT fk_email_change_requests_user
    FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE,
CONSTRAINT fk_email_change_requests_tenant
    FOREIGN KEY (tenant_id) REFERENCES core_tenants.tenants(id) ON DELETE CASCADE
```

#### Indices

```sql
CREATE INDEX idx_email_change_requests_user ON core_users.email_change_requests(user_id);
CREATE INDEX idx_email_change_requests_active ON core_users.email_change_requests(user_id)
    WHERE completed_at IS NULL AND expires_at > NOW();
```

---

### 5. user_activation_tokens

Tokens para activacion de cuentas nuevas.

#### Estructura

| Columna | Tipo | Nullable | Default | Descripcion |
|---------|------|----------|---------|-------------|
| `id` | UUID | NOT NULL | gen_random_uuid() | PK |
| `user_id` | UUID | NOT NULL | - | FK → users |
| `tenant_id` | UUID | NOT NULL | - | FK → tenants |
| `token_hash` | VARCHAR(255) | NOT NULL | - | Hash del token |
| `expires_at` | TIMESTAMPTZ | NOT NULL | - | Expiracion (24h) |
| `used_at` | TIMESTAMPTZ | NULL | - | Cuando se uso |
| `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion |

#### Constraints

```sql
CONSTRAINT pk_user_activation_tokens PRIMARY KEY (id),
CONSTRAINT fk_user_activation_tokens_user
    FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_activation_tokens_tenant
    FOREIGN KEY (tenant_id) REFERENCES core_tenants.tenants(id) ON DELETE CASCADE
```

#### Indices

```sql
CREATE INDEX idx_user_activation_tokens_user ON core_users.user_activation_tokens(user_id);
CREATE INDEX idx_user_activation_tokens_active ON core_users.user_activation_tokens(user_id)
    WHERE used_at IS NULL AND expires_at > NOW();
```

---

### 6. user_roles (Junction Table)

Relacion muchos-a-muchos entre usuarios y roles.

#### Estructura

| Columna | Tipo | Nullable | Default | Descripcion |
|---------|------|----------|---------|-------------|
| `id` | UUID | NOT NULL | gen_random_uuid() | PK |
| `user_id` | UUID | NOT NULL | - | FK → users |
| `role_id` | UUID | NOT NULL | - | FK → roles |
| `tenant_id` | UUID | NOT NULL | - | FK → tenants |
| `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha asignacion |
| `created_by` | UUID | NULL | - | Quien asigno |

#### Constraints

```sql
CONSTRAINT pk_user_roles PRIMARY KEY (id),
CONSTRAINT uk_user_roles UNIQUE (user_id, role_id),
CONSTRAINT fk_user_roles_user
    FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role
    FOREIGN KEY (role_id) REFERENCES core_roles.roles(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_tenant
    FOREIGN KEY (tenant_id) REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_created_by
    FOREIGN KEY (created_by) REFERENCES core_users.users(id) ON DELETE SET NULL
```

#### Indices

```sql
CREATE INDEX idx_user_roles_user ON core_users.user_roles(user_id);
CREATE INDEX idx_user_roles_role ON core_users.user_roles(role_id);
CREATE INDEX idx_user_roles_tenant ON core_users.user_roles(tenant_id);
```

---

## Row Level Security (RLS)

### Politicas de Seguridad

```sql
-- Habilitar RLS
ALTER TABLE core_users.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE core_users.user_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE core_users.user_avatars ENABLE ROW LEVEL SECURITY;
ALTER TABLE core_users.email_change_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE core_users.user_activation_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE core_users.user_roles ENABLE ROW LEVEL SECURITY;

-- Politica: Usuarios solo ven usuarios de su tenant
CREATE POLICY tenant_isolation_users ON core_users.users
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Politica: Usuario ve su propio perfil o es admin
CREATE POLICY self_or_admin_users ON core_users.users
    FOR SELECT
    USING (
        id = current_setting('app.current_user_id')::uuid
        OR current_setting('app.is_admin')::boolean = true
    );

-- Politica: Solo el usuario ve sus preferencias
CREATE POLICY owner_only_preferences ON core_users.user_preferences
    FOR ALL
    USING (user_id = current_setting('app.current_user_id')::uuid);

-- Politica: Solo el usuario ve sus avatares
CREATE POLICY owner_only_avatars ON core_users.user_avatars
    FOR ALL
    USING (user_id = current_setting('app.current_user_id')::uuid);

-- Politica: Solo el usuario ve sus solicitudes de cambio
CREATE POLICY owner_only_email_changes ON core_users.email_change_requests
    FOR ALL
    USING (user_id = current_setting('app.current_user_id')::uuid);
```

---

## Triggers

### 1. Actualizar updated_at automaticamente

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

CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON core_users.users
FOR EACH ROW
EXECUTE FUNCTION core_users.update_updated_at();

CREATE TRIGGER trg_user_preferences_updated_at
BEFORE UPDATE ON core_users.user_preferences
FOR EACH ROW
EXECUTE FUNCTION core_users.update_updated_at();
```

### 2. Crear preferencias por defecto al crear usuario

```sql
CREATE OR REPLACE FUNCTION core_users.create_default_preferences()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO core_users.user_preferences (user_id, tenant_id)
    VALUES (NEW.id, NEW.tenant_id);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_create_default_preferences
AFTER INSERT ON core_users.users
FOR EACH ROW
EXECUTE FUNCTION core_users.create_default_preferences();
```

### 3. Marcar avatar anterior como no actual

```sql
CREATE OR REPLACE FUNCTION core_users.update_current_avatar()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.is_current = true THEN
        UPDATE core_users.user_avatars
        SET is_current = false
        WHERE user_id = NEW.user_id
          AND id != NEW.id
          AND is_current = true;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_update_current_avatar
AFTER INSERT OR UPDATE ON core_users.user_avatars
FOR EACH ROW
WHEN (NEW.is_current = true)
EXECUTE FUNCTION core_users.update_current_avatar();
```

### 4. Sincronizar avatar_url en users

```sql
CREATE OR REPLACE FUNCTION core_users.sync_avatar_url()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.is_current = true THEN
        UPDATE core_users.users
        SET avatar_url = NEW.main_url,
            avatar_thumbnail_url = NEW.thumbnail_url
        WHERE id = NEW.user_id;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_avatar_url
AFTER INSERT OR UPDATE ON core_users.user_avatars
FOR EACH ROW
WHEN (NEW.is_current = true)
EXECUTE FUNCTION core_users.sync_avatar_url();
```

---

## Funciones de Utilidad

### 1. Buscar usuarios por texto

```sql
CREATE OR REPLACE FUNCTION core_users.search_users(
    p_tenant_id UUID,
    p_search TEXT,
    p_limit INTEGER DEFAULT 20,
    p_offset INTEGER DEFAULT 0
)
RETURNS TABLE (
    id UUID,
    email VARCHAR,
    first_name VARCHAR,
    last_name VARCHAR,
    status user_status,
    avatar_thumbnail_url VARCHAR
) AS $$
BEGIN
    RETURN QUERY
    SELECT
        u.id,
        u.email,
        u.first_name,
        u.last_name,
        u.status,
        u.avatar_thumbnail_url
    FROM core_users.users u
    WHERE u.tenant_id = p_tenant_id
      AND u.deleted_at IS NULL
      AND (
          u.email ILIKE '%' || p_search || '%'
          OR u.first_name ILIKE '%' || p_search || '%'
          OR u.last_name ILIKE '%' || p_search || '%'
          OR CONCAT(u.first_name, ' ', u.last_name) ILIKE '%' || p_search || '%'
      )
    ORDER BY u.first_name, u.last_name
    LIMIT p_limit
    OFFSET p_offset;
END;
$$ LANGUAGE plpgsql STABLE;
```

### 2. Obtener usuario con roles

```sql
CREATE OR REPLACE FUNCTION core_users.get_user_with_roles(p_user_id UUID)
RETURNS TABLE (
    user_data JSONB,
    roles JSONB
) AS $$
BEGIN
    RETURN QUERY
    SELECT
        to_jsonb(u.*) - 'password_hash' AS user_data,
        COALESCE(
            jsonb_agg(
                jsonb_build_object('id', r.id, 'name', r.name)
            ) FILTER (WHERE r.id IS NOT NULL),
            '[]'::jsonb
        ) AS roles
    FROM core_users.users u
    LEFT JOIN core_users.user_roles ur ON u.id = ur.user_id
    LEFT JOIN core_roles.roles r ON ur.role_id = r.id
    WHERE u.id = p_user_id
    GROUP BY u.id;
END;
$$ LANGUAGE plpgsql STABLE;
```

---

## Scripts de Migracion

### Crear Schema

```sql
-- 001_create_schema_core_users.sql
CREATE SCHEMA IF NOT EXISTS core_users;

COMMENT ON SCHEMA core_users IS 'Schema para gestion de usuarios, perfiles y preferencias';

-- Crear enum
CREATE TYPE core_users.user_status AS ENUM (
    'pending_activation',
    'active',
    'inactive',
    'locked'
);
```

### Crear Tablas

```sql
-- 002_create_tables_core_users.sql

-- users
CREATE TABLE IF NOT EXISTS core_users.users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    email VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    phone VARCHAR(20),
    avatar_url VARCHAR(500),
    avatar_thumbnail_url VARCHAR(500),
    status core_users.user_status NOT NULL DEFAULT 'pending_activation',
    is_active BOOLEAN NOT NULL DEFAULT false,
    email_verified_at TIMESTAMPTZ,
    last_login_at TIMESTAMPTZ,
    failed_login_attempts INTEGER NOT NULL DEFAULT 0,
    locked_until TIMESTAMPTZ,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_by UUID,
    deleted_at TIMESTAMPTZ,
    deleted_by UUID,

    CONSTRAINT uk_users_tenant_email UNIQUE (tenant_id, email),
    CONSTRAINT chk_users_email_format
        CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
    CONSTRAINT chk_users_phone_format
        CHECK (phone IS NULL OR phone ~* '^\+[0-9]{10,15}$'),
    CONSTRAINT chk_users_failed_attempts
        CHECK (failed_login_attempts >= 0)
);

-- user_preferences
CREATE TABLE IF NOT EXISTS core_users.user_preferences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL UNIQUE,
    tenant_id UUID NOT NULL,
    language VARCHAR(5) NOT NULL DEFAULT 'es',
    timezone VARCHAR(50) NOT NULL DEFAULT 'America/Mexico_City',
    date_format VARCHAR(20) NOT NULL DEFAULT 'DD/MM/YYYY',
    time_format VARCHAR(5) NOT NULL DEFAULT '24h',
    currency VARCHAR(3) NOT NULL DEFAULT 'MXN',
    number_format VARCHAR(10) NOT NULL DEFAULT 'es-MX',
    theme VARCHAR(10) NOT NULL DEFAULT 'system',
    sidebar_collapsed BOOLEAN NOT NULL DEFAULT false,
    compact_mode BOOLEAN NOT NULL DEFAULT false,
    font_size VARCHAR(10) NOT NULL DEFAULT 'medium',
    notifications JSONB NOT NULL DEFAULT '{}',
    dashboard JSONB NOT NULL DEFAULT '{}',
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT fk_user_preferences_user
        FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE,
    CONSTRAINT chk_user_preferences_language
        CHECK (language IN ('es', 'en', 'pt')),
    CONSTRAINT chk_user_preferences_theme
        CHECK (theme IN ('light', 'dark', 'system')),
    CONSTRAINT chk_user_preferences_font_size
        CHECK (font_size IN ('small', 'medium', 'large')),
    CONSTRAINT chk_user_preferences_time_format
        CHECK (time_format IN ('12h', '24h'))
);

-- user_avatars
CREATE TABLE IF NOT EXISTS core_users.user_avatars (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    tenant_id UUID NOT NULL,
    original_url VARCHAR(500) NOT NULL,
    main_url VARCHAR(500) NOT NULL,
    thumbnail_url VARCHAR(500) NOT NULL,
    mime_type VARCHAR(50) NOT NULL,
    file_size INTEGER NOT NULL,
    is_current BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT fk_user_avatars_user
        FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE,
    CONSTRAINT chk_user_avatars_mime_type
        CHECK (mime_type IN ('image/jpeg', 'image/png', 'image/webp')),
    CONSTRAINT chk_user_avatars_file_size
        CHECK (file_size > 0 AND file_size <= 10485760)
);

-- email_change_requests
CREATE TABLE IF NOT EXISTS core_users.email_change_requests (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    tenant_id UUID NOT NULL,
    current_email VARCHAR(255) NOT NULL,
    new_email VARCHAR(255) NOT NULL,
    token_hash VARCHAR(255) NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT fk_email_change_requests_user
        FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE
);

-- user_activation_tokens
CREATE TABLE IF NOT EXISTS core_users.user_activation_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    tenant_id UUID NOT NULL,
    token_hash VARCHAR(255) NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL,
    used_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT fk_user_activation_tokens_user
        FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE
);

-- user_roles
CREATE TABLE IF NOT EXISTS core_users.user_roles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    role_id UUID NOT NULL,
    tenant_id UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID,

    CONSTRAINT uk_user_roles UNIQUE (user_id, role_id),
    CONSTRAINT fk_user_roles_user
        FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE,
    CONSTRAINT fk_user_roles_created_by
        FOREIGN KEY (created_by) REFERENCES core_users.users(id) ON DELETE SET NULL
);
```

---

## Consideraciones de Performance

| Tabla | Volumen Esperado | Estrategia |
|-------|------------------|------------|
| users | Medio (miles) | Indices por tenant, paginacion |
| user_preferences | 1:1 con users | Join eficiente por PK |
| user_avatars | Bajo (1-5 por user) | Cleanup de antiguos |
| email_change_requests | Bajo | TTL cleanup |
| user_activation_tokens | Bajo | TTL cleanup |
| user_roles | Bajo (1-5 por user) | Indices compuestos |

---

## Notas de Seguridad

1. **Nunca exponer password_hash** - Excluir en queries SELECT
2. **RLS obligatorio** - Aislamiento por tenant
3. **Soft delete** - Mantener historial para auditoria
4. **Tokens hasheados** - No almacenar tokens planos
5. **Validacion de email** - Prevenir inyeccion

---

## Historial de Cambios

| Version | Fecha | Autor | Cambios |
|---------|-------|-------|---------|
| 1.0 | 2025-12-05 | System | Creacion inicial |

---

## Aprobaciones

| Rol | Nombre | Fecha | Firma |
|-----|--------|-------|-------|
| DBA | - | - | [ ] |
| Tech Lead | - | - | [ ] |
| Security | - | - | [ ] |
