-- ===================================================== -- 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.tenant_plan AS ENUM ( 'basic', 'standard', 'premium', 'enterprise' ); 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', plan auth.tenant_plan NOT NULL DEFAULT 'basic', -- Límites y uso max_users INTEGER DEFAULT 10, max_storage_mb INTEGER DEFAULT 1024, current_storage_mb INTEGER DEFAULT 0, -- Información de contacto custom_domain VARCHAR(255), contact_email VARCHAR(255), contact_phone VARCHAR(50), billing_email VARCHAR(255), tax_id VARCHAR(50), -- Suscripción trial_ends_at TIMESTAMP, subscription_ends_at TIMESTAMP, -- Configuración settings JSONB DEFAULT '{}', metadata JSONB DEFAULT '{}', -- 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), CONSTRAINT chk_tenants_max_storage CHECK (max_storage_mb > 0), CONSTRAINT chk_tenants_current_storage CHECK (current_storage_mb >= 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); CREATE INDEX idx_tenants_plan ON auth.tenants(plan); CREATE INDEX idx_tenants_custom_domain ON auth.tenants(custom_domain) WHERE custom_domain IS NOT NULL; CREATE INDEX idx_tenants_trial_ends_at ON auth.tenants(trial_ends_at) WHERE trial_ends_at IS NOT NULL; -- 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. Incluye planes de suscripción, límites de almacenamiento y datos de contacto/facturación.'; 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 -- =====================================================