-- ============================================================================ -- SCHEMA: users -- TABLE: users -- DESCRIPTION: Sistema de usuarios con autenticacion y perfiles -- VERSION: 1.0.0 -- CREATED: 2026-01-10 -- ============================================================================ -- Crear schema si no existe CREATE SCHEMA IF NOT EXISTS users; -- Enum para estado de usuario DO $$ BEGIN CREATE TYPE users.user_status AS ENUM ( 'pending', -- Pendiente de verificacion de email 'active', -- Activo y verificado 'suspended', -- Suspendido temporalmente 'banned', -- Baneado permanentemente 'deleted' -- Eliminado (soft delete) ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tabla principal de Usuarios CREATE TABLE IF NOT EXISTS users.users ( -- Identificadores id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, -- Credenciales email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, -- Perfil first_name VARCHAR(100), last_name VARCHAR(100), display_name VARCHAR(100), avatar_url TEXT, phone VARCHAR(20), -- Estado status users.user_status NOT NULL DEFAULT 'pending', is_owner BOOLEAN NOT NULL DEFAULT FALSE, -- Owner del tenant -- Verificaciones email_verified BOOLEAN NOT NULL DEFAULT FALSE, email_verified_at TIMESTAMPTZ, phone_verified BOOLEAN NOT NULL DEFAULT FALSE, phone_verified_at TIMESTAMPTZ, -- MFA mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE, mfa_secret VARCHAR(255), -- TOTP secret (encrypted) mfa_backup_codes JSONB, -- Array de backup codes hasheados -- Seguridad password_changed_at TIMESTAMPTZ DEFAULT NOW(), failed_login_attempts INTEGER NOT NULL DEFAULT 0, locked_until TIMESTAMPTZ, last_login_at TIMESTAMPTZ, last_login_ip INET, -- Preferencias (JSONB flexible) preferences JSONB NOT NULL DEFAULT '{ "theme": "dark", "language": "es", "notifications": { "email": true, "push": true, "sms": false } }'::JSONB, -- Metadata adicional metadata JSONB DEFAULT '{}'::JSONB, -- Soft delete deleted_at TIMESTAMPTZ, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT users_unique_email_per_tenant UNIQUE (tenant_id, email) ); COMMENT ON TABLE users.users IS 'Usuarios de la plataforma, aislados por tenant'; COMMENT ON COLUMN users.users.is_owner IS 'Indica si es el owner del tenant (tiene todos los permisos)'; COMMENT ON COLUMN users.users.password_hash IS 'Hash bcrypt del password (cost 10)'; -- Indices CREATE INDEX IF NOT EXISTS idx_users_tenant ON users.users(tenant_id); CREATE INDEX IF NOT EXISTS idx_users_email ON users.users(email); CREATE INDEX IF NOT EXISTS idx_users_status ON users.users(status) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_users_owner ON users.users(tenant_id, is_owner) WHERE is_owner = TRUE; CREATE INDEX IF NOT EXISTS idx_users_last_login ON users.users(last_login_at DESC); -- Trigger para updated_at CREATE OR REPLACE FUNCTION users.update_user_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS user_updated_at ON users.users; CREATE TRIGGER user_updated_at BEFORE UPDATE ON users.users FOR EACH ROW EXECUTE FUNCTION users.update_user_timestamp(); -- Funcion para limpiar failed_login_attempts despues de login exitoso CREATE OR REPLACE FUNCTION users.clear_failed_logins() RETURNS TRIGGER AS $$ BEGIN IF NEW.last_login_at IS DISTINCT FROM OLD.last_login_at THEN NEW.failed_login_attempts := 0; NEW.locked_until := NULL; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS user_clear_failed_logins ON users.users; CREATE TRIGGER user_clear_failed_logins BEFORE UPDATE ON users.users FOR EACH ROW EXECUTE FUNCTION users.clear_failed_logins(); -- RLS Policy para multi-tenancy ALTER TABLE users.users ENABLE ROW LEVEL SECURITY; CREATE POLICY users_tenant_isolation ON users.users FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -- Grants GRANT SELECT, INSERT, UPDATE ON users.users TO trading_app; GRANT SELECT ON users.users TO trading_readonly;