From e52026834847ded54efd57b3abba8c006502e36b Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Fri, 16 Jan 2026 08:32:52 -0600 Subject: [PATCH] =?UTF-8?q?Migraci=C3=B3n=20desde=20trading-platform/apps/?= =?UTF-8?q?database=20-=20Est=C3=A1ndar=20multi-repo=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- README.md | 3 - ddl/schemas/audit/tables/001_audit_logs.sql | 387 +++++++++++++ ddl/schemas/auth/tables/001_sessions.sql | 150 +++++ ddl/schemas/auth/tables/002_tokens.sql | 184 +++++++ ddl/schemas/financial/001_wallets.sql | 147 +++++ .../financial/002_wallet_transactions.sql | 212 +++++++ .../investment/001_agent_allocations.sql | 520 ++++++++++++++++++ ddl/schemas/investment/002_add_columns.sql | 77 +++ .../ml/001_predictions_marketplace.sql | 365 ++++++++++++ ddl/schemas/ml/002_predictions.sql | 188 +++++++ ddl/schemas/products/001_products.sql | 206 +++++++ ddl/schemas/rbac/tables/001_roles.sql | 176 ++++++ ddl/schemas/rbac/tables/002_permissions.sql | 160 ++++++ .../rbac/tables/003_role_permissions.sql | 180 ++++++ ddl/schemas/rbac/tables/004_user_roles.sql | 288 ++++++++++ ddl/schemas/teams/tables/001_team_members.sql | 222 ++++++++ ddl/schemas/teams/tables/002_invitations.sql | 371 +++++++++++++ ddl/schemas/tenants/tables/001_tenants.sql | 165 ++++++ ddl/schemas/users/tables/001_users.sql | 156 ++++++ ddl/schemas/vip/001_vip_system.sql | 256 +++++++++ scripts/recreate-db.sh | 187 +++++++ seeds/001_vip_tiers.sql | 131 +++++ seeds/002_products.sql | 335 +++++++++++ seeds/003_prediction_packages.sql | 167 ++++++ seeds/004_agent_configs.sql | 140 +++++ seeds/_INDEX.sql | 61 ++ 26 files changed, 5431 insertions(+), 3 deletions(-) delete mode 100644 README.md create mode 100644 ddl/schemas/audit/tables/001_audit_logs.sql create mode 100644 ddl/schemas/auth/tables/001_sessions.sql create mode 100644 ddl/schemas/auth/tables/002_tokens.sql create mode 100644 ddl/schemas/financial/001_wallets.sql create mode 100644 ddl/schemas/financial/002_wallet_transactions.sql create mode 100644 ddl/schemas/investment/001_agent_allocations.sql create mode 100644 ddl/schemas/investment/002_add_columns.sql create mode 100644 ddl/schemas/ml/001_predictions_marketplace.sql create mode 100644 ddl/schemas/ml/002_predictions.sql create mode 100644 ddl/schemas/products/001_products.sql create mode 100644 ddl/schemas/rbac/tables/001_roles.sql create mode 100644 ddl/schemas/rbac/tables/002_permissions.sql create mode 100644 ddl/schemas/rbac/tables/003_role_permissions.sql create mode 100644 ddl/schemas/rbac/tables/004_user_roles.sql create mode 100644 ddl/schemas/teams/tables/001_team_members.sql create mode 100644 ddl/schemas/teams/tables/002_invitations.sql create mode 100644 ddl/schemas/tenants/tables/001_tenants.sql create mode 100644 ddl/schemas/users/tables/001_users.sql create mode 100644 ddl/schemas/vip/001_vip_system.sql create mode 100755 scripts/recreate-db.sh create mode 100644 seeds/001_vip_tiers.sql create mode 100644 seeds/002_products.sql create mode 100644 seeds/003_prediction_packages.sql create mode 100644 seeds/004_agent_configs.sql create mode 100644 seeds/_INDEX.sql diff --git a/README.md b/README.md deleted file mode 100644 index 47dfbc0..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# trading-platform-database-v2 - -Database de trading-platform - Workspace V2 \ No newline at end of file diff --git a/ddl/schemas/audit/tables/001_audit_logs.sql b/ddl/schemas/audit/tables/001_audit_logs.sql new file mode 100644 index 0000000..24fb5d8 --- /dev/null +++ b/ddl/schemas/audit/tables/001_audit_logs.sql @@ -0,0 +1,387 @@ +-- ============================================================================ +-- Audit Schema: Audit Logs Table +-- Comprehensive audit trail for Trading Platform SaaS +-- ============================================================================ + +-- Create audit schema if not exists +CREATE SCHEMA IF NOT EXISTS audit; + +-- Grant usage +GRANT USAGE ON SCHEMA audit TO trading_user; + +-- ============================================================================ +-- AUDIT_LOGS TABLE +-- Immutable audit trail for all significant actions +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.audit_logs ( + -- Primary key (using BIGSERIAL for high volume) + id BIGSERIAL PRIMARY KEY, + + -- Event identification + event_id UUID NOT NULL DEFAULT gen_random_uuid(), + event_type VARCHAR(100) NOT NULL, + event_category VARCHAR(50) NOT NULL, + + -- Actor information (who performed the action) + actor_id UUID, + actor_email VARCHAR(255), + actor_type VARCHAR(20) NOT NULL DEFAULT 'user' + CHECK (actor_type IN ('user', 'system', 'api', 'webhook', 'scheduled')), + actor_ip INET, + actor_user_agent TEXT, + + -- Tenant context + tenant_id UUID, + + -- Target resource + resource_type VARCHAR(100) NOT NULL, + resource_id VARCHAR(255), + resource_name VARCHAR(255), + + -- Action details + action VARCHAR(50) NOT NULL, + action_result VARCHAR(20) NOT NULL DEFAULT 'success' + CHECK (action_result IN ('success', 'failure', 'partial', 'pending')), + + -- Change tracking + previous_state JSONB, + new_state JSONB, + changes JSONB, -- Computed diff between previous and new state + + -- Additional context + metadata JSONB DEFAULT '{}'::jsonb, + request_id VARCHAR(100), + session_id UUID, + + -- Error information (for failures) + error_code VARCHAR(50), + error_message TEXT, + + -- Timestamps + occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Retention marker + is_archived BOOLEAN NOT NULL DEFAULT false, + archived_at TIMESTAMPTZ +); + +-- ============================================================================ +-- INDEXES +-- Optimized for common query patterns +-- ============================================================================ + +-- Time-based queries (most common) +CREATE INDEX IF NOT EXISTS idx_audit_logs_occurred_at ON audit.audit_logs(occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_time ON audit.audit_logs(tenant_id, occurred_at DESC); + +-- Actor queries +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_id ON audit.audit_logs(actor_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_email ON audit.audit_logs(actor_email); + +-- Resource queries +CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_tenant ON audit.audit_logs(tenant_id, resource_type, occurred_at DESC); + +-- Event type queries +CREATE INDEX IF NOT EXISTS idx_audit_logs_event_type ON audit.audit_logs(event_type); +CREATE INDEX IF NOT EXISTS idx_audit_logs_event_category ON audit.audit_logs(event_category); + +-- Action queries +CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit.audit_logs(action); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action_result ON audit.audit_logs(action_result) WHERE action_result = 'failure'; + +-- Request tracking +CREATE INDEX IF NOT EXISTS idx_audit_logs_request_id ON audit.audit_logs(request_id) WHERE request_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_audit_logs_session_id ON audit.audit_logs(session_id) WHERE session_id IS NOT NULL; + +-- Archival +CREATE INDEX IF NOT EXISTS idx_audit_logs_archived ON audit.audit_logs(is_archived, occurred_at); + +-- ============================================================================ +-- PARTITIONING (for high-volume deployments) +-- Uncomment to enable monthly partitions +-- ============================================================================ + +-- CREATE TABLE audit.audit_logs_partitioned ( +-- LIKE audit.audit_logs INCLUDING ALL +-- ) PARTITION BY RANGE (occurred_at); + +-- ============================================================================ +-- ROW LEVEL SECURITY +-- ============================================================================ + +ALTER TABLE audit.audit_logs ENABLE ROW LEVEL SECURITY; + +-- Policy: Users can only see audit logs in their tenant +-- Note: System admins may need broader access +CREATE POLICY audit_logs_tenant_isolation ON audit.audit_logs + FOR SELECT + USING ( + tenant_id = current_setting('app.current_tenant_id', true)::uuid + OR current_setting('app.is_system_admin', true)::boolean = true + ); + +-- Policy: Insert is allowed for logging (no tenant check on insert) +CREATE POLICY audit_logs_insert ON audit.audit_logs + FOR INSERT + WITH CHECK (true); + +-- ============================================================================ +-- FUNCTION: Log audit event +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit.log_event( + p_event_type VARCHAR(100), + p_event_category VARCHAR(50), + p_action VARCHAR(50), + p_resource_type VARCHAR(100), + p_resource_id VARCHAR(255) DEFAULT NULL, + p_resource_name VARCHAR(255) DEFAULT NULL, + p_actor_id UUID DEFAULT NULL, + p_actor_email VARCHAR(255) DEFAULT NULL, + p_actor_type VARCHAR(20) DEFAULT 'user', + p_actor_ip INET DEFAULT NULL, + p_tenant_id UUID DEFAULT NULL, + p_previous_state JSONB DEFAULT NULL, + p_new_state JSONB DEFAULT NULL, + p_action_result VARCHAR(20) DEFAULT 'success', + p_metadata JSONB DEFAULT '{}'::jsonb, + p_request_id VARCHAR(100) DEFAULT NULL, + p_session_id UUID DEFAULT NULL, + p_error_code VARCHAR(50) DEFAULT NULL, + p_error_message TEXT DEFAULT NULL +) +RETURNS BIGINT AS $$ +DECLARE + v_log_id BIGINT; + v_changes JSONB; +BEGIN + -- Compute changes if both states provided + IF p_previous_state IS NOT NULL AND p_new_state IS NOT NULL THEN + v_changes := audit.compute_json_diff(p_previous_state, p_new_state); + END IF; + + INSERT INTO audit.audit_logs ( + event_type, event_category, action, + resource_type, resource_id, resource_name, + actor_id, actor_email, actor_type, actor_ip, + tenant_id, + previous_state, new_state, changes, + action_result, metadata, + request_id, session_id, + error_code, error_message + ) VALUES ( + p_event_type, p_event_category, p_action, + p_resource_type, p_resource_id, p_resource_name, + p_actor_id, p_actor_email, p_actor_type, p_actor_ip, + p_tenant_id, + p_previous_state, p_new_state, v_changes, + p_action_result, p_metadata, + p_request_id, p_session_id, + p_error_code, p_error_message + ) + RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- FUNCTION: Compute JSON diff +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit.compute_json_diff( + p_old JSONB, + p_new JSONB +) +RETURNS JSONB AS $$ +DECLARE + v_key TEXT; + v_diff JSONB := '{}'::jsonb; +BEGIN + -- Find changed and added keys + FOR v_key IN SELECT jsonb_object_keys(p_new) + LOOP + IF NOT p_old ? v_key THEN + -- New key added + v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_object( + 'action', 'added', + 'new', p_new->v_key + )); + ELSIF p_old->v_key != p_new->v_key THEN + -- Key modified + v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_object( + 'action', 'modified', + 'old', p_old->v_key, + 'new', p_new->v_key + )); + END IF; + END LOOP; + + -- Find deleted keys + FOR v_key IN SELECT jsonb_object_keys(p_old) + LOOP + IF NOT p_new ? v_key THEN + v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_object( + 'action', 'deleted', + 'old', p_old->v_key + )); + END IF; + END LOOP; + + RETURN v_diff; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- ============================================================================ +-- CONVENIENCE FUNCTIONS FOR COMMON EVENTS +-- ============================================================================ + +-- Log authentication event +CREATE OR REPLACE FUNCTION audit.log_auth_event( + p_action VARCHAR(50), -- 'login', 'logout', 'login_failed', 'password_changed', etc. + p_user_id UUID, + p_user_email VARCHAR(255), + p_tenant_id UUID, + p_ip INET DEFAULT NULL, + p_success BOOLEAN DEFAULT true, + p_metadata JSONB DEFAULT '{}'::jsonb, + p_error_message TEXT DEFAULT NULL +) +RETURNS BIGINT AS $$ +BEGIN + RETURN audit.log_event( + 'auth.' || p_action, + 'authentication', + p_action, + 'user', + p_user_id::text, + p_user_email, + p_user_id, + p_user_email, + 'user', + p_ip, + p_tenant_id, + NULL, NULL, + CASE WHEN p_success THEN 'success' ELSE 'failure' END, + p_metadata, + NULL, NULL, + CASE WHEN NOT p_success THEN 'AUTH_FAILED' ELSE NULL END, + p_error_message + ); +END; +$$ LANGUAGE plpgsql; + +-- Log resource CRUD event +CREATE OR REPLACE FUNCTION audit.log_resource_event( + p_action VARCHAR(50), -- 'created', 'updated', 'deleted', 'viewed' + p_resource_type VARCHAR(100), + p_resource_id VARCHAR(255), + p_resource_name VARCHAR(255), + p_actor_id UUID, + p_actor_email VARCHAR(255), + p_tenant_id UUID, + p_previous_state JSONB DEFAULT NULL, + p_new_state JSONB DEFAULT NULL, + p_metadata JSONB DEFAULT '{}'::jsonb +) +RETURNS BIGINT AS $$ +BEGIN + RETURN audit.log_event( + p_resource_type || '.' || p_action, + 'resource', + p_action, + p_resource_type, + p_resource_id, + p_resource_name, + p_actor_id, + p_actor_email, + 'user', + NULL, + p_tenant_id, + p_previous_state, + p_new_state, + 'success', + p_metadata + ); +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- VIEW: Recent audit logs +-- ============================================================================ + +CREATE OR REPLACE VIEW audit.v_recent_logs AS +SELECT + id, + event_id, + event_type, + event_category, + actor_email, + actor_type, + actor_ip, + tenant_id, + resource_type, + resource_id, + resource_name, + action, + action_result, + changes, + metadata, + error_message, + occurred_at +FROM audit.audit_logs +WHERE occurred_at > CURRENT_TIMESTAMP - INTERVAL '30 days' + AND is_archived = false +ORDER BY occurred_at DESC; + +-- ============================================================================ +-- FUNCTION: Archive old logs +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit.archive_old_logs( + p_older_than_days INTEGER DEFAULT 90 +) +RETURNS INTEGER AS $$ +DECLARE + v_count INTEGER; +BEGIN + UPDATE audit.audit_logs + SET is_archived = true, + archived_at = CURRENT_TIMESTAMP + WHERE is_archived = false + AND occurred_at < CURRENT_TIMESTAMP - (p_older_than_days || ' days')::interval; + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- GRANTS +-- ============================================================================ + +GRANT SELECT ON audit.audit_logs TO trading_user; +GRANT INSERT ON audit.audit_logs TO trading_user; +GRANT SELECT ON audit.v_recent_logs TO trading_user; +GRANT USAGE, SELECT ON SEQUENCE audit.audit_logs_id_seq TO trading_user; +GRANT EXECUTE ON FUNCTION audit.log_event TO trading_user; +GRANT EXECUTE ON FUNCTION audit.log_auth_event TO trading_user; +GRANT EXECUTE ON FUNCTION audit.log_resource_event TO trading_user; +GRANT EXECUTE ON FUNCTION audit.compute_json_diff TO trading_user; + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +COMMENT ON TABLE audit.audit_logs IS 'Immutable audit trail for all significant actions'; +COMMENT ON COLUMN audit.audit_logs.event_type IS 'Full event type e.g., auth.login, user.created'; +COMMENT ON COLUMN audit.audit_logs.event_category IS 'High-level category: authentication, resource, system'; +COMMENT ON COLUMN audit.audit_logs.actor_type IS 'Type of actor: user, system, api, webhook, scheduled'; +COMMENT ON COLUMN audit.audit_logs.changes IS 'Computed diff between previous_state and new_state'; +COMMENT ON COLUMN audit.audit_logs.is_archived IS 'Marker for log retention/archival'; +COMMENT ON FUNCTION audit.log_event IS 'Main function to log any audit event'; +COMMENT ON FUNCTION audit.log_auth_event IS 'Convenience function for authentication events'; +COMMENT ON FUNCTION audit.log_resource_event IS 'Convenience function for resource CRUD events'; diff --git a/ddl/schemas/auth/tables/001_sessions.sql b/ddl/schemas/auth/tables/001_sessions.sql new file mode 100644 index 0000000..7ad0bcf --- /dev/null +++ b/ddl/schemas/auth/tables/001_sessions.sql @@ -0,0 +1,150 @@ +-- ============================================================================ +-- SCHEMA: auth +-- TABLE: sessions +-- DESCRIPTION: Gestion de sesiones de usuario con tracking de dispositivos +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- Crear schema si no existe +CREATE SCHEMA IF NOT EXISTS auth; + +-- Enum para estado de sesion +DO $$ BEGIN + CREATE TYPE auth.session_status AS ENUM ( + 'active', -- Sesion activa + 'expired', -- Expirada por tiempo + 'revoked' -- Revocada manualmente (logout) + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para tipo de dispositivo +DO $$ BEGIN + CREATE TYPE auth.device_type AS ENUM ( + 'desktop', + 'mobile', + 'tablet', + 'unknown' + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Sesiones +CREATE TABLE IF NOT EXISTS auth.sessions ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Token hash (para validacion) + token_hash VARCHAR(255) NOT NULL, + + -- Informacion del dispositivo + device_type auth.device_type DEFAULT 'unknown', + device_name VARCHAR(255), + browser VARCHAR(100), + browser_version VARCHAR(50), + os VARCHAR(100), + os_version VARCHAR(50), + + -- Ubicacion + ip_address INET, + user_agent TEXT, + + -- Estado + status auth.session_status NOT NULL DEFAULT 'active', + + -- Timestamps + last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE auth.sessions IS +'Sesiones activas de usuarios con informacion de dispositivo para seguridad'; + +COMMENT ON COLUMN auth.sessions.token_hash IS +'Hash SHA256 del refresh token para validacion sin almacenar el token'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_sessions_user + ON auth.sessions(user_id); + +CREATE INDEX IF NOT EXISTS idx_sessions_tenant + ON auth.sessions(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_sessions_token_hash + ON auth.sessions(token_hash); + +CREATE INDEX IF NOT EXISTS idx_sessions_status + ON auth.sessions(status) WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_sessions_expires + ON auth.sessions(expires_at) WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_sessions_last_active + ON auth.sessions(last_active_at DESC); + +-- Funcion para limpiar sesiones expiradas +CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + UPDATE auth.sessions + SET status = 'expired' + WHERE status = 'active' + AND expires_at < NOW(); + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + + -- Eliminar sesiones muy antiguas (> 90 dias) + DELETE FROM auth.sessions + WHERE (status = 'expired' OR status = 'revoked') + AND created_at < NOW() - INTERVAL '90 days'; + + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.cleanup_expired_sessions() IS +'Limpia sesiones expiradas y elimina registros muy antiguos. Ejecutar periodicamente.'; + +-- Funcion para revocar todas las sesiones de un usuario +CREATE OR REPLACE FUNCTION auth.revoke_all_user_sessions( + p_user_id UUID, + p_except_session_id UUID DEFAULT NULL +) +RETURNS INTEGER AS $$ +DECLARE + revoked_count INTEGER; +BEGIN + UPDATE auth.sessions + SET status = 'revoked', + revoked_at = NOW() + WHERE user_id = p_user_id + AND status = 'active' + AND (p_except_session_id IS NULL OR id != p_except_session_id); + + GET DIAGNOSTICS revoked_count = ROW_COUNT; + RETURN revoked_count; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.revoke_all_user_sessions IS +'Revoca todas las sesiones activas de un usuario, opcionalmente excepto una sesion especifica'; + +-- RLS Policy para multi-tenancy +ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY sessions_tenant_isolation ON auth.sessions + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON auth.sessions TO trading_app; +GRANT SELECT ON auth.sessions TO trading_readonly; diff --git a/ddl/schemas/auth/tables/002_tokens.sql b/ddl/schemas/auth/tables/002_tokens.sql new file mode 100644 index 0000000..ce72e93 --- /dev/null +++ b/ddl/schemas/auth/tables/002_tokens.sql @@ -0,0 +1,184 @@ +-- ============================================================================ +-- SCHEMA: auth +-- TABLE: tokens +-- DESCRIPTION: Tokens de refresh, reset password, verificacion email, etc. +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- Enum para tipo de token +DO $$ BEGIN + CREATE TYPE auth.token_type AS ENUM ( + 'refresh', -- Refresh token para JWT + 'password_reset', -- Reset de password + 'email_verify', -- Verificacion de email + 'phone_verify', -- Verificacion de telefono + 'api_key', -- API key para integraciones + 'invitation' -- Invitacion a unirse a tenant + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para estado de token +DO $$ BEGIN + CREATE TYPE auth.token_status AS ENUM ( + 'active', -- Token activo y usable + 'used', -- Ya fue usado (one-time tokens) + 'expired', -- Expirado por tiempo + 'revoked' -- Revocado manualmente + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Tokens +CREATE TABLE IF NOT EXISTS auth.tokens ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users.users(id) ON DELETE CASCADE, -- NULL para invitations pre-user + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Token info + token_type auth.token_type NOT NULL, + token_hash VARCHAR(255) NOT NULL, -- Hash del token + + -- Estado + status auth.token_status NOT NULL DEFAULT 'active', + + -- Metadata (flexible por tipo) + metadata JSONB DEFAULT '{}'::JSONB, + -- Para invitations: { "email": "...", "role_id": "..." } + -- Para api_key: { "name": "...", "scopes": [...] } + + -- Timestamps + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE auth.tokens IS +'Tokens para diversos propositos: refresh, reset, verificacion, API keys, invitaciones'; + +COMMENT ON COLUMN auth.tokens.metadata IS +'Metadata especifica por tipo de token (email para invitations, scopes para API keys, etc.)'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_tokens_user + ON auth.tokens(user_id) WHERE user_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_tokens_tenant + ON auth.tokens(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_tokens_hash + ON auth.tokens(token_hash); + +CREATE INDEX IF NOT EXISTS idx_tokens_type_status + ON auth.tokens(token_type, status) WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_tokens_expires + ON auth.tokens(expires_at) WHERE status = 'active'; + +-- Indice para buscar invitations por email +CREATE INDEX IF NOT EXISTS idx_tokens_invitation_email + ON auth.tokens((metadata->>'email')) + WHERE token_type = 'invitation' AND status = 'active'; + +-- Funcion para crear token de reset de password +CREATE OR REPLACE FUNCTION auth.create_password_reset_token( + p_user_id UUID, + p_tenant_id UUID, + p_token_hash VARCHAR(255), + p_expires_in INTERVAL DEFAULT '1 hour' +) +RETURNS UUID AS $$ +DECLARE + v_token_id UUID; +BEGIN + -- Revocar tokens anteriores de reset + UPDATE auth.tokens + SET status = 'revoked', revoked_at = NOW() + WHERE user_id = p_user_id + AND token_type = 'password_reset' + AND status = 'active'; + + -- Crear nuevo token + INSERT INTO auth.tokens (user_id, tenant_id, token_type, token_hash, expires_at) + VALUES (p_user_id, p_tenant_id, 'password_reset', p_token_hash, NOW() + p_expires_in) + RETURNING id INTO v_token_id; + + RETURN v_token_id; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para validar y usar token +CREATE OR REPLACE FUNCTION auth.use_token( + p_token_hash VARCHAR(255), + p_token_type auth.token_type +) +RETURNS TABLE ( + user_id UUID, + tenant_id UUID, + metadata JSONB, + is_valid BOOLEAN +) AS $$ +DECLARE + v_token auth.tokens%ROWTYPE; +BEGIN + -- Buscar token activo + SELECT * INTO v_token + FROM auth.tokens t + WHERE t.token_hash = p_token_hash + AND t.token_type = p_token_type + AND t.status = 'active' + AND t.expires_at > NOW(); + + IF NOT FOUND THEN + RETURN QUERY SELECT NULL::UUID, NULL::UUID, NULL::JSONB, FALSE; + RETURN; + END IF; + + -- Marcar como usado (solo para one-time tokens) + IF p_token_type IN ('password_reset', 'email_verify', 'phone_verify', 'invitation') THEN + UPDATE auth.tokens + SET status = 'used', used_at = NOW() + WHERE id = v_token.id; + END IF; + + RETURN QUERY SELECT v_token.user_id, v_token.tenant_id, v_token.metadata, TRUE; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para limpiar tokens expirados +CREATE OR REPLACE FUNCTION auth.cleanup_expired_tokens() +RETURNS INTEGER AS $$ +DECLARE + updated_count INTEGER; +BEGIN + UPDATE auth.tokens + SET status = 'expired' + WHERE status = 'active' + AND expires_at < NOW(); + + GET DIAGNOSTICS updated_count = ROW_COUNT; + + -- Eliminar tokens muy antiguos (> 30 dias) + DELETE FROM auth.tokens + WHERE status IN ('used', 'expired', 'revoked') + AND created_at < NOW() - INTERVAL '30 days'; + + RETURN updated_count; +END; +$$ LANGUAGE plpgsql; + +-- RLS Policy para multi-tenancy +ALTER TABLE auth.tokens ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tokens_tenant_isolation ON auth.tokens + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON auth.tokens TO trading_app; +GRANT SELECT ON auth.tokens TO trading_readonly; diff --git a/ddl/schemas/financial/001_wallets.sql b/ddl/schemas/financial/001_wallets.sql new file mode 100644 index 0000000..64ab26c --- /dev/null +++ b/ddl/schemas/financial/001_wallets.sql @@ -0,0 +1,147 @@ +-- ============================================================================ +-- SCHEMA: financial +-- TABLE: wallets +-- DESCRIPTION: Sistema de Wallet Virtual con creditos USD equivalentes +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- Crear schema si no existe +CREATE SCHEMA IF NOT EXISTS financial; + +-- Enum para estado de wallet +DO $$ BEGIN + CREATE TYPE financial.wallet_status AS ENUM ( + 'active', -- Wallet activo y operativo + 'frozen', -- Congelado temporalmente (investigacion) + 'suspended', -- Suspendido por violacion de terminos + 'closed' -- Cerrado permanentemente + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla principal de Wallets +CREATE TABLE IF NOT EXISTS financial.wallets ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + + -- Saldos en creditos (equivalente USD, no dinero real) + balance DECIMAL(15, 4) NOT NULL DEFAULT 0 + CHECK (balance >= 0), + reserved DECIMAL(15, 4) NOT NULL DEFAULT 0 + CHECK (reserved >= 0), + + -- Totales acumulados (para auditoria) + total_credited DECIMAL(15, 4) NOT NULL DEFAULT 0, + total_debited DECIMAL(15, 4) NOT NULL DEFAULT 0, + + -- Creditos promocionales (separados del balance principal) + promo_balance DECIMAL(15, 4) NOT NULL DEFAULT 0 + CHECK (promo_balance >= 0), + promo_expiry TIMESTAMPTZ, + + -- Configuracion + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + status financial.wallet_status NOT NULL DEFAULT 'active', + + -- Limites operacionales + daily_spend_limit DECIMAL(15, 4) DEFAULT 1000.0000, + monthly_spend_limit DECIMAL(15, 4) DEFAULT 10000.0000, + single_transaction_limit DECIMAL(15, 4) DEFAULT 500.0000, + + -- Tracking de uso + daily_spent DECIMAL(15, 4) NOT NULL DEFAULT 0, + monthly_spent DECIMAL(15, 4) NOT NULL DEFAULT 0, + last_daily_reset DATE DEFAULT CURRENT_DATE, + last_monthly_reset DATE DEFAULT DATE_TRUNC('month', CURRENT_DATE)::DATE, + + -- Timestamps + last_transaction_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT wallets_total_balance_check CHECK (balance + reserved >= 0), + CONSTRAINT wallets_unique_user UNIQUE (tenant_id, user_id) +); + +-- Columna calculada para balance total disponible +COMMENT ON TABLE financial.wallets IS +'Wallet virtual con creditos USD equivalentes. No es dinero real.'; + +COMMENT ON COLUMN financial.wallets.balance IS +'Saldo disponible en creditos (1 credito = 1 USD equivalente)'; + +COMMENT ON COLUMN financial.wallets.reserved IS +'Creditos reservados para operaciones pendientes (ej: fondeo de agentes)'; + +COMMENT ON COLUMN financial.wallets.promo_balance IS +'Creditos promocionales separados del balance principal'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_wallets_tenant_id + ON financial.wallets(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_wallets_user_id + ON financial.wallets(user_id); + +CREATE INDEX IF NOT EXISTS idx_wallets_status + ON financial.wallets(status); + +CREATE INDEX IF NOT EXISTS idx_wallets_created_at + ON financial.wallets(created_at DESC); + +-- Trigger para updated_at +CREATE OR REPLACE FUNCTION financial.update_wallet_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS wallet_updated_at ON financial.wallets; +CREATE TRIGGER wallet_updated_at + BEFORE UPDATE ON financial.wallets + FOR EACH ROW + EXECUTE FUNCTION financial.update_wallet_timestamp(); + +-- Funcion para resetear limites diarios/mensuales +CREATE OR REPLACE FUNCTION financial.reset_wallet_limits() +RETURNS TRIGGER AS $$ +BEGIN + -- Reset diario + IF NEW.last_daily_reset < CURRENT_DATE THEN + NEW.daily_spent := 0; + NEW.last_daily_reset := CURRENT_DATE; + END IF; + + -- Reset mensual + IF NEW.last_monthly_reset < DATE_TRUNC('month', CURRENT_DATE)::DATE THEN + NEW.monthly_spent := 0; + NEW.last_monthly_reset := DATE_TRUNC('month', CURRENT_DATE)::DATE; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS wallet_reset_limits ON financial.wallets; +CREATE TRIGGER wallet_reset_limits + BEFORE UPDATE ON financial.wallets + FOR EACH ROW + EXECUTE FUNCTION financial.reset_wallet_limits(); + +-- RLS Policy para multi-tenancy +ALTER TABLE financial.wallets ENABLE ROW LEVEL SECURITY; + +CREATE POLICY wallets_tenant_isolation ON financial.wallets + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON financial.wallets TO trading_app; +GRANT SELECT ON financial.wallets TO trading_readonly; diff --git a/ddl/schemas/financial/002_wallet_transactions.sql b/ddl/schemas/financial/002_wallet_transactions.sql new file mode 100644 index 0000000..fb5d0c6 --- /dev/null +++ b/ddl/schemas/financial/002_wallet_transactions.sql @@ -0,0 +1,212 @@ +-- ============================================================================ +-- SCHEMA: financial +-- TABLE: wallet_transactions +-- DESCRIPTION: Registro de todas las transacciones de wallet +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- Enum para tipo de transaccion +DO $$ BEGIN + CREATE TYPE financial.transaction_type AS ENUM ( + 'CREDIT_PURCHASE', -- Compra de creditos con tarjeta + 'AGENT_FUNDING', -- Fondeo a Money Manager + 'AGENT_WITHDRAWAL', -- Retiro de Money Manager + 'AGENT_PROFIT', -- Ganancia de Money Manager + 'AGENT_LOSS', -- Perdida de Money Manager + 'PRODUCT_PURCHASE', -- Compra de producto (one-time) + 'SUBSCRIPTION_CHARGE', -- Cargo de suscripcion + 'PREDICTION_PURCHASE', -- Compra de prediccion individual + 'REFUND', -- Devolucion + 'PROMO_CREDIT', -- Creditos promocionales agregados + 'PROMO_EXPIRY', -- Expiracion de creditos promo + 'ADJUSTMENT', -- Ajuste manual (admin) + 'TRANSFER_IN', -- Transferencia recibida + 'TRANSFER_OUT', -- Transferencia enviada + 'FEE' -- Comision de plataforma + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para estado de transaccion +DO $$ BEGIN + CREATE TYPE financial.transaction_status AS ENUM ( + 'pending', -- Pendiente de confirmacion + 'completed', -- Completada exitosamente + 'failed', -- Fallida + 'cancelled', -- Cancelada + 'reversed' -- Revertida + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de transacciones +CREATE TABLE IF NOT EXISTS financial.wallet_transactions ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE RESTRICT, + tenant_id UUID NOT NULL, + + -- Tipo y estado + type financial.transaction_type NOT NULL, + status financial.transaction_status NOT NULL DEFAULT 'completed', + + -- Montos + amount DECIMAL(15, 4) NOT NULL, + balance_before DECIMAL(15, 4) NOT NULL, + balance_after DECIMAL(15, 4) NOT NULL, + + -- Descripcion + description TEXT, + + -- Referencias a entidades relacionadas + reference_type VARCHAR(50), + reference_id UUID, + + -- Para compras de creditos (Stripe) + stripe_payment_intent_id VARCHAR(255), + stripe_charge_id VARCHAR(255), + payment_method VARCHAR(50), + + -- Para transferencias entre wallets + related_wallet_id UUID REFERENCES financial.wallets(id), + related_transaction_id UUID, + + -- Metadata adicional + metadata JSONB DEFAULT '{}', + + -- Auditoria + ip_address INET, + user_agent TEXT, + created_by UUID, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + + -- Indices parciales para busquedas frecuentes + CONSTRAINT valid_amount CHECK ( + (type IN ('CREDIT_PURCHASE', 'AGENT_PROFIT', 'PROMO_CREDIT', 'REFUND', 'TRANSFER_IN') AND amount > 0) + OR + (type IN ('AGENT_FUNDING', 'PRODUCT_PURCHASE', 'SUBSCRIPTION_CHARGE', 'PREDICTION_PURCHASE', 'PROMO_EXPIRY', 'TRANSFER_OUT', 'FEE', 'AGENT_LOSS') AND amount < 0) + OR + (type IN ('ADJUSTMENT', 'AGENT_WITHDRAWAL')) + ) +); + +COMMENT ON TABLE financial.wallet_transactions IS +'Registro inmutable de todas las transacciones de wallet. Cada operacion crea un registro.'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_wallet_tx_wallet_id + ON financial.wallet_transactions(wallet_id); + +CREATE INDEX IF NOT EXISTS idx_wallet_tx_tenant_id + ON financial.wallet_transactions(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_wallet_tx_type + ON financial.wallet_transactions(type); + +CREATE INDEX IF NOT EXISTS idx_wallet_tx_status + ON financial.wallet_transactions(status); + +CREATE INDEX IF NOT EXISTS idx_wallet_tx_created_at + ON financial.wallet_transactions(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_wallet_tx_reference + ON financial.wallet_transactions(reference_type, reference_id) + WHERE reference_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_wallet_tx_stripe + ON financial.wallet_transactions(stripe_payment_intent_id) + WHERE stripe_payment_intent_id IS NOT NULL; + +-- Funcion para crear transaccion y actualizar wallet atomicamente +CREATE OR REPLACE FUNCTION financial.create_wallet_transaction( + p_wallet_id UUID, + p_type financial.transaction_type, + p_amount DECIMAL(15, 4), + p_description TEXT DEFAULT NULL, + p_reference_type VARCHAR(50) DEFAULT NULL, + p_reference_id UUID DEFAULT NULL, + p_metadata JSONB DEFAULT '{}' +) +RETURNS UUID AS $$ +DECLARE + v_wallet financial.wallets%ROWTYPE; + v_transaction_id UUID; + v_new_balance DECIMAL(15, 4); +BEGIN + -- Lock wallet row + SELECT * INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Wallet not found: %', p_wallet_id; + END IF; + + IF v_wallet.status != 'active' THEN + RAISE EXCEPTION 'Wallet is not active: %', v_wallet.status; + END IF; + + -- Calcular nuevo balance + v_new_balance := v_wallet.balance + p_amount; + + -- Validar que no quede negativo + IF v_new_balance < 0 THEN + RAISE EXCEPTION 'Insufficient balance. Current: %, Required: %', + v_wallet.balance, ABS(p_amount); + END IF; + + -- Validar limites si es debito + IF p_amount < 0 THEN + IF ABS(p_amount) > v_wallet.single_transaction_limit THEN + RAISE EXCEPTION 'Exceeds single transaction limit: %', + v_wallet.single_transaction_limit; + END IF; + + IF v_wallet.daily_spent + ABS(p_amount) > v_wallet.daily_spend_limit THEN + RAISE EXCEPTION 'Exceeds daily spend limit: %', + v_wallet.daily_spend_limit; + END IF; + END IF; + + -- Crear transaccion + INSERT INTO financial.wallet_transactions ( + wallet_id, tenant_id, type, amount, + balance_before, balance_after, + description, reference_type, reference_id, metadata + ) VALUES ( + p_wallet_id, v_wallet.tenant_id, p_type, p_amount, + v_wallet.balance, v_new_balance, + p_description, p_reference_type, p_reference_id, p_metadata + ) RETURNING id INTO v_transaction_id; + + -- Actualizar wallet + UPDATE financial.wallets SET + balance = v_new_balance, + total_credited = CASE WHEN p_amount > 0 THEN total_credited + p_amount ELSE total_credited END, + total_debited = CASE WHEN p_amount < 0 THEN total_debited + ABS(p_amount) ELSE total_debited END, + daily_spent = CASE WHEN p_amount < 0 THEN daily_spent + ABS(p_amount) ELSE daily_spent END, + monthly_spent = CASE WHEN p_amount < 0 THEN monthly_spent + ABS(p_amount) ELSE monthly_spent END, + last_transaction_at = NOW() + WHERE id = p_wallet_id; + + RETURN v_transaction_id; +END; +$$ LANGUAGE plpgsql; + +-- RLS Policy +ALTER TABLE financial.wallet_transactions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY wallet_tx_tenant_isolation ON financial.wallet_transactions + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT ON financial.wallet_transactions TO trading_app; +GRANT SELECT ON financial.wallet_transactions TO trading_readonly; diff --git a/ddl/schemas/investment/001_agent_allocations.sql b/ddl/schemas/investment/001_agent_allocations.sql new file mode 100644 index 0000000..f187a53 --- /dev/null +++ b/ddl/schemas/investment/001_agent_allocations.sql @@ -0,0 +1,520 @@ +-- ============================================================================ +-- SCHEMA: investment +-- TABLES: agent_allocations, allocation_transactions, profit_distributions +-- DESCRIPTION: Sistema de fondeo de Money Manager Agents desde Wallet +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- DEPENDS: financial.wallets, agents (existing) +-- ============================================================================ + +-- Crear schema si no existe +CREATE SCHEMA IF NOT EXISTS investment; + +-- Enum para tipo de agente +DO $$ BEGIN + CREATE TYPE investment.agent_type AS ENUM ( + 'ATLAS', -- Conservador - bajo riesgo + 'ORION', -- Moderado - riesgo medio + 'NOVA' -- Agresivo - alto riesgo + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Enum para estado de allocation +DO $$ BEGIN + CREATE TYPE investment.allocation_status AS ENUM ( + 'pending', -- Esperando confirmacion + 'active', -- Activa y operando + 'paused', -- Pausada por usuario + 'liquidating', -- En proceso de liquidacion + 'closed' -- Cerrada + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Enum para tipo de transaccion de allocation +DO $$ BEGIN + CREATE TYPE investment.allocation_tx_type AS ENUM ( + 'INITIAL_FUNDING', -- Fondeo inicial + 'ADDITIONAL_FUNDING', -- Fondeo adicional + 'PARTIAL_WITHDRAWAL', -- Retiro parcial + 'FULL_WITHDRAWAL', -- Retiro total + 'PROFIT_REALIZED', -- Ganancia realizada + 'LOSS_REALIZED', -- Perdida realizada + 'FEE_CHARGED', -- Comision cobrada + 'REBALANCE' -- Rebalanceo + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Tabla principal de allocations (fondeo a agentes) +CREATE TABLE IF NOT EXISTS investment.agent_allocations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + wallet_id UUID NOT NULL, + + -- Agente + agent_type investment.agent_type NOT NULL, + agent_instance_id UUID, -- Referencia a instancia especifica del agente + + -- Montos + initial_amount DECIMAL(15, 4) NOT NULL CHECK (initial_amount > 0), + current_amount DECIMAL(15, 4) NOT NULL CHECK (current_amount >= 0), + total_deposited DECIMAL(15, 4) NOT NULL DEFAULT 0, + total_withdrawn DECIMAL(15, 4) NOT NULL DEFAULT 0, + + -- Performance + total_profit DECIMAL(15, 4) NOT NULL DEFAULT 0, + total_loss DECIMAL(15, 4) NOT NULL DEFAULT 0, + realized_pnl DECIMAL(15, 4) GENERATED ALWAYS AS (total_profit - total_loss) STORED, + unrealized_pnl DECIMAL(15, 4) DEFAULT 0, + + -- Metricas + roi_percentage DECIMAL(10, 4) DEFAULT 0, + max_drawdown DECIMAL(10, 4) DEFAULT 0, + win_rate DECIMAL(5, 2) DEFAULT 0, + total_trades INT DEFAULT 0, + winning_trades INT DEFAULT 0, + losing_trades INT DEFAULT 0, + + -- Configuracion + risk_level VARCHAR(20), -- LOW, MEDIUM, HIGH + auto_compound BOOLEAN DEFAULT FALSE, + compound_threshold DECIMAL(15, 4) DEFAULT 100, + + -- Limites + max_allocation DECIMAL(15, 4), + stop_loss_percentage DECIMAL(5, 2) DEFAULT 20, + take_profit_percentage DECIMAL(5, 2), + + -- Estado + status investment.allocation_status NOT NULL DEFAULT 'pending', + + -- Fechas importantes + activated_at TIMESTAMPTZ, + last_trade_at TIMESTAMPTZ, + paused_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Un usuario puede tener multiples allocations al mismo agente + -- pero con limites + CONSTRAINT positive_performance CHECK ( + total_deposited >= total_withdrawn + OR status IN ('closed', 'liquidating') + ) +); + +COMMENT ON TABLE investment.agent_allocations IS +'Fondeos de usuarios a Money Manager Agents (Atlas, Orion, Nova)'; + +-- Tabla de transacciones de allocation +CREATE TABLE IF NOT EXISTS investment.allocation_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + allocation_id UUID NOT NULL REFERENCES investment.agent_allocations(id), + tenant_id UUID NOT NULL, + + -- Tipo + type investment.allocation_tx_type NOT NULL, + + -- Montos + amount DECIMAL(15, 4) NOT NULL, + balance_before DECIMAL(15, 4) NOT NULL, + balance_after DECIMAL(15, 4) NOT NULL, + + -- Referencia a wallet transaction + wallet_transaction_id UUID, + + -- Para trades + trade_id UUID, + trade_symbol VARCHAR(20), + trade_pnl DECIMAL(15, 4), + + -- Descripcion + description TEXT, + + -- Metadata + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMENT ON TABLE investment.allocation_transactions IS +'Historial de todas las transacciones en allocations de agentes'; + +-- Tabla de distribuciones de profit +CREATE TABLE IF NOT EXISTS investment.profit_distributions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + allocation_id UUID NOT NULL REFERENCES investment.agent_allocations(id), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + + -- Periodo + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + + -- Montos + gross_profit DECIMAL(15, 4) NOT NULL, + platform_fee DECIMAL(15, 4) NOT NULL DEFAULT 0, + agent_fee DECIMAL(15, 4) NOT NULL DEFAULT 0, + net_profit DECIMAL(15, 4) NOT NULL, + + -- Fee percentages aplicados + platform_fee_rate DECIMAL(5, 4) DEFAULT 0.10, -- 10% + agent_fee_rate DECIMAL(5, 4) DEFAULT 0.20, -- 20% + + -- Distribucion + distributed_to_wallet BOOLEAN DEFAULT FALSE, + wallet_transaction_id UUID, + distributed_at TIMESTAMPTZ, + + -- Compounding + compounded_amount DECIMAL(15, 4) DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMENT ON TABLE investment.profit_distributions IS +'Distribuciones periodicas de ganancias de agentes a usuarios'; + +-- Tabla de configuracion de agentes por tenant +CREATE TABLE IF NOT EXISTS investment.agent_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + agent_type investment.agent_type NOT NULL, + + -- Limites + min_allocation DECIMAL(15, 4) DEFAULT 100, + max_allocation_per_user DECIMAL(15, 4) DEFAULT 10000, + max_total_allocation DECIMAL(15, 4) DEFAULT 1000000, + + -- Fees + platform_fee_rate DECIMAL(5, 4) DEFAULT 0.10, + performance_fee_rate DECIMAL(5, 4) DEFAULT 0.20, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + accepting_new_allocations BOOLEAN DEFAULT TRUE, + + -- Descripcion + description TEXT, + risk_disclosure TEXT, + + -- Stats agregados + total_users INT DEFAULT 0, + total_allocated DECIMAL(15, 4) DEFAULT 0, + total_profit_generated DECIMAL(15, 4) DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(tenant_id, agent_type) +); + +COMMENT ON TABLE investment.agent_configs IS +'Configuracion de Money Manager Agents por tenant'; + +-- Indices para agent_allocations +CREATE INDEX IF NOT EXISTS idx_alloc_tenant ON investment.agent_allocations(tenant_id); +CREATE INDEX IF NOT EXISTS idx_alloc_user ON investment.agent_allocations(user_id); +CREATE INDEX IF NOT EXISTS idx_alloc_wallet ON investment.agent_allocations(wallet_id); +CREATE INDEX IF NOT EXISTS idx_alloc_agent ON investment.agent_allocations(agent_type); +CREATE INDEX IF NOT EXISTS idx_alloc_status ON investment.agent_allocations(status); +CREATE INDEX IF NOT EXISTS idx_alloc_active ON investment.agent_allocations(status, agent_type) + WHERE status = 'active'; + +-- Indices para allocation_transactions +CREATE INDEX IF NOT EXISTS idx_alloc_tx_alloc ON investment.allocation_transactions(allocation_id); +CREATE INDEX IF NOT EXISTS idx_alloc_tx_tenant ON investment.allocation_transactions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_alloc_tx_type ON investment.allocation_transactions(type); +CREATE INDEX IF NOT EXISTS idx_alloc_tx_created ON investment.allocation_transactions(created_at DESC); + +-- Indices para profit_distributions +CREATE INDEX IF NOT EXISTS idx_profit_dist_alloc ON investment.profit_distributions(allocation_id); +CREATE INDEX IF NOT EXISTS idx_profit_dist_user ON investment.profit_distributions(user_id); +CREATE INDEX IF NOT EXISTS idx_profit_dist_period ON investment.profit_distributions(period_start, period_end); +CREATE INDEX IF NOT EXISTS idx_profit_dist_pending ON investment.profit_distributions(distributed_to_wallet) + WHERE distributed_to_wallet = FALSE; + +-- Trigger updated_at +CREATE OR REPLACE FUNCTION investment.update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS alloc_updated ON investment.agent_allocations; +CREATE TRIGGER alloc_updated + BEFORE UPDATE ON investment.agent_allocations + FOR EACH ROW + EXECUTE FUNCTION investment.update_timestamp(); + +DROP TRIGGER IF EXISTS agent_config_updated ON investment.agent_configs; +CREATE TRIGGER agent_config_updated + BEFORE UPDATE ON investment.agent_configs + FOR EACH ROW + EXECUTE FUNCTION investment.update_timestamp(); + +-- Funcion para crear allocation (fondear agente) +CREATE OR REPLACE FUNCTION investment.create_allocation( + p_tenant_id UUID, + p_user_id UUID, + p_wallet_id UUID, + p_agent_type investment.agent_type, + p_amount DECIMAL(15, 4), + p_auto_compound BOOLEAN DEFAULT FALSE +) +RETURNS UUID AS $$ +DECLARE + v_config investment.agent_configs%ROWTYPE; + v_allocation_id UUID; + v_wallet_tx_id UUID; +BEGIN + -- Get agent config + SELECT * INTO v_config + FROM investment.agent_configs + WHERE tenant_id = p_tenant_id + AND agent_type = p_agent_type; + + IF NOT FOUND OR NOT v_config.is_active THEN + RAISE EXCEPTION 'Agent % is not available', p_agent_type; + END IF; + + IF NOT v_config.accepting_new_allocations THEN + RAISE EXCEPTION 'Agent % is not accepting new allocations', p_agent_type; + END IF; + + IF p_amount < v_config.min_allocation THEN + RAISE EXCEPTION 'Minimum allocation is %', v_config.min_allocation; + END IF; + + IF p_amount > v_config.max_allocation_per_user THEN + RAISE EXCEPTION 'Maximum allocation per user is %', v_config.max_allocation_per_user; + END IF; + + -- Debit from wallet (this validates balance) + v_wallet_tx_id := financial.create_wallet_transaction( + p_wallet_id, + 'AGENT_FUNDING', + -p_amount, + 'Funding to ' || p_agent_type || ' agent', + 'agent_allocation', + NULL, + jsonb_build_object('agent_type', p_agent_type) + ); + + -- Create allocation + INSERT INTO investment.agent_allocations ( + tenant_id, user_id, wallet_id, agent_type, + initial_amount, current_amount, total_deposited, + auto_compound, risk_level, status + ) VALUES ( + p_tenant_id, p_user_id, p_wallet_id, p_agent_type, + p_amount, p_amount, p_amount, + p_auto_compound, + CASE p_agent_type + WHEN 'ATLAS' THEN 'LOW' + WHEN 'ORION' THEN 'MEDIUM' + WHEN 'NOVA' THEN 'HIGH' + END, + 'active' + ) RETURNING id INTO v_allocation_id; + + -- Update allocation with wallet tx reference + UPDATE investment.agent_allocations + SET activated_at = NOW(), + metadata = jsonb_build_object('initial_wallet_tx', v_wallet_tx_id) + WHERE id = v_allocation_id; + + -- Record allocation transaction + INSERT INTO investment.allocation_transactions ( + allocation_id, tenant_id, type, + amount, balance_before, balance_after, + wallet_transaction_id, description + ) VALUES ( + v_allocation_id, p_tenant_id, 'INITIAL_FUNDING', + p_amount, 0, p_amount, + v_wallet_tx_id, 'Initial funding' + ); + + -- Update agent config stats + UPDATE investment.agent_configs + SET total_users = total_users + 1, + total_allocated = total_allocated + p_amount + WHERE tenant_id = p_tenant_id AND agent_type = p_agent_type; + + RETURN v_allocation_id; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para retirar de allocation +CREATE OR REPLACE FUNCTION investment.withdraw_from_allocation( + p_allocation_id UUID, + p_amount DECIMAL(15, 4), + p_full_withdrawal BOOLEAN DEFAULT FALSE +) +RETURNS UUID AS $$ +DECLARE + v_alloc investment.agent_allocations%ROWTYPE; + v_wallet_tx_id UUID; + v_withdraw_amount DECIMAL(15, 4); + v_tx_type investment.allocation_tx_type; +BEGIN + -- Lock allocation + SELECT * INTO v_alloc + FROM investment.agent_allocations + WHERE id = p_allocation_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Allocation not found'; + END IF; + + IF v_alloc.status NOT IN ('active', 'paused') THEN + RAISE EXCEPTION 'Cannot withdraw from allocation in status %', v_alloc.status; + END IF; + + -- Determine withdrawal amount + IF p_full_withdrawal THEN + v_withdraw_amount := v_alloc.current_amount; + v_tx_type := 'FULL_WITHDRAWAL'; + ELSE + IF p_amount > v_alloc.current_amount THEN + RAISE EXCEPTION 'Insufficient balance. Available: %', v_alloc.current_amount; + END IF; + v_withdraw_amount := p_amount; + v_tx_type := 'PARTIAL_WITHDRAWAL'; + END IF; + + -- Credit to wallet + v_wallet_tx_id := financial.create_wallet_transaction( + v_alloc.wallet_id, + 'AGENT_WITHDRAWAL', + v_withdraw_amount, + 'Withdrawal from ' || v_alloc.agent_type || ' agent', + 'agent_allocation', + p_allocation_id, + jsonb_build_object('agent_type', v_alloc.agent_type) + ); + + -- Record transaction + INSERT INTO investment.allocation_transactions ( + allocation_id, tenant_id, type, + amount, balance_before, balance_after, + wallet_transaction_id, description + ) VALUES ( + p_allocation_id, v_alloc.tenant_id, v_tx_type, + -v_withdraw_amount, v_alloc.current_amount, + v_alloc.current_amount - v_withdraw_amount, + v_wallet_tx_id, + CASE WHEN p_full_withdrawal THEN 'Full withdrawal' ELSE 'Partial withdrawal' END + ); + + -- Update allocation + UPDATE investment.agent_allocations + SET current_amount = current_amount - v_withdraw_amount, + total_withdrawn = total_withdrawn + v_withdraw_amount, + status = CASE WHEN p_full_withdrawal THEN 'closed' ELSE status END, + closed_at = CASE WHEN p_full_withdrawal THEN NOW() ELSE closed_at END + WHERE id = p_allocation_id; + + -- Update agent config stats + UPDATE investment.agent_configs + SET total_allocated = total_allocated - v_withdraw_amount, + total_users = CASE WHEN p_full_withdrawal THEN total_users - 1 ELSE total_users END + WHERE tenant_id = v_alloc.tenant_id AND agent_type = v_alloc.agent_type; + + RETURN v_wallet_tx_id; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para registrar profit/loss de trade +CREATE OR REPLACE FUNCTION investment.record_trade_result( + p_allocation_id UUID, + p_trade_id UUID, + p_symbol VARCHAR(20), + p_pnl DECIMAL(15, 4), + p_is_win BOOLEAN +) +RETURNS VOID AS $$ +DECLARE + v_alloc investment.agent_allocations%ROWTYPE; + v_tx_type investment.allocation_tx_type; +BEGIN + SELECT * INTO v_alloc + FROM investment.agent_allocations + WHERE id = p_allocation_id + FOR UPDATE; + + v_tx_type := CASE WHEN p_pnl >= 0 THEN 'PROFIT_REALIZED' ELSE 'LOSS_REALIZED' END; + + -- Record transaction + INSERT INTO investment.allocation_transactions ( + allocation_id, tenant_id, type, + amount, balance_before, balance_after, + trade_id, trade_symbol, trade_pnl + ) VALUES ( + p_allocation_id, v_alloc.tenant_id, v_tx_type, + p_pnl, v_alloc.current_amount, v_alloc.current_amount + p_pnl, + p_trade_id, p_symbol, p_pnl + ); + + -- Update allocation + UPDATE investment.agent_allocations + SET current_amount = current_amount + p_pnl, + total_profit = CASE WHEN p_pnl > 0 THEN total_profit + p_pnl ELSE total_profit END, + total_loss = CASE WHEN p_pnl < 0 THEN total_loss + ABS(p_pnl) ELSE total_loss END, + total_trades = total_trades + 1, + winning_trades = CASE WHEN p_is_win THEN winning_trades + 1 ELSE winning_trades END, + losing_trades = CASE WHEN NOT p_is_win THEN losing_trades + 1 ELSE losing_trades END, + win_rate = CASE + WHEN total_trades > 0 THEN + (winning_trades + CASE WHEN p_is_win THEN 1 ELSE 0 END)::DECIMAL * 100 / + (total_trades + 1) + ELSE 0 + END, + roi_percentage = CASE + WHEN total_deposited > 0 THEN + ((current_amount + p_pnl - total_deposited + total_withdrawn) / total_deposited) * 100 + ELSE 0 + END, + last_trade_at = NOW() + WHERE id = p_allocation_id; +END; +$$ LANGUAGE plpgsql; + +-- RLS +ALTER TABLE investment.agent_allocations ENABLE ROW LEVEL SECURITY; +ALTER TABLE investment.allocation_transactions ENABLE ROW LEVEL SECURITY; +ALTER TABLE investment.profit_distributions ENABLE ROW LEVEL SECURITY; +ALTER TABLE investment.agent_configs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY alloc_tenant ON investment.agent_allocations + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY alloc_tx_tenant ON investment.allocation_transactions + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY profit_tenant ON investment.profit_distributions + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY config_tenant ON investment.agent_configs + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON investment.agent_allocations TO trading_app; +GRANT SELECT, INSERT ON investment.allocation_transactions TO trading_app; +GRANT SELECT, INSERT, UPDATE ON investment.profit_distributions TO trading_app; +GRANT SELECT, UPDATE ON investment.agent_configs TO trading_app; +GRANT SELECT ON investment.agent_allocations TO trading_readonly; +GRANT SELECT ON investment.allocation_transactions TO trading_readonly; +GRANT SELECT ON investment.profit_distributions TO trading_readonly; +GRANT SELECT ON investment.agent_configs TO trading_readonly; diff --git a/ddl/schemas/investment/002_add_columns.sql b/ddl/schemas/investment/002_add_columns.sql new file mode 100644 index 0000000..4776eec --- /dev/null +++ b/ddl/schemas/investment/002_add_columns.sql @@ -0,0 +1,77 @@ +-- ============================================================================ +-- SCHEMA: investment +-- DESCRIPTION: Agregar columnas faltantes a tablas existentes +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- DEPENDS: investment/001_agent_allocations.sql +-- ============================================================================ + +-- ============================================================================ +-- MODIFICACIONES A agent_configs +-- ============================================================================ + +-- Agregar columna name +ALTER TABLE investment.agent_configs + ADD COLUMN IF NOT EXISTS name VARCHAR(100) DEFAULT ''; + +-- Agregar columna target_return_percent +ALTER TABLE investment.agent_configs + ADD COLUMN IF NOT EXISTS target_return_percent DECIMAL(5, 2) DEFAULT 0; + +-- Agregar columna max_drawdown_percent +ALTER TABLE investment.agent_configs + ADD COLUMN IF NOT EXISTS max_drawdown_percent DECIMAL(5, 2) DEFAULT 0; + +-- Comentarios +COMMENT ON COLUMN investment.agent_configs.name IS 'Nombre descriptivo del agente'; +COMMENT ON COLUMN investment.agent_configs.target_return_percent IS 'Retorno objetivo anual en porcentaje'; +COMMENT ON COLUMN investment.agent_configs.max_drawdown_percent IS 'Drawdown máximo permitido en porcentaje'; + +-- ============================================================================ +-- MODIFICACIONES A agent_allocations +-- ============================================================================ + +-- Agregar columna total_profit_distributed +ALTER TABLE investment.agent_allocations + ADD COLUMN IF NOT EXISTS total_profit_distributed DECIMAL(15, 4) DEFAULT 0; + +-- Agregar columna total_fees_paid +ALTER TABLE investment.agent_allocations + ADD COLUMN IF NOT EXISTS total_fees_paid DECIMAL(15, 4) DEFAULT 0; + +-- Agregar columna lock_expires_at +ALTER TABLE investment.agent_allocations + ADD COLUMN IF NOT EXISTS lock_expires_at TIMESTAMPTZ; + +-- Comentarios +COMMENT ON COLUMN investment.agent_allocations.total_profit_distributed IS 'Total de ganancias distribuidas al usuario'; +COMMENT ON COLUMN investment.agent_allocations.total_fees_paid IS 'Total de comisiones pagadas'; +COMMENT ON COLUMN investment.agent_allocations.lock_expires_at IS 'Fecha de expiración del periodo de bloqueo'; + +-- ============================================================================ +-- MODIFICACIONES A ml.prediction_outcomes +-- ============================================================================ + +-- Agregar columna verification_source +ALTER TABLE ml.prediction_outcomes + ADD COLUMN IF NOT EXISTS verification_source VARCHAR(100); + +-- Agregar columna notes +ALTER TABLE ml.prediction_outcomes + ADD COLUMN IF NOT EXISTS notes TEXT; + +-- Comentarios +COMMENT ON COLUMN ml.prediction_outcomes.verification_source IS 'Fuente de verificación del resultado (manual, API, etc.)'; +COMMENT ON COLUMN ml.prediction_outcomes.notes IS 'Notas adicionales sobre el resultado'; + +-- ============================================================================ +-- INDICES ADICIONALES +-- ============================================================================ + +-- Indice para buscar allocations con lock activo +CREATE INDEX IF NOT EXISTS idx_alloc_lock ON investment.agent_allocations(lock_expires_at) + WHERE lock_expires_at IS NOT NULL; + +-- Indice para buscar outcomes por source +CREATE INDEX IF NOT EXISTS idx_pred_out_source ON ml.prediction_outcomes(verification_source) + WHERE verification_source IS NOT NULL; diff --git a/ddl/schemas/ml/001_predictions_marketplace.sql b/ddl/schemas/ml/001_predictions_marketplace.sql new file mode 100644 index 0000000..907d5b9 --- /dev/null +++ b/ddl/schemas/ml/001_predictions_marketplace.sql @@ -0,0 +1,365 @@ +-- ============================================================================ +-- SCHEMA: ml +-- TABLES: prediction_purchases, prediction_outcomes, prediction_packages +-- DESCRIPTION: Extensiones para marketplace de predicciones ML +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- DEPENDS: ml schema (existing), financial.wallets, products.products +-- ============================================================================ + +-- Asume que ml schema ya existe con predictions table + +-- Enum para estado de outcome +DO $$ BEGIN + CREATE TYPE ml.outcome_status AS ENUM ( + 'pending', -- Esperando resultado + 'win', -- Prediccion acertada + 'loss', -- Prediccion fallida + 'partial', -- Parcialmente correcta + 'cancelled', -- Cancelada (mercado cerrado, etc) + 'expired' -- Expirada sin resultado + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Enum para tipo de prediccion comprada +DO $$ BEGIN + CREATE TYPE ml.purchase_source AS ENUM ( + 'INDIVIDUAL', -- Compra individual + 'SUBSCRIPTION', -- Incluida en suscripcion + 'VIP_ACCESS', -- Acceso VIP + 'PACKAGE', -- Parte de un paquete + 'PROMO' -- Promocional/gratuita + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Tabla de paquetes de predicciones (productos vendibles) +CREATE TABLE IF NOT EXISTS ml.prediction_packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificacion + name VARCHAR(200) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + + -- Configuracion del paquete + model_id VARCHAR(100) NOT NULL, + model_name VARCHAR(200), + prediction_count INT NOT NULL CHECK (prediction_count > 0), + + -- Simbolos incluidos (NULL = todos disponibles) + included_symbols VARCHAR[] DEFAULT NULL, + + -- Timeframes incluidos + included_timeframes VARCHAR[] DEFAULT '{H1,H4,D1}', + + -- Precios + price DECIMAL(10, 2) NOT NULL CHECK (price >= 0), + compare_price DECIMAL(10, 2), + + -- Validez + validity_days INT DEFAULT 30, + + -- Stripe + stripe_product_id VARCHAR(255), + stripe_price_id VARCHAR(255), + + -- Display + badge VARCHAR(50), + is_popular BOOLEAN DEFAULT FALSE, + sort_order INT DEFAULT 0, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMENT ON TABLE ml.prediction_packages IS +'Paquetes de predicciones vendibles (ej: 10 predicciones AMD por $29)'; + +-- Tabla de compras de predicciones +CREATE TABLE IF NOT EXISTS ml.prediction_purchases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + + -- Origen de la compra + source ml.purchase_source NOT NULL, + + -- Referencias segun origen + package_id UUID REFERENCES ml.prediction_packages(id), + product_purchase_id UUID, -- Ref a products.purchases + vip_subscription_id UUID, -- Ref a vip.subscriptions + + -- Modelo y configuracion + model_id VARCHAR(100) NOT NULL, + model_name VARCHAR(200), + + -- Creditos de predicciones + predictions_total INT NOT NULL CHECK (predictions_total > 0), + predictions_used INT NOT NULL DEFAULT 0 CHECK (predictions_used >= 0), + predictions_remaining INT GENERATED ALWAYS AS (predictions_total - predictions_used) STORED, + + -- Pago + amount_paid DECIMAL(10, 2) NOT NULL DEFAULT 0, + wallet_transaction_id UUID, + + -- Validez + valid_from TIMESTAMPTZ DEFAULT NOW(), + valid_until TIMESTAMPTZ, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + -- Metadata + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT valid_predictions CHECK (predictions_used <= predictions_total) +); + +COMMENT ON TABLE ml.prediction_purchases IS +'Registro de compras/accesos a predicciones por usuario'; + +-- Tabla de predicciones generadas y sus resultados +CREATE TABLE IF NOT EXISTS ml.prediction_outcomes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + + -- Referencia a la compra + purchase_id UUID NOT NULL REFERENCES ml.prediction_purchases(id), + + -- Referencia a la prediccion original (si existe en ml.predictions) + prediction_id UUID, + + -- Datos de la prediccion + model_id VARCHAR(100) NOT NULL, + symbol VARCHAR(20) NOT NULL, + timeframe VARCHAR(10) NOT NULL, + + -- Prediccion + predicted_direction VARCHAR(10) NOT NULL, -- BUY, SELL, NEUTRAL + predicted_price DECIMAL(20, 8), + confidence DECIMAL(5, 4) CHECK (confidence >= 0 AND confidence <= 1), + + -- Niveles predichos + entry_price DECIMAL(20, 8), + take_profit DECIMAL(20, 8), + stop_loss DECIMAL(20, 8), + + -- Resultado + outcome_status ml.outcome_status NOT NULL DEFAULT 'pending', + actual_direction VARCHAR(10), + actual_price DECIMAL(20, 8), + + -- Metricas de resultado + pips_result DECIMAL(10, 2), + percentage_result DECIMAL(10, 4), + hit_tp BOOLEAN, + hit_sl BOOLEAN, + + -- Tiempos + predicted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + target_time TIMESTAMPTZ, + resolved_at TIMESTAMPTZ, + + -- Metadata adicional del modelo + model_metadata JSONB DEFAULT '{}', + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMENT ON TABLE ml.prediction_outcomes IS +'Historial de predicciones generadas con sus resultados para validacion'; + +-- Indices para prediction_packages +CREATE INDEX IF NOT EXISTS idx_pred_pkg_model ON ml.prediction_packages(model_id); +CREATE INDEX IF NOT EXISTS idx_pred_pkg_active ON ml.prediction_packages(is_active) WHERE is_active = TRUE; +CREATE INDEX IF NOT EXISTS idx_pred_pkg_slug ON ml.prediction_packages(slug); + +-- Indices para prediction_purchases +CREATE INDEX IF NOT EXISTS idx_pred_purch_tenant ON ml.prediction_purchases(tenant_id); +CREATE INDEX IF NOT EXISTS idx_pred_purch_user ON ml.prediction_purchases(user_id); +CREATE INDEX IF NOT EXISTS idx_pred_purch_model ON ml.prediction_purchases(model_id); +CREATE INDEX IF NOT EXISTS idx_pred_purch_active ON ml.prediction_purchases(is_active, valid_until) + WHERE is_active = TRUE; +CREATE INDEX IF NOT EXISTS idx_pred_purch_source ON ml.prediction_purchases(source); + +-- Indices para prediction_outcomes +CREATE INDEX IF NOT EXISTS idx_pred_out_tenant ON ml.prediction_outcomes(tenant_id); +CREATE INDEX IF NOT EXISTS idx_pred_out_user ON ml.prediction_outcomes(user_id); +CREATE INDEX IF NOT EXISTS idx_pred_out_purchase ON ml.prediction_outcomes(purchase_id); +CREATE INDEX IF NOT EXISTS idx_pred_out_status ON ml.prediction_outcomes(outcome_status); +CREATE INDEX IF NOT EXISTS idx_pred_out_symbol ON ml.prediction_outcomes(symbol); +CREATE INDEX IF NOT EXISTS idx_pred_out_predicted ON ml.prediction_outcomes(predicted_at DESC); +CREATE INDEX IF NOT EXISTS idx_pred_out_model ON ml.prediction_outcomes(model_id); + +-- Trigger updated_at para packages +CREATE OR REPLACE FUNCTION ml.update_pkg_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS pred_pkg_updated ON ml.prediction_packages; +CREATE TRIGGER pred_pkg_updated + BEFORE UPDATE ON ml.prediction_packages + FOR EACH ROW + EXECUTE FUNCTION ml.update_pkg_timestamp(); + +-- Funcion para consumir una prediccion +CREATE OR REPLACE FUNCTION ml.consume_prediction( + p_purchase_id UUID, + p_symbol VARCHAR(20), + p_timeframe VARCHAR(10), + p_direction VARCHAR(10), + p_confidence DECIMAL(5, 4), + p_entry DECIMAL(20, 8) DEFAULT NULL, + p_tp DECIMAL(20, 8) DEFAULT NULL, + p_sl DECIMAL(20, 8) DEFAULT NULL, + p_target_time TIMESTAMPTZ DEFAULT NULL, + p_metadata JSONB DEFAULT '{}' +) +RETURNS UUID AS $$ +DECLARE + v_purchase ml.prediction_purchases%ROWTYPE; + v_outcome_id UUID; +BEGIN + -- Lock and get purchase + SELECT * INTO v_purchase + FROM ml.prediction_purchases + WHERE id = p_purchase_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Purchase not found: %', p_purchase_id; + END IF; + + IF NOT v_purchase.is_active THEN + RAISE EXCEPTION 'Purchase is not active'; + END IF; + + IF v_purchase.valid_until IS NOT NULL AND v_purchase.valid_until < NOW() THEN + RAISE EXCEPTION 'Purchase has expired'; + END IF; + + IF v_purchase.predictions_used >= v_purchase.predictions_total THEN + RAISE EXCEPTION 'No predictions remaining in this purchase'; + END IF; + + -- Create outcome record + INSERT INTO ml.prediction_outcomes ( + tenant_id, user_id, purchase_id, model_id, + symbol, timeframe, predicted_direction, confidence, + entry_price, take_profit, stop_loss, target_time, + model_metadata + ) VALUES ( + v_purchase.tenant_id, v_purchase.user_id, p_purchase_id, v_purchase.model_id, + p_symbol, p_timeframe, p_direction, p_confidence, + p_entry, p_tp, p_sl, p_target_time, + p_metadata + ) RETURNING id INTO v_outcome_id; + + -- Update purchase counter + UPDATE ml.prediction_purchases + SET predictions_used = predictions_used + 1 + WHERE id = p_purchase_id; + + RETURN v_outcome_id; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para resolver outcome +CREATE OR REPLACE FUNCTION ml.resolve_prediction_outcome( + p_outcome_id UUID, + p_status ml.outcome_status, + p_actual_direction VARCHAR(10) DEFAULT NULL, + p_actual_price DECIMAL(20, 8) DEFAULT NULL, + p_pips DECIMAL(10, 2) DEFAULT NULL, + p_percentage DECIMAL(10, 4) DEFAULT NULL, + p_hit_tp BOOLEAN DEFAULT NULL, + p_hit_sl BOOLEAN DEFAULT NULL +) +RETURNS VOID AS $$ +BEGIN + UPDATE ml.prediction_outcomes SET + outcome_status = p_status, + actual_direction = p_actual_direction, + actual_price = p_actual_price, + pips_result = p_pips, + percentage_result = p_percentage, + hit_tp = p_hit_tp, + hit_sl = p_hit_sl, + resolved_at = NOW() + WHERE id = p_outcome_id; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para obtener estadisticas de predicciones por usuario +CREATE OR REPLACE FUNCTION ml.get_user_prediction_stats( + p_user_id UUID, + p_model_id VARCHAR(100) DEFAULT NULL +) +RETURNS TABLE ( + total_predictions BIGINT, + wins BIGINT, + losses BIGINT, + pending BIGINT, + win_rate DECIMAL(5, 2), + avg_pips DECIMAL(10, 2), + avg_confidence DECIMAL(5, 4) +) AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*)::BIGINT as total_predictions, + COUNT(*) FILTER (WHERE outcome_status = 'win')::BIGINT as wins, + COUNT(*) FILTER (WHERE outcome_status = 'loss')::BIGINT as losses, + COUNT(*) FILTER (WHERE outcome_status = 'pending')::BIGINT as pending, + CASE + WHEN COUNT(*) FILTER (WHERE outcome_status IN ('win', 'loss')) > 0 + THEN ROUND( + COUNT(*) FILTER (WHERE outcome_status = 'win')::DECIMAL * 100 / + COUNT(*) FILTER (WHERE outcome_status IN ('win', 'loss')), + 2 + ) + ELSE 0 + END as win_rate, + ROUND(AVG(pips_result) FILTER (WHERE pips_result IS NOT NULL), 2) as avg_pips, + ROUND(AVG(confidence), 4) as avg_confidence + FROM ml.prediction_outcomes + WHERE user_id = p_user_id + AND (p_model_id IS NULL OR model_id = p_model_id); +END; +$$ LANGUAGE plpgsql; + +-- RLS +ALTER TABLE ml.prediction_packages ENABLE ROW LEVEL SECURITY; +ALTER TABLE ml.prediction_purchases ENABLE ROW LEVEL SECURITY; +ALTER TABLE ml.prediction_outcomes ENABLE ROW LEVEL SECURITY; + +-- Packages visibles para todos +CREATE POLICY pred_pkg_read_all ON ml.prediction_packages + FOR SELECT USING (TRUE); + +-- Purchases por tenant +CREATE POLICY pred_purch_tenant ON ml.prediction_purchases + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Outcomes por tenant +CREATE POLICY pred_out_tenant ON ml.prediction_outcomes + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT ON ml.prediction_packages TO trading_app; +GRANT SELECT, INSERT, UPDATE ON ml.prediction_purchases TO trading_app; +GRANT SELECT, INSERT, UPDATE ON ml.prediction_outcomes TO trading_app; +GRANT SELECT ON ml.prediction_packages TO trading_readonly; +GRANT SELECT ON ml.prediction_purchases TO trading_readonly; +GRANT SELECT ON ml.prediction_outcomes TO trading_readonly; diff --git a/ddl/schemas/ml/002_predictions.sql b/ddl/schemas/ml/002_predictions.sql new file mode 100644 index 0000000..bc6f138 --- /dev/null +++ b/ddl/schemas/ml/002_predictions.sql @@ -0,0 +1,188 @@ +-- ============================================================================ +-- SCHEMA: ml +-- TABLE: predictions +-- DESCRIPTION: Tabla de predicciones individuales generadas +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- DEPENDS: ml.prediction_purchases +-- ============================================================================ + +-- Enum para tipo de prediccion +DO $$ BEGIN + CREATE TYPE ml.prediction_type AS ENUM ( + 'AMD', -- Accumulation, Manipulation, Distribution + 'RANGE', -- Range prediction + 'TPSL', -- Take Profit / Stop Loss + 'ICT_SMC', -- ICT Smart Money Concepts + 'STRATEGY_ENSEMBLE' -- Combined strategies + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Enum para clase de activo +DO $$ BEGIN + CREATE TYPE ml.asset_class AS ENUM ( + 'FOREX', + 'CRYPTO', + 'INDICES', + 'COMMODITIES' + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Enum para estado de prediccion +DO $$ BEGIN + CREATE TYPE ml.prediction_status AS ENUM ( + 'pending', -- Generada, esperando entrega + 'delivered', -- Entregada al usuario + 'expired', -- Expirada sin validar + 'validated', -- Validada con resultado + 'invalidated' -- Invalidada por error + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Tabla de predicciones individuales +CREATE TABLE IF NOT EXISTS ml.predictions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + + -- Referencia a la compra + purchase_id UUID NOT NULL REFERENCES ml.prediction_purchases(id), + + -- Tipo y activo + prediction_type ml.prediction_type NOT NULL, + asset VARCHAR(20) NOT NULL, + asset_class ml.asset_class NOT NULL, + timeframe VARCHAR(10) NOT NULL, + + -- Predicción + direction VARCHAR(10) NOT NULL CHECK (direction IN ('LONG', 'SHORT', 'NEUTRAL')), + entry_price DECIMAL(20, 8), + target_price DECIMAL(20, 8), + stop_loss DECIMAL(20, 8), + confidence DECIMAL(5, 4) CHECK (confidence >= 0 AND confidence <= 1), + + -- Estado + status ml.prediction_status NOT NULL DEFAULT 'pending', + expires_at TIMESTAMPTZ NOT NULL, + delivered_at TIMESTAMPTZ, + + -- Datos adicionales + prediction_data JSONB DEFAULT '{}', + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMENT ON TABLE ml.predictions IS +'Predicciones individuales generadas por modelos ML'; + +COMMENT ON COLUMN ml.predictions.prediction_type IS 'Tipo de modelo usado para la prediccion'; +COMMENT ON COLUMN ml.predictions.asset_class IS 'Clase de activo (FOREX, CRYPTO, etc.)'; +COMMENT ON COLUMN ml.predictions.status IS 'Estado actual de la prediccion'; +COMMENT ON COLUMN ml.predictions.confidence IS 'Nivel de confianza del modelo (0-1)'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_pred_tenant ON ml.predictions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_pred_user ON ml.predictions(user_id); +CREATE INDEX IF NOT EXISTS idx_pred_purchase ON ml.predictions(purchase_id); +CREATE INDEX IF NOT EXISTS idx_pred_status ON ml.predictions(status); +CREATE INDEX IF NOT EXISTS idx_pred_type ON ml.predictions(prediction_type); +CREATE INDEX IF NOT EXISTS idx_pred_asset ON ml.predictions(asset); +CREATE INDEX IF NOT EXISTS idx_pred_asset_class ON ml.predictions(asset_class); +CREATE INDEX IF NOT EXISTS idx_pred_created ON ml.predictions(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_pred_expires ON ml.predictions(expires_at) + WHERE status = 'pending'; + +-- Indice compuesto para busquedas comunes +CREATE INDEX IF NOT EXISTS idx_pred_user_status ON ml.predictions(user_id, status, created_at DESC); + +-- RLS +ALTER TABLE ml.predictions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY pred_tenant ON ml.predictions + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON ml.predictions TO trading_app; +GRANT SELECT ON ml.predictions TO trading_readonly; + +-- Actualizar FK en prediction_outcomes para referenciar predictions +DO $$ +BEGIN + ALTER TABLE ml.prediction_outcomes + DROP CONSTRAINT IF EXISTS prediction_outcomes_prediction_id_fkey; +EXCEPTION WHEN undefined_object THEN null; +END $$; + +ALTER TABLE ml.prediction_outcomes + ADD CONSTRAINT prediction_outcomes_prediction_id_fkey + FOREIGN KEY (prediction_id) REFERENCES ml.predictions(id) + ON DELETE SET NULL; + +-- Funcion para generar prediccion desde un purchase +CREATE OR REPLACE FUNCTION ml.generate_prediction( + p_purchase_id UUID, + p_prediction_type ml.prediction_type, + p_asset VARCHAR(20), + p_asset_class ml.asset_class, + p_timeframe VARCHAR(10), + p_direction VARCHAR(10), + p_entry DECIMAL(20, 8) DEFAULT NULL, + p_target DECIMAL(20, 8) DEFAULT NULL, + p_sl DECIMAL(20, 8) DEFAULT NULL, + p_confidence DECIMAL(5, 4) DEFAULT 0.5, + p_expires_hours INT DEFAULT 24, + p_prediction_data JSONB DEFAULT '{}' +) +RETURNS UUID AS $$ +DECLARE + v_purchase ml.prediction_purchases%ROWTYPE; + v_prediction_id UUID; +BEGIN + -- Validar purchase + SELECT * INTO v_purchase + FROM ml.prediction_purchases + WHERE id = p_purchase_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Purchase not found: %', p_purchase_id; + END IF; + + IF NOT v_purchase.is_active THEN + RAISE EXCEPTION 'Purchase is not active'; + END IF; + + IF v_purchase.valid_until IS NOT NULL AND v_purchase.valid_until < NOW() THEN + RAISE EXCEPTION 'Purchase has expired'; + END IF; + + IF v_purchase.predictions_used >= v_purchase.predictions_total THEN + RAISE EXCEPTION 'No predictions remaining'; + END IF; + + -- Crear prediccion + INSERT INTO ml.predictions ( + tenant_id, user_id, purchase_id, + prediction_type, asset, asset_class, timeframe, + direction, entry_price, target_price, stop_loss, confidence, + expires_at, prediction_data + ) VALUES ( + v_purchase.tenant_id, v_purchase.user_id, p_purchase_id, + p_prediction_type, p_asset, p_asset_class, p_timeframe, + p_direction, p_entry, p_target, p_sl, p_confidence, + NOW() + (p_expires_hours || ' hours')::INTERVAL, + p_prediction_data + ) RETURNING id INTO v_prediction_id; + + -- Incrementar contador + UPDATE ml.prediction_purchases + SET predictions_used = predictions_used + 1 + WHERE id = p_purchase_id; + + RETURN v_prediction_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION ml.generate_prediction IS +'Genera una nueva prediccion desde un purchase, validando limites y estado'; diff --git a/ddl/schemas/products/001_products.sql b/ddl/schemas/products/001_products.sql new file mode 100644 index 0000000..f230c03 --- /dev/null +++ b/ddl/schemas/products/001_products.sql @@ -0,0 +1,206 @@ +-- ============================================================================ +-- SCHEMA: products +-- TABLE: products, categories, purchases +-- DESCRIPTION: Sistema de productos y servicios del marketplace +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- Crear schema +CREATE SCHEMA IF NOT EXISTS products; + +-- Enums +DO $$ BEGIN + CREATE TYPE products.product_type AS ENUM ( + 'ONE_TIME', -- Pago unico + 'SUBSCRIPTION', -- Suscripcion recurrente + 'VIP' -- Producto VIP exclusivo + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +DO $$ BEGIN + CREATE TYPE products.product_category AS ENUM ( + 'PREDICTION', -- Predicciones ML + 'EDUCATION', -- Cursos y materiales + 'CONSULTING', -- Asesoria y coaching + 'AGENT_ACCESS', -- Acceso a Money Managers + 'SIGNAL_PACK', -- Paquetes de senales + 'API_ACCESS', -- Acceso a API + 'PREMIUM_FEATURE' -- Features premium + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +DO $$ BEGIN + CREATE TYPE products.billing_interval AS ENUM ( + 'DAY', 'WEEK', 'MONTH', 'YEAR' + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +DO $$ BEGIN + CREATE TYPE products.purchase_type AS ENUM ( + 'WALLET', -- 100% wallet + 'STRIPE', -- 100% tarjeta + 'COMBINED' -- Wallet + tarjeta + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +DO $$ BEGIN + CREATE TYPE products.delivery_status AS ENUM ( + 'pending', 'processing', 'delivered', 'failed', 'refunded' + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Tabla de productos +CREATE TABLE IF NOT EXISTS products.products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificacion + sku VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + short_description VARCHAR(500), + + -- Categorizacion + type products.product_type NOT NULL, + category products.product_category NOT NULL, + + -- Precios (en creditos = USD equivalente) + price DECIMAL(10, 2) NOT NULL CHECK (price >= 0), + compare_price DECIMAL(10, 2), + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + + -- Para suscripciones + billing_interval products.billing_interval, + billing_interval_count INT DEFAULT 1, + trial_days INT DEFAULT 0, + + -- Features y limites + features JSONB NOT NULL DEFAULT '[]', + limits JSONB NOT NULL DEFAULT '{}', + + -- Asociacion con ML (para productos de prediccion) + ml_model_id VARCHAR(100), + prediction_type VARCHAR(50), + prediction_count INT, + + -- Asociacion con Agentes + agent_type VARCHAR(20), + + -- Stripe + stripe_product_id VARCHAR(255), + stripe_price_id VARCHAR(255), + + -- Display + image_url VARCHAR(500), + badge VARCHAR(50), + sort_order INT DEFAULT 0, + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_vip BOOLEAN NOT NULL DEFAULT FALSE, + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + + -- Disponibilidad + available_from TIMESTAMPTZ, + available_until TIMESTAMPTZ, + stock INT, + + -- Metadata + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE products.products IS +'Catalogo de productos y servicios disponibles en la plataforma'; + +-- Tabla de compras +CREATE TABLE IF NOT EXISTS products.purchases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + product_id UUID NOT NULL REFERENCES products.products(id), + + -- Tipo de compra + purchase_type products.purchase_type NOT NULL, + + -- Montos + product_price DECIMAL(10, 2) NOT NULL, + discount_amount DECIMAL(10, 2) DEFAULT 0, + final_price DECIMAL(10, 2) NOT NULL, + + -- Desglose de pago + wallet_amount DECIMAL(10, 2) DEFAULT 0, + stripe_amount DECIMAL(10, 2) DEFAULT 0, + + -- Referencias + wallet_transaction_id UUID, + stripe_payment_intent_id VARCHAR(255), + + -- Entrega + delivery_status products.delivery_status NOT NULL DEFAULT 'pending', + delivered_at TIMESTAMPTZ, + delivery_data JSONB DEFAULT '{}', + + -- Para predicciones compradas + prediction_ids UUID[], + + -- Codigo de descuento + discount_code VARCHAR(50), + + -- Metadata + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE products.purchases IS +'Registro de todas las compras realizadas por usuarios'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_products_type ON products.products(type); +CREATE INDEX IF NOT EXISTS idx_products_category ON products.products(category); +CREATE INDEX IF NOT EXISTS idx_products_active ON products.products(is_active) WHERE is_active = TRUE; +CREATE INDEX IF NOT EXISTS idx_products_featured ON products.products(is_featured) WHERE is_featured = TRUE; +CREATE INDEX IF NOT EXISTS idx_products_vip ON products.products(is_vip) WHERE is_vip = TRUE; +CREATE INDEX IF NOT EXISTS idx_products_slug ON products.products(slug); + +CREATE INDEX IF NOT EXISTS idx_purchases_tenant ON products.purchases(tenant_id); +CREATE INDEX IF NOT EXISTS idx_purchases_user ON products.purchases(user_id); +CREATE INDEX IF NOT EXISTS idx_purchases_product ON products.purchases(product_id); +CREATE INDEX IF NOT EXISTS idx_purchases_status ON products.purchases(delivery_status); +CREATE INDEX IF NOT EXISTS idx_purchases_created ON products.purchases(created_at DESC); + +-- Trigger updated_at +CREATE OR REPLACE FUNCTION products.update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS products_updated_at ON products.products; +CREATE TRIGGER products_updated_at + BEFORE UPDATE ON products.products + FOR EACH ROW + EXECUTE FUNCTION products.update_timestamp(); + +-- RLS +ALTER TABLE products.products ENABLE ROW LEVEL SECURITY; +ALTER TABLE products.purchases ENABLE ROW LEVEL SECURITY; + +-- Products visible para todos los activos +CREATE POLICY products_read_active ON products.products + FOR SELECT + USING (is_active = TRUE); + +CREATE POLICY purchases_tenant_isolation ON products.purchases + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT ON products.products TO trading_app; +GRANT SELECT, INSERT ON products.purchases TO trading_app; +GRANT SELECT ON products.products TO trading_readonly; +GRANT SELECT ON products.purchases TO trading_readonly; diff --git a/ddl/schemas/rbac/tables/001_roles.sql b/ddl/schemas/rbac/tables/001_roles.sql new file mode 100644 index 0000000..45d20c5 --- /dev/null +++ b/ddl/schemas/rbac/tables/001_roles.sql @@ -0,0 +1,176 @@ +-- ============================================================================ +-- RBAC Schema: Roles Table +-- Role-Based Access Control for Trading Platform SaaS +-- ============================================================================ + +-- Create RBAC schema if not exists +CREATE SCHEMA IF NOT EXISTS rbac; + +-- Grant usage +GRANT USAGE ON SCHEMA rbac TO trading_user; + +-- ============================================================================ +-- ROLES TABLE +-- Defines roles within a tenant organization +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS rbac.roles ( + -- Primary key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Tenant relationship (multi-tenancy) + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Role information + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT, + + -- Role type: system (predefined) or custom (tenant-created) + role_type VARCHAR(20) NOT NULL DEFAULT 'custom' + CHECK (role_type IN ('system', 'custom')), + + -- Hierarchy level (lower = more permissions, 0 = super admin) + hierarchy_level INTEGER NOT NULL DEFAULT 100, + + -- Status + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Role settings (JSON for extensibility) + settings JSONB NOT NULL DEFAULT '{}'::jsonb, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES users.users(id), + updated_by UUID REFERENCES users.users(id), + + -- Constraints + CONSTRAINT uq_roles_tenant_slug UNIQUE (tenant_id, slug), + CONSTRAINT uq_roles_tenant_name UNIQUE (tenant_id, name) +); + +-- ============================================================================ +-- INDEXES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_roles_tenant_id ON rbac.roles(tenant_id); +CREATE INDEX IF NOT EXISTS idx_roles_slug ON rbac.roles(slug); +CREATE INDEX IF NOT EXISTS idx_roles_role_type ON rbac.roles(role_type); +CREATE INDEX IF NOT EXISTS idx_roles_is_active ON rbac.roles(is_active); +CREATE INDEX IF NOT EXISTS idx_roles_hierarchy ON rbac.roles(tenant_id, hierarchy_level); + +-- ============================================================================ +-- ROW LEVEL SECURITY +-- ============================================================================ + +ALTER TABLE rbac.roles ENABLE ROW LEVEL SECURITY; + +-- Policy: Users can only see roles in their tenant +CREATE POLICY roles_tenant_isolation ON rbac.roles + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +-- Auto-update updated_at timestamp +CREATE OR REPLACE FUNCTION rbac.update_roles_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_roles_updated_at + BEFORE UPDATE ON rbac.roles + FOR EACH ROW + EXECUTE FUNCTION rbac.update_roles_timestamp(); + +-- ============================================================================ +-- DEFAULT SYSTEM ROLES (inserted per tenant) +-- These will be created when a new tenant is created +-- ============================================================================ + +-- Function to create default roles for a tenant +CREATE OR REPLACE FUNCTION rbac.create_default_roles(p_tenant_id UUID, p_owner_id UUID) +RETURNS void AS $$ +BEGIN + -- Owner role (highest level) + INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by) + VALUES ( + p_tenant_id, + 'Owner', + 'owner', + 'Full access to all features and settings. Can manage billing and delete organization.', + 'system', + 0, + p_owner_id + ); + + -- Admin role + INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by) + VALUES ( + p_tenant_id, + 'Admin', + 'admin', + 'Administrative access. Can manage users, roles, and most settings.', + 'system', + 10, + p_owner_id + ); + + -- Manager role + INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by) + VALUES ( + p_tenant_id, + 'Manager', + 'manager', + 'Can manage team members and view reports.', + 'system', + 50, + p_owner_id + ); + + -- Member role + INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by) + VALUES ( + p_tenant_id, + 'Member', + 'member', + 'Standard user access. Can use platform features.', + 'system', + 100, + p_owner_id + ); + + -- Viewer role (read-only) + INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by) + VALUES ( + p_tenant_id, + 'Viewer', + 'viewer', + 'Read-only access. Can view but not modify.', + 'system', + 200, + p_owner_id + ); +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- GRANTS +-- ============================================================================ + +GRANT SELECT, INSERT, UPDATE, DELETE ON rbac.roles TO trading_user; + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +COMMENT ON TABLE rbac.roles IS 'Roles for Role-Based Access Control within tenant organizations'; +COMMENT ON COLUMN rbac.roles.role_type IS 'system = predefined roles, custom = tenant-created roles'; +COMMENT ON COLUMN rbac.roles.hierarchy_level IS 'Lower value = higher permissions. Owner=0, Admin=10, etc.'; +COMMENT ON COLUMN rbac.roles.settings IS 'JSON settings for role customization'; diff --git a/ddl/schemas/rbac/tables/002_permissions.sql b/ddl/schemas/rbac/tables/002_permissions.sql new file mode 100644 index 0000000..68b9d01 --- /dev/null +++ b/ddl/schemas/rbac/tables/002_permissions.sql @@ -0,0 +1,160 @@ +-- ============================================================================ +-- RBAC Schema: Permissions Table +-- Granular permissions for Trading Platform SaaS +-- ============================================================================ + +-- ============================================================================ +-- PERMISSIONS TABLE +-- Defines available permissions in the system +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS rbac.permissions ( + -- Primary key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Permission identification + code VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(200) NOT NULL, + description TEXT, + + -- Categorization + module VARCHAR(50) NOT NULL, + category VARCHAR(50) NOT NULL, + + -- Permission type + action VARCHAR(20) NOT NULL + CHECK (action IN ('create', 'read', 'update', 'delete', 'manage', 'execute')), + + -- Resource this permission applies to + resource VARCHAR(100) NOT NULL, + + -- Is this a system permission (cannot be deleted) + is_system BOOLEAN NOT NULL DEFAULT true, + + -- Status + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================ +-- INDEXES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_permissions_code ON rbac.permissions(code); +CREATE INDEX IF NOT EXISTS idx_permissions_module ON rbac.permissions(module); +CREATE INDEX IF NOT EXISTS idx_permissions_category ON rbac.permissions(category); +CREATE INDEX IF NOT EXISTS idx_permissions_resource ON rbac.permissions(resource); +CREATE INDEX IF NOT EXISTS idx_permissions_is_active ON rbac.permissions(is_active); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE OR REPLACE FUNCTION rbac.update_permissions_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_permissions_updated_at + BEFORE UPDATE ON rbac.permissions + FOR EACH ROW + EXECUTE FUNCTION rbac.update_permissions_timestamp(); + +-- ============================================================================ +-- DEFAULT PERMISSIONS +-- ============================================================================ + +-- Organization/Tenant permissions +INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES +('org:read', 'View Organization', 'View organization details and settings', 'organization', 'general', 'read', 'organization'), +('org:update', 'Update Organization', 'Update organization settings', 'organization', 'general', 'update', 'organization'), +('org:delete', 'Delete Organization', 'Delete the organization', 'organization', 'general', 'delete', 'organization'), +('org:billing:manage', 'Manage Billing', 'Manage billing and subscriptions', 'organization', 'billing', 'manage', 'billing') +ON CONFLICT (code) DO NOTHING; + +-- User management permissions +INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES +('users:read', 'View Users', 'View user list and details', 'users', 'management', 'read', 'users'), +('users:create', 'Create Users', 'Create new users', 'users', 'management', 'create', 'users'), +('users:update', 'Update Users', 'Update user information', 'users', 'management', 'update', 'users'), +('users:delete', 'Delete Users', 'Delete or deactivate users', 'users', 'management', 'delete', 'users'), +('users:invite', 'Invite Users', 'Send invitations to new users', 'users', 'management', 'execute', 'invitations') +ON CONFLICT (code) DO NOTHING; + +-- Role management permissions +INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES +('roles:read', 'View Roles', 'View roles and permissions', 'rbac', 'management', 'read', 'roles'), +('roles:create', 'Create Roles', 'Create custom roles', 'rbac', 'management', 'create', 'roles'), +('roles:update', 'Update Roles', 'Update role permissions', 'rbac', 'management', 'update', 'roles'), +('roles:delete', 'Delete Roles', 'Delete custom roles', 'rbac', 'management', 'delete', 'roles'), +('roles:assign', 'Assign Roles', 'Assign roles to users', 'rbac', 'management', 'execute', 'role_assignments') +ON CONFLICT (code) DO NOTHING; + +-- Wallet permissions +INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES +('wallet:read', 'View Wallet', 'View wallet balance and transactions', 'wallet', 'finance', 'read', 'wallet'), +('wallet:deposit', 'Deposit Credits', 'Add credits to wallet', 'wallet', 'finance', 'execute', 'deposits'), +('wallet:withdraw', 'Withdraw Credits', 'Withdraw credits from wallet', 'wallet', 'finance', 'execute', 'withdrawals'), +('wallet:transfer', 'Transfer Credits', 'Transfer credits between wallets', 'wallet', 'finance', 'execute', 'transfers') +ON CONFLICT (code) DO NOTHING; + +-- Products/Marketplace permissions +INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES +('products:read', 'View Products', 'View marketplace products', 'products', 'marketplace', 'read', 'products'), +('products:purchase', 'Purchase Products', 'Purchase products from marketplace', 'products', 'marketplace', 'execute', 'purchases'), +('products:create', 'Create Products', 'Create new products (for sellers)', 'products', 'marketplace', 'create', 'products'), +('products:update', 'Update Products', 'Update product information', 'products', 'marketplace', 'update', 'products'), +('products:delete', 'Delete Products', 'Delete products', 'products', 'marketplace', 'delete', 'products') +ON CONFLICT (code) DO NOTHING; + +-- VIP/Subscription permissions +INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES +('vip:read', 'View VIP Plans', 'View VIP subscription plans', 'vip', 'subscriptions', 'read', 'vip_plans'), +('vip:subscribe', 'Subscribe to VIP', 'Subscribe to VIP plans', 'vip', 'subscriptions', 'execute', 'subscriptions'), +('vip:manage', 'Manage VIP Plans', 'Create and manage VIP plans', 'vip', 'subscriptions', 'manage', 'vip_plans') +ON CONFLICT (code) DO NOTHING; + +-- Investment/Agents permissions +INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES +('agents:read', 'View Agents', 'View trading agents', 'investment', 'trading', 'read', 'agents'), +('agents:allocate', 'Allocate to Agents', 'Allocate funds to agents', 'investment', 'trading', 'execute', 'allocations'), +('agents:manage', 'Manage Agents', 'Create and configure agents', 'investment', 'trading', 'manage', 'agents') +ON CONFLICT (code) DO NOTHING; + +-- Predictions permissions +INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES +('predictions:read', 'View Predictions', 'View predictions and packages', 'predictions', 'analytics', 'read', 'predictions'), +('predictions:purchase', 'Purchase Predictions', 'Purchase prediction packages', 'predictions', 'analytics', 'execute', 'purchases'), +('predictions:create', 'Create Predictions', 'Create prediction packages', 'predictions', 'analytics', 'create', 'predictions') +ON CONFLICT (code) DO NOTHING; + +-- Audit/Reports permissions +INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES +('audit:read', 'View Audit Logs', 'View audit trail and logs', 'audit', 'compliance', 'read', 'audit_logs'), +('reports:read', 'View Reports', 'View analytics and reports', 'reports', 'analytics', 'read', 'reports'), +('reports:export', 'Export Reports', 'Export reports and data', 'reports', 'analytics', 'execute', 'exports') +ON CONFLICT (code) DO NOTHING; + +-- ============================================================================ +-- GRANTS +-- ============================================================================ + +GRANT SELECT ON rbac.permissions TO trading_user; +-- Only admins should be able to modify permissions +GRANT INSERT, UPDATE, DELETE ON rbac.permissions TO trading_admin; + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +COMMENT ON TABLE rbac.permissions IS 'System-wide permissions for RBAC'; +COMMENT ON COLUMN rbac.permissions.code IS 'Unique permission code in format module:action or module:resource:action'; +COMMENT ON COLUMN rbac.permissions.module IS 'Feature module this permission belongs to'; +COMMENT ON COLUMN rbac.permissions.action IS 'CRUD action or special action (manage, execute)'; +COMMENT ON COLUMN rbac.permissions.resource IS 'Resource this permission applies to'; diff --git a/ddl/schemas/rbac/tables/003_role_permissions.sql b/ddl/schemas/rbac/tables/003_role_permissions.sql new file mode 100644 index 0000000..4f17d8a --- /dev/null +++ b/ddl/schemas/rbac/tables/003_role_permissions.sql @@ -0,0 +1,180 @@ +-- ============================================================================ +-- RBAC Schema: Role Permissions Table +-- Maps permissions to roles +-- ============================================================================ + +-- ============================================================================ +-- ROLE_PERMISSIONS TABLE +-- Junction table linking roles to their permissions +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS rbac.role_permissions ( + -- Primary key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Role reference + role_id UUID NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE, + + -- Permission reference + permission_id UUID NOT NULL REFERENCES rbac.permissions(id) ON DELETE CASCADE, + + -- Grant type: allow or deny (for permission inheritance override) + grant_type VARCHAR(10) NOT NULL DEFAULT 'allow' + CHECK (grant_type IN ('allow', 'deny')), + + -- Conditions for permission (JSON for field-level or conditional access) + conditions JSONB DEFAULT NULL, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES users.users(id), + + -- Constraints + CONSTRAINT uq_role_permission UNIQUE (role_id, permission_id) +); + +-- ============================================================================ +-- INDEXES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON rbac.role_permissions(role_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON rbac.role_permissions(permission_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_grant_type ON rbac.role_permissions(grant_type); + +-- ============================================================================ +-- FUNCTION: Assign default permissions to system roles +-- ============================================================================ + +CREATE OR REPLACE FUNCTION rbac.assign_default_role_permissions(p_tenant_id UUID) +RETURNS void AS $$ +DECLARE + v_owner_role_id UUID; + v_admin_role_id UUID; + v_manager_role_id UUID; + v_member_role_id UUID; + v_viewer_role_id UUID; + v_perm RECORD; +BEGIN + -- Get role IDs + SELECT id INTO v_owner_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'owner'; + SELECT id INTO v_admin_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'admin'; + SELECT id INTO v_manager_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'manager'; + SELECT id INTO v_member_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'member'; + SELECT id INTO v_viewer_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'viewer'; + + -- Owner gets ALL permissions + FOR v_perm IN SELECT id FROM rbac.permissions WHERE is_active = true + LOOP + INSERT INTO rbac.role_permissions (role_id, permission_id) + VALUES (v_owner_role_id, v_perm.id) + ON CONFLICT (role_id, permission_id) DO NOTHING; + END LOOP; + + -- Admin gets all except org:delete and org:billing:manage + FOR v_perm IN + SELECT id FROM rbac.permissions + WHERE is_active = true + AND code NOT IN ('org:delete', 'org:billing:manage') + LOOP + INSERT INTO rbac.role_permissions (role_id, permission_id) + VALUES (v_admin_role_id, v_perm.id) + ON CONFLICT (role_id, permission_id) DO NOTHING; + END LOOP; + + -- Manager gets user viewing, role viewing, and standard features + FOR v_perm IN + SELECT id FROM rbac.permissions + WHERE is_active = true + AND code IN ( + 'org:read', + 'users:read', 'users:invite', + 'roles:read', + 'wallet:read', 'wallet:deposit', 'wallet:withdraw', + 'products:read', 'products:purchase', + 'vip:read', 'vip:subscribe', + 'agents:read', 'agents:allocate', + 'predictions:read', 'predictions:purchase', + 'reports:read' + ) + LOOP + INSERT INTO rbac.role_permissions (role_id, permission_id) + VALUES (v_manager_role_id, v_perm.id) + ON CONFLICT (role_id, permission_id) DO NOTHING; + END LOOP; + + -- Member gets standard user features + FOR v_perm IN + SELECT id FROM rbac.permissions + WHERE is_active = true + AND code IN ( + 'org:read', + 'wallet:read', 'wallet:deposit', 'wallet:withdraw', + 'products:read', 'products:purchase', + 'vip:read', 'vip:subscribe', + 'agents:read', 'agents:allocate', + 'predictions:read', 'predictions:purchase' + ) + LOOP + INSERT INTO rbac.role_permissions (role_id, permission_id) + VALUES (v_member_role_id, v_perm.id) + ON CONFLICT (role_id, permission_id) DO NOTHING; + END LOOP; + + -- Viewer gets read-only access + FOR v_perm IN + SELECT id FROM rbac.permissions + WHERE is_active = true + AND code IN ( + 'org:read', + 'wallet:read', + 'products:read', + 'vip:read', + 'agents:read', + 'predictions:read' + ) + LOOP + INSERT INTO rbac.role_permissions (role_id, permission_id) + VALUES (v_viewer_role_id, v_perm.id) + ON CONFLICT (role_id, permission_id) DO NOTHING; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- VIEW: Role with permissions +-- ============================================================================ + +CREATE OR REPLACE VIEW rbac.v_role_permissions AS +SELECT + r.id AS role_id, + r.tenant_id, + r.name AS role_name, + r.slug AS role_slug, + r.hierarchy_level, + p.id AS permission_id, + p.code AS permission_code, + p.name AS permission_name, + p.module, + p.action, + p.resource, + rp.grant_type, + rp.conditions +FROM rbac.roles r +JOIN rbac.role_permissions rp ON r.id = rp.role_id +JOIN rbac.permissions p ON rp.permission_id = p.id +WHERE r.is_active = true AND p.is_active = true; + +-- ============================================================================ +-- GRANTS +-- ============================================================================ + +GRANT SELECT, INSERT, DELETE ON rbac.role_permissions TO trading_user; +GRANT SELECT ON rbac.v_role_permissions TO trading_user; + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +COMMENT ON TABLE rbac.role_permissions IS 'Junction table mapping permissions to roles'; +COMMENT ON COLUMN rbac.role_permissions.grant_type IS 'allow = grants permission, deny = explicitly denies (for override)'; +COMMENT ON COLUMN rbac.role_permissions.conditions IS 'JSON conditions for field-level or conditional access control'; diff --git a/ddl/schemas/rbac/tables/004_user_roles.sql b/ddl/schemas/rbac/tables/004_user_roles.sql new file mode 100644 index 0000000..a5b2b2f --- /dev/null +++ b/ddl/schemas/rbac/tables/004_user_roles.sql @@ -0,0 +1,288 @@ +-- ============================================================================ +-- RBAC Schema: User Roles Table +-- Assigns roles to users within a tenant +-- ============================================================================ + +-- ============================================================================ +-- USER_ROLES TABLE +-- Maps users to their assigned roles +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS rbac.user_roles ( + -- Primary key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- User reference + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + + -- Role reference + role_id UUID NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE, + + -- Tenant (denormalized for RLS efficiency) + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Assignment metadata + is_primary BOOLEAN NOT NULL DEFAULT false, + assigned_reason TEXT, + + -- Validity period (optional for temporary roles) + valid_from TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + valid_until TIMESTAMPTZ DEFAULT NULL, + + -- Status + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + assigned_by UUID REFERENCES users.users(id), + revoked_at TIMESTAMPTZ DEFAULT NULL, + revoked_by UUID REFERENCES users.users(id), + + -- Constraints + CONSTRAINT uq_user_role UNIQUE (user_id, role_id), + CONSTRAINT chk_valid_period CHECK (valid_until IS NULL OR valid_until > valid_from) +); + +-- ============================================================================ +-- INDEXES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON rbac.user_roles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON rbac.user_roles(role_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_tenant_id ON rbac.user_roles(tenant_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_is_active ON rbac.user_roles(is_active); +CREATE INDEX IF NOT EXISTS idx_user_roles_is_primary ON rbac.user_roles(user_id, is_primary) WHERE is_primary = true; +CREATE INDEX IF NOT EXISTS idx_user_roles_validity ON rbac.user_roles(valid_from, valid_until) WHERE is_active = true; + +-- ============================================================================ +-- ROW LEVEL SECURITY +-- ============================================================================ + +ALTER TABLE rbac.user_roles ENABLE ROW LEVEL SECURITY; + +-- Policy: Users can only see role assignments in their tenant +CREATE POLICY user_roles_tenant_isolation ON rbac.user_roles + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +-- Auto-update updated_at timestamp +CREATE OR REPLACE FUNCTION rbac.update_user_roles_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_user_roles_updated_at + BEFORE UPDATE ON rbac.user_roles + FOR EACH ROW + EXECUTE FUNCTION rbac.update_user_roles_timestamp(); + +-- Ensure only one primary role per user +CREATE OR REPLACE FUNCTION rbac.ensure_single_primary_role() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_primary = true THEN + UPDATE rbac.user_roles + SET is_primary = false + WHERE user_id = NEW.user_id + AND id != NEW.id + AND is_primary = true; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_single_primary_role + BEFORE INSERT OR UPDATE ON rbac.user_roles + FOR EACH ROW + WHEN (NEW.is_primary = true) + EXECUTE FUNCTION rbac.ensure_single_primary_role(); + +-- ============================================================================ +-- VIEW: User with roles and permissions +-- ============================================================================ + +CREATE OR REPLACE VIEW rbac.v_user_permissions AS +SELECT DISTINCT + ur.user_id, + ur.tenant_id, + u.email, + u.display_name, + r.id AS role_id, + r.name AS role_name, + r.slug AS role_slug, + r.hierarchy_level, + ur.is_primary, + p.code AS permission_code, + p.module, + p.action, + p.resource, + rp.grant_type +FROM rbac.user_roles ur +JOIN users.users u ON ur.user_id = u.id +JOIN rbac.roles r ON ur.role_id = r.id +JOIN rbac.role_permissions rp ON r.id = rp.role_id +JOIN rbac.permissions p ON rp.permission_id = p.id +WHERE ur.is_active = true + AND r.is_active = true + AND p.is_active = true + AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP) + AND ur.valid_from <= CURRENT_TIMESTAMP; + +-- ============================================================================ +-- FUNCTION: Check if user has permission +-- ============================================================================ + +CREATE OR REPLACE FUNCTION rbac.user_has_permission( + p_user_id UUID, + p_tenant_id UUID, + p_permission_code VARCHAR(100) +) +RETURNS BOOLEAN AS $$ +DECLARE + v_has_allow BOOLEAN; + v_has_deny BOOLEAN; +BEGIN + -- Check for explicit deny first + SELECT EXISTS ( + SELECT 1 + FROM rbac.v_user_permissions + WHERE user_id = p_user_id + AND tenant_id = p_tenant_id + AND permission_code = p_permission_code + AND grant_type = 'deny' + ) INTO v_has_deny; + + IF v_has_deny THEN + RETURN false; + END IF; + + -- Check for allow + SELECT EXISTS ( + SELECT 1 + FROM rbac.v_user_permissions + WHERE user_id = p_user_id + AND tenant_id = p_tenant_id + AND permission_code = p_permission_code + AND grant_type = 'allow' + ) INTO v_has_allow; + + RETURN v_has_allow; +END; +$$ LANGUAGE plpgsql STABLE; + +-- ============================================================================ +-- FUNCTION: Get user's effective permissions +-- ============================================================================ + +CREATE OR REPLACE FUNCTION rbac.get_user_permissions( + p_user_id UUID, + p_tenant_id UUID +) +RETURNS TABLE ( + permission_code VARCHAR(100), + permission_name VARCHAR(200), + module VARCHAR(50), + action VARCHAR(20), + resource VARCHAR(100) +) AS $$ +BEGIN + RETURN QUERY + SELECT DISTINCT + p.code, + p.name, + p.module, + p.action, + p.resource + FROM rbac.user_roles ur + JOIN rbac.roles r ON ur.role_id = r.id + JOIN rbac.role_permissions rp ON r.id = rp.role_id + JOIN rbac.permissions p ON rp.permission_id = p.id + WHERE ur.user_id = p_user_id + AND ur.tenant_id = p_tenant_id + AND ur.is_active = true + AND r.is_active = true + AND p.is_active = true + AND rp.grant_type = 'allow' + AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP) + AND ur.valid_from <= CURRENT_TIMESTAMP + AND NOT EXISTS ( + -- Exclude denied permissions + SELECT 1 + FROM rbac.user_roles ur2 + JOIN rbac.role_permissions rp2 ON ur2.role_id = rp2.role_id + WHERE ur2.user_id = p_user_id + AND ur2.tenant_id = p_tenant_id + AND rp2.permission_id = p.id + AND rp2.grant_type = 'deny' + AND ur2.is_active = true + ) + ORDER BY p.module, p.code; +END; +$$ LANGUAGE plpgsql STABLE; + +-- ============================================================================ +-- FUNCTION: Assign role to user +-- ============================================================================ + +CREATE OR REPLACE FUNCTION rbac.assign_role_to_user( + p_user_id UUID, + p_role_id UUID, + p_tenant_id UUID, + p_assigned_by UUID, + p_is_primary BOOLEAN DEFAULT false, + p_valid_until TIMESTAMPTZ DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_assignment_id UUID; +BEGIN + INSERT INTO rbac.user_roles ( + user_id, role_id, tenant_id, is_primary, + valid_until, assigned_by + ) VALUES ( + p_user_id, p_role_id, p_tenant_id, p_is_primary, + p_valid_until, p_assigned_by + ) + ON CONFLICT (user_id, role_id) DO UPDATE SET + is_active = true, + is_primary = EXCLUDED.is_primary, + valid_until = EXCLUDED.valid_until, + assigned_by = EXCLUDED.assigned_by, + revoked_at = NULL, + revoked_by = NULL, + updated_at = CURRENT_TIMESTAMP + RETURNING id INTO v_assignment_id; + + RETURN v_assignment_id; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- GRANTS +-- ============================================================================ + +GRANT SELECT, INSERT, UPDATE, DELETE ON rbac.user_roles TO trading_user; +GRANT SELECT ON rbac.v_user_permissions TO trading_user; +GRANT EXECUTE ON FUNCTION rbac.user_has_permission TO trading_user; +GRANT EXECUTE ON FUNCTION rbac.get_user_permissions TO trading_user; +GRANT EXECUTE ON FUNCTION rbac.assign_role_to_user TO trading_user; + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +COMMENT ON TABLE rbac.user_roles IS 'Maps users to their assigned roles within a tenant'; +COMMENT ON COLUMN rbac.user_roles.is_primary IS 'Primary role shown in UI, user can have multiple roles'; +COMMENT ON COLUMN rbac.user_roles.valid_from IS 'When the role assignment becomes active'; +COMMENT ON COLUMN rbac.user_roles.valid_until IS 'When the role assignment expires (NULL = never)'; +COMMENT ON VIEW rbac.v_user_permissions IS 'Flattened view of users with their effective permissions'; +COMMENT ON FUNCTION rbac.user_has_permission IS 'Check if a user has a specific permission'; +COMMENT ON FUNCTION rbac.get_user_permissions IS 'Get all effective permissions for a user'; diff --git a/ddl/schemas/teams/tables/001_team_members.sql b/ddl/schemas/teams/tables/001_team_members.sql new file mode 100644 index 0000000..3169a52 --- /dev/null +++ b/ddl/schemas/teams/tables/001_team_members.sql @@ -0,0 +1,222 @@ +-- ============================================================================ +-- Teams Schema: Team Members Table +-- Manages team/organization membership +-- ============================================================================ + +-- Create teams schema if not exists +CREATE SCHEMA IF NOT EXISTS teams; + +-- Grant usage +GRANT USAGE ON SCHEMA teams TO trading_user; + +-- ============================================================================ +-- TEAM_MEMBERS TABLE +-- Tracks membership status and details for users in a tenant +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS teams.team_members ( + -- Primary key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Tenant relationship + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- User relationship + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + + -- Membership status + status VARCHAR(20) NOT NULL DEFAULT 'active' + CHECK (status IN ('pending', 'active', 'suspended', 'removed')), + + -- Member type + member_type VARCHAR(20) NOT NULL DEFAULT 'member' + CHECK (member_type IN ('owner', 'admin', 'member', 'guest')), + + -- Join information + joined_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by UUID REFERENCES users.users(id), + invitation_id UUID, -- Reference to invitation (if joined via invite) + + -- Department/Team within organization + department VARCHAR(100), + job_title VARCHAR(100), + + -- Member settings + settings JSONB NOT NULL DEFAULT '{}'::jsonb, + + -- Notification preferences for team + notifications_enabled BOOLEAN NOT NULL DEFAULT true, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + removed_at TIMESTAMPTZ, + removed_by UUID REFERENCES users.users(id), + removal_reason TEXT, + + -- Constraints + CONSTRAINT uq_team_member UNIQUE (tenant_id, user_id) +); + +-- ============================================================================ +-- INDEXES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_team_members_tenant_id ON teams.team_members(tenant_id); +CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON teams.team_members(user_id); +CREATE INDEX IF NOT EXISTS idx_team_members_status ON teams.team_members(status); +CREATE INDEX IF NOT EXISTS idx_team_members_member_type ON teams.team_members(member_type); +CREATE INDEX IF NOT EXISTS idx_team_members_department ON teams.team_members(tenant_id, department); +CREATE INDEX IF NOT EXISTS idx_team_members_joined_at ON teams.team_members(joined_at); + +-- ============================================================================ +-- ROW LEVEL SECURITY +-- ============================================================================ + +ALTER TABLE teams.team_members ENABLE ROW LEVEL SECURITY; + +-- Policy: Users can only see members in their tenant +CREATE POLICY team_members_tenant_isolation ON teams.team_members + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +-- Auto-update updated_at timestamp +CREATE OR REPLACE FUNCTION teams.update_team_members_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_team_members_updated_at + BEFORE UPDATE ON teams.team_members + FOR EACH ROW + EXECUTE FUNCTION teams.update_team_members_timestamp(); + +-- ============================================================================ +-- VIEW: Team members with user details +-- ============================================================================ + +CREATE OR REPLACE VIEW teams.v_team_members AS +SELECT + tm.id, + tm.tenant_id, + tm.user_id, + u.email, + u.first_name, + u.last_name, + u.display_name, + u.avatar_url, + u.status AS user_status, + tm.status AS membership_status, + tm.member_type, + tm.department, + tm.job_title, + tm.joined_at, + tm.notifications_enabled, + inviter.email AS invited_by_email, + inviter.display_name AS invited_by_name, + -- Get primary role + ( + SELECT r.name + FROM rbac.user_roles ur + JOIN rbac.roles r ON ur.role_id = r.id + WHERE ur.user_id = tm.user_id + AND ur.tenant_id = tm.tenant_id + AND ur.is_primary = true + AND ur.is_active = true + LIMIT 1 + ) AS primary_role +FROM teams.team_members tm +JOIN users.users u ON tm.user_id = u.id +LEFT JOIN users.users inviter ON tm.invited_by = inviter.id +WHERE tm.status != 'removed'; + +-- ============================================================================ +-- FUNCTION: Add user to team +-- ============================================================================ + +CREATE OR REPLACE FUNCTION teams.add_team_member( + p_tenant_id UUID, + p_user_id UUID, + p_member_type VARCHAR(20) DEFAULT 'member', + p_invited_by UUID DEFAULT NULL, + p_invitation_id UUID DEFAULT NULL, + p_department VARCHAR(100) DEFAULT NULL, + p_job_title VARCHAR(100) DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_member_id UUID; +BEGIN + INSERT INTO teams.team_members ( + tenant_id, user_id, member_type, invited_by, + invitation_id, department, job_title, status + ) VALUES ( + p_tenant_id, p_user_id, p_member_type, p_invited_by, + p_invitation_id, p_department, p_job_title, 'active' + ) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET + status = 'active', + member_type = EXCLUDED.member_type, + department = COALESCE(EXCLUDED.department, teams.team_members.department), + job_title = COALESCE(EXCLUDED.job_title, teams.team_members.job_title), + removed_at = NULL, + removed_by = NULL, + removal_reason = NULL, + updated_at = CURRENT_TIMESTAMP + RETURNING id INTO v_member_id; + + RETURN v_member_id; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- FUNCTION: Remove user from team +-- ============================================================================ + +CREATE OR REPLACE FUNCTION teams.remove_team_member( + p_tenant_id UUID, + p_user_id UUID, + p_removed_by UUID, + p_reason TEXT DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +BEGIN + UPDATE teams.team_members + SET + status = 'removed', + removed_at = CURRENT_TIMESTAMP, + removed_by = p_removed_by, + removal_reason = p_reason, + updated_at = CURRENT_TIMESTAMP + WHERE tenant_id = p_tenant_id + AND user_id = p_user_id + AND status = 'active'; + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- GRANTS +-- ============================================================================ + +GRANT SELECT, INSERT, UPDATE, DELETE ON teams.team_members TO trading_user; +GRANT SELECT ON teams.v_team_members TO trading_user; +GRANT EXECUTE ON FUNCTION teams.add_team_member TO trading_user; +GRANT EXECUTE ON FUNCTION teams.remove_team_member TO trading_user; + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +COMMENT ON TABLE teams.team_members IS 'Tracks team/organization membership for users'; +COMMENT ON COLUMN teams.team_members.member_type IS 'Type of membership: owner, admin, member, guest'; +COMMENT ON COLUMN teams.team_members.settings IS 'JSON settings for member-specific preferences'; +COMMENT ON VIEW teams.v_team_members IS 'Team members with user details and primary role'; diff --git a/ddl/schemas/teams/tables/002_invitations.sql b/ddl/schemas/teams/tables/002_invitations.sql new file mode 100644 index 0000000..e201a07 --- /dev/null +++ b/ddl/schemas/teams/tables/002_invitations.sql @@ -0,0 +1,371 @@ +-- ============================================================================ +-- Teams Schema: Invitations Table +-- Manages team/organization invitations +-- ============================================================================ + +-- ============================================================================ +-- INVITATIONS TABLE +-- Stores pending and processed invitations +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS teams.invitations ( + -- Primary key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Tenant relationship + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Invitation details + email VARCHAR(255) NOT NULL, + token_hash VARCHAR(255) NOT NULL, + + -- Invitee info (pre-filled for registration) + first_name VARCHAR(100), + last_name VARCHAR(100), + + -- Role to assign upon acceptance + role_id UUID REFERENCES rbac.roles(id) ON DELETE SET NULL, + + -- Department/position + department VARCHAR(100), + job_title VARCHAR(100), + + -- Invitation message + personal_message TEXT, + + -- Status + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'declined', 'expired', 'revoked')), + + -- Expiration + expires_at TIMESTAMPTZ NOT NULL, + + -- Tracking + sent_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + resent_count INTEGER NOT NULL DEFAULT 0, + last_resent_at TIMESTAMPTZ, + + -- Response tracking + responded_at TIMESTAMPTZ, + accepted_user_id UUID REFERENCES users.users(id), + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by UUID NOT NULL REFERENCES users.users(id), + revoked_at TIMESTAMPTZ, + revoked_by UUID REFERENCES users.users(id), + + -- Constraints + CONSTRAINT uq_invitation_token UNIQUE (token_hash), + CONSTRAINT uq_pending_invitation UNIQUE (tenant_id, email, status) + WHERE status = 'pending' +); + +-- ============================================================================ +-- INDEXES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_invitations_tenant_id ON teams.invitations(tenant_id); +CREATE INDEX IF NOT EXISTS idx_invitations_email ON teams.invitations(email); +CREATE INDEX IF NOT EXISTS idx_invitations_token_hash ON teams.invitations(token_hash); +CREATE INDEX IF NOT EXISTS idx_invitations_status ON teams.invitations(status); +CREATE INDEX IF NOT EXISTS idx_invitations_expires_at ON teams.invitations(expires_at) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_invitations_invited_by ON teams.invitations(invited_by); + +-- ============================================================================ +-- ROW LEVEL SECURITY +-- ============================================================================ + +ALTER TABLE teams.invitations ENABLE ROW LEVEL SECURITY; + +-- Policy: Users can only see invitations in their tenant +CREATE POLICY invitations_tenant_isolation ON teams.invitations + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +-- Auto-update updated_at timestamp +CREATE OR REPLACE FUNCTION teams.update_invitations_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_invitations_updated_at + BEFORE UPDATE ON teams.invitations + FOR EACH ROW + EXECUTE FUNCTION teams.update_invitations_timestamp(); + +-- ============================================================================ +-- VIEW: Invitations with details +-- ============================================================================ + +CREATE OR REPLACE VIEW teams.v_invitations AS +SELECT + i.id, + i.tenant_id, + t.name AS tenant_name, + i.email, + i.first_name, + i.last_name, + i.role_id, + r.name AS role_name, + i.department, + i.job_title, + i.personal_message, + i.status, + i.expires_at, + i.sent_at, + i.resent_count, + i.last_resent_at, + i.responded_at, + i.created_at, + i.invited_by, + inviter.email AS invited_by_email, + inviter.display_name AS invited_by_name, + CASE + WHEN i.status = 'pending' AND i.expires_at < CURRENT_TIMESTAMP THEN true + ELSE false + END AS is_expired +FROM teams.invitations i +JOIN tenants.tenants t ON i.tenant_id = t.id +LEFT JOIN rbac.roles r ON i.role_id = r.id +LEFT JOIN users.users inviter ON i.invited_by = inviter.id; + +-- ============================================================================ +-- FUNCTION: Create invitation +-- ============================================================================ + +CREATE OR REPLACE FUNCTION teams.create_invitation( + p_tenant_id UUID, + p_email VARCHAR(255), + p_invited_by UUID, + p_role_id UUID DEFAULT NULL, + p_first_name VARCHAR(100) DEFAULT NULL, + p_last_name VARCHAR(100) DEFAULT NULL, + p_department VARCHAR(100) DEFAULT NULL, + p_job_title VARCHAR(100) DEFAULT NULL, + p_personal_message TEXT DEFAULT NULL, + p_expires_in_days INTEGER DEFAULT 7 +) +RETURNS TABLE ( + invitation_id UUID, + token VARCHAR(64) +) AS $$ +DECLARE + v_token VARCHAR(64); + v_token_hash VARCHAR(255); + v_invitation_id UUID; +BEGIN + -- Generate random token + v_token := encode(gen_random_bytes(32), 'hex'); + v_token_hash := encode(sha256(v_token::bytea), 'hex'); + + -- Revoke any existing pending invitations for this email + UPDATE teams.invitations + SET status = 'revoked', + revoked_at = CURRENT_TIMESTAMP, + revoked_by = p_invited_by + WHERE tenant_id = p_tenant_id + AND email = p_email + AND status = 'pending'; + + -- Create new invitation + INSERT INTO teams.invitations ( + tenant_id, email, token_hash, role_id, + first_name, last_name, department, job_title, + personal_message, expires_at, invited_by + ) VALUES ( + p_tenant_id, p_email, v_token_hash, p_role_id, + p_first_name, p_last_name, p_department, p_job_title, + p_personal_message, + CURRENT_TIMESTAMP + (p_expires_in_days || ' days')::interval, + p_invited_by + ) + RETURNING id INTO v_invitation_id; + + RETURN QUERY SELECT v_invitation_id, v_token; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- FUNCTION: Accept invitation +-- ============================================================================ + +CREATE OR REPLACE FUNCTION teams.accept_invitation( + p_token VARCHAR(64), + p_user_id UUID +) +RETURNS TABLE ( + success BOOLEAN, + tenant_id UUID, + role_id UUID, + message TEXT +) AS $$ +DECLARE + v_invitation teams.invitations%ROWTYPE; + v_token_hash VARCHAR(255); +BEGIN + v_token_hash := encode(sha256(p_token::bytea), 'hex'); + + -- Find invitation + SELECT * INTO v_invitation + FROM teams.invitations + WHERE token_hash = v_token_hash; + + -- Check if invitation exists + IF NOT FOUND THEN + RETURN QUERY SELECT false, NULL::UUID, NULL::UUID, 'Invalid invitation token'; + RETURN; + END IF; + + -- Check if already processed + IF v_invitation.status != 'pending' THEN + RETURN QUERY SELECT false, NULL::UUID, NULL::UUID, + 'Invitation has already been ' || v_invitation.status; + RETURN; + END IF; + + -- Check if expired + IF v_invitation.expires_at < CURRENT_TIMESTAMP THEN + UPDATE teams.invitations + SET status = 'expired', updated_at = CURRENT_TIMESTAMP + WHERE id = v_invitation.id; + + RETURN QUERY SELECT false, NULL::UUID, NULL::UUID, 'Invitation has expired'; + RETURN; + END IF; + + -- Accept invitation + UPDATE teams.invitations + SET status = 'accepted', + responded_at = CURRENT_TIMESTAMP, + accepted_user_id = p_user_id, + updated_at = CURRENT_TIMESTAMP + WHERE id = v_invitation.id; + + -- Add user to team + PERFORM teams.add_team_member( + v_invitation.tenant_id, + p_user_id, + 'member', + v_invitation.invited_by, + v_invitation.id, + v_invitation.department, + v_invitation.job_title + ); + + -- Assign role if specified + IF v_invitation.role_id IS NOT NULL THEN + PERFORM rbac.assign_role_to_user( + p_user_id, + v_invitation.role_id, + v_invitation.tenant_id, + v_invitation.invited_by, + true -- Make it primary role + ); + END IF; + + RETURN QUERY SELECT true, v_invitation.tenant_id, v_invitation.role_id, + 'Invitation accepted successfully'; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- FUNCTION: Resend invitation +-- ============================================================================ + +CREATE OR REPLACE FUNCTION teams.resend_invitation( + p_invitation_id UUID, + p_resent_by UUID +) +RETURNS TABLE ( + success BOOLEAN, + token VARCHAR(64), + message TEXT +) AS $$ +DECLARE + v_invitation teams.invitations%ROWTYPE; + v_new_token VARCHAR(64); + v_new_token_hash VARCHAR(255); +BEGIN + -- Find invitation + SELECT * INTO v_invitation + FROM teams.invitations + WHERE id = p_invitation_id; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, NULL::VARCHAR(64), 'Invitation not found'; + RETURN; + END IF; + + IF v_invitation.status != 'pending' THEN + RETURN QUERY SELECT false, NULL::VARCHAR(64), + 'Cannot resend: invitation is ' || v_invitation.status; + RETURN; + END IF; + + -- Generate new token + v_new_token := encode(gen_random_bytes(32), 'hex'); + v_new_token_hash := encode(sha256(v_new_token::bytea), 'hex'); + + -- Update invitation + UPDATE teams.invitations + SET token_hash = v_new_token_hash, + resent_count = resent_count + 1, + last_resent_at = CURRENT_TIMESTAMP, + expires_at = CURRENT_TIMESTAMP + INTERVAL '7 days', + updated_at = CURRENT_TIMESTAMP + WHERE id = p_invitation_id; + + RETURN QUERY SELECT true, v_new_token, 'Invitation resent successfully'; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- FUNCTION: Expire old invitations (for scheduled job) +-- ============================================================================ + +CREATE OR REPLACE FUNCTION teams.expire_old_invitations() +RETURNS INTEGER AS $$ +DECLARE + v_count INTEGER; +BEGIN + UPDATE teams.invitations + SET status = 'expired', updated_at = CURRENT_TIMESTAMP + WHERE status = 'pending' + AND expires_at < CURRENT_TIMESTAMP; + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- GRANTS +-- ============================================================================ + +GRANT SELECT, INSERT, UPDATE, DELETE ON teams.invitations TO trading_user; +GRANT SELECT ON teams.v_invitations TO trading_user; +GRANT EXECUTE ON FUNCTION teams.create_invitation TO trading_user; +GRANT EXECUTE ON FUNCTION teams.accept_invitation TO trading_user; +GRANT EXECUTE ON FUNCTION teams.resend_invitation TO trading_user; +GRANT EXECUTE ON FUNCTION teams.expire_old_invitations TO trading_user; + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +COMMENT ON TABLE teams.invitations IS 'Team/organization invitations for new members'; +COMMENT ON COLUMN teams.invitations.token_hash IS 'SHA256 hash of the invitation token'; +COMMENT ON COLUMN teams.invitations.role_id IS 'Role to assign when invitation is accepted'; +COMMENT ON COLUMN teams.invitations.resent_count IS 'Number of times invitation was resent'; +COMMENT ON VIEW teams.v_invitations IS 'Invitations with related details'; +COMMENT ON FUNCTION teams.create_invitation IS 'Create a new team invitation'; +COMMENT ON FUNCTION teams.accept_invitation IS 'Accept an invitation and join team'; diff --git a/ddl/schemas/tenants/tables/001_tenants.sql b/ddl/schemas/tenants/tables/001_tenants.sql new file mode 100644 index 0000000..2feb3ae --- /dev/null +++ b/ddl/schemas/tenants/tables/001_tenants.sql @@ -0,0 +1,165 @@ +-- ============================================================================ +-- SCHEMA: tenants +-- TABLE: tenants, tenant_settings +-- DESCRIPTION: Sistema de Multi-Tenancy para organizaciones/empresas +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- Crear schema si no existe +CREATE SCHEMA IF NOT EXISTS tenants; + +-- Enum para estado de tenant +DO $$ BEGIN + CREATE TYPE tenants.tenant_status AS ENUM ( + 'pending', -- Pendiente de activacion + 'active', -- Activo y operativo + 'suspended', -- Suspendido (impago o violacion) + 'deleted' -- Eliminado (soft delete) + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para estado de subscription +DO $$ BEGIN + CREATE TYPE tenants.subscription_status AS ENUM ( + 'trialing', -- En periodo de prueba + 'active', -- Activo con pagos al dia + 'past_due', -- Pago vencido + 'canceled', -- Cancelado + 'incomplete' -- Configuracion incompleta + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla principal de Tenants (Organizaciones) +CREATE TABLE IF NOT EXISTS tenants.tenants ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Informacion basica + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + domain VARCHAR(255), + logo_url TEXT, + + -- Estado + status tenants.tenant_status NOT NULL DEFAULT 'pending', + + -- Subscription info (referencia a billing) + plan_id UUID, -- FK a plans.plans (se agrega despues) + subscription_status tenants.subscription_status DEFAULT 'trialing', + trial_ends_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '14 days'), + + -- Stripe integration + stripe_customer_id VARCHAR(255), + stripe_subscription_id VARCHAR(255), + + -- Settings (JSONB flexible) + settings JSONB NOT NULL DEFAULT '{ + "timezone": "America/Mexico_City", + "locale": "es-MX", + "currency": "MXN", + "date_format": "DD/MM/YYYY" + }'::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() +); + +COMMENT ON TABLE tenants.tenants IS +'Organizaciones/empresas que usan la plataforma. Base del sistema multi-tenant.'; + +COMMENT ON COLUMN tenants.tenants.slug IS +'Identificador URL-safe unico para el tenant (ej: acme-corp)'; + +COMMENT ON COLUMN tenants.tenants.settings IS +'Configuracion del tenant: timezone, locale, currency, etc.'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_tenants_slug + ON tenants.tenants(slug); + +CREATE INDEX IF NOT EXISTS idx_tenants_status + ON tenants.tenants(status) WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_tenants_stripe_customer + ON tenants.tenants(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_tenants_subscription_status + ON tenants.tenants(subscription_status); + +-- Trigger para updated_at +CREATE OR REPLACE FUNCTION tenants.update_tenant_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS tenant_updated_at ON tenants.tenants; +CREATE TRIGGER tenant_updated_at + BEFORE UPDATE ON tenants.tenants + FOR EACH ROW + EXECUTE FUNCTION tenants.update_tenant_timestamp(); + +-- ============================================================================ +-- TABLA: tenant_settings (configuracion extendida) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS tenants.tenant_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Configuracion por categoria + category VARCHAR(50) NOT NULL, -- 'branding', 'notifications', 'security', etc. + key VARCHAR(100) NOT NULL, + value JSONB NOT NULL, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Unique por tenant + category + key + UNIQUE (tenant_id, category, key) +); + +COMMENT ON TABLE tenants.tenant_settings IS +'Configuraciones extendidas por tenant, organizadas por categoria'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_tenant_settings_tenant + ON tenants.tenant_settings(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_tenant_settings_category + ON tenants.tenant_settings(tenant_id, category); + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS tenant_settings_updated_at ON tenants.tenant_settings; +CREATE TRIGGER tenant_settings_updated_at + BEFORE UPDATE ON tenants.tenant_settings + FOR EACH ROW + EXECUTE FUNCTION tenants.update_tenant_timestamp(); + +-- RLS Policy (tenant_settings aislado por tenant) +ALTER TABLE tenants.tenant_settings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_settings_isolation ON tenants.tenant_settings + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON tenants.tenants TO trading_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON tenants.tenant_settings TO trading_app; +GRANT SELECT ON tenants.tenants TO trading_readonly; +GRANT SELECT ON tenants.tenant_settings TO trading_readonly; diff --git a/ddl/schemas/users/tables/001_users.sql b/ddl/schemas/users/tables/001_users.sql new file mode 100644 index 0000000..bd20ca0 --- /dev/null +++ b/ddl/schemas/users/tables/001_users.sql @@ -0,0 +1,156 @@ +-- ============================================================================ +-- 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; diff --git a/ddl/schemas/vip/001_vip_system.sql b/ddl/schemas/vip/001_vip_system.sql new file mode 100644 index 0000000..25d7f68 --- /dev/null +++ b/ddl/schemas/vip/001_vip_system.sql @@ -0,0 +1,256 @@ +-- ============================================================================ +-- SCHEMA: vip +-- TABLES: tiers, subscriptions, model_access +-- DESCRIPTION: Sistema VIP con tiers y modelos exclusivos +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- Crear schema +CREATE SCHEMA IF NOT EXISTS vip; + +-- Enums +DO $$ BEGIN + CREATE TYPE vip.tier_type AS ENUM ( + 'GOLD', -- Tier Gold - $199/mes + 'PLATINUM', -- Tier Platinum - $399/mes + 'DIAMOND' -- Tier Diamond - $999/mes + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +DO $$ BEGIN + CREATE TYPE vip.subscription_status AS ENUM ( + 'trialing', -- En periodo de prueba + 'active', -- Activa y pagando + 'past_due', -- Pago atrasado + 'cancelled', -- Cancelada + 'expired' -- Expirada + ); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Tabla de definicion de Tiers +CREATE TABLE IF NOT EXISTS vip.tiers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificacion + tier vip.tier_type UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + tagline VARCHAR(200), + + -- Precios + price_monthly DECIMAL(10, 2) NOT NULL, + price_yearly DECIMAL(10, 2), + currency VARCHAR(3) DEFAULT 'USD', + + -- Stripe + stripe_product_id VARCHAR(255), + stripe_price_monthly_id VARCHAR(255), + stripe_price_yearly_id VARCHAR(255), + + -- Beneficios (JSONB array) + benefits JSONB NOT NULL DEFAULT '[]', + -- Ejemplo: + -- [ + -- {"name": "Modelo Ensemble Pro", "included": true}, + -- {"name": "Predicciones VIP", "value": "50/mes"}, + -- {"name": "Soporte prioritario", "included": true} + -- ] + + -- Limites + limits JSONB NOT NULL DEFAULT '{}', + -- Ejemplo: + -- { + -- "vip_predictions_per_month": 50, + -- "api_calls_per_minute": 30, + -- "exclusive_models": ["ensemble_pro"] + -- } + + -- Modelos exclusivos incluidos + included_models VARCHAR[] DEFAULT '{}', + + -- Display + color VARCHAR(7), + icon VARCHAR(50), + sort_order INT DEFAULT 0, + is_popular BOOLEAN DEFAULT FALSE, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMENT ON TABLE vip.tiers IS +'Definicion de los tiers VIP disponibles (Gold, Platinum, Diamond)'; + +-- Tabla de suscripciones VIP +CREATE TABLE IF NOT EXISTS vip.subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + tier_id UUID NOT NULL REFERENCES vip.tiers(id), + + -- Stripe + stripe_subscription_id VARCHAR(255) UNIQUE, + stripe_customer_id VARCHAR(255), + + -- Estado + status vip.subscription_status NOT NULL DEFAULT 'trialing', + + -- Periodo + interval VARCHAR(10) DEFAULT 'month', + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + + -- Trial + trial_start TIMESTAMPTZ, + trial_end TIMESTAMPTZ, + + -- Cancelacion + cancel_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + cancel_reason VARCHAR(500), + + -- Uso del periodo actual + vip_predictions_used INT DEFAULT 0, + api_calls_this_period INT DEFAULT 0, + last_usage_reset TIMESTAMPTZ DEFAULT NOW(), + + -- Metadata + metadata JSONB DEFAULT '{}', + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Solo una suscripcion activa por usuario + CONSTRAINT unique_active_vip_subscription + UNIQUE (tenant_id, user_id) +); + +COMMENT ON TABLE vip.subscriptions IS +'Suscripciones VIP activas de usuarios'; + +-- Tabla de acceso a modelos exclusivos +CREATE TABLE IF NOT EXISTS vip.model_access ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES vip.subscriptions(id) ON DELETE CASCADE, + + -- Modelo + model_id VARCHAR(100) NOT NULL, + model_name VARCHAR(200), + + -- Uso + predictions_generated INT DEFAULT 0, + last_used_at TIMESTAMPTZ, + + -- Limites especificos del modelo + daily_limit INT, + monthly_limit INT, + daily_used INT DEFAULT 0, + monthly_used INT DEFAULT 0, + + -- Acceso + granted_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ, + + UNIQUE(subscription_id, model_id) +); + +COMMENT ON TABLE vip.model_access IS +'Registro de acceso a modelos exclusivos por suscripcion VIP'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_vip_tiers_active + ON vip.tiers(is_active) WHERE is_active = TRUE; + +CREATE INDEX IF NOT EXISTS idx_vip_subs_tenant + ON vip.subscriptions(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_vip_subs_user + ON vip.subscriptions(user_id); + +CREATE INDEX IF NOT EXISTS idx_vip_subs_status + ON vip.subscriptions(status); + +CREATE INDEX IF NOT EXISTS idx_vip_subs_stripe + ON vip.subscriptions(stripe_subscription_id); + +CREATE INDEX IF NOT EXISTS idx_vip_access_sub + ON vip.model_access(subscription_id); + +CREATE INDEX IF NOT EXISTS idx_vip_access_model + ON vip.model_access(model_id); + +-- Trigger updated_at +CREATE OR REPLACE FUNCTION vip.update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS vip_tiers_updated ON vip.tiers; +CREATE TRIGGER vip_tiers_updated + BEFORE UPDATE ON vip.tiers + FOR EACH ROW + EXECUTE FUNCTION vip.update_timestamp(); + +DROP TRIGGER IF EXISTS vip_subs_updated ON vip.subscriptions; +CREATE TRIGGER vip_subs_updated + BEFORE UPDATE ON vip.subscriptions + FOR EACH ROW + EXECUTE FUNCTION vip.update_timestamp(); + +-- Funcion para verificar acceso VIP +CREATE OR REPLACE FUNCTION vip.check_vip_access( + p_user_id UUID, + p_model_id VARCHAR(100) +) +RETURNS BOOLEAN AS $$ +DECLARE + v_has_access BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM vip.subscriptions s + JOIN vip.model_access ma ON ma.subscription_id = s.id + WHERE s.user_id = p_user_id + AND s.status = 'active' + AND ma.model_id = p_model_id + AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) + ) INTO v_has_access; + + RETURN v_has_access; +END; +$$ LANGUAGE plpgsql; + +-- RLS +ALTER TABLE vip.tiers ENABLE ROW LEVEL SECURITY; +ALTER TABLE vip.subscriptions ENABLE ROW LEVEL SECURITY; +ALTER TABLE vip.model_access ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tiers_read_all ON vip.tiers + FOR SELECT USING (TRUE); + +CREATE POLICY subs_tenant_isolation ON vip.subscriptions + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY access_via_subscription ON vip.model_access + FOR ALL + USING ( + subscription_id IN ( + SELECT id FROM vip.subscriptions + WHERE tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + ); + +-- Grants +GRANT SELECT ON vip.tiers TO trading_app; +GRANT SELECT, INSERT, UPDATE ON vip.subscriptions TO trading_app; +GRANT SELECT, INSERT, UPDATE ON vip.model_access TO trading_app; +GRANT SELECT ON vip.tiers TO trading_readonly; +GRANT SELECT ON vip.subscriptions TO trading_readonly; diff --git a/scripts/recreate-db.sh b/scripts/recreate-db.sh new file mode 100755 index 0000000..5dea51b --- /dev/null +++ b/scripts/recreate-db.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# ============================================================================ +# Script de Recreación de Base de Datos - Trading Platform +# ============================================================================ +# Uso: ./recreate-db.sh [--drop] [--seeds] +# +# Opciones: +# --drop Eliminar y recrear la base de datos +# --seeds Ejecutar archivos de seeds después de DDL +# +# Variables de entorno: +# DB_HOST Host de PostgreSQL (default: localhost) +# DB_PORT Puerto de PostgreSQL (default: 5432) +# DB_NAME Nombre de la base de datos (default: trading_platform) +# DB_USER Usuario de PostgreSQL (default: trading_user) +# DB_PASSWORD Contraseña (default: trading_dev_2025) +# ============================================================================ + +set -e + +# Configuración +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" +DB_NAME="${DB_NAME:-trading_platform}" +DB_USER="${DB_USER:-trading_user}" +DB_PASSWORD="${DB_PASSWORD:-trading_dev_2025}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DDL_DIR="$SCRIPT_DIR/../ddl/schemas" +SEEDS_DIR="$SCRIPT_DIR/../seeds" + +# Colores +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_step() { echo -e "${BLUE}[STEP]${NC} $1"; } + +# Parse arguments +DROP_DB=false +RUN_SEEDS=false + +for arg in "$@"; do + case $arg in + --drop) DROP_DB=true ;; + --seeds) RUN_SEEDS=true ;; + --help) + echo "Uso: ./recreate-db.sh [--drop] [--seeds]" + echo "" + echo "Opciones:" + echo " --drop Eliminar y recrear la base de datos" + echo " --seeds Ejecutar archivos de seeds después de DDL" + exit 0 + ;; + esac +done + +# Export password +export PGPASSWORD="$DB_PASSWORD" + +echo "" +echo "==============================================" +echo " Trading Platform - Database Recreation" +echo "==============================================" +echo " Host: $DB_HOST:$DB_PORT" +echo " Database: $DB_NAME" +echo " User: $DB_USER" +echo " Drop DB: $DROP_DB" +echo " Run Seeds: $RUN_SEEDS" +echo "==============================================" +echo "" + +# Drop database if requested +if [ "$DROP_DB" = true ]; then + log_warn "Eliminando base de datos $DB_NAME..." + psql -h "$DB_HOST" -p "$DB_PORT" -U postgres -c "DROP DATABASE IF EXISTS $DB_NAME;" 2>/dev/null || true + psql -h "$DB_HOST" -p "$DB_PORT" -U postgres -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;" + log_info "Base de datos recreada" +fi + +# Crear schemas base +log_step "Creando schemas base..." +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c " + CREATE SCHEMA IF NOT EXISTS tenants; + CREATE SCHEMA IF NOT EXISTS users; + CREATE SCHEMA IF NOT EXISTS auth; + CREATE SCHEMA IF NOT EXISTS rbac; + CREATE SCHEMA IF NOT EXISTS teams; + CREATE SCHEMA IF NOT EXISTS audit; + CREATE SCHEMA IF NOT EXISTS financial; + CREATE SCHEMA IF NOT EXISTS products; + CREATE SCHEMA IF NOT EXISTS vip; + CREATE SCHEMA IF NOT EXISTS investment; + CREATE SCHEMA IF NOT EXISTS ml; +" + +# Orden de ejecución de DDL +log_step "Ejecutando archivos DDL..." + +SCHEMAS=( + # SaaS Core (debe ir primero por dependencias) + "tenants/tables/001_tenants.sql" + "users/tables/001_users.sql" + "auth/tables/001_sessions.sql" + "auth/tables/002_tokens.sql" + # RBAC (después de users) + "rbac/tables/001_roles.sql" + "rbac/tables/002_permissions.sql" + "rbac/tables/003_role_permissions.sql" + "rbac/tables/004_user_roles.sql" + # Teams (después de rbac) + "teams/tables/001_team_members.sql" + "teams/tables/002_invitations.sql" + # Audit (independiente) + "audit/tables/001_audit_logs.sql" + # Trading Platform + "financial/001_wallets.sql" + "financial/002_wallet_transactions.sql" + "products/001_products.sql" + "vip/001_vip_system.sql" + "investment/001_agent_allocations.sql" + "ml/001_predictions_marketplace.sql" + "ml/002_predictions.sql" + "investment/002_add_columns.sql" +) + +for schema_file in "${SCHEMAS[@]}"; do + file_path="$DDL_DIR/$schema_file" + if [ -f "$file_path" ]; then + log_info " Ejecutando $schema_file..." + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$file_path" -q + else + log_warn " Archivo no encontrado: $schema_file" + fi +done + +# Execute seeds if requested +if [ "$RUN_SEEDS" = true ]; then + log_step "Ejecutando archivos de seeds..." + + SEEDS=( + "001_vip_tiers.sql" + "002_products.sql" + "003_prediction_packages.sql" + "004_agent_configs.sql" + ) + + for seed_file in "${SEEDS[@]}"; do + file_path="$SEEDS_DIR/$seed_file" + if [ -f "$file_path" ]; then + log_info " Ejecutando $seed_file..." + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$file_path" -q + else + log_warn " Archivo no encontrado: $seed_file" + fi + done +fi + +# Validación +log_step "Validando creación..." + +echo "" +log_info "Schemas creados:" +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c " + SELECT schema_name FROM information_schema.schemata + WHERE schema_name IN ('tenants', 'users', 'auth', 'rbac', 'teams', 'audit', 'financial', 'products', 'vip', 'investment', 'ml') + ORDER BY schema_name; +" + +echo "" +log_info "Tablas creadas:" +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c " + SELECT table_schema || '.' || table_name + FROM information_schema.tables + WHERE table_schema IN ('tenants', 'users', 'auth', 'rbac', 'teams', 'audit', 'financial', 'products', 'vip', 'investment', 'ml') + ORDER BY table_schema, table_name; +" + +echo "" +echo "==============================================" +log_info "Recreación completada exitosamente" +echo "==============================================" diff --git a/seeds/001_vip_tiers.sql b/seeds/001_vip_tiers.sql new file mode 100644 index 0000000..75fd6ba --- /dev/null +++ b/seeds/001_vip_tiers.sql @@ -0,0 +1,131 @@ +-- ============================================================================ +-- SEED: VIP Tiers +-- DESCRIPTION: Datos iniciales para los tiers VIP del sistema +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- Insertar tiers VIP +INSERT INTO vip.tiers ( + tier, name, description, tagline, + price_monthly, price_yearly, currency, + benefits, limits, included_models, + color, icon, sort_order, is_popular, is_active +) VALUES +-- GOLD Tier +( + 'GOLD', + 'Gold', + 'Acceso a predicciones premium y modelos avanzados para traders serios', + 'Eleva tu trading al siguiente nivel', + 199.00, + 1990.00, -- ~17% descuento anual + 'USD', + '[ + {"name": "Predicciones AMD Detector", "included": true}, + {"name": "Predicciones Range Predictor", "included": true}, + {"name": "Predicciones VIP", "value": "50/mes"}, + {"name": "API Calls", "value": "1000/mes"}, + {"name": "Soporte Email", "included": true}, + {"name": "Reportes Semanales", "included": true} + ]'::JSONB, + '{ + "vip_predictions_per_month": 50, + "api_calls_per_month": 1000, + "exclusive_models": ["amd_detector", "range_predictor"], + "priority_support": false, + "custom_alerts": 5 + }'::JSONB, + ARRAY['amd_detector', 'range_predictor'], + '#FFD700', + 'crown', + 1, + FALSE, + TRUE +), +-- PLATINUM Tier +( + 'PLATINUM', + 'Platinum', + 'Suite completa de predicciones con modelos exclusivos y soporte prioritario', + 'Para traders profesionales', + 399.00, + 3990.00, -- ~17% descuento anual + 'USD', + '[ + {"name": "Todo Gold incluido", "included": true}, + {"name": "TPSL Classifier", "included": true}, + {"name": "ICT/SMC Detector", "included": true}, + {"name": "Predicciones VIP", "value": "150/mes"}, + {"name": "API Calls", "value": "5000/mes"}, + {"name": "Soporte Prioritario", "included": true}, + {"name": "Alertas Personalizadas", "value": "20"}, + {"name": "Acceso Beta Features", "included": true} + ]'::JSONB, + '{ + "vip_predictions_per_month": 150, + "api_calls_per_month": 5000, + "exclusive_models": ["amd_detector", "range_predictor", "tpsl_classifier", "ict_smc_detector"], + "priority_support": true, + "custom_alerts": 20, + "beta_access": true + }'::JSONB, + ARRAY['amd_detector', 'range_predictor', 'tpsl_classifier', 'ict_smc_detector'], + '#E5E4E2', + 'gem', + 2, + TRUE, -- Popular + TRUE +), +-- DIAMOND Tier +( + 'DIAMOND', + 'Diamond', + 'Acceso ilimitado a todos los modelos incluyendo Strategy Ensemble exclusivo', + 'El pináculo del trading algorítmico', + 999.00, + 9990.00, -- ~17% descuento anual + 'USD', + '[ + {"name": "Todo Platinum incluido", "included": true}, + {"name": "Strategy Ensemble (Exclusivo)", "included": true}, + {"name": "Predicciones VIP", "value": "Ilimitadas"}, + {"name": "API Calls", "value": "Ilimitadas"}, + {"name": "Soporte 24/7 Dedicado", "included": true}, + {"name": "Alertas Personalizadas", "value": "Ilimitadas"}, + {"name": "Sesiones 1-on-1 Mensuales", "value": "2/mes"}, + {"name": "Early Access Features", "included": true}, + {"name": "White-glove Onboarding", "included": true} + ]'::JSONB, + '{ + "vip_predictions_per_month": -1, + "api_calls_per_month": -1, + "exclusive_models": ["amd_detector", "range_predictor", "tpsl_classifier", "ict_smc_detector", "strategy_ensemble"], + "priority_support": true, + "dedicated_support": true, + "custom_alerts": -1, + "beta_access": true, + "early_access": true, + "monthly_sessions": 2 + }'::JSONB, + ARRAY['amd_detector', 'range_predictor', 'tpsl_classifier', 'ict_smc_detector', 'strategy_ensemble'], + '#B9F2FF', + 'diamond', + 3, + FALSE, + TRUE +) +ON CONFLICT (tier) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + tagline = EXCLUDED.tagline, + price_monthly = EXCLUDED.price_monthly, + price_yearly = EXCLUDED.price_yearly, + benefits = EXCLUDED.benefits, + limits = EXCLUDED.limits, + included_models = EXCLUDED.included_models, + color = EXCLUDED.color, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + is_popular = EXCLUDED.is_popular, + updated_at = NOW(); diff --git a/seeds/002_products.sql b/seeds/002_products.sql new file mode 100644 index 0000000..d0249f1 --- /dev/null +++ b/seeds/002_products.sql @@ -0,0 +1,335 @@ +-- ============================================================================ +-- SEED: Products Catalog +-- DESCRIPTION: Catalogo inicial de productos y servicios +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- Productos de Predicciones (One-Time) +INSERT INTO products.products ( + sku, name, slug, description, short_description, + type, category, price, compare_price, currency, + features, limits, + ml_model_id, prediction_type, prediction_count, + image_url, badge, sort_order, is_active, is_vip, is_featured +) VALUES +-- Pack AMD Detector +( + 'PRED-AMD-10', + 'AMD Detector - Pack 10 Predicciones', + 'amd-detector-10', + 'Detecta patrones de Acumulación, Manipulación y Distribución en el mercado. Ideal para identificar movimientos institucionales.', + '10 predicciones AMD con 85% accuracy histórico', + 'ONE_TIME', 'PREDICTION', + 29.00, 39.00, 'USD', + '[ + {"name": "10 predicciones AMD", "included": true}, + {"name": "Válido por 30 días", "included": true}, + {"name": "Accuracy histórico 85%", "included": true}, + {"name": "Soporte email", "included": true} + ]'::JSONB, + '{"predictions": 10, "validity_days": 30}'::JSONB, + 'amd_detector', 'AMD', 10, + '/images/products/amd-detector.png', + 'Popular', + 1, TRUE, FALSE, TRUE +), +( + 'PRED-AMD-50', + 'AMD Detector - Pack 50 Predicciones', + 'amd-detector-50', + 'Pack profesional de 50 predicciones AMD. Ahorra 20% comparado con packs individuales.', + '50 predicciones AMD - Mejor valor', + 'ONE_TIME', 'PREDICTION', + 119.00, 145.00, 'USD', + '[ + {"name": "50 predicciones AMD", "included": true}, + {"name": "Válido por 60 días", "included": true}, + {"name": "20% descuento", "included": true}, + {"name": "Soporte prioritario", "included": true} + ]'::JSONB, + '{"predictions": 50, "validity_days": 60}'::JSONB, + 'amd_detector', 'AMD', 50, + '/images/products/amd-detector-pro.png', + 'Ahorra 20%', + 2, TRUE, FALSE, FALSE +), +-- Pack Range Predictor +( + 'PRED-RANGE-10', + 'Range Predictor - Pack 10 Predicciones', + 'range-predictor-10', + 'Predice rangos de precio con alta precisión. Perfecto para trading de soporte/resistencia.', + '10 predicciones de rango de precio', + 'ONE_TIME', 'PREDICTION', + 24.00, 34.00, 'USD', + '[ + {"name": "10 predicciones de rango", "included": true}, + {"name": "Válido por 30 días", "included": true}, + {"name": "Incluye niveles S/R", "included": true} + ]'::JSONB, + '{"predictions": 10, "validity_days": 30}'::JSONB, + 'range_predictor', 'RANGE', 10, + '/images/products/range-predictor.png', + NULL, + 3, TRUE, FALSE, FALSE +), +-- Pack TPSL +( + 'PRED-TPSL-20', + 'TP/SL Classifier - Pack 20 Predicciones', + 'tpsl-classifier-20', + 'Clasificador avanzado de Take Profit y Stop Loss óptimos basado en condiciones de mercado.', + 'Optimiza tus exits con ML', + 'ONE_TIME', 'PREDICTION', + 49.00, 59.00, 'USD', + '[ + {"name": "20 predicciones TP/SL", "included": true}, + {"name": "Válido por 45 días", "included": true}, + {"name": "Risk/Reward optimizado", "included": true} + ]'::JSONB, + '{"predictions": 20, "validity_days": 45}'::JSONB, + 'tpsl_classifier', 'TPSL', 20, + '/images/products/tpsl-classifier.png', + NULL, + 4, TRUE, FALSE, FALSE +), +-- Pack Combo +( + 'PRED-COMBO-STARTER', + 'Combo Starter - AMD + Range + TPSL', + 'combo-starter', + 'Pack combinado perfecto para empezar. Incluye los 3 modelos básicos.', + 'Todo lo que necesitas para empezar', + 'ONE_TIME', 'PREDICTION', + 79.00, 102.00, 'USD', + '[ + {"name": "10 predicciones AMD", "included": true}, + {"name": "10 predicciones Range", "included": true}, + {"name": "10 predicciones TPSL", "included": true}, + {"name": "Válido por 30 días", "included": true}, + {"name": "22% descuento", "included": true} + ]'::JSONB, + '{"predictions_amd": 10, "predictions_range": 10, "predictions_tpsl": 10, "validity_days": 30}'::JSONB, + NULL, 'COMBO', 30, + '/images/products/combo-starter.png', + 'Best Value', + 5, TRUE, FALSE, TRUE +) +ON CONFLICT (sku) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + price = EXCLUDED.price, + compare_price = EXCLUDED.compare_price, + features = EXCLUDED.features, + limits = EXCLUDED.limits, + badge = EXCLUDED.badge, + is_featured = EXCLUDED.is_featured, + updated_at = NOW(); + +-- Productos de Acceso a Agentes (Subscription) +INSERT INTO products.products ( + sku, name, slug, description, short_description, + type, category, price, currency, + billing_interval, billing_interval_count, + features, limits, agent_type, + image_url, badge, sort_order, is_active, is_vip, is_featured +) VALUES +( + 'AGENT-ATLAS-ACCESS', + 'Atlas Agent - Acceso Mensual', + 'atlas-agent-access', + 'Acceso al agente conservador Atlas. Estrategia de bajo riesgo con retornos estables.', + 'Trading conservador automatizado', + 'SUBSCRIPTION', 'AGENT_ACCESS', + 49.00, 'USD', + 'MONTH', 1, + '[ + {"name": "Acceso completo a Atlas", "included": true}, + {"name": "Estrategia conservadora", "included": true}, + {"name": "Max drawdown 10%", "included": true}, + {"name": "Reportes diarios", "included": true} + ]'::JSONB, + '{"max_allocation": 5000, "risk_level": "LOW"}'::JSONB, + 'ATLAS', + '/images/agents/atlas.png', + NULL, + 10, TRUE, FALSE, FALSE +), +( + 'AGENT-ORION-ACCESS', + 'Orion Agent - Acceso Mensual', + 'orion-agent-access', + 'Acceso al agente moderado Orion. Balance óptimo entre riesgo y retorno.', + 'Trading balanceado automatizado', + 'SUBSCRIPTION', 'AGENT_ACCESS', + 79.00, 'USD', + 'MONTH', 1, + '[ + {"name": "Acceso completo a Orion", "included": true}, + {"name": "Estrategia moderada", "included": true}, + {"name": "Max drawdown 20%", "included": true}, + {"name": "Reportes diarios", "included": true}, + {"name": "Alertas en tiempo real", "included": true} + ]'::JSONB, + '{"max_allocation": 10000, "risk_level": "MEDIUM"}'::JSONB, + 'ORION', + '/images/agents/orion.png', + 'Recomendado', + 11, TRUE, FALSE, TRUE +), +( + 'AGENT-NOVA-ACCESS', + 'Nova Agent - Acceso Mensual', + 'nova-agent-access', + 'Acceso al agente agresivo Nova. Máximo potencial de retorno para traders experimentados.', + 'Trading agresivo automatizado', + 'SUBSCRIPTION', 'AGENT_ACCESS', + 129.00, 'USD', + 'MONTH', 1, + '[ + {"name": "Acceso completo a Nova", "included": true}, + {"name": "Estrategia agresiva", "included": true}, + {"name": "Alto potencial de retorno", "included": true}, + {"name": "Reportes en tiempo real", "included": true}, + {"name": "Soporte prioritario", "included": true} + ]'::JSONB, + '{"max_allocation": 25000, "risk_level": "HIGH"}'::JSONB, + 'NOVA', + '/images/agents/nova.png', + 'Pro', + 12, TRUE, FALSE, FALSE +) +ON CONFLICT (sku) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + price = EXCLUDED.price, + features = EXCLUDED.features, + limits = EXCLUDED.limits, + badge = EXCLUDED.badge, + is_featured = EXCLUDED.is_featured, + updated_at = NOW(); + +-- Productos Educativos +INSERT INTO products.products ( + sku, name, slug, description, short_description, + type, category, price, compare_price, currency, + features, limits, + image_url, badge, sort_order, is_active, is_vip, is_featured +) VALUES +( + 'EDU-TRADING-101', + 'Trading Fundamentals 101', + 'trading-fundamentals-101', + 'Curso completo de fundamentos de trading. Desde conceptos básicos hasta estrategias avanzadas.', + 'Aprende trading desde cero', + 'ONE_TIME', 'EDUCATION', + 149.00, 199.00, 'USD', + '[ + {"name": "20+ horas de video", "included": true}, + {"name": "Acceso de por vida", "included": true}, + {"name": "Certificado de completación", "included": true}, + {"name": "Comunidad privada", "included": true} + ]'::JSONB, + '{"lifetime_access": true}'::JSONB, + '/images/courses/trading-101.png', + NULL, + 20, TRUE, FALSE, FALSE +), +( + 'EDU-ML-TRADING', + 'ML for Trading Masterclass', + 'ml-trading-masterclass', + 'Masterclass avanzado sobre Machine Learning aplicado al trading. Aprende a crear tus propios modelos.', + 'Domina ML para trading', + 'ONE_TIME', 'EDUCATION', + 399.00, 499.00, 'USD', + '[ + {"name": "40+ horas de contenido", "included": true}, + {"name": "Código fuente incluido", "included": true}, + {"name": "Acceso a datasets", "included": true}, + {"name": "Mentorías grupales", "value": "4 sesiones"}, + {"name": "Acceso de por vida", "included": true} + ]'::JSONB, + '{"lifetime_access": true, "mentoring_sessions": 4}'::JSONB, + '/images/courses/ml-trading.png', + 'Premium', + 21, TRUE, FALSE, TRUE +) +ON CONFLICT (sku) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + price = EXCLUDED.price, + compare_price = EXCLUDED.compare_price, + features = EXCLUDED.features, + badge = EXCLUDED.badge, + is_featured = EXCLUDED.is_featured, + updated_at = NOW(); + +-- Productos VIP (referencia a tiers) +INSERT INTO products.products ( + sku, name, slug, description, short_description, + type, category, price, currency, + billing_interval, billing_interval_count, trial_days, + features, limits, + image_url, badge, sort_order, is_active, is_vip, is_featured +) VALUES +( + 'VIP-GOLD-MONTHLY', + 'VIP Gold - Mensual', + 'vip-gold-monthly', + 'Suscripción VIP Gold con acceso a modelos premium', + 'Tier Gold - $199/mes', + 'VIP', 'PREMIUM_FEATURE', + 199.00, 'USD', + 'MONTH', 1, 7, + '[ + {"name": "Ver todos los beneficios Gold", "included": true} + ]'::JSONB, + '{"vip_tier": "GOLD"}'::JSONB, + '/images/vip/gold.png', + NULL, + 30, TRUE, TRUE, FALSE +), +( + 'VIP-PLATINUM-MONTHLY', + 'VIP Platinum - Mensual', + 'vip-platinum-monthly', + 'Suscripción VIP Platinum con acceso completo', + 'Tier Platinum - $399/mes', + 'VIP', 'PREMIUM_FEATURE', + 399.00, 'USD', + 'MONTH', 1, 7, + '[ + {"name": "Ver todos los beneficios Platinum", "included": true} + ]'::JSONB, + '{"vip_tier": "PLATINUM"}'::JSONB, + '/images/vip/platinum.png', + 'Popular', + 31, TRUE, TRUE, TRUE +), +( + 'VIP-DIAMOND-MONTHLY', + 'VIP Diamond - Mensual', + 'vip-diamond-monthly', + 'Suscripción VIP Diamond - Acceso total ilimitado', + 'Tier Diamond - $999/mes', + 'VIP', 'PREMIUM_FEATURE', + 999.00, 'USD', + 'MONTH', 1, 14, + '[ + {"name": "Ver todos los beneficios Diamond", "included": true} + ]'::JSONB, + '{"vip_tier": "DIAMOND"}'::JSONB, + '/images/vip/diamond.png', + 'Exclusive', + 32, TRUE, TRUE, FALSE +) +ON CONFLICT (sku) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + price = EXCLUDED.price, + features = EXCLUDED.features, + badge = EXCLUDED.badge, + is_featured = EXCLUDED.is_featured, + updated_at = NOW(); diff --git a/seeds/003_prediction_packages.sql b/seeds/003_prediction_packages.sql new file mode 100644 index 0000000..8f7c845 --- /dev/null +++ b/seeds/003_prediction_packages.sql @@ -0,0 +1,167 @@ +-- ============================================================================ +-- SEED: Prediction Packages +-- DESCRIPTION: Paquetes de predicciones ML para marketplace +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +INSERT INTO ml.prediction_packages ( + name, slug, description, + model_id, model_name, prediction_count, + included_symbols, included_timeframes, + price, compare_price, validity_days, + badge, is_popular, sort_order, is_active +) VALUES +-- AMD Detector Packages +( + 'AMD Detector - Starter', + 'amd-starter', + 'Pack inicial de 10 predicciones AMD. Perfecto para probar el modelo.', + 'amd_detector', + 'AMD Detector v2.0', + 10, + NULL, -- Todos los símbolos + ARRAY['H1', 'H4', 'D1'], + 29.00, 39.00, 30, + NULL, FALSE, 1, TRUE +), +( + 'AMD Detector - Pro', + 'amd-pro', + 'Pack profesional de 50 predicciones AMD. Incluye todos los símbolos y timeframes.', + 'amd_detector', + 'AMD Detector v2.0', + 50, + NULL, + ARRAY['M15', 'H1', 'H4', 'D1'], + 119.00, 145.00, 60, + 'Best Value', TRUE, 2, TRUE +), +( + 'AMD Detector - Enterprise', + 'amd-enterprise', + 'Pack empresarial de 200 predicciones AMD para traders de alto volumen.', + 'amd_detector', + 'AMD Detector v2.0', + 200, + NULL, + ARRAY['M15', 'H1', 'H4', 'D1', 'W1'], + 399.00, 580.00, 90, + 'Enterprise', FALSE, 3, TRUE +), + +-- Range Predictor Packages +( + 'Range Predictor - Starter', + 'range-starter', + 'Pack inicial de 10 predicciones de rango. Ideal para trading de S/R.', + 'range_predictor', + 'Range Predictor v1.5', + 10, + NULL, + ARRAY['H1', 'H4', 'D1'], + 24.00, 34.00, 30, + NULL, FALSE, 4, TRUE +), +( + 'Range Predictor - Pro', + 'range-pro', + 'Pack profesional de 50 predicciones de rango.', + 'range_predictor', + 'Range Predictor v1.5', + 50, + NULL, + ARRAY['M15', 'H1', 'H4', 'D1'], + 99.00, 120.00, 60, + NULL, FALSE, 5, TRUE +), + +-- TPSL Classifier Packages +( + 'TPSL Classifier - Starter', + 'tpsl-starter', + 'Pack inicial de 20 clasificaciones TP/SL óptimas.', + 'tpsl_classifier', + 'TPSL Classifier v1.2', + 20, + NULL, + ARRAY['H1', 'H4', 'D1'], + 49.00, 59.00, 45, + NULL, FALSE, 6, TRUE +), +( + 'TPSL Classifier - Pro', + 'tpsl-pro', + 'Pack profesional de 100 clasificaciones TP/SL.', + 'tpsl_classifier', + 'TPSL Classifier v1.2', + 100, + NULL, + ARRAY['M15', 'H1', 'H4', 'D1'], + 199.00, 245.00, 90, + NULL, TRUE, 7, TRUE +), + +-- ICT/SMC Detector Packages (VIP Only) +( + 'ICT/SMC Detector - Gold', + 'ict-smc-gold', + 'Pack de 30 detecciones ICT/SMC para traders VIP Gold+.', + 'ict_smc_detector', + 'ICT/SMC Detector v1.0', + 30, + NULL, + ARRAY['H1', 'H4', 'D1'], + 149.00, 179.00, 45, + 'VIP Only', FALSE, 8, TRUE +), + +-- Strategy Ensemble Packages (Diamond Only) +( + 'Strategy Ensemble - Diamond', + 'ensemble-diamond', + 'Pack exclusivo de 50 predicciones Ensemble combinando todos los modelos.', + 'strategy_ensemble', + 'Strategy Ensemble v1.0', + 50, + NULL, + ARRAY['H1', 'H4', 'D1'], + 299.00, 399.00, 60, + 'Diamond Exclusive', FALSE, 9, TRUE +), + +-- Combo Packages +( + 'Combo Trader - Básico', + 'combo-basico', + 'Combo de AMD + Range + TPSL. 30 predicciones totales.', + 'combo_basic', + 'AMD + Range + TPSL', + 30, + NULL, + ARRAY['H1', 'H4', 'D1'], + 79.00, 102.00, 30, + 'Best Starter', TRUE, 10, TRUE +), +( + 'Combo Trader - Avanzado', + 'combo-avanzado', + 'Combo completo con todos los modelos básicos. 100 predicciones.', + 'combo_advanced', + 'AMD + Range + TPSL + ICT', + 100, + NULL, + ARRAY['M15', 'H1', 'H4', 'D1'], + 249.00, 320.00, 60, + 'Pro Trader', FALSE, 11, TRUE +) +ON CONFLICT (slug) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + price = EXCLUDED.price, + compare_price = EXCLUDED.compare_price, + prediction_count = EXCLUDED.prediction_count, + validity_days = EXCLUDED.validity_days, + badge = EXCLUDED.badge, + is_popular = EXCLUDED.is_popular, + updated_at = NOW(); diff --git a/seeds/004_agent_configs.sql b/seeds/004_agent_configs.sql new file mode 100644 index 0000000..f46b749 --- /dev/null +++ b/seeds/004_agent_configs.sql @@ -0,0 +1,140 @@ +-- ============================================================================ +-- SEED: Agent Configurations +-- DESCRIPTION: Configuracion inicial de Money Manager Agents por tenant +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- NOTE: Este seed debe ejecutarse por tenant o con tenant_id especifico +-- ============================================================================ + +-- Funcion helper para crear configs de agentes para un tenant +CREATE OR REPLACE FUNCTION investment.seed_agent_configs(p_tenant_id UUID) +RETURNS VOID AS $$ +BEGIN + -- Atlas - Conservador + INSERT INTO investment.agent_configs ( + tenant_id, agent_type, + name, -- NUEVO + min_allocation, max_allocation_per_user, max_total_allocation, + platform_fee_rate, performance_fee_rate, + target_return_percent, max_drawdown_percent, -- NUEVOS + is_active, accepting_new_allocations, + description, risk_disclosure + ) VALUES ( + p_tenant_id, + 'ATLAS', + 'Atlas', -- NUEVO + 100.00, -- Min $100 + 5000.00, -- Max $5,000 por usuario + 500000.00, -- Max $500,000 total + 0.05, -- 5% platform fee + 0.15, -- 15% performance fee + 8.00, -- NUEVO: 8% target return + 10.00, -- NUEVO: 10% max drawdown + TRUE, + TRUE, + 'Atlas es nuestro agente conservador diseñado para preservación de capital con crecimiento estable. ' || + 'Utiliza estrategias de bajo riesgo con drawdown máximo del 10%. Ideal para inversores que priorizan ' || + 'la seguridad sobre altos retornos.', + 'ADVERTENCIA: El trading conlleva riesgos. Aunque Atlas utiliza estrategias conservadoras, ' || + 'existe la posibilidad de pérdidas. Los rendimientos pasados no garantizan resultados futuros. ' || + 'Solo invierta capital que pueda permitirse perder. Max drawdown esperado: 10%.' + ) + ON CONFLICT (tenant_id, agent_type) DO UPDATE SET + name = EXCLUDED.name, + min_allocation = EXCLUDED.min_allocation, + max_allocation_per_user = EXCLUDED.max_allocation_per_user, + target_return_percent = EXCLUDED.target_return_percent, + max_drawdown_percent = EXCLUDED.max_drawdown_percent, + description = EXCLUDED.description, + updated_at = NOW(); + + -- Orion - Moderado + INSERT INTO investment.agent_configs ( + tenant_id, agent_type, + name, + min_allocation, max_allocation_per_user, max_total_allocation, + platform_fee_rate, performance_fee_rate, + target_return_percent, max_drawdown_percent, + is_active, accepting_new_allocations, + description, risk_disclosure + ) VALUES ( + p_tenant_id, + 'ORION', + 'Orion', + 250.00, -- Min $250 + 10000.00, -- Max $10,000 por usuario + 1000000.00, -- Max $1,000,000 total + 0.08, -- 8% platform fee + 0.20, -- 20% performance fee + 15.00, -- 15% target return + 20.00, -- 20% max drawdown + TRUE, + TRUE, + 'Orion es nuestro agente de riesgo moderado que balancea preservación de capital con oportunidades ' || + 'de crecimiento. Combina múltiples estrategias ML para optimizar el ratio riesgo/retorno. ' || + 'Recomendado para traders con experiencia intermedia.', + 'ADVERTENCIA: Orion opera con riesgo moderado. Drawdown máximo esperado: 20%. ' || + 'Los resultados pueden variar significativamente según condiciones de mercado. ' || + 'Se recomienda un horizonte de inversión de al menos 3 meses.' + ) + ON CONFLICT (tenant_id, agent_type) DO UPDATE SET + name = EXCLUDED.name, + min_allocation = EXCLUDED.min_allocation, + max_allocation_per_user = EXCLUDED.max_allocation_per_user, + target_return_percent = EXCLUDED.target_return_percent, + max_drawdown_percent = EXCLUDED.max_drawdown_percent, + description = EXCLUDED.description, + updated_at = NOW(); + + -- Nova - Agresivo + INSERT INTO investment.agent_configs ( + tenant_id, agent_type, + name, + min_allocation, max_allocation_per_user, max_total_allocation, + platform_fee_rate, performance_fee_rate, + target_return_percent, max_drawdown_percent, + is_active, accepting_new_allocations, + description, risk_disclosure + ) VALUES ( + p_tenant_id, + 'NOVA', + 'Nova', + 500.00, -- Min $500 + 25000.00, -- Max $25,000 por usuario + 2000000.00, -- Max $2,000,000 total + 0.10, -- 10% platform fee + 0.25, -- 25% performance fee + 25.00, -- 25% target return + 35.00, -- 35% max drawdown + TRUE, + TRUE, + 'Nova es nuestro agente de alto rendimiento diseñado para traders experimentados que buscan ' || + 'maximizar retornos. Utiliza estrategias agresivas incluyendo Strategy Ensemble y posiciones ' || + 'de mayor tamaño. Solo para inversores que comprenden y aceptan alto riesgo.', + 'ALTO RIESGO: Nova opera con estrategias agresivas. Drawdown máximo esperado: 35%. ' || + 'Este agente puede experimentar pérdidas significativas en cortos periodos. ' || + 'SOLO PARA INVERSORES EXPERIMENTADOS que pueden tolerar alta volatilidad. ' || + 'No invierta más del 10% de su capital total en este agente.' + ) + ON CONFLICT (tenant_id, agent_type) DO UPDATE SET + name = EXCLUDED.name, + min_allocation = EXCLUDED.min_allocation, + max_allocation_per_user = EXCLUDED.max_allocation_per_user, + target_return_percent = EXCLUDED.target_return_percent, + max_drawdown_percent = EXCLUDED.max_drawdown_percent, + description = EXCLUDED.description, + updated_at = NOW(); +END; +$$ LANGUAGE plpgsql; + +-- Comentario sobre uso +COMMENT ON FUNCTION investment.seed_agent_configs IS +'Ejecutar con: SELECT investment.seed_agent_configs(''your-tenant-uuid'');'; + +-- Para desarrollo/demo, crear config para tenant demo si existe +-- DO $$ +-- DECLARE +-- v_demo_tenant_id UUID := '00000000-0000-0000-0000-000000000001'; +-- BEGIN +-- PERFORM investment.seed_agent_configs(v_demo_tenant_id); +-- END $$; diff --git a/seeds/_INDEX.sql b/seeds/_INDEX.sql new file mode 100644 index 0000000..261ff73 --- /dev/null +++ b/seeds/_INDEX.sql @@ -0,0 +1,61 @@ +-- ============================================================================ +-- SEEDS INDEX +-- DESCRIPTION: Orden de ejecución de seeds para trading-platform +-- VERSION: 1.0.0 +-- CREATED: 2026-01-10 +-- ============================================================================ + +-- IMPORTANTE: Ejecutar seeds en este orden después de los DDL schemas + +-- PASO 1: Asegurarse que todos los DDL están ejecutados +-- \i schemas/financial/001_wallets.sql +-- \i schemas/financial/002_wallet_transactions.sql +-- \i schemas/products/001_products.sql +-- \i schemas/vip/001_vip_system.sql +-- \i schemas/ml/001_predictions_marketplace.sql +-- \i schemas/investment/001_agent_allocations.sql + +-- PASO 2: Ejecutar seeds en orden +-- 001: VIP Tiers (sin dependencias) +-- 002: Products (sin dependencias) +-- 003: Prediction Packages (requiere ml schema) +-- 004: Agent Configs (requiere investment schema, ejecutar por tenant) + +-- ============================================================================ +-- EJECUCIÓN: +-- ============================================================================ + +-- Opción 1: Ejecutar individualmente +-- \i 001_vip_tiers.sql +-- \i 002_products.sql +-- \i 003_prediction_packages.sql +-- \i 004_agent_configs.sql + +-- Opción 2: Ejecutar este archivo (runner) +\echo 'Ejecutando seeds de trading-platform...' + +\echo ' [1/4] VIP Tiers...' +\i 001_vip_tiers.sql + +\echo ' [2/4] Products...' +\i 002_products.sql + +\echo ' [3/4] Prediction Packages...' +\i 003_prediction_packages.sql + +\echo ' [4/4] Agent Configs (función creada)...' +\i 004_agent_configs.sql + +\echo '' +\echo 'Seeds completados!' +\echo '' +\echo 'NOTA: Para configurar agentes por tenant, ejecutar:' +\echo ' SELECT investment.seed_agent_configs(''tenant-uuid'');' +\echo '' + +-- ============================================================================ +-- VERIFICACIÓN: +-- ============================================================================ +-- SELECT COUNT(*) as vip_tiers FROM vip.tiers; +-- SELECT COUNT(*) as products FROM products.products; +-- SELECT COUNT(*) as prediction_packages FROM ml.prediction_packages;