erp-core-database/ddl/01-auth.sql

621 lines
20 KiB
PL/PgSQL

-- =====================================================
-- SCHEMA: auth
-- PROPÓSITO: Autenticación, usuarios, roles, permisos
-- MÓDULOS: MGN-001 (Fundamentos), MGN-002 (Empresas)
-- FECHA: 2025-11-24
-- =====================================================
-- Crear schema
CREATE SCHEMA IF NOT EXISTS auth;
-- =====================================================
-- TYPES (ENUMs)
-- =====================================================
CREATE TYPE auth.user_status AS ENUM (
'active',
'inactive',
'suspended',
'pending_verification'
);
CREATE TYPE auth.tenant_status AS ENUM (
'active',
'suspended',
'trial',
'cancelled'
);
CREATE TYPE auth.session_status AS ENUM (
'active',
'expired',
'revoked'
);
CREATE TYPE auth.permission_action AS ENUM (
'create',
'read',
'update',
'delete',
'approve',
'cancel',
'export'
);
-- =====================================================
-- TABLES
-- =====================================================
-- Tabla: tenants (Multi-Tenancy)
CREATE TABLE auth.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
subdomain VARCHAR(100) UNIQUE NOT NULL,
schema_name VARCHAR(100) UNIQUE NOT NULL,
status auth.tenant_status NOT NULL DEFAULT 'active',
settings JSONB DEFAULT '{}',
plan VARCHAR(50) DEFAULT 'basic', -- basic, pro, enterprise
max_users INTEGER DEFAULT 10,
-- Auditoría (tenant no tiene tenant_id)
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID, -- Puede ser NULL para primer tenant
updated_at TIMESTAMP,
updated_by UUID,
deleted_at TIMESTAMP,
deleted_by UUID,
CONSTRAINT chk_tenants_subdomain_format CHECK (subdomain ~ '^[a-z0-9-]+$'),
CONSTRAINT chk_tenants_max_users CHECK (max_users > 0)
);
-- Tabla: companies (Multi-Company dentro de tenant)
CREATE TABLE auth.companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
legal_name VARCHAR(255),
tax_id VARCHAR(50),
currency_id UUID, -- FK a core.currencies (se crea después)
parent_company_id UUID REFERENCES auth.companies(id),
partner_id UUID, -- FK a core.partners (se crea después)
settings JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID,
updated_at TIMESTAMP,
updated_by UUID,
deleted_at TIMESTAMP,
deleted_by UUID,
CONSTRAINT uq_companies_tax_id_tenant UNIQUE (tenant_id, tax_id),
CONSTRAINT chk_companies_no_self_parent CHECK (id != parent_company_id)
);
-- Tabla: users
CREATE TABLE auth.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
avatar_url VARCHAR(500),
status auth.user_status NOT NULL DEFAULT 'active',
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
email_verified_at TIMESTAMP,
last_login_at TIMESTAMP,
last_login_ip INET,
login_count INTEGER DEFAULT 0,
language VARCHAR(10) DEFAULT 'es', -- es, en
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
settings JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID,
updated_at TIMESTAMP,
updated_by UUID,
deleted_at TIMESTAMP,
deleted_by UUID,
CONSTRAINT uq_users_email_tenant UNIQUE (tenant_id, email),
CONSTRAINT chk_users_email_format CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$')
);
-- Tabla: roles
CREATE TABLE auth.roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
code VARCHAR(50) NOT NULL,
description TEXT,
is_system BOOLEAN NOT NULL DEFAULT FALSE, -- Roles del sistema no editables
color VARCHAR(20),
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMP,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_roles_code_tenant UNIQUE (tenant_id, code)
);
-- Tabla: permissions
CREATE TABLE auth.permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource VARCHAR(100) NOT NULL, -- Tabla/endpoint
action auth.permission_action NOT NULL,
description TEXT,
module VARCHAR(50), -- MGN-001, MGN-004, etc.
-- Sin tenant_id: permisos son globales
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_permissions_resource_action UNIQUE (resource, action)
);
-- Tabla: user_roles (many-to-many)
CREATE TABLE auth.user_roles (
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
assigned_by UUID REFERENCES auth.users(id),
PRIMARY KEY (user_id, role_id)
);
-- Tabla: role_permissions (many-to-many)
CREATE TABLE auth.role_permissions (
role_id UUID NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES auth.permissions(id) ON DELETE CASCADE,
granted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
granted_by UUID REFERENCES auth.users(id),
PRIMARY KEY (role_id, permission_id)
);
-- Tabla: sessions
CREATE TABLE auth.sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
token VARCHAR(500) NOT NULL UNIQUE,
refresh_token VARCHAR(500) UNIQUE,
status auth.session_status NOT NULL DEFAULT 'active',
expires_at TIMESTAMP NOT NULL,
refresh_expires_at TIMESTAMP,
ip_address INET,
user_agent TEXT,
device_info JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP,
revoked_reason VARCHAR(100),
CONSTRAINT chk_sessions_expiration CHECK (expires_at > created_at),
CONSTRAINT chk_sessions_refresh_expiration CHECK (
refresh_expires_at IS NULL OR refresh_expires_at > expires_at
)
);
-- Tabla: user_companies (many-to-many)
-- Usuario puede acceder a múltiples empresas
CREATE TABLE auth.user_companies (
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
is_default BOOLEAN DEFAULT FALSE,
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, company_id)
);
-- Tabla: password_resets
CREATE TABLE auth.password_resets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
token VARCHAR(500) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
ip_address INET,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_password_resets_expiration CHECK (expires_at > created_at)
);
-- =====================================================
-- INDICES
-- =====================================================
-- Tenants
CREATE INDEX idx_tenants_subdomain ON auth.tenants(subdomain);
CREATE INDEX idx_tenants_status ON auth.tenants(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_tenants_created_at ON auth.tenants(created_at);
-- Companies
CREATE INDEX idx_companies_tenant_id ON auth.companies(tenant_id);
CREATE INDEX idx_companies_parent_company_id ON auth.companies(parent_company_id);
CREATE INDEX idx_companies_active ON auth.companies(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_companies_tax_id ON auth.companies(tax_id);
-- Users
CREATE INDEX idx_users_tenant_id ON auth.users(tenant_id);
CREATE INDEX idx_users_email ON auth.users(email);
CREATE INDEX idx_users_status ON auth.users(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_email_tenant ON auth.users(tenant_id, email);
CREATE INDEX idx_users_created_at ON auth.users(created_at);
-- Roles
CREATE INDEX idx_roles_tenant_id ON auth.roles(tenant_id);
CREATE INDEX idx_roles_code ON auth.roles(code);
CREATE INDEX idx_roles_is_system ON auth.roles(is_system);
-- Permissions
CREATE INDEX idx_permissions_resource ON auth.permissions(resource);
CREATE INDEX idx_permissions_action ON auth.permissions(action);
CREATE INDEX idx_permissions_module ON auth.permissions(module);
-- Sessions
CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id);
CREATE INDEX idx_sessions_token ON auth.sessions(token);
CREATE INDEX idx_sessions_status ON auth.sessions(status);
CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at);
-- User Roles
CREATE INDEX idx_user_roles_user_id ON auth.user_roles(user_id);
CREATE INDEX idx_user_roles_role_id ON auth.user_roles(role_id);
-- Role Permissions
CREATE INDEX idx_role_permissions_role_id ON auth.role_permissions(role_id);
CREATE INDEX idx_role_permissions_permission_id ON auth.role_permissions(permission_id);
-- User Companies
CREATE INDEX idx_user_companies_user_id ON auth.user_companies(user_id);
CREATE INDEX idx_user_companies_company_id ON auth.user_companies(company_id);
-- Password Resets
CREATE INDEX idx_password_resets_user_id ON auth.password_resets(user_id);
CREATE INDEX idx_password_resets_token ON auth.password_resets(token);
CREATE INDEX idx_password_resets_expires_at ON auth.password_resets(expires_at);
-- =====================================================
-- FUNCTIONS
-- =====================================================
-- Función: get_current_tenant_id
CREATE OR REPLACE FUNCTION get_current_tenant_id()
RETURNS UUID AS $$
BEGIN
RETURN current_setting('app.current_tenant_id', true)::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
COMMENT ON FUNCTION get_current_tenant_id() IS 'Obtiene el tenant_id del contexto actual';
-- Función: get_current_user_id
CREATE OR REPLACE FUNCTION get_current_user_id()
RETURNS UUID AS $$
BEGIN
RETURN current_setting('app.current_user_id', true)::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
COMMENT ON FUNCTION get_current_user_id() IS 'Obtiene el user_id del contexto actual';
-- Función: get_current_company_id
CREATE OR REPLACE FUNCTION get_current_company_id()
RETURNS UUID AS $$
BEGIN
RETURN current_setting('app.current_company_id', true)::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
COMMENT ON FUNCTION get_current_company_id() IS 'Obtiene el company_id del contexto actual';
-- Función: user_has_permission
CREATE OR REPLACE FUNCTION auth.user_has_permission(
p_user_id UUID,
p_resource VARCHAR,
p_action auth.permission_action
)
RETURNS BOOLEAN AS $$
DECLARE
v_has_permission BOOLEAN;
BEGIN
-- Superusers tienen todos los permisos
IF EXISTS (
SELECT 1 FROM auth.users
WHERE id = p_user_id AND is_superuser = TRUE AND deleted_at IS NULL
) THEN
RETURN TRUE;
END IF;
-- Verificar si el usuario tiene el permiso a través de sus roles
SELECT EXISTS (
SELECT 1
FROM auth.user_roles ur
JOIN auth.role_permissions rp ON ur.role_id = rp.role_id
JOIN auth.permissions p ON rp.permission_id = p.id
WHERE ur.user_id = p_user_id
AND p.resource = p_resource
AND p.action = p_action
) INTO v_has_permission;
RETURN v_has_permission;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
COMMENT ON FUNCTION auth.user_has_permission IS 'Verifica si un usuario tiene un permiso específico';
-- Función: clean_expired_sessions
CREATE OR REPLACE FUNCTION auth.clean_expired_sessions()
RETURNS INTEGER AS $$
DECLARE
v_deleted_count INTEGER;
BEGIN
WITH deleted AS (
DELETE FROM auth.sessions
WHERE status = 'active'
AND expires_at < CURRENT_TIMESTAMP
RETURNING id
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN v_deleted_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION auth.clean_expired_sessions IS 'Limpia sesiones expiradas (ejecutar periódicamente)';
-- =====================================================
-- TRIGGERS
-- =====================================================
-- Trigger: Actualizar updated_at automáticamente
CREATE OR REPLACE FUNCTION auth.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
NEW.updated_by = get_current_user_id();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_tenants_updated_at
BEFORE UPDATE ON auth.tenants
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at_column();
CREATE TRIGGER trg_companies_updated_at
BEFORE UPDATE ON auth.companies
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at_column();
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at_column();
CREATE TRIGGER trg_roles_updated_at
BEFORE UPDATE ON auth.roles
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at_column();
-- Trigger: Validar que tenant tenga al menos 1 admin
CREATE OR REPLACE FUNCTION auth.validate_tenant_has_admin()
RETURNS TRIGGER AS $$
BEGIN
-- Al eliminar user_role, verificar que no sea el último admin
IF TG_OP = 'DELETE' THEN
IF EXISTS (
SELECT 1
FROM auth.users u
JOIN auth.roles r ON r.tenant_id = u.tenant_id
WHERE u.id = OLD.user_id
AND r.code = 'admin'
AND r.id = OLD.role_id
) THEN
-- Contar admins restantes
IF NOT EXISTS (
SELECT 1
FROM auth.user_roles ur
JOIN auth.roles r ON r.id = ur.role_id
JOIN auth.users u ON u.id = ur.user_id
WHERE r.code = 'admin'
AND u.tenant_id = (SELECT tenant_id FROM auth.users WHERE id = OLD.user_id)
AND ur.user_id != OLD.user_id
) THEN
RAISE EXCEPTION 'Cannot remove last admin from tenant';
END IF;
END IF;
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_validate_tenant_has_admin
BEFORE DELETE ON auth.user_roles
FOR EACH ROW
EXECUTE FUNCTION auth.validate_tenant_has_admin();
-- Trigger: Auto-marcar sesión como expirada
CREATE OR REPLACE FUNCTION auth.auto_expire_session()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.expires_at < CURRENT_TIMESTAMP AND NEW.status = 'active' THEN
NEW.status = 'expired';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_auto_expire_session
BEFORE UPDATE ON auth.sessions
FOR EACH ROW
EXECUTE FUNCTION auth.auto_expire_session();
-- =====================================================
-- ROW LEVEL SECURITY (RLS)
-- =====================================================
-- Habilitar RLS en tablas con tenant_id
ALTER TABLE auth.companies ENABLE ROW LEVEL SECURITY;
ALTER TABLE auth.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE auth.roles ENABLE ROW LEVEL SECURITY;
-- Policy: Tenant Isolation - Companies
CREATE POLICY tenant_isolation_companies
ON auth.companies
USING (tenant_id = get_current_tenant_id());
-- Policy: Tenant Isolation - Users
CREATE POLICY tenant_isolation_users
ON auth.users
USING (tenant_id = get_current_tenant_id());
-- Policy: Tenant Isolation - Roles
CREATE POLICY tenant_isolation_roles
ON auth.roles
USING (tenant_id = get_current_tenant_id());
-- =====================================================
-- DATOS INICIALES (Seed Data)
-- =====================================================
-- Permisos estándar para recursos comunes
INSERT INTO auth.permissions (resource, action, description, module) VALUES
-- Auth
('users', 'create', 'Crear usuarios', 'MGN-001'),
('users', 'read', 'Ver usuarios', 'MGN-001'),
('users', 'update', 'Actualizar usuarios', 'MGN-001'),
('users', 'delete', 'Eliminar usuarios', 'MGN-001'),
('roles', 'create', 'Crear roles', 'MGN-001'),
('roles', 'read', 'Ver roles', 'MGN-001'),
('roles', 'update', 'Actualizar roles', 'MGN-001'),
('roles', 'delete', 'Eliminar roles', 'MGN-001'),
-- Financial
('invoices', 'create', 'Crear facturas', 'MGN-004'),
('invoices', 'read', 'Ver facturas', 'MGN-004'),
('invoices', 'update', 'Actualizar facturas', 'MGN-004'),
('invoices', 'delete', 'Eliminar facturas', 'MGN-004'),
('invoices', 'approve', 'Aprobar facturas', 'MGN-004'),
('invoices', 'cancel', 'Cancelar facturas', 'MGN-004'),
('journal_entries', 'create', 'Crear asientos contables', 'MGN-004'),
('journal_entries', 'read', 'Ver asientos contables', 'MGN-004'),
('journal_entries', 'approve', 'Aprobar asientos contables', 'MGN-004'),
-- Purchase
('purchase_orders', 'create', 'Crear órdenes de compra', 'MGN-006'),
('purchase_orders', 'read', 'Ver órdenes de compra', 'MGN-006'),
('purchase_orders', 'update', 'Actualizar órdenes de compra', 'MGN-006'),
('purchase_orders', 'delete', 'Eliminar órdenes de compra', 'MGN-006'),
('purchase_orders', 'approve', 'Aprobar órdenes de compra', 'MGN-006'),
-- Sales
('sale_orders', 'create', 'Crear órdenes de venta', 'MGN-007'),
('sale_orders', 'read', 'Ver órdenes de venta', 'MGN-007'),
('sale_orders', 'update', 'Actualizar órdenes de venta', 'MGN-007'),
('sale_orders', 'delete', 'Eliminar órdenes de venta', 'MGN-007'),
('sale_orders', 'approve', 'Aprobar órdenes de venta', 'MGN-007'),
-- Inventory
('products', 'create', 'Crear productos', 'MGN-005'),
('products', 'read', 'Ver productos', 'MGN-005'),
('products', 'update', 'Actualizar productos', 'MGN-005'),
('products', 'delete', 'Eliminar productos', 'MGN-005'),
('stock_moves', 'create', 'Crear movimientos de inventario', 'MGN-005'),
('stock_moves', 'read', 'Ver movimientos de inventario', 'MGN-005'),
('stock_moves', 'approve', 'Aprobar movimientos de inventario', 'MGN-005'),
-- Projects
('projects', 'create', 'Crear proyectos', 'MGN-011'),
('projects', 'read', 'Ver proyectos', 'MGN-011'),
('projects', 'update', 'Actualizar proyectos', 'MGN-011'),
('projects', 'delete', 'Eliminar proyectos', 'MGN-011'),
('tasks', 'create', 'Crear tareas', 'MGN-011'),
('tasks', 'read', 'Ver tareas', 'MGN-011'),
('tasks', 'update', 'Actualizar tareas', 'MGN-011'),
('tasks', 'delete', 'Eliminar tareas', 'MGN-011'),
-- Reports
('reports', 'read', 'Ver reportes', 'MGN-012'),
('reports', 'export', 'Exportar reportes', 'MGN-012');
-- =====================================================
-- COMENTARIOS EN TABLAS
-- =====================================================
COMMENT ON SCHEMA auth IS 'Schema de autenticación, usuarios, roles y permisos';
COMMENT ON TABLE auth.tenants IS 'Tenants (organizaciones raíz) con schema-level isolation';
COMMENT ON TABLE auth.companies IS 'Empresas dentro de un tenant (multi-company)';
COMMENT ON TABLE auth.users IS 'Usuarios del sistema con RBAC';
COMMENT ON TABLE auth.roles IS 'Roles con permisos asignados';
COMMENT ON TABLE auth.permissions IS 'Permisos granulares por recurso y acción';
COMMENT ON TABLE auth.user_roles IS 'Asignación de roles a usuarios (many-to-many)';
COMMENT ON TABLE auth.role_permissions IS 'Asignación de permisos a roles (many-to-many)';
COMMENT ON TABLE auth.sessions IS 'Sesiones JWT activas de usuarios';
COMMENT ON TABLE auth.user_companies IS 'Asignación de usuarios a empresas (multi-company)';
COMMENT ON TABLE auth.password_resets IS 'Tokens de reset de contraseña';
-- =====================================================
-- VISTAS ÚTILES
-- =====================================================
-- Vista: user_permissions (permisos efectivos de usuario)
CREATE OR REPLACE VIEW auth.user_permissions_view AS
SELECT DISTINCT
ur.user_id,
u.email,
u.full_name,
p.resource,
p.action,
p.description,
r.name as role_name,
r.code as role_code
FROM auth.user_roles ur
JOIN auth.users u ON ur.user_id = u.id
JOIN auth.roles r ON ur.role_id = r.id
JOIN auth.role_permissions rp ON r.id = rp.role_id
JOIN auth.permissions p ON rp.permission_id = p.id
WHERE u.deleted_at IS NULL
AND u.status = 'active';
COMMENT ON VIEW auth.user_permissions_view IS 'Vista de permisos efectivos por usuario';
-- Vista: active_sessions (sesiones activas)
CREATE OR REPLACE VIEW auth.active_sessions_view AS
SELECT
s.id,
s.user_id,
u.email,
u.full_name,
s.ip_address,
s.user_agent,
s.created_at as login_at,
s.expires_at,
EXTRACT(EPOCH FROM (s.expires_at - CURRENT_TIMESTAMP))/60 as minutes_until_expiry
FROM auth.sessions s
JOIN auth.users u ON s.user_id = u.id
WHERE s.status = 'active'
AND s.expires_at > CURRENT_TIMESTAMP;
COMMENT ON VIEW auth.active_sessions_view IS 'Vista de sesiones activas con tiempo restante';
-- =====================================================
-- FIN DEL SCHEMA AUTH
-- =====================================================