Initial commit - trading-platform-database

This commit is contained in:
rckrdmrd 2026-01-04 07:05:19 -06:00
commit ce9ae25a9b
122 changed files with 14338 additions and 0 deletions

View File

@ -0,0 +1,259 @@
# DIRECTIVA: Politica de Carga Limpia (DDL-First)
**ID:** DIR-DB-001
**Version:** 1.0.0
**Fecha:** 2025-12-06
**Estado:** ACTIVA
**Aplica a:** Todos los agentes que trabajen con base de datos
---
## OBJETIVO
Establecer una politica clara y obligatoria para la gestion del esquema de base de datos del proyecto Trading Platform (OrbiQuant IA), garantizando que la base de datos pueda ser creada o recreada completamente desde archivos DDL sin dependencia de migraciones incrementales.
---
## PRINCIPIO FUNDAMENTAL
> **La base de datos SIEMPRE debe poder ser creada desde cero ejecutando unicamente los archivos DDL.**
Esto significa:
- NO migraciones incrementales
- NO archivos de "fix" o "patch"
- NO scripts de correccion
- NO ALTER TABLE en archivos separados
---
## REGLAS OBLIGATORIAS
### 1. Estructura de Archivos
```
apps/database/
├── ddl/
│ └── schemas/
│ ├── {schema}/
│ │ ├── 00-enums.sql # Tipos enumerados
│ │ ├── tables/
│ │ │ ├── 01-{tabla}.sql # Una tabla por archivo
│ │ │ ├── 02-{tabla}.sql
│ │ │ └── ...
│ │ ├── functions/
│ │ │ ├── 01-{funcion}.sql
│ │ │ └── ...
│ │ ├── triggers/
│ │ │ └── ...
│ │ └── views/
│ │ └── ...
├── seeds/
│ ├── prod/ # Datos de produccion
│ └── dev/ # Datos de desarrollo
└── scripts/
├── create-database.sh # Crear BD
└── drop-and-recreate-database.sh # Recrear BD
```
### 2. Nomenclatura de Archivos
| Tipo | Patron | Ejemplo |
|------|--------|---------|
| Enums | `00-enums.sql` | `00-enums.sql` |
| Tablas | `NN-{nombre}.sql` | `01-users.sql`, `02-profiles.sql` |
| Funciones | `NN-{nombre}.sql` | `01-update_updated_at.sql` |
| Triggers | `NN-{nombre}.sql` | `01-audit_trigger.sql` |
| Views | `NN-{nombre}.sql` | `01-user_summary.sql` |
El numero `NN` indica el orden de ejecucion dentro de cada carpeta.
### 3. Modificaciones al Schema
**CORRECTO:**
```sql
-- Editar directamente el archivo DDL original
-- apps/database/ddl/schemas/auth/tables/01-users.sql
CREATE TABLE IF NOT EXISTS auth.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
-- Agregar nuevas columnas aqui
phone VARCHAR(20), -- <-- Nueva columna
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
**INCORRECTO:**
```sql
-- NO crear archivos de migracion
-- migrations/20251206_add_phone_to_users.sql <-- PROHIBIDO
ALTER TABLE auth.users ADD COLUMN phone VARCHAR(20);
```
### 4. Cuando Necesitas Cambiar el Schema
1. **Edita el archivo DDL original** de la tabla/funcion/trigger
2. **Ejecuta recreacion** en tu ambiente de desarrollo:
```bash
./scripts/drop-and-recreate-database.sh
```
3. **Verifica** que todo funcione correctamente
4. **Commit** los cambios al DDL
### 5. Prohibiciones Explicitas
| Accion | Permitido | Razon |
|--------|-----------|-------|
| Crear archivo `migrations/*.sql` | NO | Rompe carga limpia |
| Crear archivo `fix-*.sql` | NO | Rompe carga limpia |
| Crear archivo `patch-*.sql` | NO | Rompe carga limpia |
| Crear archivo `alter-*.sql` | NO | Rompe carga limpia |
| Usar `ALTER TABLE` en archivo separado | NO | Debe estar en DDL original |
| Modificar DDL original directamente | SI | Es la forma correcta |
---
## ESTANDARES TECNICOS
### Tipos de Datos
| Uso | Tipo Correcto | Tipo Incorrecto |
|-----|---------------|-----------------|
| Timestamps | `TIMESTAMPTZ` | `TIMESTAMP` |
| UUIDs | `gen_random_uuid()` | `uuid_generate_v4()` |
| Moneda | `DECIMAL(20,8)` | `FLOAT`, `DOUBLE` |
| Texto variable | `VARCHAR(n)` | `CHAR(n)` para texto variable |
### Convenciones SQL
```sql
-- Nombres en snake_case
CREATE TABLE auth.user_profiles ( -- Correcto
CREATE TABLE auth.UserProfiles ( -- Incorrecto
-- Siempre incluir schema
CREATE TABLE auth.users ( -- Correcto
CREATE TABLE users ( -- Incorrecto (usa public)
-- IF NOT EXISTS en CREATE
CREATE TABLE IF NOT EXISTS auth.users (
CREATE TYPE IF NOT EXISTS auth.user_status AS ENUM (
-- Indices con prefijo descriptivo
CREATE INDEX idx_users_email ON auth.users(email);
CREATE INDEX idx_users_created ON auth.users(created_at DESC);
```
### Foreign Keys
```sql
-- Siempre referenciar con schema completo
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Nunca asumir schema
user_id UUID NOT NULL REFERENCES users(id), -- INCORRECTO
```
---
## ORDEN DE CARGA
El script `create-database.sh` ejecuta en este orden:
1. **Extensiones**
- uuid-ossp
- pgcrypto
- pg_trgm
- btree_gin
- vector (si disponible)
2. **Schemas** (en orden)
- auth
- education
- financial
- trading
- investment
- ml
- llm
- audit
3. **Por cada schema:**
- 00-enums.sql (si existe)
- tables/*.sql (orden numerico)
- functions/*.sql (orden numerico)
- triggers/*.sql (orden numerico)
- views/*.sql (orden numerico)
4. **Seeds**
- prod/ o dev/ segun ambiente
---
## VALIDACION
### Pre-commit Checklist
Antes de hacer commit de cambios a DDL:
- [ ] No existen archivos `migrations/`, `fix-*`, `patch-*`, `alter-*`
- [ ] Todos los cambios estan en archivos DDL originales
- [ ] Se puede ejecutar `drop-and-recreate-database.sh` sin errores
- [ ] Todas las FKs usan schema completo (`auth.users`, no `users`)
- [ ] Todos los timestamps son `TIMESTAMPTZ`
- [ ] Todos los UUIDs usan `gen_random_uuid()`
### Script de Validacion
```bash
# Verificar que no hay archivos prohibidos
find apps/database -name "fix-*.sql" -o -name "patch-*.sql" -o -name "alter-*.sql"
# Debe retornar vacio
# Verificar que no hay carpeta migrations con contenido nuevo
ls apps/database/migrations/
# Solo debe existir si hay migraciones legacy (a eliminar)
```
---
## EXCEPCIONES
### Unica Excepcion: Datos de Produccion
Si hay datos de produccion que NO pueden perderse:
1. **Exportar datos** antes de recrear
2. **Recrear schema** con DDL limpio
3. **Importar datos** desde backup
Esto NO es una migracion, es un proceso de backup/restore.
---
## CONSECUENCIAS DE VIOLAR ESTA DIRECTIVA
1. **Build fallara** - CI/CD rechazara archivos prohibidos
2. **PR sera rechazado** - Code review detectara violaciones
3. **Deuda tecnica** - Se acumularan inconsistencias
---
## REFERENCIAS
- [_MAP.md - Database Schemas](../apps/database/schemas/_MAP.md)
- [DECISIONES-ARQUITECTONICAS.md](../docs/99-analisis/DECISIONES-ARQUITECTONICAS.md)
- [create-database.sh](../apps/database/scripts/create-database.sh)
---
## HISTORIAL DE CAMBIOS
| Version | Fecha | Cambio |
|---------|-------|--------|
| 1.0.0 | 2025-12-06 | Version inicial |
---
*Directiva establecida por Requirements-Analyst Agent*
*OrbiQuant IA Trading Platform*

26
ddl/00-extensions.sql Normal file
View File

@ -0,0 +1,26 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- File: 00-extensions.sql
-- Description: PostgreSQL extensions required globally
-- ============================================================================
-- UUID generation extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Cryptographic functions for password hashing and token generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Network address types and functions
CREATE EXTENSION IF NOT EXISTS "citext";
-- Full text search
CREATE EXTENSION IF NOT EXISTS "unaccent";
-- Trigram similarity for fuzzy text matching
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions';
COMMENT ON EXTENSION "pgcrypto" IS 'Cryptographic functions for secure password and token handling';
COMMENT ON EXTENSION "citext" IS 'Case-insensitive text type for email addresses';
COMMENT ON EXTENSION "unaccent" IS 'Text search dictionary that removes accents';
COMMENT ON EXTENSION "pg_trgm" IS 'Trigram matching for similarity searches';

37
ddl/01-schemas.sql Normal file
View File

@ -0,0 +1,37 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- File: 01-schemas.sql
-- Description: Database schemas creation
-- ============================================================================
-- Authentication and User Management
CREATE SCHEMA IF NOT EXISTS auth;
COMMENT ON SCHEMA auth IS 'Authentication, authorization, and user management';
-- Education and Learning
CREATE SCHEMA IF NOT EXISTS education;
COMMENT ON SCHEMA education IS 'Educational content, courses, and learning progress';
-- Trading Operations
CREATE SCHEMA IF NOT EXISTS trading;
COMMENT ON SCHEMA trading IS 'Trading bots, orders, positions, and market data';
-- Investment Management
CREATE SCHEMA IF NOT EXISTS investment;
COMMENT ON SCHEMA investment IS 'Investment products, accounts, and transactions';
-- Financial Operations
CREATE SCHEMA IF NOT EXISTS financial;
COMMENT ON SCHEMA financial IS 'Wallets, payments, subscriptions, and financial transactions';
-- Machine Learning
CREATE SCHEMA IF NOT EXISTS ml;
COMMENT ON SCHEMA ml IS 'ML models, predictions, and feature store';
-- Large Language Models
CREATE SCHEMA IF NOT EXISTS llm;
COMMENT ON SCHEMA llm IS 'LLM conversations, messages, and user preferences';
-- Audit and Compliance
CREATE SCHEMA IF NOT EXISTS audit;
COMMENT ON SCHEMA audit IS 'Audit logs, security events, and compliance tracking';

View File

@ -0,0 +1,63 @@
-- ============================================================================
-- AUDIT SCHEMA - ENUMs
-- ============================================================================
-- OrbiQuant IA Trading Platform
-- Schema: audit
-- Propósito: Tipos enumerados para auditoría y logging
-- ============================================================================
-- Tipo de evento de auditoría
CREATE TYPE audit.audit_event_type AS ENUM (
'create',
'read',
'update',
'delete',
'login',
'logout',
'permission_change',
'config_change',
'export',
'import'
);
-- Severidad del evento
CREATE TYPE audit.event_severity AS ENUM (
'debug',
'info',
'warning',
'error',
'critical'
);
-- Categoría del evento de seguridad
CREATE TYPE audit.security_event_category AS ENUM (
'authentication',
'authorization',
'data_access',
'configuration',
'suspicious_activity',
'compliance'
);
-- Estado del evento
CREATE TYPE audit.event_status AS ENUM (
'success',
'failure',
'blocked',
'pending_review'
);
-- Tipo de recurso auditado
CREATE TYPE audit.resource_type AS ENUM (
'user',
'account',
'transaction',
'order',
'position',
'bot',
'subscription',
'payment',
'course',
'model',
'system_config'
);

View File

@ -0,0 +1,54 @@
-- ============================================================================
-- AUDIT SCHEMA - Tabla: audit_logs
-- ============================================================================
-- Tabla principal de auditoría general
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Información del evento
event_type audit.audit_event_type NOT NULL,
event_status audit.event_status NOT NULL DEFAULT 'success',
severity audit.event_severity NOT NULL DEFAULT 'info',
-- Quién realizó la acción
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
session_id UUID,
ip_address INET,
user_agent TEXT,
-- Qué se modificó
resource_type audit.resource_type NOT NULL,
resource_id UUID,
resource_name VARCHAR(255),
-- Detalles del cambio
action VARCHAR(100) NOT NULL,
description TEXT,
old_values JSONB,
new_values JSONB,
metadata JSONB DEFAULT '{}',
-- Contexto
request_id UUID,
correlation_id UUID,
service_name VARCHAR(50),
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices para consultas frecuentes
CREATE INDEX idx_audit_logs_user_id ON audit.audit_logs(user_id);
CREATE INDEX idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_event_type ON audit.audit_logs(event_type);
CREATE INDEX idx_audit_logs_created_at ON audit.audit_logs(created_at DESC);
CREATE INDEX idx_audit_logs_severity ON audit.audit_logs(severity) WHERE severity IN ('error', 'critical');
CREATE INDEX idx_audit_logs_correlation ON audit.audit_logs(correlation_id) WHERE correlation_id IS NOT NULL;
-- Índice GIN para búsqueda en JSONB
CREATE INDEX idx_audit_logs_metadata ON audit.audit_logs USING GIN (metadata);
COMMENT ON TABLE audit.audit_logs IS 'Log general de auditoría para todas las acciones del sistema';
COMMENT ON COLUMN audit.audit_logs.correlation_id IS 'ID para correlacionar eventos relacionados en una misma operación';

View File

@ -0,0 +1,57 @@
-- ============================================================================
-- AUDIT SCHEMA - Tabla: security_events
-- ============================================================================
-- Eventos de seguridad específicos
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit.security_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Categorización
category audit.security_event_category NOT NULL,
severity audit.event_severity NOT NULL,
event_status audit.event_status NOT NULL DEFAULT 'success',
-- Actor
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
ip_address INET NOT NULL,
user_agent TEXT,
geo_location JSONB,
-- Detalles del evento
event_code VARCHAR(50) NOT NULL,
event_name VARCHAR(255) NOT NULL,
description TEXT,
-- Contexto técnico
request_path VARCHAR(500),
request_method VARCHAR(10),
response_code INTEGER,
-- Datos adicionales
risk_score DECIMAL(3, 2),
is_blocked BOOLEAN DEFAULT FALSE,
block_reason TEXT,
requires_review BOOLEAN DEFAULT FALSE,
reviewed_by UUID REFERENCES auth.users(id),
reviewed_at TIMESTAMPTZ,
review_notes TEXT,
-- Metadata
raw_data JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_security_events_user ON audit.security_events(user_id);
CREATE INDEX idx_security_events_category ON audit.security_events(category);
CREATE INDEX idx_security_events_severity ON audit.security_events(severity);
CREATE INDEX idx_security_events_ip ON audit.security_events(ip_address);
CREATE INDEX idx_security_events_created ON audit.security_events(created_at DESC);
CREATE INDEX idx_security_events_blocked ON audit.security_events(is_blocked) WHERE is_blocked = TRUE;
CREATE INDEX idx_security_events_review ON audit.security_events(requires_review) WHERE requires_review = TRUE;
COMMENT ON TABLE audit.security_events IS 'Eventos de seguridad para monitoreo y respuesta a incidentes';
COMMENT ON COLUMN audit.security_events.risk_score IS 'Puntuación de riesgo calculada (0.00-1.00)';

View File

@ -0,0 +1,47 @@
-- ============================================================================
-- AUDIT SCHEMA - Tabla: system_events
-- ============================================================================
-- Eventos del sistema (jobs, tareas programadas, errores)
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit.system_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Categorización
event_type VARCHAR(50) NOT NULL,
severity audit.event_severity NOT NULL DEFAULT 'info',
-- Origen
service_name VARCHAR(100) NOT NULL,
component VARCHAR(100),
environment VARCHAR(20) NOT NULL DEFAULT 'production',
hostname VARCHAR(255),
-- Detalles
event_name VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
stack_trace TEXT,
-- Contexto
correlation_id UUID,
job_id VARCHAR(100),
duration_ms INTEGER,
-- Metadata
metadata JSONB DEFAULT '{}',
tags TEXT[],
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_system_events_service ON audit.system_events(service_name);
CREATE INDEX idx_system_events_type ON audit.system_events(event_type);
CREATE INDEX idx_system_events_severity ON audit.system_events(severity);
CREATE INDEX idx_system_events_created ON audit.system_events(created_at DESC);
CREATE INDEX idx_system_events_correlation ON audit.system_events(correlation_id) WHERE correlation_id IS NOT NULL;
CREATE INDEX idx_system_events_job ON audit.system_events(job_id) WHERE job_id IS NOT NULL;
CREATE INDEX idx_system_events_tags ON audit.system_events USING GIN (tags);
COMMENT ON TABLE audit.system_events IS 'Eventos del sistema para monitoreo de infraestructura y jobs';

View File

@ -0,0 +1,57 @@
-- ============================================================================
-- AUDIT SCHEMA - Tabla: trading_audit
-- ============================================================================
-- Auditoría específica de operaciones de trading
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit.trading_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Actor
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
bot_id UUID,
-- Acción
action VARCHAR(50) NOT NULL,
action_status audit.event_status NOT NULL,
-- Objeto de la acción
order_id UUID,
position_id UUID,
symbol VARCHAR(20) NOT NULL,
-- Detalles de la operación
side VARCHAR(4) NOT NULL, -- 'buy' o 'sell'
order_type VARCHAR(20),
quantity DECIMAL(20, 8) NOT NULL,
price DECIMAL(20, 8),
executed_price DECIMAL(20, 8),
-- Resultado
pnl DECIMAL(20, 8),
fees DECIMAL(20, 8),
-- Contexto
strategy_id UUID,
signal_id UUID,
is_paper_trading BOOLEAN DEFAULT FALSE,
-- Metadata
execution_time_ms INTEGER,
broker_response JSONB,
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_trading_audit_user ON audit.trading_audit(user_id);
CREATE INDEX idx_trading_audit_bot ON audit.trading_audit(bot_id) WHERE bot_id IS NOT NULL;
CREATE INDEX idx_trading_audit_symbol ON audit.trading_audit(symbol);
CREATE INDEX idx_trading_audit_action ON audit.trading_audit(action);
CREATE INDEX idx_trading_audit_created ON audit.trading_audit(created_at DESC);
CREATE INDEX idx_trading_audit_order ON audit.trading_audit(order_id) WHERE order_id IS NOT NULL;
CREATE INDEX idx_trading_audit_position ON audit.trading_audit(position_id) WHERE position_id IS NOT NULL;
COMMENT ON TABLE audit.trading_audit IS 'Auditoría detallada de todas las operaciones de trading';

View File

@ -0,0 +1,49 @@
-- ============================================================================
-- AUDIT SCHEMA - Tabla: api_request_logs
-- ============================================================================
-- Log de requests a la API (para debugging y análisis)
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit.api_request_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Request
request_id UUID NOT NULL,
method VARCHAR(10) NOT NULL,
path VARCHAR(500) NOT NULL,
query_params JSONB,
headers JSONB,
body_size INTEGER,
-- Actor
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
api_key_id UUID,
ip_address INET NOT NULL,
user_agent TEXT,
-- Response
status_code INTEGER NOT NULL,
response_size INTEGER,
response_time_ms INTEGER NOT NULL,
-- Contexto
service_name VARCHAR(50),
version VARCHAR(20),
error_code VARCHAR(50),
error_message TEXT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices optimizados para consultas de análisis
CREATE INDEX idx_api_logs_user ON audit.api_request_logs(user_id);
CREATE INDEX idx_api_logs_path ON audit.api_request_logs(path);
CREATE INDEX idx_api_logs_status ON audit.api_request_logs(status_code);
CREATE INDEX idx_api_logs_created ON audit.api_request_logs(created_at DESC);
CREATE INDEX idx_api_logs_ip ON audit.api_request_logs(ip_address);
CREATE INDEX idx_api_logs_slow ON audit.api_request_logs(response_time_ms) WHERE response_time_ms > 1000;
CREATE INDEX idx_api_logs_errors ON audit.api_request_logs(status_code) WHERE status_code >= 400;
COMMENT ON TABLE audit.api_request_logs IS 'Log de requests HTTP para análisis y debugging';
COMMENT ON COLUMN audit.api_request_logs.body_size IS 'Tamaño del body en bytes (no se guarda contenido por seguridad)';

View File

@ -0,0 +1,45 @@
-- ============================================================================
-- AUDIT SCHEMA - Tabla: data_access_logs
-- ============================================================================
-- Log de acceso a datos sensibles (cumplimiento regulatorio)
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit.data_access_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Quién accedió
accessor_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
accessor_role VARCHAR(50) NOT NULL,
-- A qué datos se accedió
target_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
data_category VARCHAR(50) NOT NULL, -- 'pii', 'financial', 'health', 'credentials'
data_fields TEXT[], -- campos específicos accedidos
-- Cómo se accedió
access_type VARCHAR(20) NOT NULL, -- 'view', 'export', 'modify', 'delete'
access_reason TEXT,
-- Contexto
request_id UUID,
ip_address INET,
user_agent TEXT,
-- Compliance
consent_verified BOOLEAN DEFAULT FALSE,
legal_basis VARCHAR(100),
retention_days INTEGER,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_data_access_accessor ON audit.data_access_logs(accessor_user_id);
CREATE INDEX idx_data_access_target ON audit.data_access_logs(target_user_id);
CREATE INDEX idx_data_access_category ON audit.data_access_logs(data_category);
CREATE INDEX idx_data_access_type ON audit.data_access_logs(access_type);
CREATE INDEX idx_data_access_created ON audit.data_access_logs(created_at DESC);
COMMENT ON TABLE audit.data_access_logs IS 'Registro de acceso a datos sensibles para cumplimiento GDPR/CCPA';
COMMENT ON COLUMN audit.data_access_logs.legal_basis IS 'Base legal para el acceso (consentimiento, contrato, obligación legal, etc.)';

View File

@ -0,0 +1,52 @@
-- ============================================================================
-- AUDIT SCHEMA - Tabla: compliance_logs
-- ============================================================================
-- Log de eventos de cumplimiento regulatorio
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit.compliance_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Regulación
regulation VARCHAR(50) NOT NULL, -- 'GDPR', 'CCPA', 'SOX', 'PCI-DSS', 'MiFID'
requirement VARCHAR(100) NOT NULL,
-- Evento
event_type VARCHAR(50) NOT NULL,
event_description TEXT NOT NULL,
-- Actor
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
system_initiated BOOLEAN DEFAULT FALSE,
-- Estado
compliance_status VARCHAR(20) NOT NULL, -- 'compliant', 'non_compliant', 'remediation'
risk_level VARCHAR(20), -- 'low', 'medium', 'high', 'critical'
-- Detalles
evidence JSONB,
remediation_required BOOLEAN DEFAULT FALSE,
remediation_deadline TIMESTAMPTZ,
remediation_notes TEXT,
-- Revisión
reviewed_by UUID REFERENCES auth.users(id),
reviewed_at TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_compliance_regulation ON audit.compliance_logs(regulation);
CREATE INDEX idx_compliance_status ON audit.compliance_logs(compliance_status);
CREATE INDEX idx_compliance_risk ON audit.compliance_logs(risk_level);
CREATE INDEX idx_compliance_created ON audit.compliance_logs(created_at DESC);
CREATE INDEX idx_compliance_remediation ON audit.compliance_logs(remediation_required)
WHERE remediation_required = TRUE;
COMMENT ON TABLE audit.compliance_logs IS 'Registro de cumplimiento regulatorio para auditorías';

View File

@ -0,0 +1,19 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: 00-extensions.sql
-- Description: PostgreSQL extensions required for authentication schema
-- ============================================================================
-- UUID generation extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Cryptographic functions for password hashing and token generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Network address types and functions
CREATE EXTENSION IF NOT EXISTS "citext";
COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions';
COMMENT ON EXTENSION "pgcrypto" IS 'Cryptographic functions for secure password and token handling';
COMMENT ON EXTENSION "citext" IS 'Case-insensitive text type for email addresses';

View File

@ -0,0 +1,80 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: 01-enums.sql
-- Description: Enumerated types for authentication and authorization
-- ============================================================================
-- User account status
CREATE TYPE auth.user_status AS ENUM (
'pending_verification',
'active',
'suspended',
'deactivated',
'banned'
);
COMMENT ON TYPE auth.user_status IS 'User account status lifecycle states';
-- User role for RBAC
CREATE TYPE auth.user_role AS ENUM (
'user',
'trader',
'analyst',
'admin',
'super_admin'
);
COMMENT ON TYPE auth.user_role IS 'Role-based access control roles';
-- OAuth provider types
CREATE TYPE auth.oauth_provider AS ENUM (
'google',
'facebook',
'apple',
'github',
'microsoft',
'twitter'
);
COMMENT ON TYPE auth.oauth_provider IS 'Supported OAuth 2.0 providers';
-- Phone verification channel
CREATE TYPE auth.phone_channel AS ENUM (
'sms',
'whatsapp'
);
COMMENT ON TYPE auth.phone_channel IS 'Phone verification delivery channels';
-- Authentication event types for logging
CREATE TYPE auth.auth_event_type AS ENUM (
'login',
'logout',
'register',
'password_change',
'password_reset_request',
'password_reset_complete',
'email_verification',
'phone_verification',
'mfa_enabled',
'mfa_disabled',
'session_expired',
'account_suspended',
'account_reactivated',
'failed_login',
'oauth_linked',
'oauth_unlinked'
);
COMMENT ON TYPE auth.auth_event_type IS 'Types of authentication events for audit logging';
-- MFA method types
CREATE TYPE auth.mfa_method AS ENUM (
'none',
'totp',
'sms',
'email'
);
COMMENT ON TYPE auth.mfa_method IS 'Multi-factor authentication methods';

View File

@ -0,0 +1,48 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: functions/01-update_updated_at.sql
-- Description: Trigger function to automatically update updated_at timestamp
-- ============================================================================
CREATE OR REPLACE FUNCTION auth.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION auth.update_updated_at() IS 'Trigger function to automatically update updated_at column on row modification';
-- Apply trigger to all tables with updated_at column
-- Users table
CREATE TRIGGER trigger_update_users_updated_at
BEFORE UPDATE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at();
-- User profiles table
CREATE TRIGGER trigger_update_user_profiles_updated_at
BEFORE UPDATE ON auth.user_profiles
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at();
-- OAuth accounts table
CREATE TRIGGER trigger_update_oauth_accounts_updated_at
BEFORE UPDATE ON auth.oauth_accounts
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at();
-- Sessions table
CREATE TRIGGER trigger_update_sessions_updated_at
BEFORE UPDATE ON auth.sessions
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at();
-- Rate limiting config table
CREATE TRIGGER trigger_update_rate_limiting_config_updated_at
BEFORE UPDATE ON auth.rate_limiting_config
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at();

View File

@ -0,0 +1,75 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: functions/02-log_auth_event.sql
-- Description: Function to log authentication events to auth_logs table
-- ============================================================================
CREATE OR REPLACE FUNCTION auth.log_auth_event(
p_event_type auth.auth_event_type,
p_user_id UUID DEFAULT NULL,
p_email CITEXT DEFAULT NULL,
p_ip_address INET DEFAULT NULL,
p_user_agent TEXT DEFAULT NULL,
p_session_id UUID DEFAULT NULL,
p_success BOOLEAN DEFAULT true,
p_failure_reason VARCHAR(255) DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'::jsonb
)
RETURNS UUID AS $$
DECLARE
v_log_id UUID;
BEGIN
INSERT INTO auth.auth_logs (
event_type,
user_id,
email,
ip_address,
user_agent,
session_id,
success,
failure_reason,
metadata,
created_at
) VALUES (
p_event_type,
p_user_id,
p_email,
p_ip_address,
p_user_agent,
p_session_id,
p_success,
p_failure_reason,
p_metadata,
NOW()
)
RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION auth.log_auth_event(
auth.auth_event_type,
UUID,
CITEXT,
INET,
TEXT,
UUID,
BOOLEAN,
VARCHAR,
JSONB
) IS 'Logs authentication events to the auth_logs table with optional metadata';
-- Example usage:
-- SELECT auth.log_auth_event(
-- 'login'::auth.auth_event_type,
-- '123e4567-e89b-12d3-a456-426614174000'::UUID,
-- 'user@example.com'::CITEXT,
-- '192.168.1.1'::INET,
-- 'Mozilla/5.0...',
-- '123e4567-e89b-12d3-a456-426614174001'::UUID,
-- true,
-- NULL,
-- '{"device": "mobile"}'::JSONB
-- );

View File

@ -0,0 +1,58 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: functions/03-cleanup_expired_sessions.sql
-- Description: Function to cleanup expired and inactive sessions
-- ============================================================================
CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions(
p_batch_size INTEGER DEFAULT 1000
)
RETURNS TABLE(
deleted_count INTEGER,
execution_time_ms NUMERIC
) AS $$
DECLARE
v_start_time TIMESTAMPTZ;
v_deleted_count INTEGER;
BEGIN
v_start_time := clock_timestamp();
-- Delete expired sessions
WITH deleted AS (
DELETE FROM auth.sessions
WHERE (
-- Expired sessions
expires_at < NOW() OR
-- Inactive sessions older than 30 days
(is_active = false AND invalidated_at < NOW() - INTERVAL '30 days')
)
AND id IN (
SELECT id FROM auth.sessions
WHERE (
expires_at < NOW() OR
(is_active = false AND invalidated_at < NOW() - INTERVAL '30 days')
)
LIMIT p_batch_size
)
RETURNING *
)
SELECT COUNT(*)::INTEGER INTO v_deleted_count FROM deleted;
RETURN QUERY
SELECT
v_deleted_count,
EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time) * 1000)::NUMERIC;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION auth.cleanup_expired_sessions(INTEGER) IS
'Deletes expired and old inactive sessions in batches. Returns deleted count and execution time.';
-- Example usage:
-- SELECT * FROM auth.cleanup_expired_sessions(1000);
-- Recommended: Schedule this function to run periodically via pg_cron or external scheduler
-- Example pg_cron schedule (runs daily at 2 AM):
-- SELECT cron.schedule('cleanup-expired-sessions', '0 2 * * *',
-- 'SELECT auth.cleanup_expired_sessions(1000);');

View File

@ -0,0 +1,46 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: functions/04-create_user_profile_trigger.sql
-- Description: Automatically create user profile when new user is created
-- ============================================================================
CREATE OR REPLACE FUNCTION auth.create_user_profile()
RETURNS TRIGGER AS $$
BEGIN
-- Create a new user profile for the newly created user
INSERT INTO auth.user_profiles (
user_id,
language,
timezone,
newsletter_subscribed,
marketing_emails_enabled,
notifications_enabled,
created_at,
updated_at
) VALUES (
NEW.id,
'en',
'UTC',
false,
false,
true,
NOW(),
NOW()
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION auth.create_user_profile() IS
'Trigger function to automatically create a user profile when a new user is registered';
-- Create trigger on users table
CREATE TRIGGER trigger_create_user_profile
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION auth.create_user_profile();
COMMENT ON TRIGGER trigger_create_user_profile ON auth.users IS
'Automatically creates a user profile entry when a new user is inserted';

View File

@ -0,0 +1,107 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/01-users.sql
-- Description: Core users table for authentication and user management
-- ============================================================================
CREATE TABLE auth.users (
-- Primary Key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Authentication Credentials
email CITEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT false,
email_verified_at TIMESTAMPTZ,
password_hash VARCHAR(255),
-- User Status and Role
status auth.user_status NOT NULL DEFAULT 'pending_verification',
role auth.user_role NOT NULL DEFAULT 'user',
-- Multi-Factor Authentication
mfa_enabled BOOLEAN NOT NULL DEFAULT false,
mfa_method auth.mfa_method NOT NULL DEFAULT 'none',
mfa_secret VARCHAR(255),
backup_codes JSONB DEFAULT '[]',
-- Phone Information
phone_number VARCHAR(20),
phone_verified BOOLEAN NOT NULL DEFAULT false,
phone_verified_at TIMESTAMPTZ,
-- Security Settings
last_login_at TIMESTAMPTZ,
last_login_ip INET,
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TIMESTAMPTZ,
-- Account Lifecycle
suspended_at TIMESTAMPTZ,
suspended_reason TEXT,
deactivated_at TIMESTAMPTZ,
-- Audit Fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by_id UUID,
updated_by_id UUID,
-- Constraints
CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
CONSTRAINT password_or_oauth CHECK (password_hash IS NOT NULL OR EXISTS (
SELECT 1 FROM auth.oauth_accounts WHERE user_id = users.id
)),
CONSTRAINT failed_attempts_non_negative CHECK (failed_login_attempts >= 0),
CONSTRAINT email_verified_at_consistency CHECK (
(email_verified = true AND email_verified_at IS NOT NULL) OR
(email_verified = false AND email_verified_at IS NULL)
),
CONSTRAINT phone_verified_at_consistency CHECK (
(phone_verified = true AND phone_verified_at IS NOT NULL) OR
(phone_verified = false AND phone_verified_at IS NULL)
),
CONSTRAINT mfa_secret_consistency CHECK (
(mfa_enabled = true AND mfa_secret IS NOT NULL AND mfa_method != 'none') OR
(mfa_enabled = false)
)
);
-- Indexes for Performance
CREATE INDEX idx_users_email ON auth.users(email);
CREATE INDEX idx_users_status ON auth.users(status);
CREATE INDEX idx_users_role ON auth.users(role);
CREATE INDEX idx_users_last_login ON auth.users(last_login_at DESC);
CREATE INDEX idx_users_created_at ON auth.users(created_at DESC);
CREATE INDEX idx_users_email_verified ON auth.users(email_verified) WHERE email_verified = false;
CREATE INDEX idx_users_locked ON auth.users(locked_until) WHERE locked_until IS NOT NULL;
CREATE INDEX idx_users_phone ON auth.users(phone_number) WHERE phone_number IS NOT NULL;
-- Table Comments
COMMENT ON TABLE auth.users IS 'Core users table for authentication and user management';
-- Column Comments
COMMENT ON COLUMN auth.users.id IS 'Unique identifier for the user';
COMMENT ON COLUMN auth.users.email IS 'User email address (case-insensitive, unique)';
COMMENT ON COLUMN auth.users.email_verified IS 'Whether the email has been verified';
COMMENT ON COLUMN auth.users.email_verified_at IS 'Timestamp when email was verified';
COMMENT ON COLUMN auth.users.password_hash IS 'Bcrypt hashed password (null for OAuth-only users)';
COMMENT ON COLUMN auth.users.status IS 'Current status of the user account';
COMMENT ON COLUMN auth.users.role IS 'User role for role-based access control';
COMMENT ON COLUMN auth.users.mfa_enabled IS 'Whether multi-factor authentication is enabled';
COMMENT ON COLUMN auth.users.mfa_method IS 'MFA method used (totp, sms, email)';
COMMENT ON COLUMN auth.users.mfa_secret IS 'Secret key for TOTP MFA';
COMMENT ON COLUMN auth.users.phone_number IS 'User phone number for SMS verification';
COMMENT ON COLUMN auth.users.phone_verified IS 'Whether the phone number has been verified';
COMMENT ON COLUMN auth.users.phone_verified_at IS 'Timestamp when phone was verified';
COMMENT ON COLUMN auth.users.last_login_at IS 'Timestamp of last successful login';
COMMENT ON COLUMN auth.users.last_login_ip IS 'IP address of last successful login';
COMMENT ON COLUMN auth.users.failed_login_attempts IS 'Counter for failed login attempts';
COMMENT ON COLUMN auth.users.locked_until IS 'Account locked until this timestamp (null if not locked)';
COMMENT ON COLUMN auth.users.suspended_at IS 'Timestamp when account was suspended';
COMMENT ON COLUMN auth.users.suspended_reason IS 'Reason for account suspension';
COMMENT ON COLUMN auth.users.deactivated_at IS 'Timestamp when account was deactivated';
COMMENT ON COLUMN auth.users.created_at IS 'Timestamp when user was created';
COMMENT ON COLUMN auth.users.updated_at IS 'Timestamp when user was last updated';
COMMENT ON COLUMN auth.users.created_by_id IS 'ID of user who created this record';
COMMENT ON COLUMN auth.users.updated_by_id IS 'ID of user who last updated this record';

View File

@ -0,0 +1,70 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/02-user_profiles.sql
-- Description: Extended user profile information
-- ============================================================================
CREATE TABLE auth.user_profiles (
-- Primary Key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Foreign Key to Users
user_id UUID NOT NULL UNIQUE,
-- Personal Information
first_name VARCHAR(100),
last_name VARCHAR(100),
display_name VARCHAR(200),
avatar_url TEXT,
bio TEXT,
-- Localization
language VARCHAR(10) DEFAULT 'en',
timezone VARCHAR(50) DEFAULT 'UTC',
country_code VARCHAR(2),
-- Preferences
newsletter_subscribed BOOLEAN NOT NULL DEFAULT false,
marketing_emails_enabled BOOLEAN NOT NULL DEFAULT false,
notifications_enabled BOOLEAN NOT NULL DEFAULT true,
-- Metadata
metadata JSONB DEFAULT '{}'::jsonb,
-- Audit Fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Foreign Key Constraints
CONSTRAINT fk_user_profiles_user FOREIGN KEY (user_id)
REFERENCES auth.users(id)
ON DELETE CASCADE
);
-- Indexes for Performance
CREATE INDEX idx_user_profiles_user_id ON auth.user_profiles(user_id);
CREATE INDEX idx_user_profiles_display_name ON auth.user_profiles(display_name);
CREATE INDEX idx_user_profiles_country ON auth.user_profiles(country_code);
CREATE INDEX idx_user_profiles_metadata ON auth.user_profiles USING gin(metadata);
-- Table Comments
COMMENT ON TABLE auth.user_profiles IS 'Extended user profile information and preferences';
-- Column Comments
COMMENT ON COLUMN auth.user_profiles.id IS 'Unique identifier for the profile';
COMMENT ON COLUMN auth.user_profiles.user_id IS 'Reference to the user account';
COMMENT ON COLUMN auth.user_profiles.first_name IS 'User first name';
COMMENT ON COLUMN auth.user_profiles.last_name IS 'User last name';
COMMENT ON COLUMN auth.user_profiles.display_name IS 'Public display name';
COMMENT ON COLUMN auth.user_profiles.avatar_url IS 'URL to user avatar image';
COMMENT ON COLUMN auth.user_profiles.bio IS 'User biography or description';
COMMENT ON COLUMN auth.user_profiles.language IS 'Preferred language (ISO 639-1 code)';
COMMENT ON COLUMN auth.user_profiles.timezone IS 'User timezone (IANA timezone database)';
COMMENT ON COLUMN auth.user_profiles.country_code IS 'Country code (ISO 3166-1 alpha-2)';
COMMENT ON COLUMN auth.user_profiles.newsletter_subscribed IS 'Newsletter subscription preference';
COMMENT ON COLUMN auth.user_profiles.marketing_emails_enabled IS 'Marketing emails preference';
COMMENT ON COLUMN auth.user_profiles.notifications_enabled IS 'In-app notifications preference';
COMMENT ON COLUMN auth.user_profiles.metadata IS 'Additional profile metadata as JSON';
COMMENT ON COLUMN auth.user_profiles.created_at IS 'Timestamp when profile was created';
COMMENT ON COLUMN auth.user_profiles.updated_at IS 'Timestamp when profile was last updated';

View File

@ -0,0 +1,69 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/03-oauth_accounts.sql
-- Description: OAuth provider accounts linked to users
-- ============================================================================
CREATE TABLE auth.oauth_accounts (
-- Primary Key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Foreign Key to Users
user_id UUID NOT NULL,
-- OAuth Provider Information
provider auth.oauth_provider NOT NULL,
provider_account_id VARCHAR(255) NOT NULL,
provider_email CITEXT,
-- OAuth Tokens
access_token TEXT,
refresh_token TEXT,
token_expires_at TIMESTAMPTZ,
-- Provider Profile Data
profile_data JSONB DEFAULT '{}'::jsonb,
-- Audit Fields
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Foreign Key Constraints
CONSTRAINT fk_oauth_accounts_user FOREIGN KEY (user_id)
REFERENCES auth.users(id)
ON DELETE CASCADE,
-- Unique Constraint: One provider account per user
CONSTRAINT unique_user_provider UNIQUE (user_id, provider),
-- Unique Constraint: Provider account can only link to one user
CONSTRAINT unique_provider_account UNIQUE (provider, provider_account_id)
);
-- Indexes for Performance
CREATE INDEX idx_oauth_accounts_user_id ON auth.oauth_accounts(user_id);
CREATE INDEX idx_oauth_accounts_provider ON auth.oauth_accounts(provider);
CREATE INDEX idx_oauth_accounts_provider_email ON auth.oauth_accounts(provider_email);
CREATE INDEX idx_oauth_accounts_last_used ON auth.oauth_accounts(last_used_at DESC);
CREATE INDEX idx_oauth_accounts_profile_data ON auth.oauth_accounts USING gin(profile_data);
-- Table Comments
COMMENT ON TABLE auth.oauth_accounts IS 'OAuth provider accounts linked to users for social authentication';
-- Column Comments
COMMENT ON COLUMN auth.oauth_accounts.id IS 'Unique identifier for the OAuth account';
COMMENT ON COLUMN auth.oauth_accounts.user_id IS 'Reference to the user account';
COMMENT ON COLUMN auth.oauth_accounts.provider IS 'OAuth provider (google, facebook, etc.)';
COMMENT ON COLUMN auth.oauth_accounts.provider_account_id IS 'User ID from the OAuth provider';
COMMENT ON COLUMN auth.oauth_accounts.provider_email IS 'Email address from OAuth provider';
COMMENT ON COLUMN auth.oauth_accounts.access_token IS 'OAuth access token (encrypted)';
COMMENT ON COLUMN auth.oauth_accounts.refresh_token IS 'OAuth refresh token (encrypted)';
COMMENT ON COLUMN auth.oauth_accounts.token_expires_at IS 'Access token expiration timestamp';
COMMENT ON COLUMN auth.oauth_accounts.profile_data IS 'Profile data from OAuth provider as JSON';
COMMENT ON COLUMN auth.oauth_accounts.linked_at IS 'Timestamp when account was linked';
COMMENT ON COLUMN auth.oauth_accounts.last_used_at IS 'Timestamp when last used for authentication';
COMMENT ON COLUMN auth.oauth_accounts.created_at IS 'Timestamp when record was created';
COMMENT ON COLUMN auth.oauth_accounts.updated_at IS 'Timestamp when record was last updated';

View File

@ -0,0 +1,87 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/04-sessions.sql
-- Description: User session management for authentication
-- ============================================================================
CREATE TABLE auth.sessions (
-- Primary Key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Foreign Key to Users
user_id UUID NOT NULL,
-- Session Token
session_token VARCHAR(255) NOT NULL UNIQUE,
-- Session Lifecycle
expires_at TIMESTAMPTZ NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
-- Session Metadata
ip_address INET,
user_agent TEXT,
device_type VARCHAR(50),
device_name VARCHAR(100),
browser VARCHAR(50),
os VARCHAR(50),
-- Geolocation
country_code VARCHAR(2),
city VARCHAR(100),
-- Security
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
invalidated_at TIMESTAMPTZ,
invalidation_reason VARCHAR(100),
-- Audit Fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Foreign Key Constraints
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id)
REFERENCES auth.users(id)
ON DELETE CASCADE,
-- Check Constraints
CONSTRAINT valid_session_dates CHECK (expires_at > created_at),
CONSTRAINT invalidated_consistency CHECK (
(is_active = false AND invalidated_at IS NOT NULL) OR
(is_active = true AND invalidated_at IS NULL)
)
);
-- Indexes for Performance
CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id);
CREATE INDEX idx_sessions_token ON auth.sessions(session_token);
CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at);
CREATE INDEX idx_sessions_active ON auth.sessions(is_active, expires_at) WHERE is_active = true;
CREATE INDEX idx_sessions_last_activity ON auth.sessions(last_activity_at DESC);
CREATE INDEX idx_sessions_ip_address ON auth.sessions(ip_address);
CREATE INDEX idx_sessions_user_active ON auth.sessions(user_id, is_active, expires_at)
WHERE is_active = true;
-- Table Comments
COMMENT ON TABLE auth.sessions IS 'User session management for authentication and activity tracking';
-- Column Comments
COMMENT ON COLUMN auth.sessions.id IS 'Unique identifier for the session';
COMMENT ON COLUMN auth.sessions.user_id IS 'Reference to the user account';
COMMENT ON COLUMN auth.sessions.session_token IS 'Unique session token for authentication';
COMMENT ON COLUMN auth.sessions.expires_at IS 'Session expiration timestamp';
COMMENT ON COLUMN auth.sessions.is_active IS 'Whether the session is currently active';
COMMENT ON COLUMN auth.sessions.ip_address IS 'IP address of the session';
COMMENT ON COLUMN auth.sessions.user_agent IS 'User agent string from the browser';
COMMENT ON COLUMN auth.sessions.device_type IS 'Device type (desktop, mobile, tablet)';
COMMENT ON COLUMN auth.sessions.device_name IS 'Device name or model';
COMMENT ON COLUMN auth.sessions.browser IS 'Browser name and version';
COMMENT ON COLUMN auth.sessions.os IS 'Operating system name and version';
COMMENT ON COLUMN auth.sessions.country_code IS 'Country code from IP geolocation';
COMMENT ON COLUMN auth.sessions.city IS 'City from IP geolocation';
COMMENT ON COLUMN auth.sessions.last_activity_at IS 'Timestamp of last session activity';
COMMENT ON COLUMN auth.sessions.invalidated_at IS 'Timestamp when session was invalidated';
COMMENT ON COLUMN auth.sessions.invalidation_reason IS 'Reason for session invalidation';
COMMENT ON COLUMN auth.sessions.created_at IS 'Timestamp when session was created';
COMMENT ON COLUMN auth.sessions.updated_at IS 'Timestamp when session was last updated';

View File

@ -0,0 +1,65 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/05-email_verifications.sql
-- Description: Email verification tokens and tracking
-- ============================================================================
CREATE TABLE auth.email_verifications (
-- Primary Key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Foreign Key to Users
user_id UUID NOT NULL,
-- Email and Token
email CITEXT NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
-- Token Lifecycle
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
is_verified BOOLEAN NOT NULL DEFAULT false,
-- Metadata
ip_address INET,
user_agent TEXT,
-- Audit Fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Foreign Key Constraints
CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id)
REFERENCES auth.users(id)
ON DELETE CASCADE,
-- Check Constraints
CONSTRAINT valid_expiration CHECK (expires_at > created_at),
CONSTRAINT verified_consistency CHECK (
(is_verified = true AND verified_at IS NOT NULL) OR
(is_verified = false AND verified_at IS NULL)
)
);
-- Indexes for Performance
CREATE INDEX idx_email_verifications_user_id ON auth.email_verifications(user_id);
CREATE INDEX idx_email_verifications_token ON auth.email_verifications(token);
CREATE INDEX idx_email_verifications_email ON auth.email_verifications(email);
CREATE INDEX idx_email_verifications_expires ON auth.email_verifications(expires_at);
CREATE INDEX idx_email_verifications_pending ON auth.email_verifications(user_id, is_verified, expires_at)
WHERE is_verified = false;
-- Table Comments
COMMENT ON TABLE auth.email_verifications IS 'Email verification tokens and tracking for user email confirmation';
-- Column Comments
COMMENT ON COLUMN auth.email_verifications.id IS 'Unique identifier for the verification record';
COMMENT ON COLUMN auth.email_verifications.user_id IS 'Reference to the user account';
COMMENT ON COLUMN auth.email_verifications.email IS 'Email address to be verified';
COMMENT ON COLUMN auth.email_verifications.token IS 'Unique verification token sent to email';
COMMENT ON COLUMN auth.email_verifications.expires_at IS 'Token expiration timestamp';
COMMENT ON COLUMN auth.email_verifications.verified_at IS 'Timestamp when email was verified';
COMMENT ON COLUMN auth.email_verifications.is_verified IS 'Whether the email has been verified';
COMMENT ON COLUMN auth.email_verifications.ip_address IS 'IP address when verification was requested';
COMMENT ON COLUMN auth.email_verifications.user_agent IS 'User agent when verification was requested';
COMMENT ON COLUMN auth.email_verifications.created_at IS 'Timestamp when verification was created';

View File

@ -0,0 +1,78 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/06-phone_verifications.sql
-- Description: Phone number verification tokens and tracking
-- ============================================================================
CREATE TABLE auth.phone_verifications (
-- Primary Key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Foreign Key to Users
user_id UUID NOT NULL,
-- Phone and Verification Code
phone_number VARCHAR(20) NOT NULL,
verification_code VARCHAR(10) NOT NULL,
channel auth.phone_channel NOT NULL DEFAULT 'sms',
-- Token Lifecycle
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
is_verified BOOLEAN NOT NULL DEFAULT false,
-- Attempt Tracking
send_attempts INTEGER NOT NULL DEFAULT 0,
verification_attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
-- Metadata
ip_address INET,
user_agent TEXT,
-- Audit Fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Foreign Key Constraints
CONSTRAINT fk_phone_verifications_user FOREIGN KEY (user_id)
REFERENCES auth.users(id)
ON DELETE CASCADE,
-- Check Constraints
CONSTRAINT valid_expiration CHECK (expires_at > created_at),
CONSTRAINT verified_consistency CHECK (
(is_verified = true AND verified_at IS NOT NULL) OR
(is_verified = false AND verified_at IS NULL)
),
CONSTRAINT valid_attempts CHECK (
send_attempts >= 0 AND
verification_attempts >= 0 AND
verification_attempts <= max_attempts
)
);
-- Indexes for Performance
CREATE INDEX idx_phone_verifications_user_id ON auth.phone_verifications(user_id);
CREATE INDEX idx_phone_verifications_phone ON auth.phone_verifications(phone_number);
CREATE INDEX idx_phone_verifications_expires ON auth.phone_verifications(expires_at);
CREATE INDEX idx_phone_verifications_pending ON auth.phone_verifications(user_id, is_verified, expires_at)
WHERE is_verified = false;
-- Table Comments
COMMENT ON TABLE auth.phone_verifications IS 'Phone number verification codes and tracking for user phone confirmation';
-- Column Comments
COMMENT ON COLUMN auth.phone_verifications.id IS 'Unique identifier for the verification record';
COMMENT ON COLUMN auth.phone_verifications.user_id IS 'Reference to the user account';
COMMENT ON COLUMN auth.phone_verifications.phone_number IS 'Phone number to be verified';
COMMENT ON COLUMN auth.phone_verifications.verification_code IS 'Verification code sent via SMS';
COMMENT ON COLUMN auth.phone_verifications.expires_at IS 'Code expiration timestamp';
COMMENT ON COLUMN auth.phone_verifications.verified_at IS 'Timestamp when phone was verified';
COMMENT ON COLUMN auth.phone_verifications.is_verified IS 'Whether the phone has been verified';
COMMENT ON COLUMN auth.phone_verifications.send_attempts IS 'Number of times code was sent';
COMMENT ON COLUMN auth.phone_verifications.verification_attempts IS 'Number of verification attempts';
COMMENT ON COLUMN auth.phone_verifications.max_attempts IS 'Maximum allowed verification attempts';
COMMENT ON COLUMN auth.phone_verifications.ip_address IS 'IP address when verification was requested';
COMMENT ON COLUMN auth.phone_verifications.user_agent IS 'User agent when verification was requested';
COMMENT ON COLUMN auth.phone_verifications.created_at IS 'Timestamp when verification was created';

View File

@ -0,0 +1,65 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/07-password_reset_tokens.sql
-- Description: Password reset tokens and tracking
-- ============================================================================
CREATE TABLE auth.password_reset_tokens (
-- Primary Key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Foreign Key to Users
user_id UUID NOT NULL,
-- Email and Token
email CITEXT NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
-- Token Lifecycle
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
is_used BOOLEAN NOT NULL DEFAULT false,
-- Metadata
ip_address INET,
user_agent TEXT,
-- Audit Fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Foreign Key Constraints
CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id)
REFERENCES auth.users(id)
ON DELETE CASCADE,
-- Check Constraints
CONSTRAINT valid_expiration CHECK (expires_at > created_at),
CONSTRAINT used_consistency CHECK (
(is_used = true AND used_at IS NOT NULL) OR
(is_used = false AND used_at IS NULL)
)
);
-- Indexes for Performance
CREATE INDEX idx_password_reset_tokens_user_id ON auth.password_reset_tokens(user_id);
CREATE INDEX idx_password_reset_tokens_token ON auth.password_reset_tokens(token);
CREATE INDEX idx_password_reset_tokens_email ON auth.password_reset_tokens(email);
CREATE INDEX idx_password_reset_tokens_expires ON auth.password_reset_tokens(expires_at);
CREATE INDEX idx_password_reset_tokens_active ON auth.password_reset_tokens(user_id, is_used, expires_at)
WHERE is_used = false;
-- Table Comments
COMMENT ON TABLE auth.password_reset_tokens IS 'Password reset tokens for secure password recovery';
-- Column Comments
COMMENT ON COLUMN auth.password_reset_tokens.id IS 'Unique identifier for the reset token';
COMMENT ON COLUMN auth.password_reset_tokens.user_id IS 'Reference to the user account';
COMMENT ON COLUMN auth.password_reset_tokens.email IS 'Email address for password reset';
COMMENT ON COLUMN auth.password_reset_tokens.token IS 'Unique reset token sent to email';
COMMENT ON COLUMN auth.password_reset_tokens.expires_at IS 'Token expiration timestamp';
COMMENT ON COLUMN auth.password_reset_tokens.used_at IS 'Timestamp when token was used';
COMMENT ON COLUMN auth.password_reset_tokens.is_used IS 'Whether the token has been used';
COMMENT ON COLUMN auth.password_reset_tokens.ip_address IS 'IP address when reset was requested';
COMMENT ON COLUMN auth.password_reset_tokens.user_agent IS 'User agent when reset was requested';
COMMENT ON COLUMN auth.password_reset_tokens.created_at IS 'Timestamp when token was created';

View File

@ -0,0 +1,74 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/08-auth_logs.sql
-- Description: Authentication event audit logging with optional partitioning
-- ============================================================================
CREATE TABLE auth.auth_logs (
-- Primary Key
id UUID DEFAULT gen_random_uuid(),
-- Event Information
event_type auth.auth_event_type NOT NULL,
user_id UUID,
email CITEXT,
-- Request Context
ip_address INET,
user_agent TEXT,
session_id UUID,
-- Event Details
success BOOLEAN NOT NULL DEFAULT false,
failure_reason VARCHAR(255),
-- Additional Metadata
metadata JSONB DEFAULT '{}'::jsonb,
-- Timestamp (partition key)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Primary Key includes partition key for partitioned tables
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- Create initial partitions for current and next month
-- These should be created dynamically by a maintenance job in production
-- Current month partition
CREATE TABLE auth.auth_logs_current PARTITION OF auth.auth_logs
FOR VALUES FROM (DATE_TRUNC('month', CURRENT_DATE))
TO (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month'));
-- Next month partition
CREATE TABLE auth.auth_logs_next PARTITION OF auth.auth_logs
FOR VALUES FROM (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month'))
TO (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '2 months'));
-- Indexes for Performance (will be inherited by partitions)
CREATE INDEX idx_auth_logs_user_id ON auth.auth_logs(user_id, created_at DESC);
CREATE INDEX idx_auth_logs_email ON auth.auth_logs(email, created_at DESC);
CREATE INDEX idx_auth_logs_event_type ON auth.auth_logs(event_type, created_at DESC);
CREATE INDEX idx_auth_logs_ip_address ON auth.auth_logs(ip_address, created_at DESC);
CREATE INDEX idx_auth_logs_session_id ON auth.auth_logs(session_id);
CREATE INDEX idx_auth_logs_created_at ON auth.auth_logs(created_at DESC);
CREATE INDEX idx_auth_logs_failures ON auth.auth_logs(user_id, created_at DESC)
WHERE success = false;
CREATE INDEX idx_auth_logs_metadata ON auth.auth_logs USING gin(metadata);
-- Table Comments
COMMENT ON TABLE auth.auth_logs IS 'Authentication event audit logging with monthly partitioning for performance';
-- Column Comments
COMMENT ON COLUMN auth.auth_logs.id IS 'Unique identifier for the log entry';
COMMENT ON COLUMN auth.auth_logs.event_type IS 'Type of authentication event';
COMMENT ON COLUMN auth.auth_logs.user_id IS 'Reference to the user (null for failed logins)';
COMMENT ON COLUMN auth.auth_logs.email IS 'Email address associated with the event';
COMMENT ON COLUMN auth.auth_logs.ip_address IS 'IP address of the request';
COMMENT ON COLUMN auth.auth_logs.user_agent IS 'User agent string from the request';
COMMENT ON COLUMN auth.auth_logs.session_id IS 'Session ID if applicable';
COMMENT ON COLUMN auth.auth_logs.success IS 'Whether the event was successful';
COMMENT ON COLUMN auth.auth_logs.failure_reason IS 'Reason for failure if applicable';
COMMENT ON COLUMN auth.auth_logs.metadata IS 'Additional event metadata as JSON';
COMMENT ON COLUMN auth.auth_logs.created_at IS 'Timestamp when event occurred (partition key)';

View File

@ -0,0 +1,67 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/09-login_attempts.sql
-- Description: Login attempt tracking for rate limiting and security monitoring
-- ============================================================================
CREATE TABLE auth.login_attempts (
-- Primary Key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Attempt Information
email CITEXT,
user_id UUID,
-- Request Context
ip_address INET NOT NULL,
user_agent TEXT,
-- Attempt Result
success BOOLEAN NOT NULL,
failure_reason VARCHAR(100),
-- Additional Details
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Metadata
metadata JSONB DEFAULT '{}'::jsonb,
-- Audit Fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Check Constraints
CONSTRAINT login_attempt_has_identifier CHECK (
email IS NOT NULL OR user_id IS NOT NULL
),
CONSTRAINT failure_reason_consistency CHECK (
(success = false AND failure_reason IS NOT NULL) OR
(success = true AND failure_reason IS NULL)
)
);
-- Indexes for Performance
CREATE INDEX idx_login_attempts_email ON auth.login_attempts(email, created_at DESC);
CREATE INDEX idx_login_attempts_user_id ON auth.login_attempts(user_id, created_at DESC);
CREATE INDEX idx_login_attempts_ip ON auth.login_attempts(ip_address, created_at DESC);
CREATE INDEX idx_login_attempts_created ON auth.login_attempts(created_at DESC);
CREATE INDEX idx_login_attempts_failures ON auth.login_attempts(email, ip_address, created_at DESC)
WHERE success = false;
CREATE INDEX idx_login_attempts_success ON auth.login_attempts(email, created_at DESC)
WHERE success = true;
CREATE INDEX idx_login_attempts_metadata ON auth.login_attempts USING gin(metadata);
-- Table Comments
COMMENT ON TABLE auth.login_attempts IS 'Login attempt tracking for rate limiting, brute force detection, and security monitoring';
-- Column Comments
COMMENT ON COLUMN auth.login_attempts.id IS 'Unique identifier for the login attempt';
COMMENT ON COLUMN auth.login_attempts.email IS 'Email address used in login attempt';
COMMENT ON COLUMN auth.login_attempts.user_id IS 'User ID if resolved from email';
COMMENT ON COLUMN auth.login_attempts.ip_address IS 'IP address of the login attempt';
COMMENT ON COLUMN auth.login_attempts.user_agent IS 'User agent string from the request';
COMMENT ON COLUMN auth.login_attempts.success IS 'Whether the login attempt was successful';
COMMENT ON COLUMN auth.login_attempts.failure_reason IS 'Reason for login failure (invalid_password, account_locked, etc.)';
COMMENT ON COLUMN auth.login_attempts.attempted_at IS 'Timestamp when login was attempted';
COMMENT ON COLUMN auth.login_attempts.metadata IS 'Additional attempt metadata as JSON';
COMMENT ON COLUMN auth.login_attempts.created_at IS 'Timestamp when record was created';

View File

@ -0,0 +1,82 @@
-- ============================================================================
-- OrbiQuant IA - Trading Platform
-- Schema: auth
-- File: tables/10-rate_limiting_config.sql
-- Description: Rate limiting configuration for API endpoints and auth operations
-- ============================================================================
CREATE TABLE auth.rate_limiting_config (
-- Primary Key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Endpoint Configuration
endpoint VARCHAR(200) NOT NULL UNIQUE,
description TEXT,
-- Rate Limiting Parameters
max_requests INTEGER NOT NULL DEFAULT 100,
window_seconds INTEGER NOT NULL DEFAULT 60,
block_duration_seconds INTEGER DEFAULT 300,
-- Scope Configuration
scope VARCHAR(50) NOT NULL DEFAULT 'ip',
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Metadata
metadata JSONB DEFAULT '{}'::jsonb,
-- Audit Fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by_id UUID,
updated_by_id UUID,
-- Check Constraints
CONSTRAINT valid_rate_limits CHECK (
max_requests > 0 AND
window_seconds > 0 AND
(block_duration_seconds IS NULL OR block_duration_seconds > 0)
),
CONSTRAINT valid_scope CHECK (
scope IN ('ip', 'user', 'email', 'global')
)
);
-- Indexes for Performance
CREATE INDEX idx_rate_limiting_endpoint ON auth.rate_limiting_config(endpoint);
CREATE INDEX idx_rate_limiting_active ON auth.rate_limiting_config(is_active) WHERE is_active = true;
CREATE INDEX idx_rate_limiting_scope ON auth.rate_limiting_config(scope);
CREATE INDEX idx_rate_limiting_metadata ON auth.rate_limiting_config USING gin(metadata);
-- Insert Default Rate Limiting Rules
INSERT INTO auth.rate_limiting_config (endpoint, description, max_requests, window_seconds, block_duration_seconds, scope) VALUES
('/auth/login', 'Login endpoint rate limit', 5, 300, 900, 'ip'),
('/auth/register', 'Registration endpoint rate limit', 3, 3600, 1800, 'ip'),
('/auth/password-reset/request', 'Password reset request limit', 3, 3600, 1800, 'email'),
('/auth/password-reset/verify', 'Password reset verification limit', 5, 300, 900, 'ip'),
('/auth/verify-email', 'Email verification limit', 10, 3600, 1800, 'user'),
('/auth/verify-phone', 'Phone verification limit', 5, 3600, 1800, 'user'),
('/auth/refresh-token', 'Token refresh limit', 20, 300, 600, 'user'),
('/auth/logout', 'Logout endpoint limit', 10, 60, NULL, 'user'),
('/auth/mfa/enable', 'MFA enable limit', 5, 3600, NULL, 'user'),
('/auth/mfa/verify', 'MFA verification limit', 5, 300, 900, 'user');
-- Table Comments
COMMENT ON TABLE auth.rate_limiting_config IS 'Rate limiting configuration for API endpoints to prevent abuse and brute force attacks';
-- Column Comments
COMMENT ON COLUMN auth.rate_limiting_config.id IS 'Unique identifier for the configuration';
COMMENT ON COLUMN auth.rate_limiting_config.endpoint IS 'API endpoint path to rate limit';
COMMENT ON COLUMN auth.rate_limiting_config.description IS 'Description of the rate limit purpose';
COMMENT ON COLUMN auth.rate_limiting_config.max_requests IS 'Maximum requests allowed within the time window';
COMMENT ON COLUMN auth.rate_limiting_config.window_seconds IS 'Time window in seconds for rate limiting';
COMMENT ON COLUMN auth.rate_limiting_config.block_duration_seconds IS 'Duration to block after exceeding limit (null for no block)';
COMMENT ON COLUMN auth.rate_limiting_config.scope IS 'Scope of rate limit (ip, user, email, global)';
COMMENT ON COLUMN auth.rate_limiting_config.is_active IS 'Whether this rate limit is currently active';
COMMENT ON COLUMN auth.rate_limiting_config.metadata IS 'Additional configuration metadata as JSON';
COMMENT ON COLUMN auth.rate_limiting_config.created_at IS 'Timestamp when configuration was created';
COMMENT ON COLUMN auth.rate_limiting_config.updated_at IS 'Timestamp when configuration was last updated';
COMMENT ON COLUMN auth.rate_limiting_config.created_by_id IS 'ID of user who created this configuration';
COMMENT ON COLUMN auth.rate_limiting_config.updated_by_id IS 'ID of user who last updated this configuration';

View File

@ -0,0 +1,64 @@
-- =====================================================
-- ENUMS - Schema Education
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- PostgreSQL: 15+
-- =====================================================
-- Nivel de dificultad
CREATE TYPE education.difficulty_level AS ENUM (
'beginner',
'intermediate',
'advanced',
'expert'
);
-- Estado de curso
CREATE TYPE education.course_status AS ENUM (
'draft',
'published',
'archived'
);
-- Estado de enrollment
CREATE TYPE education.enrollment_status AS ENUM (
'active',
'completed',
'expired',
'cancelled'
);
-- Tipo de contenido de lección
CREATE TYPE education.lesson_content_type AS ENUM (
'video',
'article',
'interactive',
'quiz'
);
-- Tipo de pregunta de quiz
CREATE TYPE education.question_type AS ENUM (
'multiple_choice',
'true_false',
'multiple_select',
'fill_blank',
'code_challenge'
);
-- Tipo de logro/badge
CREATE TYPE education.achievement_type AS ENUM (
'course_completion',
'quiz_perfect_score',
'streak_milestone',
'level_up',
'special_event'
);
COMMENT ON TYPE education.difficulty_level IS 'Nivel de dificultad de cursos';
COMMENT ON TYPE education.course_status IS 'Estado del curso (draft, published, archived)';
COMMENT ON TYPE education.enrollment_status IS 'Estado de la inscripción del usuario';
COMMENT ON TYPE education.lesson_content_type IS 'Tipo de contenido de la lección';
COMMENT ON TYPE education.question_type IS 'Tipo de pregunta en quizzes';
COMMENT ON TYPE education.achievement_type IS 'Tipo de logro/badge';

View File

@ -0,0 +1,353 @@
# Schema: education
**Proyecto:** OrbiQuant IA (Trading Platform)
**Módulo:** OQI-002 - Education
**Especificación:** ET-EDU-001-database.md
**PostgreSQL:** 15+
---
## Descripción
Schema completo para el módulo educativo de OrbiQuant IA, implementando:
- Gestión de cursos, módulos y lecciones
- Sistema de enrollments y progreso de estudiantes
- Quizzes y evaluaciones
- Certificados de finalización
- Sistema de gamificación (XP, niveles, streaks, achievements)
- Reviews de cursos
- Activity logging
---
## Estructura de Archivos
```
education/
├── 00-enums.sql # Tipos ENUM
├── tables/
│ ├── 01-categories.sql # Categorías de cursos
│ ├── 02-courses.sql # Cursos
│ ├── 03-modules.sql # Módulos del curso
│ ├── 04-lessons.sql # Lecciones
│ ├── 05-enrollments.sql # Inscripciones de usuarios
│ ├── 06-progress.sql # Progreso en lecciones
│ ├── 07-quizzes.sql # Quizzes/evaluaciones
│ ├── 08-quiz_questions.sql # Preguntas de quiz
│ ├── 09-quiz_attempts.sql # Intentos de quiz
│ ├── 10-certificates.sql # Certificados
│ ├── 11-user_achievements.sql # Logros/badges
│ ├── 12-user_gamification_profile.sql # Perfil de gamificación
│ ├── 13-user_activity_log.sql # Log de actividades
│ └── 14-course_reviews.sql # Reviews de cursos
└── functions/
├── 01-update_updated_at.sql # Trigger updated_at
├── 02-update_enrollment_progress.sql # Actualizar progreso
├── 03-auto_complete_enrollment.sql # Auto-completar enrollment
├── 04-generate_certificate.sql # Generar certificados
├── 05-update_course_stats.sql # Actualizar estadísticas
├── 06-update_enrollment_count.sql # Contador de enrollments
├── 07-update_gamification_profile.sql # Sistema de gamificación
└── 08-views.sql # Vistas útiles
```
---
## Orden de Ejecución
Para crear el schema completo, ejecutar en este orden:
```bash
# 1. ENUMs
psql -f 00-enums.sql
# 2. Tablas (en orden de dependencias)
psql -f tables/01-categories.sql
psql -f tables/02-courses.sql
psql -f tables/03-modules.sql
psql -f tables/04-lessons.sql
psql -f tables/05-enrollments.sql
psql -f tables/06-progress.sql
psql -f tables/07-quizzes.sql
psql -f tables/08-quiz_questions.sql
psql -f tables/09-quiz_attempts.sql
psql -f tables/10-certificates.sql
psql -f tables/11-user_achievements.sql
psql -f tables/12-user_gamification_profile.sql
psql -f tables/13-user_activity_log.sql
psql -f tables/14-course_reviews.sql
# 3. Funciones y Triggers
psql -f functions/01-update_updated_at.sql
psql -f functions/02-update_enrollment_progress.sql
psql -f functions/03-auto_complete_enrollment.sql
psql -f functions/04-generate_certificate.sql
psql -f functions/05-update_course_stats.sql
psql -f functions/06-update_enrollment_count.sql
psql -f functions/07-update_gamification_profile.sql
psql -f functions/08-views.sql
```
---
## Tablas
### Principales
| Tabla | Descripción |
|-------|-------------|
| `categories` | Categorías de cursos con soporte para jerarquía |
| `courses` | Cursos del módulo educativo |
| `modules` | Módulos que agrupan lecciones |
| `lessons` | Lecciones individuales (video, artículo, interactivo) |
| `enrollments` | Inscripciones de usuarios a cursos |
| `progress` | Progreso del usuario en cada lección |
### Evaluación
| Tabla | Descripción |
|-------|-------------|
| `quizzes` | Quizzes/evaluaciones |
| `quiz_questions` | Preguntas de los quizzes |
| `quiz_attempts` | Intentos de usuarios en quizzes |
### Logros
| Tabla | Descripción |
|-------|-------------|
| `certificates` | Certificados de finalización |
| `user_achievements` | Logros/badges obtenidos |
### Gamificación
| Tabla | Descripción |
|-------|-------------|
| `user_gamification_profile` | XP, niveles, streaks, estadísticas |
| `user_activity_log` | Log de todas las actividades |
| `course_reviews` | Reviews y calificaciones de cursos |
---
## ENUMs
- `difficulty_level`: beginner, intermediate, advanced, expert
- `course_status`: draft, published, archived
- `enrollment_status`: active, completed, expired, cancelled
- `lesson_content_type`: video, article, interactive, quiz
- `question_type`: multiple_choice, true_false, multiple_select, fill_blank, code_challenge
- `achievement_type`: course_completion, quiz_perfect_score, streak_milestone, level_up, special_event
---
## Funciones Principales
### Gamificación
```sql
-- Actualizar XP del usuario
SELECT education.update_user_xp(
'user-uuid', -- user_id
100 -- xp_to_add
);
-- Actualizar streak del usuario
SELECT education.update_user_streak('user-uuid');
```
### Triggers Automáticos
- `updated_at`: Se actualiza automáticamente en todas las tablas
- `update_enrollment_progress()`: Calcula progreso al completar lecciones
- `auto_complete_enrollment()`: Completa enrollment al alcanzar 100%
- `generate_certificate_number()`: Genera número único de certificado
- `update_course_rating_stats()`: Actualiza rating promedio del curso
- `update_enrollment_count()`: Actualiza contador de enrollments
- `update_streak_on_activity()`: Actualiza streak en cada actividad
---
## Vistas
| Vista | Descripción |
|-------|-------------|
| `v_courses_with_stats` | Cursos con estadísticas agregadas |
| `v_user_course_progress` | Progreso del usuario por curso |
| `v_leaderboard_weekly` | Top 100 usuarios por XP semanal |
| `v_leaderboard_monthly` | Top 100 usuarios por XP mensual |
| `v_leaderboard_alltime` | Top 100 usuarios por XP total |
| `v_user_statistics` | Estadísticas completas del usuario |
| `v_popular_courses` | Top 50 cursos más populares |
---
## Dependencias
### Schemas externos
- `auth.users` - Tabla de usuarios (requerida)
### Extensions
- `gen_random_uuid()` - Built-in en PostgreSQL 13+
---
## Políticas de Seguridad (RLS)
Para habilitar Row Level Security (implementar según necesidad):
```sql
-- Habilitar RLS
ALTER TABLE education.enrollments ENABLE ROW LEVEL SECURITY;
ALTER TABLE education.progress ENABLE ROW LEVEL SECURITY;
ALTER TABLE education.quiz_attempts ENABLE ROW LEVEL SECURITY;
ALTER TABLE education.certificates ENABLE ROW LEVEL SECURITY;
-- Política: usuarios solo ven sus propios datos
CREATE POLICY user_own_data ON education.enrollments
FOR ALL
USING (user_id = current_setting('app.user_id')::UUID);
```
---
## Ejemplos de Uso
### Enrollar usuario a un curso
```sql
INSERT INTO education.enrollments (user_id, course_id)
VALUES ('user-uuid', 'course-uuid')
RETURNING *;
```
### Registrar progreso en lección
```sql
INSERT INTO education.progress (
user_id,
lesson_id,
enrollment_id,
is_completed,
watch_percentage
) VALUES (
'user-uuid',
'lesson-uuid',
'enrollment-uuid',
true,
100.00
);
-- Esto automáticamente actualizará el enrollment progress
```
### Completar quiz
```sql
INSERT INTO education.quiz_attempts (
user_id,
quiz_id,
enrollment_id,
is_completed,
is_passed,
user_answers,
score_percentage,
xp_earned
) VALUES (
'user-uuid',
'quiz-uuid',
'enrollment-uuid',
true,
true,
'[{"questionId": "q1", "answer": "A", "isCorrect": true}]'::jsonb,
85.00,
50
);
```
### Emitir certificado
```sql
INSERT INTO education.certificates (
user_id,
course_id,
enrollment_id,
user_name,
course_title,
completion_date,
final_score
) VALUES (
'user-uuid',
'course-uuid',
'enrollment-uuid',
'John Doe',
'Introducción al Trading',
CURRENT_DATE,
92.50
);
-- El número de certificado y código de verificación se generan automáticamente
```
### Agregar review a curso
```sql
INSERT INTO education.course_reviews (
user_id,
course_id,
enrollment_id,
rating,
title,
content
) VALUES (
'user-uuid',
'course-uuid',
'enrollment-uuid',
5,
'Excelente curso',
'Muy bien explicado y con ejemplos prácticos'
);
```
---
## Notas Importantes
1. **Referencias**: Todas las FKs a usuarios usan `auth.users(id)`
2. **Cascadas**: Las eliminaciones en CASCADE están definidas donde corresponde
3. **Índices**: Creados para optimizar queries frecuentes
4. **Constraints**: Validaciones de lógica de negocio implementadas
5. **JSONB**: Usado para metadata flexible (attachments, user_answers, etc.)
6. **Denormalización**: Algunas estadísticas están denormalizadas para performance
---
## Mantenimiento
### Resetear XP semanal/mensual
```sql
-- Resetear XP semanal (ejecutar cada lunes)
UPDATE education.user_gamification_profile SET weekly_xp = 0;
-- Resetear XP mensual (ejecutar el 1ro de cada mes)
UPDATE education.user_gamification_profile SET monthly_xp = 0;
```
### Recalcular estadísticas de curso
```sql
-- Recalcular total de módulos y lecciones
UPDATE education.courses c
SET
total_modules = (SELECT COUNT(*) FROM education.modules WHERE course_id = c.id),
total_lessons = (
SELECT COUNT(*)
FROM education.lessons l
JOIN education.modules m ON l.module_id = m.id
WHERE m.course_id = c.id
);
```
---
## Versión
**Versión:** 1.0.0
**Última actualización:** 2025-12-06

View File

@ -0,0 +1,458 @@
# Documentación Técnica - Schema Education
**Proyecto:** OrbiQuant IA (Trading Platform)
**Schema:** education
**PostgreSQL:** 15+
**Versión:** 1.0.0
---
## Estadísticas del Schema
- **ENUMs:** 6 tipos
- **Tablas:** 14 tablas
- **Funciones:** 8 funciones
- **Triggers:** 15+ triggers
- **Vistas:** 7 vistas
- **Índices:** 60+ índices
- **Total líneas SQL:** ~1,350 líneas
---
## Índices por Tabla
### categories
- `idx_categories_parent` - parent_id
- `idx_categories_slug` - slug
- `idx_categories_active` - is_active (WHERE is_active = true)
### courses
- `idx_courses_category` - category_id
- `idx_courses_slug` - slug
- `idx_courses_status` - status
- `idx_courses_difficulty` - difficulty_level
- `idx_courses_instructor` - instructor_id
- `idx_courses_published` - published_at (WHERE status = 'published')
### modules
- `idx_modules_course` - course_id
- `idx_modules_order` - course_id, display_order
### lessons
- `idx_lessons_module` - module_id
- `idx_lessons_order` - module_id, display_order
- `idx_lessons_type` - content_type
- `idx_lessons_preview` - is_preview (WHERE is_preview = true)
### enrollments
- `idx_enrollments_user` - user_id
- `idx_enrollments_course` - course_id
- `idx_enrollments_status` - status
- `idx_enrollments_user_active` - user_id, status (WHERE status = 'active')
### progress
- `idx_progress_user` - user_id
- `idx_progress_lesson` - lesson_id
- `idx_progress_enrollment` - enrollment_id
- `idx_progress_completed` - is_completed (WHERE is_completed = true)
- `idx_progress_user_enrollment` - user_id, enrollment_id
### quizzes
- `idx_quizzes_module` - module_id
- `idx_quizzes_lesson` - lesson_id
- `idx_quizzes_active` - is_active (WHERE is_active = true)
### quiz_questions
- `idx_quiz_questions_quiz` - quiz_id
- `idx_quiz_questions_order` - quiz_id, display_order
### quiz_attempts
- `idx_quiz_attempts_user` - user_id
- `idx_quiz_attempts_quiz` - quiz_id
- `idx_quiz_attempts_enrollment` - enrollment_id
- `idx_quiz_attempts_user_quiz` - user_id, quiz_id
- `idx_quiz_attempts_completed` - is_completed, completed_at
### certificates
- `idx_certificates_user` - user_id
- `idx_certificates_course` - course_id
- `idx_certificates_number` - certificate_number
- `idx_certificates_verification` - verification_code
### user_achievements
- `idx_user_achievements_user` - user_id
- `idx_user_achievements_type` - achievement_type
- `idx_user_achievements_earned` - earned_at DESC
- `idx_user_achievements_course` - course_id
### user_gamification_profile
- `idx_gamification_user` - user_id
- `idx_gamification_level` - current_level DESC
- `idx_gamification_xp` - total_xp DESC
- `idx_gamification_weekly` - weekly_xp DESC
- `idx_gamification_monthly` - monthly_xp DESC
### user_activity_log
- `idx_activity_user` - user_id
- `idx_activity_type` - activity_type
- `idx_activity_created` - created_at DESC
- `idx_activity_user_date` - user_id, created_at DESC
- `idx_activity_course` - course_id (WHERE course_id IS NOT NULL)
### course_reviews
- `idx_reviews_course` - course_id
- `idx_reviews_user` - user_id
- `idx_reviews_rating` - rating
- `idx_reviews_approved` - is_approved (WHERE is_approved = true)
- `idx_reviews_featured` - is_featured (WHERE is_featured = true)
- `idx_reviews_helpful` - helpful_votes DESC
---
## Constraints
### CHECK Constraints
**categories:**
- `valid_color_format` - Color debe ser formato #RRGGBB
**courses:**
- `valid_rating` - avg_rating >= 0 AND <= 5
- `valid_price` - price_usd >= 0
**lessons:**
- `video_fields_required` - Si content_type='video', video_url y video_duration_seconds requeridos
**enrollments:**
- `valid_progress` - progress_percentage >= 0 AND <= 100
- `valid_completion` - Si status='completed', completed_at y progress=100 requeridos
**progress:**
- `valid_watch_percentage` - watch_percentage >= 0 AND <= 100
- `completion_requires_date` - Si is_completed=true, completed_at requerido
**quizzes:**
- `valid_passing_score` - passing_score_percentage > 0 AND <= 100
- `quiz_association` - Debe tener module_id O lesson_id (no ambos)
**quiz_questions:**
- `valid_options` - Si question_type requiere options, options no puede ser NULL
**quiz_attempts:**
- `valid_score_percentage` - score_percentage >= 0 AND <= 100
**user_gamification_profile:**
- `valid_level` - current_level >= 1
- `valid_xp` - total_xp >= 0
- `valid_streak` - current_streak_days >= 0 AND longest_streak_days >= 0
- `valid_avg_score` - average_quiz_score >= 0 AND <= 100
**course_reviews:**
- `rating` - rating >= 1 AND <= 5
### UNIQUE Constraints
- `categories.slug` - UNIQUE
- `courses.slug` - UNIQUE
- `modules.unique_course_order` - UNIQUE(course_id, display_order)
- `lessons.unique_module_order` - UNIQUE(module_id, display_order)
- `enrollments.unique_user_course` - UNIQUE(user_id, course_id)
- `progress.unique_user_lesson` - UNIQUE(user_id, lesson_id)
- `certificates.certificate_number` - UNIQUE
- `certificates.verification_code` - UNIQUE
- `certificates.unique_user_course_cert` - UNIQUE(user_id, course_id)
- `user_gamification_profile.unique_user_gamification` - UNIQUE(user_id)
- `course_reviews.unique_user_course_review` - UNIQUE(user_id, course_id)
---
## Foreign Keys
### Relaciones con auth.users
- `courses.instructor_id``auth.users(id)` ON DELETE RESTRICT
- `enrollments.user_id``auth.users(id)` ON DELETE CASCADE
- `progress.user_id``auth.users(id)` ON DELETE CASCADE
- `quiz_attempts.user_id``auth.users(id)` ON DELETE CASCADE
- `certificates.user_id``auth.users(id)` ON DELETE CASCADE
- `user_achievements.user_id``auth.users(id)` ON DELETE CASCADE
- `user_gamification_profile.user_id``auth.users(id)` ON DELETE CASCADE
- `user_activity_log.user_id``auth.users(id)` ON DELETE CASCADE
- `course_reviews.user_id``auth.users(id)` ON DELETE CASCADE
- `course_reviews.approved_by``auth.users(id)`
### Relaciones internas
- `categories.parent_id``categories(id)` ON DELETE SET NULL
- `courses.category_id``categories(id)` ON DELETE RESTRICT
- `modules.course_id``courses(id)` ON DELETE CASCADE
- `modules.unlock_after_module_id``modules(id)` ON DELETE SET NULL
- `lessons.module_id``modules(id)` ON DELETE CASCADE
- `enrollments.course_id``courses(id)` ON DELETE RESTRICT
- `progress.lesson_id``lessons(id)` ON DELETE CASCADE
- `progress.enrollment_id``enrollments(id)` ON DELETE CASCADE
- `quizzes.module_id``modules(id)` ON DELETE CASCADE
- `quizzes.lesson_id``lessons(id)` ON DELETE CASCADE
- `quiz_questions.quiz_id``quizzes(id)` ON DELETE CASCADE
- `quiz_attempts.quiz_id``quizzes(id)` ON DELETE RESTRICT
- `quiz_attempts.enrollment_id``enrollments(id)` ON DELETE SET NULL
- `certificates.course_id``courses(id)` ON DELETE RESTRICT
- `certificates.enrollment_id``enrollments(id)` ON DELETE RESTRICT
- `user_achievements.course_id``courses(id)` ON DELETE SET NULL
- `user_achievements.quiz_id``quizzes(id)` ON DELETE SET NULL
- `user_activity_log.course_id``courses(id)` ON DELETE SET NULL
- `user_activity_log.lesson_id``lessons(id)` ON DELETE SET NULL
- `user_activity_log.quiz_id``quizzes(id)` ON DELETE SET NULL
- `course_reviews.course_id``courses(id)` ON DELETE CASCADE
- `course_reviews.enrollment_id``enrollments(id)` ON DELETE CASCADE
---
## Triggers
### Triggers de updated_at
Aplica a: categories, courses, modules, lessons, enrollments, progress, quizzes, quiz_questions, user_gamification_profile, course_reviews
**Función:** `education.update_updated_at_column()`
**Trigger:** `update_{table}_updated_at`
**Evento:** BEFORE UPDATE
**Acción:** Actualiza `updated_at = NOW()`
### Triggers de lógica de negocio
**update_enrollment_on_progress**
- Tabla: progress
- Función: `education.update_enrollment_progress()`
- Evento: AFTER INSERT OR UPDATE
- Condición: WHEN (NEW.is_completed = true)
- Acción: Recalcula progreso del enrollment
**auto_complete_enrollment_trigger**
- Tabla: enrollments
- Función: `education.auto_complete_enrollment()`
- Evento: BEFORE UPDATE
- Acción: Completa enrollment si progress >= 100%
**generate_certificate_number_trigger**
- Tabla: certificates
- Función: `education.generate_certificate_number()`
- Evento: BEFORE INSERT
- Acción: Genera certificate_number y verification_code
**update_course_rating_on_review_insert**
- Tabla: course_reviews
- Función: `education.update_course_rating_stats()`
- Evento: AFTER INSERT
- Acción: Actualiza avg_rating del curso
**update_course_rating_on_review_update**
- Tabla: course_reviews
- Función: `education.update_course_rating_stats()`
- Evento: AFTER UPDATE
- Condición: rating o is_approved cambió
- Acción: Actualiza avg_rating del curso
**update_course_rating_on_review_delete**
- Tabla: course_reviews
- Función: `education.update_course_rating_stats()`
- Evento: AFTER DELETE
- Acción: Actualiza avg_rating del curso
**update_enrollment_count_on_insert**
- Tabla: enrollments
- Función: `education.update_enrollment_count()`
- Evento: AFTER INSERT
- Acción: Incrementa contador en courses
**update_enrollment_count_on_delete**
- Tabla: enrollments
- Función: `education.update_enrollment_count()`
- Evento: AFTER DELETE
- Acción: Decrementa contador en courses
**update_streak_on_activity**
- Tabla: user_activity_log
- Función: `education.trigger_update_streak()`
- Evento: AFTER INSERT
- Acción: Actualiza streak del usuario
---
## Funciones Públicas
### education.update_user_xp(user_id UUID, xp_to_add INTEGER)
Actualiza XP del usuario y recalcula nivel.
**Parámetros:**
- `user_id`: UUID del usuario
- `xp_to_add`: Cantidad de XP a agregar
**Lógica:**
- Suma XP al total
- Calcula nuevo nivel basado en fórmula cuadrática
- Actualiza weekly_xp y monthly_xp
- Crea achievement si subió de nivel
**Ejemplo:**
```sql
SELECT education.update_user_xp(
'00000000-0000-0000-0000-000000000001',
100
);
```
### education.update_user_streak(user_id UUID)
Actualiza streak del usuario basado en actividad diaria.
**Parámetros:**
- `user_id`: UUID del usuario
**Lógica:**
- Verifica última actividad
- Incrementa streak si es día consecutivo
- Resetea streak si se rompió
- Crea achievement en milestones (7, 30, 100 días)
**Ejemplo:**
```sql
SELECT education.update_user_streak(
'00000000-0000-0000-0000-000000000001'
);
```
---
## Vistas Materializadas Recomendadas
Para mejorar performance en queries frecuentes:
```sql
-- Top cursos por enrollments (actualizar diariamente)
CREATE MATERIALIZED VIEW education.mv_top_courses AS
SELECT * FROM education.v_popular_courses;
CREATE UNIQUE INDEX ON education.mv_top_courses(id);
-- Leaderboards (actualizar cada hora)
CREATE MATERIALIZED VIEW education.mv_leaderboard_weekly AS
SELECT * FROM education.v_leaderboard_weekly;
CREATE UNIQUE INDEX ON education.mv_leaderboard_weekly(user_id);
```
---
## Optimizaciones Recomendadas
### 1. Particionamiento de user_activity_log
Para logs con alto volumen:
```sql
-- Particionar por mes
CREATE TABLE education.user_activity_log_2025_12
PARTITION OF education.user_activity_log
FOR VALUES FROM ('2025-12-01') TO ('2026-01-01');
```
### 2. Índices adicionales según uso
```sql
-- Si hay muchas búsquedas por título de curso
CREATE INDEX idx_courses_title_trgm ON education.courses
USING gin(title gin_trgm_ops);
-- Requiere extension pg_trgm
CREATE EXTENSION IF NOT EXISTS pg_trgm;
```
### 3. Vacuum y Analyze automático
```sql
-- Configurar autovacuum para tablas con alta escritura
ALTER TABLE education.user_activity_log
SET (autovacuum_vacuum_scale_factor = 0.01);
ALTER TABLE education.progress
SET (autovacuum_vacuum_scale_factor = 0.02);
```
---
## Monitoreo
### Queries útiles para monitoreo
**Tamaño de tablas:**
```sql
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname = 'education'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
```
**Índices no usados:**
```sql
SELECT
schemaname,
tablename,
indexname,
idx_scan
FROM pg_stat_user_indexes
WHERE schemaname = 'education' AND idx_scan = 0;
```
**Actividad de enrollments hoy:**
```sql
SELECT COUNT(*)
FROM education.enrollments
WHERE enrolled_at::date = CURRENT_DATE;
```
**Cursos más populares (últimos 7 días):**
```sql
SELECT
c.title,
COUNT(e.id) as new_enrollments
FROM education.courses c
LEFT JOIN education.enrollments e ON c.id = e.course_id
AND e.enrolled_at >= NOW() - INTERVAL '7 days'
GROUP BY c.id, c.title
ORDER BY new_enrollments DESC
LIMIT 10;
```
---
## Backup y Restore
### Backup solo del schema education
```bash
pg_dump -h localhost -U postgres -n education orbiquant > education_backup.sql
```
### Restore
```bash
psql -h localhost -U postgres orbiquant < education_backup.sql
```
---
## Versión y Changelog
**v1.0.0** (2025-12-06)
- Implementación inicial completa
- 14 tablas
- 8 funciones
- 7 vistas
- Sistema de gamificación completo
- Reviews de cursos
- Activity logging
---
**Documentación generada:** 2025-12-06
**Última revisión:** 2025-12-06

View File

@ -0,0 +1,69 @@
-- =====================================================
-- FUNCTION: education.update_updated_at_column()
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- Descripción: Actualiza automáticamente el campo updated_at
-- =====================================================
CREATE OR REPLACE FUNCTION education.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION education.update_updated_at_column() IS 'Actualiza automáticamente updated_at en cada UPDATE';
-- Aplicar trigger a todas las tablas relevantes
CREATE TRIGGER update_categories_updated_at
BEFORE UPDATE ON education.categories
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();
CREATE TRIGGER update_courses_updated_at
BEFORE UPDATE ON education.courses
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();
CREATE TRIGGER update_modules_updated_at
BEFORE UPDATE ON education.modules
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();
CREATE TRIGGER update_lessons_updated_at
BEFORE UPDATE ON education.lessons
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();
CREATE TRIGGER update_enrollments_updated_at
BEFORE UPDATE ON education.enrollments
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();
CREATE TRIGGER update_progress_updated_at
BEFORE UPDATE ON education.progress
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();
CREATE TRIGGER update_quizzes_updated_at
BEFORE UPDATE ON education.quizzes
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();
CREATE TRIGGER update_quiz_questions_updated_at
BEFORE UPDATE ON education.quiz_questions
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();
CREATE TRIGGER update_user_gamification_profile_updated_at
BEFORE UPDATE ON education.user_gamification_profile
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();
CREATE TRIGGER update_course_reviews_updated_at
BEFORE UPDATE ON education.course_reviews
FOR EACH ROW
EXECUTE FUNCTION education.update_updated_at_column();

View File

@ -0,0 +1,57 @@
-- =====================================================
-- FUNCTION: education.update_enrollment_progress()
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- Descripción: Actualiza el progreso del enrollment cuando se completa una lección
-- =====================================================
CREATE OR REPLACE FUNCTION education.update_enrollment_progress()
RETURNS TRIGGER AS $$
DECLARE
v_total_lessons INTEGER;
v_completed_lessons INTEGER;
v_progress_percentage DECIMAL(5,2);
BEGIN
-- Obtener total de lecciones obligatorias del curso
SELECT COUNT(*)
INTO v_total_lessons
FROM education.lessons l
JOIN education.modules m ON l.module_id = m.id
JOIN education.courses c ON m.course_id = c.id
WHERE c.id = (
SELECT course_id FROM education.enrollments WHERE id = NEW.enrollment_id
) AND l.is_mandatory = true;
-- Obtener lecciones completadas
SELECT COUNT(*)
INTO v_completed_lessons
FROM education.progress
WHERE enrollment_id = NEW.enrollment_id
AND is_completed = true;
-- Calcular porcentaje de progreso
v_progress_percentage := (v_completed_lessons::DECIMAL / NULLIF(v_total_lessons, 0)::DECIMAL) * 100;
-- Actualizar enrollment
UPDATE education.enrollments
SET
progress_percentage = COALESCE(v_progress_percentage, 0),
completed_lessons = v_completed_lessons,
total_lessons = v_total_lessons,
updated_at = NOW()
WHERE id = NEW.enrollment_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION education.update_enrollment_progress() IS 'Actualiza progreso del enrollment al completar lecciones';
-- Trigger para actualizar el progreso
CREATE TRIGGER update_enrollment_on_progress
AFTER INSERT OR UPDATE ON education.progress
FOR EACH ROW
WHEN (NEW.is_completed = true)
EXECUTE FUNCTION education.update_enrollment_progress();

View File

@ -0,0 +1,29 @@
-- =====================================================
-- FUNCTION: education.auto_complete_enrollment()
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- Descripción: Completa automáticamente el enrollment cuando alcanza 100%
-- =====================================================
CREATE OR REPLACE FUNCTION education.auto_complete_enrollment()
RETURNS TRIGGER AS $$
BEGIN
-- Si el progreso llegó al 100% y está activo, completarlo
IF NEW.progress_percentage >= 100 AND NEW.status = 'active' THEN
NEW.status := 'completed';
NEW.completed_at := NOW();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION education.auto_complete_enrollment() IS 'Completa automáticamente el enrollment al alcanzar 100%';
-- Trigger para auto-completar enrollment
CREATE TRIGGER auto_complete_enrollment_trigger
BEFORE UPDATE ON education.enrollments
FOR EACH ROW
EXECUTE FUNCTION education.auto_complete_enrollment();

View File

@ -0,0 +1,47 @@
-- =====================================================
-- FUNCTION: education.generate_certificate_number()
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- Descripción: Genera automáticamente el número de certificado y código de verificación
-- =====================================================
CREATE OR REPLACE FUNCTION education.generate_certificate_number()
RETURNS TRIGGER AS $$
DECLARE
v_year INTEGER;
v_sequence INTEGER;
BEGIN
v_year := EXTRACT(YEAR FROM NOW());
-- Obtener siguiente número de secuencia para el año
SELECT COALESCE(MAX(
CAST(SUBSTRING(certificate_number FROM 14) AS INTEGER)
), 0) + 1
INTO v_sequence
FROM education.certificates
WHERE certificate_number LIKE 'OQI-CERT-' || v_year || '-%';
-- Generar número de certificado: OQI-CERT-2025-00001
NEW.certificate_number := FORMAT('OQI-CERT-%s-%s',
v_year,
LPAD(v_sequence::TEXT, 5, '0')
);
-- Generar código de verificación único
NEW.verification_code := UPPER(
SUBSTRING(MD5(RANDOM()::TEXT || NOW()::TEXT) FROM 1 FOR 16)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION education.generate_certificate_number() IS 'Genera número de certificado y código de verificación automáticamente';
-- Trigger para generar número de certificado
CREATE TRIGGER generate_certificate_number_trigger
BEFORE INSERT ON education.certificates
FOR EACH ROW
EXECUTE FUNCTION education.generate_certificate_number();

View File

@ -0,0 +1,59 @@
-- =====================================================
-- FUNCTION: education.update_course_stats()
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- Descripción: Actualiza estadísticas denormalizadas del curso
-- =====================================================
-- Función para actualizar estadísticas de reviews
CREATE OR REPLACE FUNCTION education.update_course_rating_stats()
RETURNS TRIGGER AS $$
DECLARE
v_course_id UUID;
v_avg_rating DECIMAL(3,2);
v_total_reviews INTEGER;
BEGIN
-- Obtener course_id del NEW o OLD record
v_course_id := COALESCE(NEW.course_id, OLD.course_id);
-- Calcular promedio solo de reviews aprobadas
SELECT
COALESCE(AVG(rating), 0),
COUNT(*)
INTO v_avg_rating, v_total_reviews
FROM education.course_reviews
WHERE course_id = v_course_id
AND is_approved = true;
-- Actualizar estadísticas en el curso
UPDATE education.courses
SET
avg_rating = v_avg_rating,
total_reviews = v_total_reviews,
updated_at = NOW()
WHERE id = v_course_id;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION education.update_course_rating_stats() IS 'Actualiza avg_rating y total_reviews del curso';
-- Triggers para actualizar estadísticas
CREATE TRIGGER update_course_rating_on_review_insert
AFTER INSERT ON education.course_reviews
FOR EACH ROW
EXECUTE FUNCTION education.update_course_rating_stats();
CREATE TRIGGER update_course_rating_on_review_update
AFTER UPDATE ON education.course_reviews
FOR EACH ROW
WHEN (OLD.rating IS DISTINCT FROM NEW.rating OR OLD.is_approved IS DISTINCT FROM NEW.is_approved)
EXECUTE FUNCTION education.update_course_rating_stats();
CREATE TRIGGER update_course_rating_on_review_delete
AFTER DELETE ON education.course_reviews
FOR EACH ROW
EXECUTE FUNCTION education.update_course_rating_stats();

View File

@ -0,0 +1,41 @@
-- =====================================================
-- FUNCTION: education.update_enrollment_count()
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- Descripción: Actualiza el contador de enrollments en courses
-- =====================================================
CREATE OR REPLACE FUNCTION education.update_enrollment_count()
RETURNS TRIGGER AS $$
DECLARE
v_course_id UUID;
BEGIN
v_course_id := COALESCE(NEW.course_id, OLD.course_id);
-- Actualizar contador de enrollments
UPDATE education.courses
SET
total_enrollments = (
SELECT COUNT(*) FROM education.enrollments WHERE course_id = v_course_id
),
updated_at = NOW()
WHERE id = v_course_id;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION education.update_enrollment_count() IS 'Actualiza total_enrollments del curso';
-- Triggers para actualizar contador
CREATE TRIGGER update_enrollment_count_on_insert
AFTER INSERT ON education.enrollments
FOR EACH ROW
EXECUTE FUNCTION education.update_enrollment_count();
CREATE TRIGGER update_enrollment_count_on_delete
AFTER DELETE ON education.enrollments
FOR EACH ROW
EXECUTE FUNCTION education.update_enrollment_count();

View File

@ -0,0 +1,158 @@
-- =====================================================
-- FUNCTION: education.update_gamification_profile()
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: Función adicional para gamificación
-- Descripción: Actualiza el perfil de gamificación del usuario
-- =====================================================
-- Función para actualizar XP y nivel
CREATE OR REPLACE FUNCTION education.update_user_xp(
p_user_id UUID,
p_xp_to_add INTEGER
)
RETURNS VOID AS $$
DECLARE
v_profile RECORD;
v_new_total_xp INTEGER;
v_new_level INTEGER;
v_xp_to_next INTEGER;
BEGIN
-- Obtener o crear perfil
INSERT INTO education.user_gamification_profile (user_id)
VALUES (p_user_id)
ON CONFLICT (user_id) DO NOTHING;
-- Obtener perfil actual
SELECT * INTO v_profile
FROM education.user_gamification_profile
WHERE user_id = p_user_id;
-- Calcular nuevo XP total
v_new_total_xp := v_profile.total_xp + p_xp_to_add;
-- Calcular nuevo nivel (cada nivel requiere 100 XP más que el anterior)
-- Nivel 1: 0-99 XP, Nivel 2: 100-299 XP, Nivel 3: 300-599 XP, etc.
v_new_level := FLOOR((-100 + SQRT(10000 + 800 * v_new_total_xp)) / 200) + 1;
-- XP necesario para siguiente nivel
v_xp_to_next := (v_new_level * 100 + (v_new_level * (v_new_level - 1) * 100)) - v_new_total_xp;
-- Actualizar perfil
UPDATE education.user_gamification_profile
SET
total_xp = v_new_total_xp,
current_level = v_new_level,
xp_to_next_level = v_xp_to_next,
weekly_xp = weekly_xp + p_xp_to_add,
monthly_xp = monthly_xp + p_xp_to_add,
last_activity_date = CURRENT_DATE,
updated_at = NOW()
WHERE user_id = p_user_id;
-- Si subió de nivel, crear achievement
IF v_new_level > v_profile.current_level THEN
INSERT INTO education.user_achievements (
user_id,
achievement_type,
title,
description,
xp_bonus
) VALUES (
p_user_id,
'level_up',
'Level Up! - Nivel ' || v_new_level,
'Has alcanzado el nivel ' || v_new_level,
50
);
END IF;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION education.update_user_xp(UUID, INTEGER) IS 'Actualiza XP del usuario y recalcula nivel';
-- Función para actualizar streak
CREATE OR REPLACE FUNCTION education.update_user_streak(p_user_id UUID)
RETURNS VOID AS $$
DECLARE
v_last_activity DATE;
v_current_streak INTEGER;
v_longest_streak INTEGER;
BEGIN
-- Obtener o crear perfil
INSERT INTO education.user_gamification_profile (user_id)
VALUES (p_user_id)
ON CONFLICT (user_id) DO NOTHING;
-- Obtener datos actuales
SELECT last_activity_date, current_streak_days, longest_streak_days
INTO v_last_activity, v_current_streak, v_longest_streak
FROM education.user_gamification_profile
WHERE user_id = p_user_id;
-- Actualizar streak
IF v_last_activity IS NULL THEN
-- Primera actividad
v_current_streak := 1;
ELSIF v_last_activity = CURRENT_DATE THEN
-- Ya tuvo actividad hoy, no hacer nada
RETURN;
ELSIF v_last_activity = CURRENT_DATE - INTERVAL '1 day' THEN
-- Actividad día consecutivo
v_current_streak := v_current_streak + 1;
ELSE
-- Se rompió el streak
v_current_streak := 1;
END IF;
-- Actualizar longest streak si corresponde
IF v_current_streak > v_longest_streak THEN
v_longest_streak := v_current_streak;
-- Crear achievement por streak milestones
IF v_current_streak IN (7, 30, 100) THEN
INSERT INTO education.user_achievements (
user_id,
achievement_type,
title,
description,
xp_bonus,
metadata
) VALUES (
p_user_id,
'streak_milestone',
'Streak de ' || v_current_streak || ' días',
'Has mantenido una racha de ' || v_current_streak || ' días consecutivos',
v_current_streak * 5,
jsonb_build_object('streak_days', v_current_streak)
);
END IF;
END IF;
-- Actualizar perfil
UPDATE education.user_gamification_profile
SET
current_streak_days = v_current_streak,
longest_streak_days = v_longest_streak,
last_activity_date = CURRENT_DATE,
updated_at = NOW()
WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION education.update_user_streak(UUID) IS 'Actualiza streak del usuario basado en actividad diaria';
-- Trigger para actualizar streak en actividades
CREATE OR REPLACE FUNCTION education.trigger_update_streak()
RETURNS TRIGGER AS $$
BEGIN
PERFORM education.update_user_streak(NEW.user_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_streak_on_activity
AFTER INSERT ON education.user_activity_log
FOR EACH ROW
EXECUTE FUNCTION education.trigger_update_streak();

View File

@ -0,0 +1,142 @@
-- =====================================================
-- VIEWS - Schema Education
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
-- Vista: Cursos con estadísticas completas
CREATE OR REPLACE VIEW education.v_courses_with_stats AS
SELECT
c.*,
cat.name as category_name,
cat.slug as category_slug,
COUNT(DISTINCT m.id) as modules_count,
COUNT(DISTINCT l.id) as lessons_count,
SUM(l.video_duration_seconds) as total_duration_seconds,
COUNT(DISTINCT e.id) as enrollments_count,
COUNT(DISTINCT CASE WHEN e.status = 'completed' THEN e.id END) as completions_count
FROM education.courses c
LEFT JOIN education.categories cat ON c.category_id = cat.id
LEFT JOIN education.modules m ON c.id = m.course_id
LEFT JOIN education.lessons l ON m.id = l.module_id
LEFT JOIN education.enrollments e ON c.id = e.course_id
GROUP BY c.id, cat.name, cat.slug;
COMMENT ON VIEW education.v_courses_with_stats IS 'Cursos con estadísticas agregadas de módulos, lecciones y enrollments';
-- Vista: Progreso del usuario por curso
CREATE OR REPLACE VIEW education.v_user_course_progress AS
SELECT
e.user_id,
e.course_id,
c.title as course_title,
c.slug as course_slug,
c.thumbnail_url,
e.status as enrollment_status,
e.progress_percentage,
e.enrolled_at,
e.completed_at,
e.total_xp_earned,
COUNT(DISTINCT p.id) as lessons_viewed,
COUNT(DISTINCT CASE WHEN p.is_completed THEN p.id END) as lessons_completed
FROM education.enrollments e
JOIN education.courses c ON e.course_id = c.id
LEFT JOIN education.progress p ON e.id = p.enrollment_id
GROUP BY e.id, e.user_id, e.course_id, c.title, c.slug, c.thumbnail_url;
COMMENT ON VIEW education.v_user_course_progress IS 'Progreso detallado del usuario en cada curso enrollado';
-- Vista: Leaderboard de usuarios
CREATE OR REPLACE VIEW education.v_leaderboard_weekly AS
SELECT
ugp.user_id,
ugp.weekly_xp,
ugp.current_level,
ugp.current_streak_days,
RANK() OVER (ORDER BY ugp.weekly_xp DESC) as rank
FROM education.user_gamification_profile ugp
WHERE ugp.weekly_xp > 0
ORDER BY ugp.weekly_xp DESC
LIMIT 100;
COMMENT ON VIEW education.v_leaderboard_weekly IS 'Top 100 usuarios por XP semanal';
CREATE OR REPLACE VIEW education.v_leaderboard_monthly AS
SELECT
ugp.user_id,
ugp.monthly_xp,
ugp.current_level,
ugp.current_streak_days,
RANK() OVER (ORDER BY ugp.monthly_xp DESC) as rank
FROM education.user_gamification_profile ugp
WHERE ugp.monthly_xp > 0
ORDER BY ugp.monthly_xp DESC
LIMIT 100;
COMMENT ON VIEW education.v_leaderboard_monthly IS 'Top 100 usuarios por XP mensual';
CREATE OR REPLACE VIEW education.v_leaderboard_alltime AS
SELECT
ugp.user_id,
ugp.total_xp,
ugp.current_level,
ugp.total_courses_completed,
RANK() OVER (ORDER BY ugp.total_xp DESC) as rank
FROM education.user_gamification_profile ugp
WHERE ugp.total_xp > 0
ORDER BY ugp.total_xp DESC
LIMIT 100;
COMMENT ON VIEW education.v_leaderboard_alltime IS 'Top 100 usuarios por XP total histórico';
-- Vista: Estadísticas del usuario
CREATE OR REPLACE VIEW education.v_user_statistics AS
SELECT
ugp.user_id,
ugp.total_xp,
ugp.current_level,
ugp.xp_to_next_level,
ugp.current_streak_days,
ugp.longest_streak_days,
ugp.total_courses_completed,
ugp.total_lessons_completed,
ugp.total_quizzes_passed,
ugp.total_certificates_earned,
ugp.average_quiz_score,
COUNT(DISTINCT e.id) as total_enrollments,
COUNT(DISTINCT CASE WHEN e.status = 'active' THEN e.id END) as active_enrollments,
COUNT(DISTINCT ua.id) as total_achievements
FROM education.user_gamification_profile ugp
LEFT JOIN education.enrollments e ON ugp.user_id = e.user_id
LEFT JOIN education.user_achievements ua ON ugp.user_id = ua.user_id
GROUP BY ugp.user_id, ugp.total_xp, ugp.current_level, ugp.xp_to_next_level,
ugp.current_streak_days, ugp.longest_streak_days, ugp.total_courses_completed,
ugp.total_lessons_completed, ugp.total_quizzes_passed, ugp.total_certificates_earned,
ugp.average_quiz_score;
COMMENT ON VIEW education.v_user_statistics IS 'Estadísticas completas del usuario (gamificación + progreso)';
-- Vista: Cursos populares
CREATE OR REPLACE VIEW education.v_popular_courses AS
SELECT
c.id,
c.title,
c.slug,
c.thumbnail_url,
c.difficulty_level,
c.avg_rating,
c.total_reviews,
c.total_enrollments,
COUNT(DISTINCT e.id) as recent_enrollments_30d,
COUNT(DISTINCT CASE WHEN e.status = 'completed' THEN e.id END) as completions
FROM education.courses c
LEFT JOIN education.enrollments e ON c.id = e.course_id
AND e.enrolled_at >= NOW() - INTERVAL '30 days'
WHERE c.status = 'published'
GROUP BY c.id
ORDER BY recent_enrollments_30d DESC, c.avg_rating DESC
LIMIT 50;
COMMENT ON VIEW education.v_popular_courses IS 'Top 50 cursos más populares (enrollments últimos 30 días)';

132
ddl/schemas/education/install.sh Executable file
View File

@ -0,0 +1,132 @@
#!/bin/bash
# =====================================================
# INSTALL SCRIPT - Schema Education
# =====================================================
# Proyecto: OrbiQuant IA (Trading Platform)
# Módulo: OQI-002 - Education
# Especificación: ET-EDU-001-database.md
# =====================================================
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Configuration
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-orbiquant}"
DB_USER="${DB_USER:-postgres}"
SCHEMA_NAME="education"
echo -e "${GREEN}=================================================${NC}"
echo -e "${GREEN} OrbiQuant IA - Education Schema Installation${NC}"
echo -e "${GREEN}=================================================${NC}"
echo ""
# Check if psql is available
if ! command -v psql &> /dev/null; then
echo -e "${RED}Error: psql command not found${NC}"
echo "Please install PostgreSQL client"
exit 1
fi
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "Configuration:"
echo " Database: $DB_NAME"
echo " Host: $DB_HOST:$DB_PORT"
echo " User: $DB_USER"
echo " Schema: $SCHEMA_NAME"
echo ""
# Function to execute SQL file
execute_sql() {
local file=$1
local description=$2
echo -e "${YELLOW}${NC} $description"
if [ ! -f "$file" ]; then
echo -e "${RED} ✗ File not found: $file${NC}"
return 1
fi
if PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$file" > /dev/null 2>&1; then
echo -e "${GREEN} ✓ Success${NC}"
return 0
else
echo -e "${RED} ✗ Failed${NC}"
return 1
fi
}
# Create schema if not exists
echo -e "${YELLOW}${NC} Creating schema: $SCHEMA_NAME"
PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "CREATE SCHEMA IF NOT EXISTS $SCHEMA_NAME;" > /dev/null 2>&1
echo -e "${GREEN} ✓ Schema created/verified${NC}"
echo ""
# 1. Install ENUMs
echo -e "${GREEN}[1/3] Installing ENUMs...${NC}"
execute_sql "$SCRIPT_DIR/00-enums.sql" "Creating ENUM types"
echo ""
# 2. Install Tables
echo -e "${GREEN}[2/3] Installing Tables...${NC}"
execute_sql "$SCRIPT_DIR/tables/01-categories.sql" "Creating table: categories"
execute_sql "$SCRIPT_DIR/tables/02-courses.sql" "Creating table: courses"
execute_sql "$SCRIPT_DIR/tables/03-modules.sql" "Creating table: modules"
execute_sql "$SCRIPT_DIR/tables/04-lessons.sql" "Creating table: lessons"
execute_sql "$SCRIPT_DIR/tables/05-enrollments.sql" "Creating table: enrollments"
execute_sql "$SCRIPT_DIR/tables/06-progress.sql" "Creating table: progress"
execute_sql "$SCRIPT_DIR/tables/07-quizzes.sql" "Creating table: quizzes"
execute_sql "$SCRIPT_DIR/tables/08-quiz_questions.sql" "Creating table: quiz_questions"
execute_sql "$SCRIPT_DIR/tables/09-quiz_attempts.sql" "Creating table: quiz_attempts"
execute_sql "$SCRIPT_DIR/tables/10-certificates.sql" "Creating table: certificates"
execute_sql "$SCRIPT_DIR/tables/11-user_achievements.sql" "Creating table: user_achievements"
execute_sql "$SCRIPT_DIR/tables/12-user_gamification_profile.sql" "Creating table: user_gamification_profile"
execute_sql "$SCRIPT_DIR/tables/13-user_activity_log.sql" "Creating table: user_activity_log"
execute_sql "$SCRIPT_DIR/tables/14-course_reviews.sql" "Creating table: course_reviews"
echo ""
# 3. Install Functions and Triggers
echo -e "${GREEN}[3/3] Installing Functions and Triggers...${NC}"
execute_sql "$SCRIPT_DIR/functions/01-update_updated_at.sql" "Creating trigger: update_updated_at"
execute_sql "$SCRIPT_DIR/functions/02-update_enrollment_progress.sql" "Creating function: update_enrollment_progress"
execute_sql "$SCRIPT_DIR/functions/03-auto_complete_enrollment.sql" "Creating function: auto_complete_enrollment"
execute_sql "$SCRIPT_DIR/functions/04-generate_certificate.sql" "Creating function: generate_certificate_number"
execute_sql "$SCRIPT_DIR/functions/05-update_course_stats.sql" "Creating function: update_course_stats"
execute_sql "$SCRIPT_DIR/functions/06-update_enrollment_count.sql" "Creating function: update_enrollment_count"
execute_sql "$SCRIPT_DIR/functions/07-update_gamification_profile.sql" "Creating functions: gamification"
execute_sql "$SCRIPT_DIR/functions/08-views.sql" "Creating views"
echo ""
# Verify installation
echo -e "${YELLOW}${NC} Verifying installation..."
TABLE_COUNT=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA_NAME' AND table_type = 'BASE TABLE';" 2>/dev/null | xargs)
if [ "$TABLE_COUNT" -eq "14" ]; then
echo -e "${GREEN} ✓ All 14 tables created successfully${NC}"
else
echo -e "${RED} ✗ Expected 14 tables, found $TABLE_COUNT${NC}"
fi
echo ""
echo -e "${GREEN}=================================================${NC}"
echo -e "${GREEN} Installation Complete!${NC}"
echo -e "${GREEN}=================================================${NC}"
echo ""
echo "Schema '$SCHEMA_NAME' has been installed successfully."
echo ""
echo "Next steps:"
echo " 1. Review the README.md for usage examples"
echo " 2. Run seed data scripts if needed"
echo " 3. Configure Row Level Security (RLS) policies"
echo ""

View File

@ -0,0 +1,238 @@
-- =====================================================
-- SEED DATA - Schema Education (EJEMPLO)
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
-- NOTA: Este es un archivo de ejemplo.
-- Los datos reales deben ir en /apps/database/seeds/
-- =====================================================
BEGIN;
-- =====================================================
-- 1. CATEGORIES
-- =====================================================
INSERT INTO education.categories (id, name, slug, description, color, display_order, is_active) VALUES
('11111111-1111-1111-1111-111111111111', 'Trading Básico', 'trading-basico', 'Fundamentos del trading y mercados financieros', '#3B82F6', 1, true),
('22222222-2222-2222-2222-222222222222', 'Análisis Técnico', 'analisis-tecnico', 'Herramientas y técnicas de análisis técnico', '#10B981', 2, true),
('33333333-3333-3333-3333-333333333333', 'Análisis Fundamental', 'analisis-fundamental', 'Evaluación fundamental de activos', '#F59E0B', 3, true),
('44444444-4444-4444-4444-444444444444', 'Gestión de Riesgo', 'gestion-riesgo', 'Estrategias de gestión de riesgo y capital', '#EF4444', 4, true),
('55555555-5555-5555-5555-555555555555', 'Trading Algorítmico', 'trading-algoritmico', 'Automatización y estrategias algorítmicas', '#8B5CF6', 5, true),
('66666666-6666-6666-6666-666666666666', 'Psicología del Trading', 'psicologia-trading', 'Aspectos psicológicos y emocionales del trading', '#EC4899', 6, true);
-- =====================================================
-- 2. COURSES
-- =====================================================
-- NOTA: instructor_id debe existir en auth.users
-- Para este ejemplo, usar un UUID válido de tu sistema
INSERT INTO education.courses (
id,
title,
slug,
short_description,
full_description,
category_id,
difficulty_level,
instructor_id,
instructor_name,
is_free,
xp_reward,
status,
published_at,
total_modules,
total_lessons
) VALUES
(
'c1111111-1111-1111-1111-111111111111',
'Introducción al Trading',
'introduccion-trading',
'Aprende los conceptos básicos del trading desde cero',
'Este curso te enseñará los fundamentos del trading, incluyendo tipos de mercados, instrumentos financieros, y cómo realizar tus primeras operaciones de forma segura.',
'11111111-1111-1111-1111-111111111111',
'beginner',
'00000000-0000-0000-0000-000000000001', -- Reemplazar con ID real
'Instructor Demo',
true,
500,
'published',
NOW(),
3,
12
);
-- =====================================================
-- 3. MODULES
-- =====================================================
INSERT INTO education.modules (id, course_id, title, description, display_order, duration_minutes) VALUES
('m1111111-1111-1111-1111-111111111111', 'c1111111-1111-1111-1111-111111111111', 'Módulo 1: Fundamentos', 'Conceptos básicos del trading', 1, 120),
('m2222222-2222-2222-2222-222222222222', 'c1111111-1111-1111-1111-111111111111', 'Módulo 2: Mercados Financieros', 'Tipos de mercados y activos', 2, 180),
('m3333333-3333-3333-3333-333333333333', 'c1111111-1111-1111-1111-111111111111', 'Módulo 3: Primeros Pasos', 'Cómo empezar a operar', 3, 150);
-- =====================================================
-- 4. LESSONS
-- =====================================================
INSERT INTO education.lessons (
id,
module_id,
title,
description,
content_type,
video_url,
video_duration_seconds,
display_order,
is_preview,
xp_reward
) VALUES
(
'l1111111-1111-1111-1111-111111111111',
'm1111111-1111-1111-1111-111111111111',
'¿Qué es el Trading?',
'Introducción a los conceptos básicos del trading',
'video',
'https://example.com/videos/lesson-1.mp4',
900,
1,
true,
10
),
(
'l2222222-2222-2222-2222-222222222222',
'm1111111-1111-1111-1111-111111111111',
'Tipos de Traders',
'Conoce los diferentes estilos de trading',
'video',
'https://example.com/videos/lesson-2.mp4',
1200,
2,
false,
10
),
(
'l3333333-3333-3333-3333-333333333333',
'm1111111-1111-1111-1111-111111111111',
'Terminología Básica',
'Vocabulario esencial del trading',
'article',
NULL,
NULL,
3,
false,
15
);
-- =====================================================
-- 5. QUIZZES
-- =====================================================
INSERT INTO education.quizzes (
id,
module_id,
title,
description,
passing_score_percentage,
max_attempts,
xp_reward,
xp_perfect_score_bonus
) VALUES
(
'q1111111-1111-1111-1111-111111111111',
'm1111111-1111-1111-1111-111111111111',
'Quiz: Fundamentos del Trading',
'Evalúa tus conocimientos sobre los conceptos básicos',
70,
3,
50,
20
);
-- =====================================================
-- 6. QUIZ QUESTIONS
-- =====================================================
INSERT INTO education.quiz_questions (
id,
quiz_id,
question_text,
question_type,
options,
explanation,
points,
display_order
) VALUES
(
'qq111111-1111-1111-1111-111111111111',
'q1111111-1111-1111-1111-111111111111',
'¿Qué es el trading?',
'multiple_choice',
'[
{"id": "a", "text": "Comprar y vender activos financieros", "isCorrect": true},
{"id": "b", "text": "Solo comprar acciones", "isCorrect": false},
{"id": "c", "text": "Invertir a largo plazo únicamente", "isCorrect": false},
{"id": "d", "text": "Ahorrar dinero en un banco", "isCorrect": false}
]'::jsonb,
'El trading implica la compra y venta de activos financieros con el objetivo de obtener ganancias a corto o mediano plazo.',
1,
1
),
(
'qq222222-2222-2222-2222-222222222222',
'q1111111-1111-1111-1111-111111111111',
'¿El trading es una actividad de riesgo?',
'true_false',
'[
{"id": "true", "text": "Verdadero", "isCorrect": true},
{"id": "false", "text": "Falso", "isCorrect": false}
]'::jsonb,
'Sí, el trading es una actividad que conlleva riesgos y es posible perder dinero. Por eso es importante la gestión de riesgo.',
1,
2
);
-- =====================================================
-- EJEMPLO DE USO - ENROLLMENTS
-- =====================================================
-- NOTA: Estos son ejemplos comentados. NO ejecutar sin IDs reales.
/*
-- Enrollar un usuario a un curso
INSERT INTO education.enrollments (user_id, course_id, total_lessons)
VALUES (
'00000000-0000-0000-0000-000000000001', -- ID del usuario
'c1111111-1111-1111-1111-111111111111', -- ID del curso
12 -- Total de lecciones del curso
);
-- Registrar progreso en una lección
INSERT INTO education.progress (
user_id,
lesson_id,
enrollment_id,
is_completed,
watch_percentage
) VALUES (
'00000000-0000-0000-0000-000000000001',
'l1111111-1111-1111-1111-111111111111',
'<enrollment-id>',
true,
100.00
);
*/
COMMIT;
-- =====================================================
-- Verificación
-- =====================================================
SELECT 'Categories created:' as info, COUNT(*) as count FROM education.categories;
SELECT 'Courses created:' as info, COUNT(*) as count FROM education.courses;
SELECT 'Modules created:' as info, COUNT(*) as count FROM education.modules;
SELECT 'Lessons created:' as info, COUNT(*) as count FROM education.lessons;
SELECT 'Quizzes created:' as info, COUNT(*) as count FROM education.quizzes;
SELECT 'Questions created:' as info, COUNT(*) as count FROM education.quiz_questions;

View File

@ -0,0 +1,42 @@
-- =====================================================
-- TABLE: education.categories
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Información básica
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
-- Jerarquía
parent_id UUID REFERENCES education.categories(id) ON DELETE SET NULL,
-- Ordenamiento y visualización
display_order INTEGER DEFAULT 0,
icon_url VARCHAR(500),
color VARCHAR(7), -- Código hex #RRGGBB
-- Metadata
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT valid_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$')
);
-- Índices
CREATE INDEX idx_categories_parent ON education.categories(parent_id);
CREATE INDEX idx_categories_slug ON education.categories(slug);
CREATE INDEX idx_categories_active ON education.categories(is_active) WHERE is_active = true;
-- Comentarios
COMMENT ON TABLE education.categories IS 'Categorías de cursos con soporte para jerarquía';
COMMENT ON COLUMN education.categories.parent_id IS 'Categoría padre para jerarquía';
COMMENT ON COLUMN education.categories.display_order IS 'Orden de visualización';
COMMENT ON COLUMN education.categories.color IS 'Color en formato hexadecimal #RRGGBB';

View File

@ -0,0 +1,74 @@
-- =====================================================
-- TABLE: education.courses
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Información básica
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) NOT NULL UNIQUE,
short_description VARCHAR(500),
full_description TEXT,
-- Categorización
category_id UUID NOT NULL REFERENCES education.categories(id) ON DELETE RESTRICT,
difficulty_level education.difficulty_level NOT NULL DEFAULT 'beginner',
-- Contenido
thumbnail_url VARCHAR(500),
trailer_url VARCHAR(500), -- Video de presentación
-- Metadata educativa
duration_minutes INTEGER, -- Duración estimada total
prerequisites TEXT[], -- IDs de cursos prerequisitos
learning_objectives TEXT[], -- Array de objetivos
-- Instructor
instructor_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
instructor_name VARCHAR(200), -- Denormalizado para performance
-- Pricing (para futuras features)
is_free BOOLEAN DEFAULT true,
price_usd DECIMAL(10,2),
-- Gamificación
xp_reward INTEGER DEFAULT 0, -- XP al completar el curso
-- Estado
status education.course_status DEFAULT 'draft',
published_at TIMESTAMPTZ,
-- Estadísticas (denormalizadas)
total_modules INTEGER DEFAULT 0,
total_lessons INTEGER DEFAULT 0,
total_enrollments INTEGER DEFAULT 0,
avg_rating DECIMAL(3,2) DEFAULT 0.00,
total_reviews INTEGER DEFAULT 0,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT valid_rating CHECK (avg_rating >= 0 AND avg_rating <= 5),
CONSTRAINT valid_price CHECK (price_usd >= 0)
);
-- Índices
CREATE INDEX idx_courses_category ON education.courses(category_id);
CREATE INDEX idx_courses_slug ON education.courses(slug);
CREATE INDEX idx_courses_status ON education.courses(status);
CREATE INDEX idx_courses_difficulty ON education.courses(difficulty_level);
CREATE INDEX idx_courses_instructor ON education.courses(instructor_id);
CREATE INDEX idx_courses_published ON education.courses(published_at) WHERE status = 'published';
-- Comentarios
COMMENT ON TABLE education.courses IS 'Cursos del módulo educativo';
COMMENT ON COLUMN education.courses.instructor_name IS 'Denormalizado para performance en queries';
COMMENT ON COLUMN education.courses.prerequisites IS 'Array de UUIDs de cursos prerequisitos';
COMMENT ON COLUMN education.courses.learning_objectives IS 'Array de objetivos de aprendizaje';
COMMENT ON COLUMN education.courses.xp_reward IS 'XP otorgado al completar el curso';

View File

@ -0,0 +1,43 @@
-- =====================================================
-- TABLE: education.modules
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relación con curso
course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE,
-- Información básica
title VARCHAR(200) NOT NULL,
description TEXT,
-- Ordenamiento
display_order INTEGER NOT NULL DEFAULT 0,
-- Metadata
duration_minutes INTEGER,
-- Control de acceso
is_locked BOOLEAN DEFAULT false, -- Requiere completar módulos anteriores
unlock_after_module_id UUID REFERENCES education.modules(id) ON DELETE SET NULL,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_course_order UNIQUE(course_id, display_order)
);
-- Índices
CREATE INDEX idx_modules_course ON education.modules(course_id);
CREATE INDEX idx_modules_order ON education.modules(course_id, display_order);
-- Comentarios
COMMENT ON TABLE education.modules IS 'Módulos que agrupan lecciones dentro de un curso';
COMMENT ON COLUMN education.modules.is_locked IS 'Si requiere completar módulos anteriores para desbloquearse';
COMMENT ON COLUMN education.modules.unlock_after_module_id IS 'Módulo que debe completarse antes de acceder a este';

View File

@ -0,0 +1,66 @@
-- =====================================================
-- TABLE: education.lessons
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relación con módulo
module_id UUID NOT NULL REFERENCES education.modules(id) ON DELETE CASCADE,
-- Información básica
title VARCHAR(200) NOT NULL,
description TEXT,
-- Tipo de contenido
content_type education.lesson_content_type NOT NULL DEFAULT 'video',
-- Contenido video
video_url VARCHAR(500), -- URL de Vimeo/S3
video_duration_seconds INTEGER,
video_provider VARCHAR(50), -- 'vimeo', 's3', etc.
video_id VARCHAR(200), -- ID del video en el provider
-- Contenido texto/article
article_content TEXT,
-- Recursos adicionales
attachments JSONB, -- [{name, url, type, size}]
-- Ordenamiento
display_order INTEGER NOT NULL DEFAULT 0,
-- Configuración
is_preview BOOLEAN DEFAULT false, -- Puede verse sin enrollment
is_mandatory BOOLEAN DEFAULT true, -- Requerido para completar el curso
-- Gamificación
xp_reward INTEGER DEFAULT 10, -- XP al completar la lección
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_module_order UNIQUE(module_id, display_order),
CONSTRAINT video_fields_required CHECK (
(content_type != 'video') OR
(video_url IS NOT NULL AND video_duration_seconds IS NOT NULL)
)
);
-- Índices
CREATE INDEX idx_lessons_module ON education.lessons(module_id);
CREATE INDEX idx_lessons_order ON education.lessons(module_id, display_order);
CREATE INDEX idx_lessons_type ON education.lessons(content_type);
CREATE INDEX idx_lessons_preview ON education.lessons(is_preview) WHERE is_preview = true;
-- Comentarios
COMMENT ON TABLE education.lessons IS 'Lecciones individuales dentro de módulos';
COMMENT ON COLUMN education.lessons.attachments IS 'Archivos adjuntos en formato JSON: [{name, url, type, size}]';
COMMENT ON COLUMN education.lessons.is_preview IS 'Puede verse sin enrollment (preview gratuito)';
COMMENT ON COLUMN education.lessons.is_mandatory IS 'Requerido para completar el curso';
COMMENT ON COLUMN education.lessons.xp_reward IS 'XP otorgado al completar la lección';

View File

@ -0,0 +1,56 @@
-- =====================================================
-- TABLE: education.enrollments
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT,
-- Estado
status education.enrollment_status DEFAULT 'active',
-- Progreso
progress_percentage DECIMAL(5,2) DEFAULT 0.00,
completed_lessons INTEGER DEFAULT 0,
total_lessons INTEGER DEFAULT 0, -- Snapshot del total al enrollarse
-- Fechas importantes
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ, -- Primera lección vista
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- Para cursos con límite de tiempo
-- Gamificación
total_xp_earned INTEGER DEFAULT 0,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_user_course UNIQUE(user_id, course_id),
CONSTRAINT valid_progress CHECK (progress_percentage >= 0 AND progress_percentage <= 100),
CONSTRAINT valid_completion CHECK (
(status != 'completed') OR
(completed_at IS NOT NULL AND progress_percentage = 100)
)
);
-- Índices
CREATE INDEX idx_enrollments_user ON education.enrollments(user_id);
CREATE INDEX idx_enrollments_course ON education.enrollments(course_id);
CREATE INDEX idx_enrollments_status ON education.enrollments(status);
CREATE INDEX idx_enrollments_user_active ON education.enrollments(user_id, status)
WHERE status = 'active';
-- Comentarios
COMMENT ON TABLE education.enrollments IS 'Inscripciones de usuarios a cursos';
COMMENT ON COLUMN education.enrollments.total_lessons IS 'Snapshot del total de lecciones al momento de enrollarse';
COMMENT ON COLUMN education.enrollments.started_at IS 'Timestamp de la primera lección vista';
COMMENT ON COLUMN education.enrollments.expires_at IS 'Fecha de expiración para cursos con límite de tiempo';

View File

@ -0,0 +1,52 @@
-- =====================================================
-- TABLE: education.progress
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
lesson_id UUID NOT NULL REFERENCES education.lessons(id) ON DELETE CASCADE,
enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE,
-- Estado
is_completed BOOLEAN DEFAULT false,
-- Progreso de video
last_position_seconds INTEGER DEFAULT 0,
total_watch_time_seconds INTEGER DEFAULT 0, -- Tiempo total visto
watch_percentage DECIMAL(5,2) DEFAULT 0.00,
-- Tracking
first_viewed_at TIMESTAMPTZ,
last_viewed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_user_lesson UNIQUE(user_id, lesson_id),
CONSTRAINT valid_watch_percentage CHECK (watch_percentage >= 0 AND watch_percentage <= 100),
CONSTRAINT completion_requires_date CHECK (
(NOT is_completed) OR (completed_at IS NOT NULL)
)
);
-- Índices
CREATE INDEX idx_progress_user ON education.progress(user_id);
CREATE INDEX idx_progress_lesson ON education.progress(lesson_id);
CREATE INDEX idx_progress_enrollment ON education.progress(enrollment_id);
CREATE INDEX idx_progress_completed ON education.progress(is_completed) WHERE is_completed = true;
CREATE INDEX idx_progress_user_enrollment ON education.progress(user_id, enrollment_id);
-- Comentarios
COMMENT ON TABLE education.progress IS 'Progreso individual del usuario en cada lección';
COMMENT ON COLUMN education.progress.last_position_seconds IS 'Última posición del video en segundos';
COMMENT ON COLUMN education.progress.total_watch_time_seconds IS 'Tiempo total de visualización acumulado';
COMMENT ON COLUMN education.progress.watch_percentage IS 'Porcentaje de la lección completada';

View File

@ -0,0 +1,57 @@
-- =====================================================
-- TABLE: education.quizzes
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.quizzes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relación (puede estar asociado a módulo o lección)
module_id UUID REFERENCES education.modules(id) ON DELETE CASCADE,
lesson_id UUID REFERENCES education.lessons(id) ON DELETE CASCADE,
-- Información básica
title VARCHAR(200) NOT NULL,
description TEXT,
-- Configuración
passing_score_percentage INTEGER DEFAULT 70, -- % mínimo para aprobar
max_attempts INTEGER, -- NULL = intentos ilimitados
time_limit_minutes INTEGER, -- NULL = sin límite de tiempo
-- Opciones
shuffle_questions BOOLEAN DEFAULT true,
shuffle_answers BOOLEAN DEFAULT true,
show_correct_answers BOOLEAN DEFAULT true, -- Después de completar
-- Gamificación
xp_reward INTEGER DEFAULT 50,
xp_perfect_score_bonus INTEGER DEFAULT 20,
-- Estado
is_active BOOLEAN DEFAULT true,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT valid_passing_score CHECK (passing_score_percentage > 0 AND passing_score_percentage <= 100),
CONSTRAINT quiz_association CHECK (
(module_id IS NOT NULL AND lesson_id IS NULL) OR
(module_id IS NULL AND lesson_id IS NOT NULL)
)
);
-- Índices
CREATE INDEX idx_quizzes_module ON education.quizzes(module_id);
CREATE INDEX idx_quizzes_lesson ON education.quizzes(lesson_id);
CREATE INDEX idx_quizzes_active ON education.quizzes(is_active) WHERE is_active = true;
-- Comentarios
COMMENT ON TABLE education.quizzes IS 'Quizzes/evaluaciones asociadas a módulos o lecciones';
COMMENT ON COLUMN education.quizzes.max_attempts IS 'NULL = intentos ilimitados';
COMMENT ON COLUMN education.quizzes.time_limit_minutes IS 'NULL = sin límite de tiempo';
COMMENT ON COLUMN education.quizzes.xp_perfect_score_bonus IS 'XP bonus por obtener 100% de score';

View File

@ -0,0 +1,56 @@
-- =====================================================
-- TABLE: education.quiz_questions
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.quiz_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relación
quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE CASCADE,
-- Pregunta
question_text TEXT NOT NULL,
question_type education.question_type NOT NULL DEFAULT 'multiple_choice',
-- Opciones de respuesta (para multiple_choice, true_false, multiple_select)
options JSONB, -- [{id, text, isCorrect}]
-- Respuesta correcta (para fill_blank, code_challenge)
correct_answer TEXT,
-- Explicación
explanation TEXT, -- Mostrar después de responder
-- Recursos adicionales
image_url VARCHAR(500),
code_snippet TEXT,
-- Puntuación
points INTEGER DEFAULT 1,
-- Ordenamiento
display_order INTEGER DEFAULT 0,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT valid_options CHECK (
(question_type NOT IN ('multiple_choice', 'true_false', 'multiple_select')) OR
(options IS NOT NULL)
)
);
-- Índices
CREATE INDEX idx_quiz_questions_quiz ON education.quiz_questions(quiz_id);
CREATE INDEX idx_quiz_questions_order ON education.quiz_questions(quiz_id, display_order);
-- Comentarios
COMMENT ON TABLE education.quiz_questions IS 'Preguntas individuales de los quizzes';
COMMENT ON COLUMN education.quiz_questions.options IS 'Array JSON de opciones: [{id, text, isCorrect}]';
COMMENT ON COLUMN education.quiz_questions.correct_answer IS 'Respuesta correcta para fill_blank y code_challenge';
COMMENT ON COLUMN education.quiz_questions.explanation IS 'Explicación mostrada después de responder';

View File

@ -0,0 +1,53 @@
-- =====================================================
-- TABLE: education.quiz_attempts
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.quiz_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE RESTRICT,
enrollment_id UUID REFERENCES education.enrollments(id) ON DELETE SET NULL,
-- Estado del intento
is_completed BOOLEAN DEFAULT false,
is_passed BOOLEAN DEFAULT false,
-- Respuestas del usuario
user_answers JSONB, -- [{questionId, answer, isCorrect, points}]
-- Puntuación
score_points INTEGER DEFAULT 0,
max_points INTEGER DEFAULT 0,
score_percentage DECIMAL(5,2) DEFAULT 0.00,
-- Tiempo
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
time_taken_seconds INTEGER,
-- XP ganado
xp_earned INTEGER DEFAULT 0,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT valid_score_percentage CHECK (score_percentage >= 0 AND score_percentage <= 100)
);
-- Índices
CREATE INDEX idx_quiz_attempts_user ON education.quiz_attempts(user_id);
CREATE INDEX idx_quiz_attempts_quiz ON education.quiz_attempts(quiz_id);
CREATE INDEX idx_quiz_attempts_enrollment ON education.quiz_attempts(enrollment_id);
CREATE INDEX idx_quiz_attempts_user_quiz ON education.quiz_attempts(user_id, quiz_id);
CREATE INDEX idx_quiz_attempts_completed ON education.quiz_attempts(is_completed, completed_at);
-- Comentarios
COMMENT ON TABLE education.quiz_attempts IS 'Intentos de los usuarios en los quizzes';
COMMENT ON COLUMN education.quiz_attempts.user_answers IS 'Respuestas del usuario: [{questionId, answer, isCorrect, points}]';
COMMENT ON COLUMN education.quiz_attempts.time_taken_seconds IS 'Tiempo total empleado en segundos';

View File

@ -0,0 +1,54 @@
-- =====================================================
-- TABLE: education.certificates
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT,
enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE RESTRICT,
-- Información del certificado
certificate_number VARCHAR(50) NOT NULL UNIQUE, -- OQI-CERT-XXXX-YYYY
-- Datos del certificado (snapshot)
user_name VARCHAR(200) NOT NULL,
course_title VARCHAR(200) NOT NULL,
instructor_name VARCHAR(200),
completion_date DATE NOT NULL,
-- Metadata de logro
final_score DECIMAL(5,2), -- Promedio de quizzes
total_xp_earned INTEGER,
-- URL del PDF generado
certificate_url VARCHAR(500),
-- Verificación
verification_code VARCHAR(100) UNIQUE, -- Para verificar autenticidad
is_verified BOOLEAN DEFAULT true,
-- Metadata
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_user_course_cert UNIQUE(user_id, course_id)
);
-- Índices
CREATE INDEX idx_certificates_user ON education.certificates(user_id);
CREATE INDEX idx_certificates_course ON education.certificates(course_id);
CREATE INDEX idx_certificates_number ON education.certificates(certificate_number);
CREATE INDEX idx_certificates_verification ON education.certificates(verification_code);
-- Comentarios
COMMENT ON TABLE education.certificates IS 'Certificados emitidos al completar cursos';
COMMENT ON COLUMN education.certificates.certificate_number IS 'Número único formato: OQI-CERT-YYYY-NNNNN';
COMMENT ON COLUMN education.certificates.verification_code IS 'Código para verificar autenticidad del certificado';
COMMENT ON COLUMN education.certificates.final_score IS 'Promedio de todos los quizzes del curso';

View File

@ -0,0 +1,47 @@
-- =====================================================
-- TABLE: education.user_achievements
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: ET-EDU-001-database.md
-- =====================================================
CREATE TABLE education.user_achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relación
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Tipo de logro
achievement_type education.achievement_type NOT NULL,
-- Información del logro
title VARCHAR(200) NOT NULL,
description TEXT,
badge_icon_url VARCHAR(500),
-- Metadata del logro
metadata JSONB, -- Información específica del logro
-- Referencias
course_id UUID REFERENCES education.courses(id) ON DELETE SET NULL,
quiz_id UUID REFERENCES education.quizzes(id) ON DELETE SET NULL,
-- XP bonus por el logro
xp_bonus INTEGER DEFAULT 0,
-- Metadata
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_user_achievements_user ON education.user_achievements(user_id);
CREATE INDEX idx_user_achievements_type ON education.user_achievements(achievement_type);
CREATE INDEX idx_user_achievements_earned ON education.user_achievements(earned_at DESC);
CREATE INDEX idx_user_achievements_course ON education.user_achievements(course_id);
-- Comentarios
COMMENT ON TABLE education.user_achievements IS 'Logros/badges obtenidos por los usuarios';
COMMENT ON COLUMN education.user_achievements.metadata IS 'Información adicional específica del tipo de logro';
COMMENT ON COLUMN education.user_achievements.xp_bonus IS 'XP bonus otorgado por este logro';

View File

@ -0,0 +1,56 @@
-- =====================================================
-- TABLE: education.user_gamification_profile
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: Tabla adicional para gamificación
-- =====================================================
CREATE TABLE education.user_gamification_profile (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- XP y nivel
total_xp INTEGER NOT NULL DEFAULT 0,
current_level INTEGER NOT NULL DEFAULT 1,
xp_to_next_level INTEGER NOT NULL DEFAULT 100,
-- Streaks
current_streak_days INTEGER DEFAULT 0,
longest_streak_days INTEGER DEFAULT 0,
last_activity_date DATE,
-- Estadísticas
total_courses_completed INTEGER DEFAULT 0,
total_lessons_completed INTEGER DEFAULT 0,
total_quizzes_passed INTEGER DEFAULT 0,
total_certificates_earned INTEGER DEFAULT 0,
average_quiz_score DECIMAL(5,2) DEFAULT 0.00,
-- Ranking
weekly_xp INTEGER DEFAULT 0,
monthly_xp INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_user_gamification UNIQUE(user_id),
CONSTRAINT valid_level CHECK (current_level >= 1),
CONSTRAINT valid_xp CHECK (total_xp >= 0),
CONSTRAINT valid_streak CHECK (current_streak_days >= 0 AND longest_streak_days >= 0),
CONSTRAINT valid_avg_score CHECK (average_quiz_score >= 0 AND average_quiz_score <= 100)
);
-- Índices
CREATE INDEX idx_gamification_user ON education.user_gamification_profile(user_id);
CREATE INDEX idx_gamification_level ON education.user_gamification_profile(current_level DESC);
CREATE INDEX idx_gamification_xp ON education.user_gamification_profile(total_xp DESC);
CREATE INDEX idx_gamification_weekly ON education.user_gamification_profile(weekly_xp DESC);
CREATE INDEX idx_gamification_monthly ON education.user_gamification_profile(monthly_xp DESC);
-- Comentarios
COMMENT ON TABLE education.user_gamification_profile IS 'Perfil de gamificación del usuario con XP, niveles, streaks y estadísticas';
COMMENT ON COLUMN education.user_gamification_profile.current_streak_days IS 'Días consecutivos de actividad actual';
COMMENT ON COLUMN education.user_gamification_profile.longest_streak_days IS 'Racha más larga de días consecutivos';
COMMENT ON COLUMN education.user_gamification_profile.weekly_xp IS 'XP acumulado en la semana actual (para leaderboards)';
COMMENT ON COLUMN education.user_gamification_profile.monthly_xp IS 'XP acumulado en el mes actual (para leaderboards)';

View File

@ -0,0 +1,43 @@
-- =====================================================
-- TABLE: education.user_activity_log
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: Tabla adicional para tracking de actividad
-- =====================================================
CREATE TABLE education.user_activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Tipo de actividad
activity_type VARCHAR(50) NOT NULL, -- lesson_view, quiz_complete, course_enroll, etc.
-- Referencias opcionales
course_id UUID REFERENCES education.courses(id) ON DELETE SET NULL,
lesson_id UUID REFERENCES education.lessons(id) ON DELETE SET NULL,
quiz_id UUID REFERENCES education.quizzes(id) ON DELETE SET NULL,
-- Metadata
metadata JSONB DEFAULT '{}',
xp_earned INTEGER DEFAULT 0,
-- Contexto
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_activity_user ON education.user_activity_log(user_id);
CREATE INDEX idx_activity_type ON education.user_activity_log(activity_type);
CREATE INDEX idx_activity_created ON education.user_activity_log(created_at DESC);
CREATE INDEX idx_activity_user_date ON education.user_activity_log(user_id, created_at DESC);
CREATE INDEX idx_activity_course ON education.user_activity_log(course_id) WHERE course_id IS NOT NULL;
-- Comentarios
COMMENT ON TABLE education.user_activity_log IS 'Log de actividades del usuario en el módulo educativo';
COMMENT ON COLUMN education.user_activity_log.activity_type IS 'Tipos: lesson_view, lesson_complete, quiz_start, quiz_complete, course_enroll, etc.';
COMMENT ON COLUMN education.user_activity_log.metadata IS 'Información adicional específica del tipo de actividad';
COMMENT ON COLUMN education.user_activity_log.xp_earned IS 'XP ganado en esta actividad (si aplica)';

View File

@ -0,0 +1,48 @@
-- =====================================================
-- TABLE: education.course_reviews
-- =====================================================
-- Proyecto: OrbiQuant IA (Trading Platform)
-- Módulo: OQI-002 - Education
-- Especificación: Tabla adicional para reviews de cursos
-- =====================================================
CREATE TABLE education.course_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE,
enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE,
-- Review
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
title VARCHAR(200),
content TEXT,
-- Moderación
is_approved BOOLEAN DEFAULT false,
is_featured BOOLEAN DEFAULT false,
approved_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMPTZ,
-- Votos útiles
helpful_votes INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_user_course_review UNIQUE(user_id, course_id)
);
-- Índices
CREATE INDEX idx_reviews_course ON education.course_reviews(course_id);
CREATE INDEX idx_reviews_user ON education.course_reviews(user_id);
CREATE INDEX idx_reviews_rating ON education.course_reviews(rating);
CREATE INDEX idx_reviews_approved ON education.course_reviews(is_approved) WHERE is_approved = true;
CREATE INDEX idx_reviews_featured ON education.course_reviews(is_featured) WHERE is_featured = true;
CREATE INDEX idx_reviews_helpful ON education.course_reviews(helpful_votes DESC);
-- Comentarios
COMMENT ON TABLE education.course_reviews IS 'Reviews y calificaciones de cursos por usuarios';
COMMENT ON COLUMN education.course_reviews.is_approved IS 'Review aprobada por moderador';
COMMENT ON COLUMN education.course_reviews.is_featured IS 'Review destacada para mostrar en página del curso';
COMMENT ON COLUMN education.course_reviews.helpful_votes IS 'Número de votos útiles de otros usuarios';

View File

@ -0,0 +1,55 @@
#!/bin/bash
# =====================================================
# UNINSTALL SCRIPT - Schema Education
# =====================================================
# Proyecto: OrbiQuant IA (Trading Platform)
# Módulo: OQI-002 - Education
# =====================================================
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Configuration
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-orbiquant}"
DB_USER="${DB_USER:-postgres}"
SCHEMA_NAME="education"
echo -e "${YELLOW}=================================================${NC}"
echo -e "${YELLOW} OrbiQuant IA - Education Schema Uninstall${NC}"
echo -e "${YELLOW}=================================================${NC}"
echo ""
echo -e "${RED}WARNING: This will DROP the entire '$SCHEMA_NAME' schema and ALL its data!${NC}"
echo ""
read -p "Are you sure you want to continue? (type 'yes' to confirm): " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "Uninstall cancelled."
exit 0
fi
echo ""
echo -e "${YELLOW}${NC} Dropping schema: $SCHEMA_NAME (CASCADE)"
if PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "DROP SCHEMA IF EXISTS $SCHEMA_NAME CASCADE;" > /dev/null 2>&1; then
echo -e "${GREEN} ✓ Schema dropped successfully${NC}"
else
echo -e "${RED} ✗ Failed to drop schema${NC}"
exit 1
fi
echo ""
echo -e "${GREEN}=================================================${NC}"
echo -e "${GREEN} Uninstall Complete!${NC}"
echo -e "${GREEN}=================================================${NC}"
echo ""
echo "Schema '$SCHEMA_NAME' has been removed."
echo ""

145
ddl/schemas/education/verify.sh Executable file
View File

@ -0,0 +1,145 @@
#!/bin/bash
# =====================================================
# VERIFY SCRIPT - Schema Education
# =====================================================
# Proyecto: OrbiQuant IA (Trading Platform)
# Módulo: OQI-002 - Education
# =====================================================
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Configuration
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-orbiquant}"
DB_USER="${DB_USER:-postgres}"
SCHEMA_NAME="education"
echo -e "${BLUE}=================================================${NC}"
echo -e "${BLUE} OrbiQuant IA - Education Schema Verification${NC}"
echo -e "${BLUE}=================================================${NC}"
echo ""
# Check if psql is available
if ! command -v psql &> /dev/null; then
echo -e "${RED}Error: psql command not found${NC}"
exit 1
fi
# Function to run query and return result
run_query() {
local query=$1
PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "$query" 2>/dev/null | xargs
}
echo "Configuration:"
echo " Database: $DB_NAME"
echo " Host: $DB_HOST:$DB_PORT"
echo " Schema: $SCHEMA_NAME"
echo ""
# Check if schema exists
echo -e "${YELLOW}${NC} Checking schema existence..."
SCHEMA_EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = '$SCHEMA_NAME';")
if [ "$SCHEMA_EXISTS" -eq "1" ]; then
echo -e "${GREEN} ✓ Schema exists${NC}"
else
echo -e "${RED} ✗ Schema not found${NC}"
exit 1
fi
# Check ENUMs
echo ""
echo -e "${YELLOW}${NC} Checking ENUMs..."
EXPECTED_ENUMS=("difficulty_level" "course_status" "enrollment_status" "lesson_content_type" "question_type" "achievement_type")
ENUM_COUNT=0
for enum_name in "${EXPECTED_ENUMS[@]}"; do
EXISTS=$(run_query "SELECT COUNT(*) FROM pg_type WHERE typname = '$enum_name' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '$SCHEMA_NAME');")
if [ "$EXISTS" -eq "1" ]; then
echo -e "${GREEN}$enum_name${NC}"
((ENUM_COUNT++))
else
echo -e "${RED}$enum_name${NC}"
fi
done
# Check tables
echo ""
echo -e "${YELLOW}${NC} Checking tables..."
EXPECTED_TABLES=("categories" "courses" "modules" "lessons" "enrollments" "progress" "quizzes" "quiz_questions" "quiz_attempts" "certificates" "user_achievements" "user_gamification_profile" "user_activity_log" "course_reviews")
TABLE_COUNT=0
for table_name in "${EXPECTED_TABLES[@]}"; do
EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA_NAME' AND table_name = '$table_name';")
if [ "$EXISTS" -eq "1" ]; then
ROW_COUNT=$(run_query "SELECT COUNT(*) FROM $SCHEMA_NAME.$table_name;")
echo -e "${GREEN}$table_name${NC} ($ROW_COUNT rows)"
((TABLE_COUNT++))
else
echo -e "${RED}$table_name${NC}"
fi
done
# Check functions
echo ""
echo -e "${YELLOW}${NC} Checking functions..."
EXPECTED_FUNCTIONS=("update_updated_at_column" "update_enrollment_progress" "auto_complete_enrollment" "generate_certificate_number" "update_course_rating_stats" "update_enrollment_count" "update_user_xp" "update_user_streak")
FUNCTION_COUNT=0
for function_name in "${EXPECTED_FUNCTIONS[@]}"; do
EXISTS=$(run_query "SELECT COUNT(*) FROM pg_proc WHERE proname = '$function_name' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = '$SCHEMA_NAME');")
if [ "$EXISTS" -ge "1" ]; then
echo -e "${GREEN}$function_name${NC}"
((FUNCTION_COUNT++))
else
echo -e "${RED}$function_name${NC}"
fi
done
# Check views
echo ""
echo -e "${YELLOW}${NC} Checking views..."
EXPECTED_VIEWS=("v_courses_with_stats" "v_user_course_progress" "v_leaderboard_weekly" "v_leaderboard_monthly" "v_leaderboard_alltime" "v_user_statistics" "v_popular_courses")
VIEW_COUNT=0
for view_name in "${EXPECTED_VIEWS[@]}"; do
EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.views WHERE table_schema = '$SCHEMA_NAME' AND table_name = '$view_name';")
if [ "$EXISTS" -eq "1" ]; then
echo -e "${GREEN}$view_name${NC}"
((VIEW_COUNT++))
else
echo -e "${RED}$view_name${NC}"
fi
done
# Summary
echo ""
echo -e "${BLUE}=================================================${NC}"
echo -e "${BLUE} Verification Summary${NC}"
echo -e "${BLUE}=================================================${NC}"
echo ""
echo "ENUMs: $ENUM_COUNT / ${#EXPECTED_ENUMS[@]}"
echo "Tables: $TABLE_COUNT / ${#EXPECTED_TABLES[@]}"
echo "Functions: $FUNCTION_COUNT / ${#EXPECTED_FUNCTIONS[@]}"
echo "Views: $VIEW_COUNT / ${#EXPECTED_VIEWS[@]}"
echo ""
TOTAL_EXPECTED=$((${#EXPECTED_ENUMS[@]} + ${#EXPECTED_TABLES[@]} + ${#EXPECTED_FUNCTIONS[@]} + ${#EXPECTED_VIEWS[@]}))
TOTAL_FOUND=$((ENUM_COUNT + TABLE_COUNT + FUNCTION_COUNT + VIEW_COUNT))
if [ "$TOTAL_FOUND" -eq "$TOTAL_EXPECTED" ]; then
echo -e "${GREEN}✓ All components verified successfully!${NC}"
exit 0
else
echo -e "${YELLOW}⚠ Some components are missing ($TOTAL_FOUND / $TOTAL_EXPECTED)${NC}"
exit 1
fi

View File

@ -0,0 +1,131 @@
-- =====================================================
-- ORBIQUANT IA - FINANCIAL SCHEMA ENUMS
-- =====================================================
-- Description: Type definitions for financial domain
-- Schema: financial
-- =====================================================
-- Tipos de wallet
CREATE TYPE financial.wallet_type AS ENUM (
'trading', -- Para operaciones de trading
'investment', -- Para cuentas PAMM
'earnings', -- Para ganancias/distribuciones
'referral' -- Para bonos de referidos
);
-- Estados de wallet
CREATE TYPE financial.wallet_status AS ENUM (
'active',
'frozen',
'closed'
);
-- Tipos de transacción
CREATE TYPE financial.transaction_type AS ENUM (
'deposit',
'withdrawal',
'transfer_in',
'transfer_out',
'fee',
'refund',
'earning',
'distribution',
'bonus'
);
-- Estados de transacción
CREATE TYPE financial.transaction_status AS ENUM (
'pending',
'processing',
'completed',
'failed',
'cancelled',
'reversed'
);
-- Planes de suscripción
CREATE TYPE financial.subscription_plan AS ENUM (
'free',
'basic',
'pro',
'premium',
'enterprise'
);
-- Estados de suscripción
CREATE TYPE financial.subscription_status AS ENUM (
'active',
'past_due',
'cancelled',
'incomplete',
'trialing',
'unpaid',
'paused'
);
-- Monedas soportadas
CREATE TYPE financial.currency_code AS ENUM (
'USD',
'MXN',
'EUR'
);
-- Métodos de pago
CREATE TYPE financial.payment_method AS ENUM (
'card',
'bank_transfer',
'wire',
'crypto',
'paypal',
'stripe'
);
-- Estados de pago
CREATE TYPE financial.payment_status AS ENUM (
'pending',
'processing',
'succeeded',
'failed',
'cancelled',
'refunded'
);
-- Tipos de invoice
CREATE TYPE financial.invoice_type AS ENUM (
'subscription',
'one_time',
'usage'
);
-- Estados de invoice
CREATE TYPE financial.invoice_status AS ENUM (
'draft',
'open',
'paid',
'void',
'uncollectible'
);
-- Acciones de auditoría
CREATE TYPE financial.audit_action AS ENUM (
'created',
'balance_updated',
'status_changed',
'limit_changed',
'frozen',
'unfrozen',
'closed'
);
COMMENT ON TYPE financial.wallet_type IS 'Tipos de wallets en el sistema';
COMMENT ON TYPE financial.wallet_status IS 'Estados posibles de una wallet';
COMMENT ON TYPE financial.transaction_type IS 'Tipos de transacciones financieras';
COMMENT ON TYPE financial.transaction_status IS 'Estados del ciclo de vida de una transacción';
COMMENT ON TYPE financial.subscription_plan IS 'Planes de suscripción disponibles';
COMMENT ON TYPE financial.subscription_status IS 'Estados de suscripción según Stripe';
COMMENT ON TYPE financial.currency_code IS 'Códigos de moneda ISO 4217 soportados';
COMMENT ON TYPE financial.payment_method IS 'Métodos de pago aceptados';
COMMENT ON TYPE financial.payment_status IS 'Estados de procesamiento de pagos';
COMMENT ON TYPE financial.invoice_type IS 'Tipos de factura';
COMMENT ON TYPE financial.invoice_status IS 'Estados de factura';
COMMENT ON TYPE financial.audit_action IS 'Acciones auditables en wallets';

View File

@ -0,0 +1,283 @@
-- =====================================================
-- ORBIQUANT IA - UPDATE WALLET BALANCE FUNCTION
-- =====================================================
-- Description: Safely update wallet balance with audit trail
-- Schema: financial
-- =====================================================
CREATE OR REPLACE FUNCTION financial.update_wallet_balance(
p_wallet_id UUID,
p_amount DECIMAL(20,8),
p_operation VARCHAR(20), -- 'add', 'subtract', 'set'
p_transaction_id UUID DEFAULT NULL,
p_actor_id UUID DEFAULT NULL,
p_actor_type VARCHAR(50) DEFAULT 'system',
p_reason TEXT DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'
)
RETURNS TABLE (
success BOOLEAN,
new_balance DECIMAL(20,8),
new_available DECIMAL(20,8),
error_message TEXT
)
LANGUAGE plpgsql
AS $$
DECLARE
v_wallet RECORD;
v_old_balance DECIMAL(20,8);
v_old_available DECIMAL(20,8);
v_new_balance DECIMAL(20,8);
v_new_available DECIMAL(20,8);
BEGIN
-- Lock wallet row for update
SELECT * INTO v_wallet
FROM financial.wallets
WHERE id = p_wallet_id
FOR UPDATE;
-- Validar que existe
IF NOT FOUND THEN
RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found';
RETURN;
END IF;
-- Validar que está activa
IF v_wallet.status != 'active' THEN
RETURN QUERY SELECT false, v_wallet.balance, v_wallet.available_balance,
'Wallet is not active (status: ' || v_wallet.status::TEXT || ')';
RETURN;
END IF;
-- Guardar valores antiguos
v_old_balance := v_wallet.balance;
v_old_available := v_wallet.available_balance;
-- Calcular nuevo balance según operación
CASE p_operation
WHEN 'add' THEN
v_new_balance := v_old_balance + p_amount;
v_new_available := v_old_available + p_amount;
WHEN 'subtract' THEN
v_new_balance := v_old_balance - p_amount;
v_new_available := v_old_available - p_amount;
WHEN 'set' THEN
v_new_balance := p_amount;
v_new_available := p_amount - v_wallet.pending_balance;
ELSE
RETURN QUERY SELECT false, v_old_balance, v_old_available,
'Invalid operation: ' || p_operation;
RETURN;
END CASE;
-- Validar que no quede negativo
IF v_new_balance < 0 THEN
RETURN QUERY SELECT false, v_old_balance, v_old_available,
'Insufficient balance (current: ' || v_old_balance::TEXT || ', required: ' || p_amount::TEXT || ')';
RETURN;
END IF;
IF v_new_available < 0 THEN
RETURN QUERY SELECT false, v_old_balance, v_old_available,
'Insufficient available balance (current: ' || v_old_available::TEXT || ')';
RETURN;
END IF;
-- Validar min_balance si existe
IF v_wallet.min_balance IS NOT NULL AND v_new_available < v_wallet.min_balance THEN
RETURN QUERY SELECT false, v_old_balance, v_old_available,
'Would violate minimum balance requirement (min: ' || v_wallet.min_balance::TEXT || ')';
RETURN;
END IF;
-- Actualizar wallet
UPDATE financial.wallets
SET
balance = v_new_balance,
available_balance = v_new_available,
last_transaction_at = NOW(),
updated_at = NOW()
WHERE id = p_wallet_id;
-- Registrar en audit log
INSERT INTO financial.wallet_audit_log (
wallet_id,
action,
actor_id,
actor_type,
old_values,
new_values,
balance_before,
balance_after,
transaction_id,
reason,
metadata
) VALUES (
p_wallet_id,
'balance_updated',
p_actor_id,
p_actor_type,
jsonb_build_object(
'balance', v_old_balance,
'available_balance', v_old_available
),
jsonb_build_object(
'balance', v_new_balance,
'available_balance', v_new_available
),
v_old_balance,
v_new_balance,
p_transaction_id,
p_reason,
p_metadata
);
-- Retornar éxito
RETURN QUERY SELECT true, v_new_balance, v_new_available, NULL::TEXT;
END;
$$;
COMMENT ON FUNCTION financial.update_wallet_balance IS 'Safely update wallet balance with validation and audit trail';
-- Función helper para reservar fondos (pending balance)
CREATE OR REPLACE FUNCTION financial.reserve_wallet_funds(
p_wallet_id UUID,
p_amount DECIMAL(20,8),
p_reason TEXT DEFAULT NULL
)
RETURNS TABLE (
success BOOLEAN,
new_available DECIMAL(20,8),
new_pending DECIMAL(20,8),
error_message TEXT
)
LANGUAGE plpgsql
AS $$
DECLARE
v_wallet RECORD;
BEGIN
-- Lock wallet
SELECT * INTO v_wallet
FROM financial.wallets
WHERE id = p_wallet_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found';
RETURN;
END IF;
IF v_wallet.status != 'active' THEN
RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance,
'Wallet is not active';
RETURN;
END IF;
IF v_wallet.available_balance < p_amount THEN
RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance,
'Insufficient available balance';
RETURN;
END IF;
-- Mover de available a pending
UPDATE financial.wallets
SET
available_balance = available_balance - p_amount,
pending_balance = pending_balance + p_amount,
updated_at = NOW()
WHERE id = p_wallet_id;
-- Audit log
INSERT INTO financial.wallet_audit_log (
wallet_id, action, actor_type, reason,
old_values, new_values
) VALUES (
p_wallet_id, 'balance_updated', 'system', p_reason,
jsonb_build_object('available', v_wallet.available_balance, 'pending', v_wallet.pending_balance),
jsonb_build_object('available', v_wallet.available_balance - p_amount, 'pending', v_wallet.pending_balance + p_amount)
);
RETURN QUERY SELECT
true,
v_wallet.available_balance - p_amount,
v_wallet.pending_balance + p_amount,
NULL::TEXT;
END;
$$;
COMMENT ON FUNCTION financial.reserve_wallet_funds IS 'Reserve funds by moving from available to pending balance';
-- Función helper para liberar fondos reservados
CREATE OR REPLACE FUNCTION financial.release_wallet_funds(
p_wallet_id UUID,
p_amount DECIMAL(20,8),
p_to_available BOOLEAN DEFAULT true,
p_reason TEXT DEFAULT NULL
)
RETURNS TABLE (
success BOOLEAN,
new_available DECIMAL(20,8),
new_pending DECIMAL(20,8),
error_message TEXT
)
LANGUAGE plpgsql
AS $$
DECLARE
v_wallet RECORD;
v_new_balance DECIMAL(20,8);
BEGIN
-- Lock wallet
SELECT * INTO v_wallet
FROM financial.wallets
WHERE id = p_wallet_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found';
RETURN;
END IF;
IF v_wallet.pending_balance < p_amount THEN
RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance,
'Insufficient pending balance';
RETURN;
END IF;
-- Liberar fondos
IF p_to_available THEN
-- Devolver a available
v_new_balance := v_wallet.balance;
UPDATE financial.wallets
SET
available_balance = available_balance + p_amount,
pending_balance = pending_balance - p_amount,
updated_at = NOW()
WHERE id = p_wallet_id;
ELSE
-- Remover completamente (ej: después de withdrawal exitoso)
v_new_balance := v_wallet.balance - p_amount;
UPDATE financial.wallets
SET
balance = balance - p_amount,
pending_balance = pending_balance - p_amount,
updated_at = NOW()
WHERE id = p_wallet_id;
END IF;
-- Audit log
INSERT INTO financial.wallet_audit_log (
wallet_id, action, actor_type, reason, metadata
) VALUES (
p_wallet_id, 'balance_updated', 'system', p_reason,
jsonb_build_object('released_amount', p_amount, 'to_available', p_to_available)
);
SELECT available_balance, pending_balance INTO v_wallet
FROM financial.wallets
WHERE id = p_wallet_id;
RETURN QUERY SELECT true, v_wallet.available_balance, v_wallet.pending_balance, NULL::TEXT;
END;
$$;
COMMENT ON FUNCTION financial.release_wallet_funds IS 'Release reserved funds back to available or remove from balance';

View File

@ -0,0 +1,326 @@
-- =====================================================
-- ORBIQUANT IA - PROCESS TRANSACTION FUNCTION
-- =====================================================
-- Description: Create and process wallet transactions atomically
-- Schema: financial
-- =====================================================
CREATE OR REPLACE FUNCTION financial.process_transaction(
p_wallet_id UUID,
p_transaction_type financial.transaction_type,
p_amount DECIMAL(20,8),
p_currency financial.currency_code,
p_fee DECIMAL(15,8) DEFAULT 0,
p_description TEXT DEFAULT NULL,
p_reference_id VARCHAR(100) DEFAULT NULL,
p_destination_wallet_id UUID DEFAULT NULL,
p_idempotency_key VARCHAR(255) DEFAULT NULL,
p_metadata JSONB DEFAULT '{}',
p_auto_complete BOOLEAN DEFAULT false
)
RETURNS TABLE (
success BOOLEAN,
transaction_id UUID,
new_balance DECIMAL(20,8),
error_message TEXT
)
LANGUAGE plpgsql
AS $$
DECLARE
v_wallet RECORD;
v_tx_id UUID;
v_balance_before DECIMAL(20,8);
v_balance_after DECIMAL(20,8);
v_update_result RECORD;
v_dest_tx_id UUID;
BEGIN
-- Validar idempotency
IF p_idempotency_key IS NOT NULL THEN
SELECT id, wallet_transactions.status INTO v_tx_id, v_wallet
FROM financial.wallet_transactions
WHERE idempotency_key = p_idempotency_key;
IF FOUND THEN
-- Transacción ya existe
SELECT balance INTO v_balance_after
FROM financial.wallets
WHERE id = p_wallet_id;
RETURN QUERY SELECT
true,
v_tx_id,
v_balance_after,
'Transaction already exists (idempotent)'::TEXT;
RETURN;
END IF;
END IF;
-- Lock wallet
SELECT * INTO v_wallet
FROM financial.wallets
WHERE id = p_wallet_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN QUERY SELECT false, NULL::UUID, 0::DECIMAL, 'Wallet not found';
RETURN;
END IF;
IF v_wallet.status != 'active' THEN
RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance,
'Wallet is not active (status: ' || v_wallet.status::TEXT || ')';
RETURN;
END IF;
-- Validar currency match
IF v_wallet.currency != p_currency THEN
RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance,
'Currency mismatch (wallet: ' || v_wallet.currency::TEXT || ', transaction: ' || p_currency::TEXT || ')';
RETURN;
END IF;
-- Validar destination para transfers
IF p_transaction_type IN ('transfer_out', 'transfer_in') AND p_destination_wallet_id IS NULL THEN
RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance,
'Transfer requires destination_wallet_id';
RETURN;
END IF;
-- No permitir self-transfers
IF p_destination_wallet_id = p_wallet_id THEN
RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance,
'Cannot transfer to same wallet';
RETURN;
END IF;
v_balance_before := v_wallet.balance;
-- Crear transacción
INSERT INTO financial.wallet_transactions (
wallet_id,
transaction_type,
status,
amount,
fee,
currency,
balance_before,
destination_wallet_id,
reference_id,
description,
metadata,
idempotency_key,
processed_at
) VALUES (
p_wallet_id,
p_transaction_type,
CASE WHEN p_auto_complete THEN 'completed'::financial.transaction_status
ELSE 'pending'::financial.transaction_status END,
p_amount,
p_fee,
p_currency,
v_balance_before,
p_destination_wallet_id,
p_reference_id,
p_description,
p_metadata,
p_idempotency_key,
CASE WHEN p_auto_complete THEN NOW() ELSE NULL END
)
RETURNING id INTO v_tx_id;
-- Si es auto_complete, procesar inmediatamente
IF p_auto_complete THEN
-- Determinar operación de balance
CASE p_transaction_type
WHEN 'deposit', 'transfer_in', 'earning', 'distribution', 'bonus', 'refund' THEN
-- Aumentar balance
SELECT * INTO v_update_result
FROM financial.update_wallet_balance(
p_wallet_id,
p_amount - p_fee,
'add',
v_tx_id,
NULL,
'system',
'Transaction: ' || p_transaction_type::TEXT
);
WHEN 'withdrawal', 'transfer_out', 'fee' THEN
-- Disminuir balance
SELECT * INTO v_update_result
FROM financial.update_wallet_balance(
p_wallet_id,
p_amount + p_fee,
'subtract',
v_tx_id,
NULL,
'system',
'Transaction: ' || p_transaction_type::TEXT
);
ELSE
RETURN QUERY SELECT false, v_tx_id, v_balance_before,
'Unknown transaction type: ' || p_transaction_type::TEXT;
RETURN;
END CASE;
-- Verificar éxito de actualización
IF NOT v_update_result.success THEN
-- Marcar transacción como fallida
UPDATE financial.wallet_transactions
SET
status = 'failed',
failed_reason = v_update_result.error_message,
failed_at = NOW()
WHERE id = v_tx_id;
RETURN QUERY SELECT false, v_tx_id, v_balance_before, v_update_result.error_message;
RETURN;
END IF;
v_balance_after := v_update_result.new_balance;
-- Actualizar balance_after en transacción
UPDATE financial.wallet_transactions
SET
balance_after = v_balance_after,
completed_at = NOW()
WHERE id = v_tx_id;
-- Si es transfer_out, crear transfer_in en destino
IF p_transaction_type = 'transfer_out' AND p_destination_wallet_id IS NOT NULL THEN
SELECT * INTO v_update_result
FROM financial.process_transaction(
p_destination_wallet_id,
'transfer_in',
p_amount - p_fee, -- El fee lo paga el origen
p_currency,
0, -- Sin fee adicional en destino
'Transfer from wallet ' || p_wallet_id::TEXT,
p_reference_id,
p_wallet_id, -- Origen como destino inverso
p_idempotency_key || '_dest', -- Idempotency para destino
p_metadata,
true -- Auto-complete
);
IF v_update_result.success THEN
v_dest_tx_id := v_update_result.transaction_id;
-- Vincular transacciones
UPDATE financial.wallet_transactions
SET related_transaction_id = v_dest_tx_id
WHERE id = v_tx_id;
UPDATE financial.wallet_transactions
SET related_transaction_id = v_tx_id
WHERE id = v_dest_tx_id;
END IF;
END IF;
-- Actualizar totals en wallet
IF p_transaction_type IN ('deposit', 'transfer_in') THEN
UPDATE financial.wallets
SET total_deposits = total_deposits + p_amount
WHERE id = p_wallet_id;
ELSIF p_transaction_type IN ('withdrawal', 'transfer_out') THEN
UPDATE financial.wallets
SET total_withdrawals = total_withdrawals + p_amount
WHERE id = p_wallet_id;
END IF;
ELSE
-- Transaction pending, no balance update yet
v_balance_after := v_balance_before;
END IF;
RETURN QUERY SELECT true, v_tx_id, v_balance_after, NULL::TEXT;
END;
$$;
COMMENT ON FUNCTION financial.process_transaction IS 'Create and optionally complete a wallet transaction atomically';
-- Función para completar transacción pendiente
CREATE OR REPLACE FUNCTION financial.complete_transaction(
p_transaction_id UUID
)
RETURNS TABLE (
success BOOLEAN,
new_balance DECIMAL(20,8),
error_message TEXT
)
LANGUAGE plpgsql
AS $$
DECLARE
v_tx RECORD;
v_update_result RECORD;
BEGIN
-- Lock transaction
SELECT * INTO v_tx
FROM financial.wallet_transactions
WHERE id = p_transaction_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN QUERY SELECT false, 0::DECIMAL, 'Transaction not found';
RETURN;
END IF;
IF v_tx.status != 'pending' THEN
RETURN QUERY SELECT false, 0::DECIMAL,
'Transaction is not pending (status: ' || v_tx.status::TEXT || ')';
RETURN;
END IF;
-- Procesar según tipo
CASE v_tx.transaction_type
WHEN 'deposit', 'transfer_in', 'earning', 'distribution', 'bonus', 'refund' THEN
SELECT * INTO v_update_result
FROM financial.update_wallet_balance(
v_tx.wallet_id,
v_tx.amount - v_tx.fee,
'add',
p_transaction_id
);
WHEN 'withdrawal', 'transfer_out', 'fee' THEN
SELECT * INTO v_update_result
FROM financial.update_wallet_balance(
v_tx.wallet_id,
v_tx.amount + v_tx.fee,
'subtract',
p_transaction_id
);
ELSE
RETURN QUERY SELECT false, 0::DECIMAL, 'Unknown transaction type';
RETURN;
END CASE;
IF NOT v_update_result.success THEN
-- Marcar como fallida
UPDATE financial.wallet_transactions
SET
status = 'failed',
failed_reason = v_update_result.error_message,
failed_at = NOW()
WHERE id = p_transaction_id;
RETURN QUERY SELECT false, 0::DECIMAL, v_update_result.error_message;
RETURN;
END IF;
-- Marcar como completada
UPDATE financial.wallet_transactions
SET
status = 'completed',
balance_after = v_update_result.new_balance,
completed_at = NOW(),
processed_at = COALESCE(processed_at, NOW())
WHERE id = p_transaction_id;
RETURN QUERY SELECT true, v_update_result.new_balance, NULL::TEXT;
END;
$$;
COMMENT ON FUNCTION financial.complete_transaction IS 'Complete a pending wallet transaction';

View File

@ -0,0 +1,278 @@
-- =====================================================
-- ORBIQUANT IA - FINANCIAL SCHEMA TRIGGERS
-- =====================================================
-- Description: Automated triggers for data integrity and audit
-- Schema: financial
-- =====================================================
-- =====================================================
-- TRIGGER: Update timestamps
-- =====================================================
CREATE OR REPLACE FUNCTION financial.update_timestamp()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
-- Apply to all tables with updated_at
CREATE TRIGGER trigger_wallets_updated_at
BEFORE UPDATE ON financial.wallets
FOR EACH ROW
EXECUTE FUNCTION financial.update_timestamp();
CREATE TRIGGER trigger_transactions_updated_at
BEFORE UPDATE ON financial.wallet_transactions
FOR EACH ROW
EXECUTE FUNCTION financial.update_timestamp();
CREATE TRIGGER trigger_subscriptions_updated_at
BEFORE UPDATE ON financial.subscriptions
FOR EACH ROW
EXECUTE FUNCTION financial.update_timestamp();
CREATE TRIGGER trigger_payments_updated_at
BEFORE UPDATE ON financial.payments
FOR EACH ROW
EXECUTE FUNCTION financial.update_timestamp();
CREATE TRIGGER trigger_invoices_updated_at
BEFORE UPDATE ON financial.invoices
FOR EACH ROW
EXECUTE FUNCTION financial.update_timestamp();
CREATE TRIGGER trigger_exchange_rates_updated_at
BEFORE UPDATE ON financial.currency_exchange_rates
FOR EACH ROW
EXECUTE FUNCTION financial.update_timestamp();
CREATE TRIGGER trigger_wallet_limits_updated_at
BEFORE UPDATE ON financial.wallet_limits
FOR EACH ROW
EXECUTE FUNCTION financial.update_timestamp();
-- =====================================================
-- TRIGGER: Auto-generate invoice number
-- =====================================================
CREATE OR REPLACE FUNCTION financial.generate_invoice_number()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.invoice_number IS NULL THEN
NEW.invoice_number := 'INV-' || TO_CHAR(NOW(), 'YYYYMM') || '-' ||
LPAD(nextval('financial.invoice_number_seq')::TEXT, 6, '0');
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_invoice_number
BEFORE INSERT ON financial.invoices
FOR EACH ROW
EXECUTE FUNCTION financial.generate_invoice_number();
-- =====================================================
-- TRIGGER: Validate wallet balance consistency
-- =====================================================
CREATE OR REPLACE FUNCTION financial.validate_wallet_balance()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Validar que balance = available + pending
IF NEW.balance != (NEW.available_balance + NEW.pending_balance) THEN
RAISE EXCEPTION 'Balance consistency error: balance (%) != available (%) + pending (%)',
NEW.balance, NEW.available_balance, NEW.pending_balance;
END IF;
-- Validar que no haya negativos
IF NEW.balance < 0 OR NEW.available_balance < 0 OR NEW.pending_balance < 0 THEN
RAISE EXCEPTION 'Negative balance detected: balance=%, available=%, pending=%',
NEW.balance, NEW.available_balance, NEW.pending_balance;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_wallet_balance_validation
BEFORE INSERT OR UPDATE ON financial.wallets
FOR EACH ROW
EXECUTE FUNCTION financial.validate_wallet_balance();
-- =====================================================
-- TRIGGER: Audit wallet status changes
-- =====================================================
CREATE OR REPLACE FUNCTION financial.audit_wallet_status_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Solo auditar si cambió el status
IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO financial.wallet_audit_log (
wallet_id,
action,
actor_type,
old_values,
new_values,
reason
) VALUES (
NEW.id,
'status_changed',
'system',
jsonb_build_object('status', OLD.status),
jsonb_build_object('status', NEW.status),
'Status changed from ' || OLD.status::TEXT || ' to ' || NEW.status::TEXT
);
-- Si se cerró, registrar timestamp
IF NEW.status = 'closed' AND NEW.closed_at IS NULL THEN
NEW.closed_at := NOW();
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_wallet_status_audit
BEFORE UPDATE ON financial.wallets
FOR EACH ROW
EXECUTE FUNCTION financial.audit_wallet_status_change();
-- =====================================================
-- TRIGGER: Prevent modification of completed transactions
-- =====================================================
CREATE OR REPLACE FUNCTION financial.protect_completed_transactions()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.status = 'completed' AND NEW.status != 'completed' THEN
RAISE EXCEPTION 'Cannot modify completed transaction %', OLD.id;
END IF;
IF OLD.status = 'completed' AND (
OLD.amount != NEW.amount OR
OLD.wallet_id != NEW.wallet_id OR
OLD.transaction_type != NEW.transaction_type
) THEN
RAISE EXCEPTION 'Cannot modify core fields of completed transaction %', OLD.id;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_protect_completed_tx
BEFORE UPDATE ON financial.wallet_transactions
FOR EACH ROW
EXECUTE FUNCTION financial.protect_completed_transactions();
-- =====================================================
-- TRIGGER: Set payment succeeded_at timestamp
-- =====================================================
CREATE OR REPLACE FUNCTION financial.set_payment_timestamps()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Set succeeded_at when status changes to succeeded
IF NEW.status = 'succeeded' AND OLD.status != 'succeeded' THEN
NEW.succeeded_at := NOW();
END IF;
-- Set failed_at when status changes to failed
IF NEW.status = 'failed' AND OLD.status != 'failed' THEN
NEW.failed_at := NOW();
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_payment_timestamps
BEFORE UPDATE ON financial.payments
FOR EACH ROW
EXECUTE FUNCTION financial.set_payment_timestamps();
-- =====================================================
-- TRIGGER: Update subscription ended_at
-- =====================================================
CREATE OR REPLACE FUNCTION financial.set_subscription_ended_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Set ended_at when status changes to cancelled and cancel_at_period_end is false
IF NEW.status = 'cancelled' AND
OLD.status != 'cancelled' AND
NOT NEW.cancel_at_period_end AND
NEW.ended_at IS NULL THEN
NEW.ended_at := NOW();
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_subscription_ended_at
BEFORE UPDATE ON financial.subscriptions
FOR EACH ROW
EXECUTE FUNCTION financial.set_subscription_ended_at();
-- =====================================================
-- TRIGGER: Validate transaction currency matches wallet
-- =====================================================
CREATE OR REPLACE FUNCTION financial.validate_transaction_currency()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
v_wallet_currency financial.currency_code;
BEGIN
-- Get wallet currency
SELECT currency INTO v_wallet_currency
FROM financial.wallets
WHERE id = NEW.wallet_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Wallet % not found', NEW.wallet_id;
END IF;
-- Validate currency match
IF NEW.currency != v_wallet_currency THEN
RAISE EXCEPTION 'Transaction currency (%) does not match wallet currency (%)',
NEW.currency, v_wallet_currency;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_transaction_currency_validation
BEFORE INSERT ON financial.wallet_transactions
FOR EACH ROW
EXECUTE FUNCTION financial.validate_transaction_currency();
COMMENT ON FUNCTION financial.update_timestamp IS 'Auto-update updated_at timestamp';
COMMENT ON FUNCTION financial.generate_invoice_number IS 'Auto-generate invoice number with format INV-YYYYMM-XXXXXX';
COMMENT ON FUNCTION financial.validate_wallet_balance IS 'Ensure balance = available + pending';
COMMENT ON FUNCTION financial.audit_wallet_status_change IS 'Log wallet status changes to audit log';
COMMENT ON FUNCTION financial.protect_completed_transactions IS 'Prevent modification of completed transactions';
COMMENT ON FUNCTION financial.set_payment_timestamps IS 'Auto-set succeeded_at and failed_at timestamps';
COMMENT ON FUNCTION financial.set_subscription_ended_at IS 'Auto-set ended_at when subscription is cancelled';
COMMENT ON FUNCTION financial.validate_transaction_currency IS 'Ensure transaction currency matches wallet currency';

View File

@ -0,0 +1,258 @@
-- =====================================================
-- ORBIQUANT IA - FINANCIAL SCHEMA VIEWS
-- =====================================================
-- Description: Useful views for common financial queries
-- Schema: financial
-- =====================================================
-- =====================================================
-- VIEW: Active user wallets summary
-- =====================================================
CREATE OR REPLACE VIEW financial.v_user_wallets_summary AS
SELECT
w.user_id,
w.wallet_type,
w.currency,
w.balance,
w.available_balance,
w.pending_balance,
w.status,
w.last_transaction_at,
w.total_deposits,
w.total_withdrawals,
w.created_at,
-- Transaction counts
(SELECT COUNT(*)
FROM financial.wallet_transactions wt
WHERE wt.wallet_id = w.id AND wt.status = 'completed') as total_transactions,
(SELECT COUNT(*)
FROM financial.wallet_transactions wt
WHERE wt.wallet_id = w.id AND wt.status = 'pending') as pending_transactions,
-- Latest transaction
(SELECT wt.created_at
FROM financial.wallet_transactions wt
WHERE wt.wallet_id = w.id
ORDER BY wt.created_at DESC
LIMIT 1) as last_tx_date
FROM financial.wallets w
WHERE w.status = 'active';
COMMENT ON VIEW financial.v_user_wallets_summary IS 'Active wallets with transaction statistics';
-- =====================================================
-- VIEW: User total balance across all wallets (USD)
-- =====================================================
CREATE OR REPLACE VIEW financial.v_user_total_balance AS
SELECT
user_id,
SUM(CASE WHEN currency = 'USD' THEN balance ELSE 0 END) as total_usd,
SUM(CASE WHEN currency = 'MXN' THEN balance ELSE 0 END) as total_mxn,
SUM(CASE WHEN currency = 'EUR' THEN balance ELSE 0 END) as total_eur,
-- Totals by wallet type
SUM(CASE WHEN wallet_type = 'trading' AND currency = 'USD' THEN balance ELSE 0 END) as trading_usd,
SUM(CASE WHEN wallet_type = 'investment' AND currency = 'USD' THEN balance ELSE 0 END) as investment_usd,
SUM(CASE WHEN wallet_type = 'earnings' AND currency = 'USD' THEN balance ELSE 0 END) as earnings_usd,
SUM(CASE WHEN wallet_type = 'referral' AND currency = 'USD' THEN balance ELSE 0 END) as referral_usd,
COUNT(*) as wallet_count,
MAX(last_transaction_at) as last_activity
FROM financial.wallets
WHERE status = 'active'
GROUP BY user_id;
COMMENT ON VIEW financial.v_user_total_balance IS 'Aggregated balance per user across all wallets';
-- =====================================================
-- VIEW: Recent transactions (last 30 days)
-- =====================================================
CREATE OR REPLACE VIEW financial.v_recent_transactions AS
SELECT
wt.id,
wt.wallet_id,
w.user_id,
w.wallet_type,
wt.transaction_type,
wt.status,
wt.amount,
wt.fee,
wt.net_amount,
wt.currency,
wt.description,
wt.reference_id,
wt.balance_before,
wt.balance_after,
wt.created_at,
wt.completed_at,
-- Days since transaction
EXTRACT(DAY FROM NOW() - wt.created_at) as days_ago
FROM financial.wallet_transactions wt
JOIN financial.wallets w ON w.id = wt.wallet_id
WHERE wt.created_at >= NOW() - INTERVAL '30 days'
ORDER BY wt.created_at DESC;
COMMENT ON VIEW financial.v_recent_transactions IS 'Wallet transactions from last 30 days';
-- =====================================================
-- VIEW: Active subscriptions with user details
-- =====================================================
CREATE OR REPLACE VIEW financial.v_active_subscriptions AS
SELECT
s.id,
s.user_id,
s.plan,
s.status,
s.price,
s.currency,
s.billing_interval,
s.current_period_start,
s.current_period_end,
s.next_payment_at,
s.cancel_at_period_end,
s.stripe_subscription_id,
-- Days until renewal
EXTRACT(DAY FROM s.current_period_end - NOW()) as days_until_renewal,
-- Is in trial
(s.status = 'trialing') as is_trial,
-- Trial days remaining
CASE
WHEN s.trial_end IS NOT NULL THEN EXTRACT(DAY FROM s.trial_end - NOW())
ELSE NULL
END as trial_days_remaining
FROM financial.subscriptions s
WHERE s.status IN ('active', 'trialing', 'past_due')
ORDER BY s.current_period_end ASC;
COMMENT ON VIEW financial.v_active_subscriptions IS 'Active subscriptions with renewal information';
-- =====================================================
-- VIEW: Pending payments
-- =====================================================
CREATE OR REPLACE VIEW financial.v_pending_payments AS
SELECT
p.id,
p.user_id,
p.subscription_id,
p.amount,
p.currency,
p.payment_method,
p.status,
p.description,
p.stripe_payment_intent_id,
p.created_at,
-- Days pending
EXTRACT(DAY FROM NOW() - p.created_at) as days_pending
FROM financial.payments p
WHERE p.status IN ('pending', 'processing')
ORDER BY p.created_at ASC;
COMMENT ON VIEW financial.v_pending_payments IS 'Payments awaiting completion';
-- =====================================================
-- VIEW: Unpaid invoices
-- =====================================================
CREATE OR REPLACE VIEW financial.v_unpaid_invoices AS
SELECT
i.id,
i.user_id,
i.invoice_number,
i.total,
i.amount_due,
i.currency,
i.due_date,
i.status,
i.invoice_date,
i.hosted_invoice_url,
-- Days overdue
CASE
WHEN i.due_date IS NOT NULL AND i.due_date < NOW()
THEN EXTRACT(DAY FROM NOW() - i.due_date)
ELSE 0
END as days_overdue,
-- Is overdue
(i.due_date IS NOT NULL AND i.due_date < NOW()) as is_overdue
FROM financial.invoices i
WHERE i.status = 'open' AND i.paid = false
ORDER BY i.due_date ASC NULLS LAST;
COMMENT ON VIEW financial.v_unpaid_invoices IS 'Open invoices with overdue status';
-- =====================================================
-- VIEW: Daily transaction volume
-- =====================================================
CREATE OR REPLACE VIEW financial.v_daily_transaction_volume AS
SELECT
DATE(wt.created_at) as transaction_date,
wt.transaction_type,
wt.currency,
COUNT(*) as transaction_count,
SUM(wt.amount) as total_amount,
SUM(wt.fee) as total_fees,
SUM(wt.net_amount) as total_net_amount,
AVG(wt.amount) as avg_amount
FROM financial.wallet_transactions wt
WHERE wt.status = 'completed'
AND wt.created_at >= NOW() - INTERVAL '90 days'
GROUP BY DATE(wt.created_at), wt.transaction_type, wt.currency
ORDER BY transaction_date DESC, transaction_type;
COMMENT ON VIEW financial.v_daily_transaction_volume IS 'Daily aggregated transaction statistics';
-- =====================================================
-- VIEW: Wallet activity summary (last 7 days)
-- =====================================================
CREATE OR REPLACE VIEW financial.v_wallet_activity_7d AS
SELECT
w.id as wallet_id,
w.user_id,
w.wallet_type,
w.currency,
w.balance,
-- Transaction counts by type
COUNT(CASE WHEN wt.transaction_type = 'deposit' THEN 1 END) as deposits_7d,
COUNT(CASE WHEN wt.transaction_type = 'withdrawal' THEN 1 END) as withdrawals_7d,
COUNT(CASE WHEN wt.transaction_type IN ('transfer_in', 'transfer_out') THEN 1 END) as transfers_7d,
-- Amounts
SUM(CASE WHEN wt.transaction_type = 'deposit' THEN wt.amount ELSE 0 END) as deposit_amount_7d,
SUM(CASE WHEN wt.transaction_type = 'withdrawal' THEN wt.amount ELSE 0 END) as withdrawal_amount_7d,
-- Total activity
COUNT(wt.id) as total_transactions_7d
FROM financial.wallets w
LEFT JOIN financial.wallet_transactions wt ON wt.wallet_id = w.id
AND wt.created_at >= NOW() - INTERVAL '7 days'
AND wt.status = 'completed'
WHERE w.status = 'active'
GROUP BY w.id, w.user_id, w.wallet_type, w.currency, w.balance;
COMMENT ON VIEW financial.v_wallet_activity_7d IS 'Wallet activity summary for last 7 days';
-- =====================================================
-- VIEW: Subscription revenue metrics
-- =====================================================
CREATE OR REPLACE VIEW financial.v_subscription_revenue AS
SELECT
s.plan,
s.billing_interval,
s.currency,
COUNT(*) as active_count,
SUM(s.price) as total_monthly_value,
AVG(s.price) as avg_price,
-- MRR calculation (Monthly Recurring Revenue)
SUM(CASE
WHEN s.billing_interval = 'month' THEN s.price
WHEN s.billing_interval = 'year' THEN s.price / 12
ELSE 0
END) as monthly_recurring_revenue
FROM financial.subscriptions s
WHERE s.status IN ('active', 'trialing')
GROUP BY s.plan, s.billing_interval, s.currency
ORDER BY s.plan, s.billing_interval;
COMMENT ON VIEW financial.v_subscription_revenue IS 'Subscription metrics and MRR calculation';

View File

@ -0,0 +1,85 @@
-- =====================================================
-- ORBIQUANT IA - WALLETS TABLE (UNIFIED)
-- =====================================================
-- Description: Single source of truth for all wallet types
-- Schema: financial
-- =====================================================
-- UNIFICACIÓN: Esta tabla reemplaza definiciones previas en:
-- - OQI-004 (trading schema)
-- - OQI-005 (investment schema)
-- - OQI-008 (financial schema - legacy)
-- =====================================================
CREATE TABLE financial.wallets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
-- Tipo y estado
wallet_type financial.wallet_type NOT NULL,
status financial.wallet_status NOT NULL DEFAULT 'active',
-- Balance (precisión de 8 decimales para soportar crypto)
balance DECIMAL(20,8) NOT NULL DEFAULT 0.00,
available_balance DECIMAL(20,8) NOT NULL DEFAULT 0.00,
pending_balance DECIMAL(20,8) NOT NULL DEFAULT 0.00,
-- Moneda
currency financial.currency_code NOT NULL DEFAULT 'USD',
-- Stripe integration (si aplica)
stripe_account_id VARCHAR(255),
stripe_customer_id VARCHAR(255),
-- Límites operacionales
daily_withdrawal_limit DECIMAL(15,2),
monthly_withdrawal_limit DECIMAL(15,2),
min_balance DECIMAL(15,2) DEFAULT 0.00,
-- Tracking de uso
last_transaction_at TIMESTAMPTZ,
total_deposits DECIMAL(20,8) DEFAULT 0.00,
total_withdrawals DECIMAL(20,8) DEFAULT 0.00,
-- Metadata extensible
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
closed_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT positive_balance CHECK (balance >= 0),
CONSTRAINT positive_available CHECK (available_balance >= 0),
CONSTRAINT positive_pending CHECK (pending_balance >= 0),
CONSTRAINT available_lte_balance CHECK (available_balance <= balance),
CONSTRAINT balance_equation CHECK (balance = available_balance + pending_balance),
CONSTRAINT positive_limits CHECK (
(daily_withdrawal_limit IS NULL OR daily_withdrawal_limit > 0) AND
(monthly_withdrawal_limit IS NULL OR monthly_withdrawal_limit > 0)
),
CONSTRAINT unique_user_wallet_type UNIQUE(user_id, wallet_type, currency),
CONSTRAINT closed_status_has_date CHECK (
(status = 'closed' AND closed_at IS NOT NULL) OR
(status != 'closed' AND closed_at IS NULL)
)
);
-- Indexes para performance
CREATE INDEX idx_wallets_user_id ON financial.wallets(user_id);
CREATE INDEX idx_wallets_wallet_type ON financial.wallets(wallet_type);
CREATE INDEX idx_wallets_status ON financial.wallets(status) WHERE status = 'active';
CREATE INDEX idx_wallets_currency ON financial.wallets(currency);
CREATE INDEX idx_wallets_user_type_currency ON financial.wallets(user_id, wallet_type, currency);
CREATE INDEX idx_wallets_stripe_account ON financial.wallets(stripe_account_id) WHERE stripe_account_id IS NOT NULL;
CREATE INDEX idx_wallets_last_transaction ON financial.wallets(last_transaction_at DESC NULLS LAST);
-- Comments
COMMENT ON TABLE financial.wallets IS 'Unified wallet table - single source of truth for all wallet types';
COMMENT ON COLUMN financial.wallets.wallet_type IS 'Type of wallet: trading, investment, earnings, referral';
COMMENT ON COLUMN financial.wallets.balance IS 'Total balance = available + pending';
COMMENT ON COLUMN financial.wallets.available_balance IS 'Balance available for immediate use';
COMMENT ON COLUMN financial.wallets.pending_balance IS 'Balance in pending transactions';
COMMENT ON COLUMN financial.wallets.stripe_account_id IS 'Stripe Connect account ID for payouts';
COMMENT ON COLUMN financial.wallets.stripe_customer_id IS 'Stripe Customer ID for payments';
COMMENT ON COLUMN financial.wallets.metadata IS 'Extensible JSON field for additional data';

View File

@ -0,0 +1,101 @@
-- =====================================================
-- ORBIQUANT IA - WALLET TRANSACTIONS TABLE
-- =====================================================
-- Description: Complete transaction history for all wallets
-- Schema: financial
-- =====================================================
CREATE TABLE financial.wallet_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Wallet relacionada
wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE RESTRICT,
-- Tipo y estado
transaction_type financial.transaction_type NOT NULL,
status financial.transaction_status NOT NULL DEFAULT 'pending',
-- Montos (precisión de 8 decimales)
amount DECIMAL(20,8) NOT NULL,
fee DECIMAL(15,8) DEFAULT 0,
net_amount DECIMAL(20,8) GENERATED ALWAYS AS (amount - fee) STORED,
-- Moneda
currency financial.currency_code NOT NULL,
-- Balances snapshot (para auditoría)
balance_before DECIMAL(20,8),
balance_after DECIMAL(20,8),
-- Referencias externas
stripe_payment_intent_id VARCHAR(255),
stripe_transfer_id VARCHAR(255),
stripe_charge_id VARCHAR(255),
reference_id VARCHAR(100), -- ID de referencia interna (ej: trade_id, investment_id)
-- Para transfers entre wallets
destination_wallet_id UUID REFERENCES financial.wallets(id) ON DELETE RESTRICT,
related_transaction_id UUID REFERENCES financial.wallet_transactions(id), -- TX relacionada (para transfers bidireccionales)
-- Descripción y notas
description TEXT,
notes TEXT,
metadata JSONB DEFAULT '{}',
-- Procesamiento
processed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failed_reason TEXT,
-- Idempotency
idempotency_key VARCHAR(255) UNIQUE,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT positive_amount CHECK (amount > 0),
CONSTRAINT positive_fee CHECK (fee >= 0),
CONSTRAINT fee_lte_amount CHECK (fee <= amount),
CONSTRAINT destination_for_transfers CHECK (
(transaction_type IN ('transfer_in', 'transfer_out') AND destination_wallet_id IS NOT NULL) OR
(transaction_type NOT IN ('transfer_in', 'transfer_out'))
),
CONSTRAINT no_self_transfer CHECK (wallet_id != destination_wallet_id),
CONSTRAINT completed_has_timestamp CHECK (
(status = 'completed' AND completed_at IS NOT NULL) OR
(status != 'completed')
),
CONSTRAINT failed_has_reason CHECK (
(status = 'failed' AND failed_reason IS NOT NULL AND failed_at IS NOT NULL) OR
(status != 'failed')
)
);
-- Indexes para performance y queries comunes
CREATE INDEX idx_wt_wallet_id ON financial.wallet_transactions(wallet_id);
CREATE INDEX idx_wt_transaction_type ON financial.wallet_transactions(transaction_type);
CREATE INDEX idx_wt_status ON financial.wallet_transactions(status);
CREATE INDEX idx_wt_created_at ON financial.wallet_transactions(created_at DESC);
CREATE INDEX idx_wt_reference_id ON financial.wallet_transactions(reference_id) WHERE reference_id IS NOT NULL;
CREATE INDEX idx_wt_stripe_payment ON financial.wallet_transactions(stripe_payment_intent_id) WHERE stripe_payment_intent_id IS NOT NULL;
CREATE INDEX idx_wt_stripe_transfer ON financial.wallet_transactions(stripe_transfer_id) WHERE stripe_transfer_id IS NOT NULL;
CREATE INDEX idx_wt_destination ON financial.wallet_transactions(destination_wallet_id) WHERE destination_wallet_id IS NOT NULL;
CREATE INDEX idx_wt_wallet_status_created ON financial.wallet_transactions(wallet_id, status, created_at DESC);
CREATE INDEX idx_wt_idempotency ON financial.wallet_transactions(idempotency_key) WHERE idempotency_key IS NOT NULL;
-- Composite index para queries de rango por wallet
CREATE INDEX idx_wt_wallet_date_range ON financial.wallet_transactions(wallet_id, created_at DESC)
WHERE status = 'completed';
-- Comments
COMMENT ON TABLE financial.wallet_transactions IS 'Complete transaction history for all wallet operations';
COMMENT ON COLUMN financial.wallet_transactions.transaction_type IS 'Type of transaction: deposit, withdrawal, transfer, fee, etc.';
COMMENT ON COLUMN financial.wallet_transactions.net_amount IS 'Amount after fees (computed column)';
COMMENT ON COLUMN financial.wallet_transactions.reference_id IS 'Internal reference to related entity (trade, investment, etc.)';
COMMENT ON COLUMN financial.wallet_transactions.destination_wallet_id IS 'Target wallet for transfer operations';
COMMENT ON COLUMN financial.wallet_transactions.related_transaction_id IS 'Related transaction for bidirectional transfers';
COMMENT ON COLUMN financial.wallet_transactions.idempotency_key IS 'Unique key to prevent duplicate transactions';
COMMENT ON COLUMN financial.wallet_transactions.metadata IS 'Extensible JSON field for transaction-specific data';

View File

@ -0,0 +1,107 @@
-- =====================================================
-- ORBIQUANT IA - SUBSCRIPTIONS TABLE
-- =====================================================
-- Description: User subscription management with Stripe integration
-- Schema: financial
-- =====================================================
-- DECISION: Planes en USD como moneda base
-- =====================================================
CREATE TABLE financial.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
-- Plan y estado
plan financial.subscription_plan NOT NULL,
status financial.subscription_status NOT NULL DEFAULT 'incomplete',
-- Stripe integration
stripe_subscription_id VARCHAR(255) UNIQUE,
stripe_customer_id VARCHAR(255),
stripe_price_id VARCHAR(255),
stripe_product_id VARCHAR(255),
-- Pricing
price DECIMAL(10,2) NOT NULL,
currency financial.currency_code NOT NULL DEFAULT 'USD',
billing_interval VARCHAR(20) NOT NULL DEFAULT 'month', -- month, year
-- Billing periods
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
trial_start TIMESTAMPTZ,
trial_end TIMESTAMPTZ,
-- Cancelación
cancelled_at TIMESTAMPTZ,
cancel_at_period_end BOOLEAN DEFAULT false,
cancellation_reason TEXT,
cancellation_feedback JSONB,
-- Downgrade/Upgrade tracking
previous_plan financial.subscription_plan,
scheduled_plan financial.subscription_plan,
scheduled_plan_effective_at TIMESTAMPTZ,
-- Payment tracking
last_payment_at TIMESTAMPTZ,
next_payment_at TIMESTAMPTZ,
failed_payment_count INTEGER DEFAULT 0,
-- Features/Quotas (se pueden mover a tabla separada si crece)
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT positive_price CHECK (price >= 0),
CONSTRAINT valid_billing_interval CHECK (billing_interval IN ('month', 'year')),
CONSTRAINT trial_dates_order CHECK (
(trial_start IS NULL AND trial_end IS NULL) OR
(trial_start IS NOT NULL AND trial_end IS NOT NULL AND trial_start < trial_end)
),
CONSTRAINT period_dates_order CHECK (
(current_period_start IS NULL AND current_period_end IS NULL) OR
(current_period_start IS NOT NULL AND current_period_end IS NOT NULL AND current_period_start < current_period_end)
),
CONSTRAINT cancel_date_valid CHECK (
(cancelled_at IS NULL) OR
(cancelled_at >= created_at)
),
CONSTRAINT ended_when_cancelled CHECK (
(ended_at IS NULL) OR
(cancelled_at IS NOT NULL AND ended_at >= cancelled_at)
),
CONSTRAINT scheduled_plan_different CHECK (
scheduled_plan IS NULL OR scheduled_plan != plan
)
);
-- Indexes
CREATE INDEX idx_subscriptions_user_id ON financial.subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON financial.subscriptions(status);
CREATE INDEX idx_subscriptions_plan ON financial.subscriptions(plan);
CREATE INDEX idx_subscriptions_stripe_sub ON financial.subscriptions(stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL;
CREATE INDEX idx_subscriptions_stripe_customer ON financial.subscriptions(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
CREATE INDEX idx_subscriptions_active ON financial.subscriptions(user_id, status) WHERE status = 'active';
CREATE INDEX idx_subscriptions_period_end ON financial.subscriptions(current_period_end) WHERE status = 'active';
CREATE INDEX idx_subscriptions_trial_end ON financial.subscriptions(trial_end) WHERE status = 'trialing';
CREATE INDEX idx_subscriptions_next_payment ON financial.subscriptions(next_payment_at) WHERE next_payment_at IS NOT NULL;
-- Unique constraint: un usuario solo puede tener una suscripción activa a la vez
CREATE UNIQUE INDEX idx_subscriptions_user_active ON financial.subscriptions(user_id)
WHERE status IN ('active', 'trialing', 'past_due');
-- Comments
COMMENT ON TABLE financial.subscriptions IS 'User subscription management with Stripe integration';
COMMENT ON COLUMN financial.subscriptions.plan IS 'Subscription plan tier';
COMMENT ON COLUMN financial.subscriptions.status IS 'Subscription status (Stripe-compatible states)';
COMMENT ON COLUMN financial.subscriptions.price IS 'Subscription price in specified currency';
COMMENT ON COLUMN financial.subscriptions.billing_interval IS 'Billing frequency: month or year';
COMMENT ON COLUMN financial.subscriptions.cancel_at_period_end IS 'If true, subscription will cancel at end of current period';
COMMENT ON COLUMN financial.subscriptions.scheduled_plan IS 'Plan to switch to at scheduled_plan_effective_at';
COMMENT ON COLUMN financial.subscriptions.failed_payment_count IS 'Number of consecutive failed payment attempts';
COMMENT ON COLUMN financial.subscriptions.metadata IS 'Plan features, quotas, and additional configuration';

View File

@ -0,0 +1,86 @@
-- =====================================================
-- ORBIQUANT IA - PAYMENTS TABLE
-- =====================================================
-- Description: Payment transaction records
-- Schema: financial
-- =====================================================
CREATE TABLE financial.payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Referencias
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
subscription_id UUID REFERENCES financial.subscriptions(id) ON DELETE SET NULL,
invoice_id UUID REFERENCES financial.invoices(id) ON DELETE SET NULL,
wallet_transaction_id UUID REFERENCES financial.wallet_transactions(id) ON DELETE SET NULL,
-- Stripe integration
stripe_payment_intent_id VARCHAR(255) UNIQUE,
stripe_charge_id VARCHAR(255),
stripe_payment_method_id VARCHAR(255),
-- Payment details
amount DECIMAL(15,2) NOT NULL,
currency financial.currency_code NOT NULL,
payment_method financial.payment_method NOT NULL,
status financial.payment_status NOT NULL DEFAULT 'pending',
-- Descripción
description TEXT,
statement_descriptor VARCHAR(255), -- Lo que ve el usuario en su estado de cuenta
-- Refunds
refunded BOOLEAN DEFAULT false,
refund_amount DECIMAL(15,2),
refund_reason TEXT,
refunded_at TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Error handling
failure_code VARCHAR(100),
failure_message TEXT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
succeeded_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT positive_amount CHECK (amount > 0),
CONSTRAINT positive_refund CHECK (refund_amount IS NULL OR refund_amount > 0),
CONSTRAINT refund_lte_amount CHECK (refund_amount IS NULL OR refund_amount <= amount),
CONSTRAINT refunded_has_data CHECK (
(refunded = false AND refund_amount IS NULL AND refunded_at IS NULL) OR
(refunded = true AND refund_amount IS NOT NULL AND refunded_at IS NOT NULL)
),
CONSTRAINT succeeded_has_timestamp CHECK (
(status = 'succeeded' AND succeeded_at IS NOT NULL) OR
(status != 'succeeded')
),
CONSTRAINT failed_has_data CHECK (
(status = 'failed' AND failed_at IS NOT NULL) OR
(status != 'failed')
)
);
-- Indexes
CREATE INDEX idx_payments_user_id ON financial.payments(user_id);
CREATE INDEX idx_payments_subscription_id ON financial.payments(subscription_id) WHERE subscription_id IS NOT NULL;
CREATE INDEX idx_payments_invoice_id ON financial.payments(invoice_id) WHERE invoice_id IS NOT NULL;
CREATE INDEX idx_payments_status ON financial.payments(status);
CREATE INDEX idx_payments_stripe_intent ON financial.payments(stripe_payment_intent_id) WHERE stripe_payment_intent_id IS NOT NULL;
CREATE INDEX idx_payments_created_at ON financial.payments(created_at DESC);
CREATE INDEX idx_payments_user_created ON financial.payments(user_id, created_at DESC);
CREATE INDEX idx_payments_payment_method ON financial.payments(payment_method);
CREATE INDEX idx_payments_refunded ON financial.payments(refunded) WHERE refunded = true;
-- Comments
COMMENT ON TABLE financial.payments IS 'Payment transaction records with Stripe integration';
COMMENT ON COLUMN financial.payments.stripe_payment_intent_id IS 'Stripe PaymentIntent ID';
COMMENT ON COLUMN financial.payments.payment_method IS 'Payment method used: card, bank_transfer, crypto, etc.';
COMMENT ON COLUMN financial.payments.statement_descriptor IS 'Text shown on customer bank statement';
COMMENT ON COLUMN financial.payments.wallet_transaction_id IS 'Related wallet transaction if payment funds a wallet';
COMMENT ON COLUMN financial.payments.metadata IS 'Additional payment metadata and context';

View File

@ -0,0 +1,120 @@
-- =====================================================
-- ORBIQUANT IA - INVOICES TABLE
-- =====================================================
-- Description: Invoice records for subscriptions and one-time charges
-- Schema: financial
-- =====================================================
CREATE TABLE financial.invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Referencias
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
subscription_id UUID REFERENCES financial.subscriptions(id) ON DELETE SET NULL,
-- Stripe integration
stripe_invoice_id VARCHAR(255) UNIQUE,
stripe_customer_id VARCHAR(255),
-- Invoice details
invoice_number VARCHAR(100) UNIQUE, -- Número de factura interno
invoice_type financial.invoice_type NOT NULL,
status financial.invoice_status NOT NULL DEFAULT 'draft',
-- Amounts
subtotal DECIMAL(15,2) NOT NULL DEFAULT 0,
tax DECIMAL(15,2) DEFAULT 0,
total DECIMAL(15,2) NOT NULL,
amount_paid DECIMAL(15,2) DEFAULT 0,
amount_due DECIMAL(15,2) GENERATED ALWAYS AS (total - amount_paid) STORED,
currency financial.currency_code NOT NULL DEFAULT 'USD',
-- Dates
invoice_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
due_date TIMESTAMPTZ,
period_start TIMESTAMPTZ,
period_end TIMESTAMPTZ,
-- Payment
paid BOOLEAN DEFAULT false,
paid_at TIMESTAMPTZ,
attempted BOOLEAN DEFAULT false,
attempt_count INTEGER DEFAULT 0,
next_payment_attempt TIMESTAMPTZ,
-- URLs
hosted_invoice_url TEXT, -- Stripe hosted invoice URL
invoice_pdf_url TEXT,
-- Billing details
billing_email VARCHAR(255),
billing_name VARCHAR(255),
billing_address JSONB,
-- Line items (si se quiere detalle simple)
line_items JSONB DEFAULT '[]',
-- Metadata
description TEXT,
notes TEXT,
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finalized_at TIMESTAMPTZ,
voided_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT positive_subtotal CHECK (subtotal >= 0),
CONSTRAINT positive_tax CHECK (tax >= 0),
CONSTRAINT positive_total CHECK (total >= 0),
CONSTRAINT positive_paid CHECK (amount_paid >= 0),
CONSTRAINT paid_lte_total CHECK (amount_paid <= total),
CONSTRAINT total_equals_subtotal_plus_tax CHECK (total = subtotal + tax),
CONSTRAINT paid_has_timestamp CHECK (
(paid = false) OR
(paid = true AND paid_at IS NOT NULL)
),
CONSTRAINT finalized_for_open_paid CHECK (
(status IN ('open', 'paid') AND finalized_at IS NOT NULL) OR
(status NOT IN ('open', 'paid'))
),
CONSTRAINT void_has_timestamp CHECK (
(status = 'void' AND voided_at IS NOT NULL) OR
(status != 'void')
),
CONSTRAINT due_date_after_invoice CHECK (
due_date IS NULL OR due_date >= invoice_date
),
CONSTRAINT period_dates_order CHECK (
(period_start IS NULL AND period_end IS NULL) OR
(period_start IS NOT NULL AND period_end IS NOT NULL AND period_start < period_end)
)
);
-- Sequence para invoice numbers
CREATE SEQUENCE financial.invoice_number_seq START 1000;
-- Indexes
CREATE INDEX idx_invoices_user_id ON financial.invoices(user_id);
CREATE INDEX idx_invoices_subscription_id ON financial.invoices(subscription_id) WHERE subscription_id IS NOT NULL;
CREATE INDEX idx_invoices_status ON financial.invoices(status);
CREATE INDEX idx_invoices_stripe_id ON financial.invoices(stripe_invoice_id) WHERE stripe_invoice_id IS NOT NULL;
CREATE INDEX idx_invoices_invoice_number ON financial.invoices(invoice_number);
CREATE INDEX idx_invoices_due_date ON financial.invoices(due_date) WHERE due_date IS NOT NULL AND status = 'open';
CREATE INDEX idx_invoices_invoice_date ON financial.invoices(invoice_date DESC);
CREATE INDEX idx_invoices_user_date ON financial.invoices(user_id, invoice_date DESC);
CREATE INDEX idx_invoices_unpaid ON financial.invoices(user_id, status) WHERE paid = false AND status = 'open';
CREATE INDEX idx_invoices_period ON financial.invoices(period_start, period_end) WHERE period_start IS NOT NULL;
-- Comments
COMMENT ON TABLE financial.invoices IS 'Invoice records for subscriptions and one-time charges';
COMMENT ON COLUMN financial.invoices.invoice_number IS 'Internal unique invoice number (auto-generated)';
COMMENT ON COLUMN financial.invoices.invoice_type IS 'Type: subscription, one_time, or usage-based';
COMMENT ON COLUMN financial.invoices.amount_due IS 'Computed: total - amount_paid';
COMMENT ON COLUMN financial.invoices.line_items IS 'JSON array of invoice line items with description, amount, quantity';
COMMENT ON COLUMN financial.invoices.billing_address IS 'JSON object with billing address details';
COMMENT ON COLUMN financial.invoices.hosted_invoice_url IS 'URL to Stripe-hosted invoice page';
COMMENT ON COLUMN financial.invoices.attempt_count IS 'Number of payment attempts made';

View File

@ -0,0 +1,68 @@
-- =====================================================
-- ORBIQUANT IA - WALLET AUDIT LOG TABLE
-- =====================================================
-- Description: Audit trail for all wallet state changes
-- Schema: financial
-- =====================================================
CREATE TABLE financial.wallet_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Wallet referencia
wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE CASCADE,
-- Acción
action financial.audit_action NOT NULL,
-- Actor (quien realizó el cambio)
actor_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
actor_type VARCHAR(50) DEFAULT 'user', -- user, system, admin, api
-- Cambios registrados
old_values JSONB,
new_values JSONB,
-- Balance snapshot
balance_before DECIMAL(20,8),
balance_after DECIMAL(20,8),
-- Transacción relacionada (si aplica)
transaction_id UUID REFERENCES financial.wallet_transactions(id) ON DELETE SET NULL,
-- Contexto
reason TEXT,
metadata JSONB DEFAULT '{}',
-- IP y user agent (para auditoría de seguridad)
ip_address INET,
user_agent TEXT,
-- Timestamp
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT balance_change_has_amounts CHECK (
(action = 'balance_updated' AND balance_before IS NOT NULL AND balance_after IS NOT NULL) OR
(action != 'balance_updated')
)
);
-- Indexes
CREATE INDEX idx_wal_wallet_id ON financial.wallet_audit_log(wallet_id);
CREATE INDEX idx_wal_action ON financial.wallet_audit_log(action);
CREATE INDEX idx_wal_actor_id ON financial.wallet_audit_log(actor_id) WHERE actor_id IS NOT NULL;
CREATE INDEX idx_wal_created_at ON financial.wallet_audit_log(created_at DESC);
CREATE INDEX idx_wal_wallet_created ON financial.wallet_audit_log(wallet_id, created_at DESC);
CREATE INDEX idx_wal_transaction_id ON financial.wallet_audit_log(transaction_id) WHERE transaction_id IS NOT NULL;
-- Partitioning hint: Esta tabla puede crecer mucho, considerar particionamiento por created_at
-- PARTITION BY RANGE (created_at);
-- Comments
COMMENT ON TABLE financial.wallet_audit_log IS 'Immutable audit trail for all wallet state changes';
COMMENT ON COLUMN financial.wallet_audit_log.action IS 'Type of action performed on wallet';
COMMENT ON COLUMN financial.wallet_audit_log.actor_id IS 'User who performed the action (NULL for system actions)';
COMMENT ON COLUMN financial.wallet_audit_log.actor_type IS 'Type of actor: user, system, admin, api';
COMMENT ON COLUMN financial.wallet_audit_log.old_values IS 'JSON snapshot of values before change';
COMMENT ON COLUMN financial.wallet_audit_log.new_values IS 'JSON snapshot of values after change';
COMMENT ON COLUMN financial.wallet_audit_log.metadata IS 'Additional context and metadata';

View File

@ -0,0 +1,131 @@
-- =====================================================
-- ORBIQUANT IA - CURRENCY EXCHANGE RATES TABLE
-- =====================================================
-- Description: Historical exchange rates for multi-currency support
-- Schema: financial
-- =====================================================
CREATE TABLE financial.currency_exchange_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Par de monedas
from_currency financial.currency_code NOT NULL,
to_currency financial.currency_code NOT NULL,
-- Tasa de cambio
rate DECIMAL(18,8) NOT NULL,
-- Fuente de datos
source VARCHAR(100) NOT NULL DEFAULT 'manual', -- manual, api, stripe, coinbase, etc.
provider VARCHAR(100), -- nombre del proveedor si es API
-- Validez temporal
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_to TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT positive_rate CHECK (rate > 0),
CONSTRAINT different_currencies CHECK (from_currency != to_currency),
CONSTRAINT valid_dates_order CHECK (
valid_to IS NULL OR valid_to > valid_from
),
CONSTRAINT unique_rate_period UNIQUE(from_currency, to_currency, valid_from)
);
-- Indexes
CREATE INDEX idx_cer_currencies ON financial.currency_exchange_rates(from_currency, to_currency);
CREATE INDEX idx_cer_valid_from ON financial.currency_exchange_rates(valid_from DESC);
CREATE INDEX idx_cer_valid_period ON financial.currency_exchange_rates(from_currency, to_currency, valid_from DESC)
WHERE valid_to IS NULL OR valid_to > NOW();
CREATE INDEX idx_cer_source ON financial.currency_exchange_rates(source);
-- Comments
COMMENT ON TABLE financial.currency_exchange_rates IS 'Historical exchange rates for currency conversion';
COMMENT ON COLUMN financial.currency_exchange_rates.rate IS 'Exchange rate: 1 from_currency = rate * to_currency';
COMMENT ON COLUMN financial.currency_exchange_rates.source IS 'Source of exchange rate data';
COMMENT ON COLUMN financial.currency_exchange_rates.valid_from IS 'Start of rate validity period';
COMMENT ON COLUMN financial.currency_exchange_rates.valid_to IS 'End of rate validity period (NULL = currently valid)';
COMMENT ON COLUMN financial.currency_exchange_rates.metadata IS 'Additional rate metadata (bid, ask, spread, etc.)';
-- Función helper para obtener tasa de cambio actual
CREATE OR REPLACE FUNCTION financial.get_exchange_rate(
p_from_currency financial.currency_code,
p_to_currency financial.currency_code,
p_at_time TIMESTAMPTZ DEFAULT NOW()
)
RETURNS DECIMAL(18,8)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_rate DECIMAL(18,8);
BEGIN
-- Si son la misma moneda, retornar 1
IF p_from_currency = p_to_currency THEN
RETURN 1.0;
END IF;
-- Buscar tasa de cambio válida
SELECT rate INTO v_rate
FROM financial.currency_exchange_rates
WHERE from_currency = p_from_currency
AND to_currency = p_to_currency
AND valid_from <= p_at_time
AND (valid_to IS NULL OR valid_to > p_at_time)
ORDER BY valid_from DESC
LIMIT 1;
-- Si no se encuentra, intentar inversa
IF v_rate IS NULL THEN
SELECT 1.0 / rate INTO v_rate
FROM financial.currency_exchange_rates
WHERE from_currency = p_to_currency
AND to_currency = p_from_currency
AND valid_from <= p_at_time
AND (valid_to IS NULL OR valid_to > p_at_time)
ORDER BY valid_from DESC
LIMIT 1;
END IF;
-- Si aún no hay tasa, retornar NULL
RETURN v_rate;
END;
$$;
COMMENT ON FUNCTION financial.get_exchange_rate IS 'Get exchange rate between currencies at specific time';
-- Función para convertir montos
CREATE OR REPLACE FUNCTION financial.convert_currency(
p_amount DECIMAL,
p_from_currency financial.currency_code,
p_to_currency financial.currency_code,
p_at_time TIMESTAMPTZ DEFAULT NOW()
)
RETURNS DECIMAL(20,8)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_rate DECIMAL(18,8);
BEGIN
-- Obtener tasa de cambio
v_rate := financial.get_exchange_rate(p_from_currency, p_to_currency, p_at_time);
-- Si no hay tasa, retornar NULL
IF v_rate IS NULL THEN
RETURN NULL;
END IF;
-- Convertir y retornar
RETURN p_amount * v_rate;
END;
$$;
COMMENT ON FUNCTION financial.convert_currency IS 'Convert amount between currencies at specific time';

View File

@ -0,0 +1,101 @@
-- =====================================================
-- ORBIQUANT IA - WALLET LIMITS TABLE
-- =====================================================
-- Description: Configurable limits and thresholds for wallets
-- Schema: financial
-- =====================================================
-- Separado de wallets para permitir límites más complejos
-- y dinámicos basados en plan, nivel de verificación, etc.
-- =====================================================
CREATE TABLE financial.wallet_limits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Wallet o configuración global
wallet_id UUID REFERENCES financial.wallets(id) ON DELETE CASCADE,
wallet_type financial.wallet_type, -- Para límites por tipo de wallet
subscription_plan financial.subscription_plan, -- Para límites por plan
-- Límites de transacción única
min_deposit DECIMAL(15,2),
max_deposit DECIMAL(15,2),
min_withdrawal DECIMAL(15,2),
max_withdrawal DECIMAL(15,2),
min_transfer DECIMAL(15,2),
max_transfer DECIMAL(15,2),
-- Límites periódicos
daily_deposit_limit DECIMAL(15,2),
daily_withdrawal_limit DECIMAL(15,2),
daily_transfer_limit DECIMAL(15,2),
weekly_deposit_limit DECIMAL(15,2),
weekly_withdrawal_limit DECIMAL(15,2),
weekly_transfer_limit DECIMAL(15,2),
monthly_deposit_limit DECIMAL(15,2),
monthly_withdrawal_limit DECIMAL(15,2),
monthly_transfer_limit DECIMAL(15,2),
-- Límites de volumen
max_pending_transactions INTEGER,
max_daily_transaction_count INTEGER,
-- Balance limits
min_balance DECIMAL(15,2) DEFAULT 0,
max_balance DECIMAL(15,2),
-- Moneda de los límites
currency financial.currency_code NOT NULL DEFAULT 'USD',
-- Prioridad (mayor número = mayor prioridad)
priority INTEGER DEFAULT 0,
-- Vigencia
active BOOLEAN DEFAULT true,
valid_from TIMESTAMPTZ DEFAULT NOW(),
valid_to TIMESTAMPTZ,
-- Metadata
description TEXT,
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT wallet_or_type_or_plan CHECK (
(wallet_id IS NOT NULL AND wallet_type IS NULL AND subscription_plan IS NULL) OR
(wallet_id IS NULL AND wallet_type IS NOT NULL AND subscription_plan IS NULL) OR
(wallet_id IS NULL AND wallet_type IS NULL AND subscription_plan IS NOT NULL)
),
CONSTRAINT positive_limits CHECK (
(min_deposit IS NULL OR min_deposit > 0) AND
(max_deposit IS NULL OR max_deposit > 0) AND
(min_withdrawal IS NULL OR min_withdrawal > 0) AND
(max_withdrawal IS NULL OR max_withdrawal > 0) AND
(min_transfer IS NULL OR min_transfer > 0) AND
(max_transfer IS NULL OR max_transfer > 0)
),
CONSTRAINT min_max_deposit CHECK (min_deposit IS NULL OR max_deposit IS NULL OR min_deposit <= max_deposit),
CONSTRAINT min_max_withdrawal CHECK (min_withdrawal IS NULL OR max_withdrawal IS NULL OR min_withdrawal <= max_withdrawal),
CONSTRAINT min_max_transfer CHECK (min_transfer IS NULL OR max_transfer IS NULL OR min_transfer <= max_transfer),
CONSTRAINT valid_dates_order CHECK (valid_to IS NULL OR valid_to > valid_from)
);
-- Indexes
CREATE INDEX idx_wl_wallet_id ON financial.wallet_limits(wallet_id) WHERE wallet_id IS NOT NULL;
CREATE INDEX idx_wl_wallet_type ON financial.wallet_limits(wallet_type) WHERE wallet_type IS NOT NULL;
CREATE INDEX idx_wl_subscription_plan ON financial.wallet_limits(subscription_plan) WHERE subscription_plan IS NOT NULL;
CREATE INDEX idx_wl_active ON financial.wallet_limits(active, priority DESC) WHERE active = true;
CREATE INDEX idx_wl_valid_period ON financial.wallet_limits(valid_from, valid_to)
WHERE active = true AND (valid_to IS NULL OR valid_to > NOW());
-- Comments
COMMENT ON TABLE financial.wallet_limits IS 'Configurable transaction limits for wallets';
COMMENT ON COLUMN financial.wallet_limits.wallet_id IS 'Specific wallet (takes highest priority)';
COMMENT ON COLUMN financial.wallet_limits.wallet_type IS 'Limits for all wallets of this type';
COMMENT ON COLUMN financial.wallet_limits.subscription_plan IS 'Limits based on subscription plan';
COMMENT ON COLUMN financial.wallet_limits.priority IS 'Higher number = higher priority when multiple limits apply';
COMMENT ON COLUMN financial.wallet_limits.currency IS 'Currency for all limit amounts';

View File

@ -0,0 +1,68 @@
-- ============================================================================
-- FINANCIAL SCHEMA - Tabla: customers
-- ============================================================================
-- Clientes de Stripe y datos de facturacion
-- Vincula usuarios con su informacion de pago
-- ============================================================================
CREATE TABLE IF NOT EXISTS financial.customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relacion con usuario
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Stripe
stripe_customer_id VARCHAR(100) UNIQUE,
stripe_default_payment_method_id VARCHAR(100),
-- Datos de facturacion
billing_name VARCHAR(255),
billing_email VARCHAR(255),
billing_phone VARCHAR(50),
-- Direccion de facturacion
billing_address_line1 VARCHAR(255),
billing_address_line2 VARCHAR(255),
billing_city VARCHAR(100),
billing_state VARCHAR(100),
billing_postal_code VARCHAR(20),
billing_country VARCHAR(2), -- ISO 3166-1 alpha-2
-- Datos fiscales (Mexico)
tax_id VARCHAR(20), -- RFC
tax_id_type VARCHAR(20) DEFAULT 'mx_rfc', -- Tipo de ID fiscal
legal_name VARCHAR(255), -- Razon social
-- Preferencias
currency financial.currency_code NOT NULL DEFAULT 'USD',
locale VARCHAR(10) DEFAULT 'en-US',
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
delinquent BOOLEAN NOT NULL DEFAULT FALSE,
delinquent_since TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT uq_customers_user UNIQUE(user_id),
CONSTRAINT chk_valid_country CHECK (billing_country IS NULL OR LENGTH(billing_country) = 2)
);
-- Indices
CREATE INDEX idx_customers_user ON financial.customers(user_id);
CREATE INDEX idx_customers_stripe ON financial.customers(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
CREATE INDEX idx_customers_email ON financial.customers(billing_email) WHERE billing_email IS NOT NULL;
CREATE INDEX idx_customers_delinquent ON financial.customers(delinquent) WHERE delinquent = TRUE;
CREATE INDEX idx_customers_tax_id ON financial.customers(tax_id) WHERE tax_id IS NOT NULL;
-- Comentarios
COMMENT ON TABLE financial.customers IS 'Clientes de Stripe con datos de facturacion';
COMMENT ON COLUMN financial.customers.stripe_customer_id IS 'ID del cliente en Stripe (cus_xxx)';
COMMENT ON COLUMN financial.customers.tax_id IS 'RFC para Mexico, VAT para EU, etc.';
COMMENT ON COLUMN financial.customers.delinquent IS 'True si tiene pagos vencidos';

View File

@ -0,0 +1,180 @@
-- ============================================================================
-- FINANCIAL SCHEMA - Tabla: payment_methods
-- ============================================================================
-- Metodos de pago guardados por usuarios para pagos recurrentes
-- Integra con Stripe para almacenamiento seguro de tarjetas y cuentas
-- ============================================================================
-- Enum para tipo de metodo de pago guardado
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'saved_payment_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'financial')) THEN
CREATE TYPE financial.saved_payment_type AS ENUM (
'card',
'bank_account',
'sepa_debit',
'crypto_wallet'
);
END IF;
END$$;
-- Enum para estado del metodo de pago
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'payment_method_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'financial')) THEN
CREATE TYPE financial.payment_method_status AS ENUM (
'pending_verification',
'active',
'expired',
'failed',
'removed'
);
END IF;
END$$;
CREATE TABLE IF NOT EXISTS financial.payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
customer_id UUID REFERENCES financial.customers(id) ON DELETE SET NULL,
-- Stripe integration
stripe_payment_method_id VARCHAR(100) UNIQUE,
stripe_fingerprint VARCHAR(100), -- Para detectar duplicados
-- Tipo y estado
payment_type financial.saved_payment_type NOT NULL,
status financial.payment_method_status NOT NULL DEFAULT 'pending_verification',
-- Informacion del metodo (datos no sensibles)
-- Para tarjetas: last4, brand, exp_month, exp_year
-- Para bancos: last4, bank_name, account_type
display_info JSONB NOT NULL DEFAULT '{}',
-- Metodo por defecto
is_default BOOLEAN NOT NULL DEFAULT FALSE,
-- Datos de tarjeta (solo informacion visible)
card_brand VARCHAR(20), -- 'visa', 'mastercard', 'amex', etc.
card_last4 VARCHAR(4),
card_exp_month INTEGER,
card_exp_year INTEGER,
card_funding VARCHAR(20), -- 'credit', 'debit', 'prepaid'
-- Datos de cuenta bancaria (solo informacion visible)
bank_name VARCHAR(100),
bank_last4 VARCHAR(4),
bank_account_type VARCHAR(20), -- 'checking', 'savings'
-- Datos de crypto wallet
crypto_network VARCHAR(20), -- 'ethereum', 'bitcoin', 'polygon'
crypto_address_last8 VARCHAR(8),
-- Verificacion
verified_at TIMESTAMPTZ,
verification_method VARCHAR(50), -- 'micro_deposits', '3d_secure', 'instant'
-- Billing address (para 3DS y validacion)
billing_address JSONB DEFAULT '{}',
-- Metadata
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- Para tarjetas con fecha de expiracion
removed_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT chk_card_info CHECK (
payment_type != 'card' OR (
card_brand IS NOT NULL AND
card_last4 IS NOT NULL AND
card_exp_month IS NOT NULL AND
card_exp_year IS NOT NULL
)
),
CONSTRAINT chk_bank_info CHECK (
payment_type != 'bank_account' OR (
bank_name IS NOT NULL AND
bank_last4 IS NOT NULL
)
),
CONSTRAINT chk_crypto_info CHECK (
payment_type != 'crypto_wallet' OR (
crypto_network IS NOT NULL AND
crypto_address_last8 IS NOT NULL
)
),
CONSTRAINT chk_valid_exp_month CHECK (
card_exp_month IS NULL OR (card_exp_month >= 1 AND card_exp_month <= 12)
),
CONSTRAINT chk_valid_exp_year CHECK (
card_exp_year IS NULL OR card_exp_year >= 2024
)
);
-- Indices
CREATE INDEX idx_payment_methods_user ON financial.payment_methods(user_id);
CREATE INDEX idx_payment_methods_customer ON financial.payment_methods(customer_id);
CREATE INDEX idx_payment_methods_stripe ON financial.payment_methods(stripe_payment_method_id);
CREATE INDEX idx_payment_methods_status ON financial.payment_methods(status);
CREATE INDEX idx_payment_methods_default ON financial.payment_methods(user_id, is_default)
WHERE is_default = TRUE;
CREATE INDEX idx_payment_methods_fingerprint ON financial.payment_methods(stripe_fingerprint)
WHERE stripe_fingerprint IS NOT NULL;
CREATE INDEX idx_payment_methods_active ON financial.payment_methods(user_id, payment_type)
WHERE status = 'active';
-- Comentarios
COMMENT ON TABLE financial.payment_methods IS 'Metodos de pago guardados por usuarios con integracion Stripe';
COMMENT ON COLUMN financial.payment_methods.stripe_payment_method_id IS 'ID del PaymentMethod en Stripe';
COMMENT ON COLUMN financial.payment_methods.stripe_fingerprint IS 'Fingerprint para detectar tarjetas duplicadas';
COMMENT ON COLUMN financial.payment_methods.display_info IS 'Informacion visible del metodo para UI';
COMMENT ON COLUMN financial.payment_methods.is_default IS 'Metodo de pago por defecto del usuario';
COMMENT ON COLUMN financial.payment_methods.billing_address IS 'Direccion de facturacion para 3D Secure';
-- Trigger para asegurar un solo metodo por defecto por usuario
CREATE OR REPLACE FUNCTION financial.ensure_single_default_payment_method()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.is_default = TRUE THEN
UPDATE financial.payment_methods
SET is_default = FALSE, updated_at = NOW()
WHERE user_id = NEW.user_id
AND id != NEW.id
AND is_default = TRUE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_ensure_single_default_payment_method
BEFORE INSERT OR UPDATE OF is_default ON financial.payment_methods
FOR EACH ROW
WHEN (NEW.is_default = TRUE)
EXECUTE FUNCTION financial.ensure_single_default_payment_method();
-- Funcion para marcar tarjetas expiradas
CREATE OR REPLACE FUNCTION financial.check_expired_cards()
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
UPDATE financial.payment_methods
SET status = 'expired', updated_at = NOW()
WHERE payment_type = 'card'
AND status = 'active'
AND (
card_exp_year < EXTRACT(YEAR FROM CURRENT_DATE) OR
(card_exp_year = EXTRACT(YEAR FROM CURRENT_DATE) AND card_exp_month < EXTRACT(MONTH FROM CURRENT_DATE))
);
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION financial.check_expired_cards() IS 'Marca como expiradas las tarjetas vencidas. Ejecutar mensualmente.';

View File

@ -0,0 +1,52 @@
-- =====================================================
-- INVESTMENT SCHEMA - ENUMS
-- =====================================================
-- Description: Enumerations for PAMM investment system
-- Schema: investment
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
-- Agentes de inversión (Trading Agents)
CREATE TYPE investment.trading_agent AS ENUM (
'atlas', -- Conservador: 3-5% mensual
'orion', -- Moderado: 5-10% mensual
'nova' -- Agresivo: 10%+ mensual
);
-- Perfil de riesgo (unificado con cuestionario)
CREATE TYPE investment.risk_profile AS ENUM (
'conservative',
'moderate',
'aggressive'
);
-- Estado de cuenta PAMM
CREATE TYPE investment.account_status AS ENUM (
'pending_kyc',
'active',
'suspended',
'closed'
);
-- Frecuencia de distribución (DECISIÓN: mensual por defecto)
CREATE TYPE investment.distribution_frequency AS ENUM (
'monthly',
'quarterly'
);
-- Tipo de transacción
CREATE TYPE investment.transaction_type AS ENUM (
'deposit',
'withdrawal',
'distribution'
);
-- Estado de transacción
CREATE TYPE investment.transaction_status AS ENUM (
'pending',
'processing',
'completed',
'failed',
'cancelled'
);

View File

@ -0,0 +1,60 @@
-- =====================================================
-- INVESTMENT SCHEMA - PRODUCTS TABLE
-- =====================================================
-- Description: PAMM investment products
-- Schema: investment
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE investment.products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
code VARCHAR(20) NOT NULL UNIQUE, -- PAMM-ATLAS, PAMM-ORION, PAMM-NOVA
name VARCHAR(100) NOT NULL,
description TEXT,
-- Agente asociado
trading_agent investment.trading_agent NOT NULL,
-- Parámetros de inversión
min_investment DECIMAL(15,2) NOT NULL,
max_investment DECIMAL(15,2),
-- Rentabilidad objetivo
target_return_min DECIMAL(5,2), -- % mensual mínimo esperado
target_return_max DECIMAL(5,2), -- % mensual máximo esperado
-- Distribución de ganancias
distribution_frequency investment.distribution_frequency DEFAULT 'monthly',
investor_share_percent DECIMAL(5,2) DEFAULT 80.00, -- 80% para inversor
platform_share_percent DECIMAL(5,2) DEFAULT 20.00, -- 20% para plataforma
-- Perfil de riesgo recomendado
recommended_risk_profile investment.risk_profile NOT NULL,
-- Estado
is_active BOOLEAN DEFAULT true,
is_accepting_new_investors BOOLEAN DEFAULT true,
-- Límites
total_capacity DECIMAL(15,2), -- Capacidad total del producto
current_aum DECIMAL(15,2) DEFAULT 0.00, -- Assets Under Management
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_products_agent ON investment.products(trading_agent);
CREATE INDEX idx_products_active ON investment.products(is_active) WHERE is_active = true;
CREATE INDEX idx_products_risk_profile ON investment.products(recommended_risk_profile);
-- Comentarios
COMMENT ON TABLE investment.products IS 'PAMM investment products linked to trading agents';
COMMENT ON COLUMN investment.products.code IS 'Unique product code (e.g., PAMM-ATLAS)';
COMMENT ON COLUMN investment.products.current_aum IS 'Current Assets Under Management';
COMMENT ON COLUMN investment.products.investor_share_percent IS 'Percentage of profits distributed to investors (80%)';
COMMENT ON COLUMN investment.products.platform_share_percent IS 'Percentage of profits retained by platform (20%)';

View File

@ -0,0 +1,67 @@
-- =====================================================
-- INVESTMENT SCHEMA - ACCOUNTS TABLE
-- =====================================================
-- Description: Individual investor PAMM accounts
-- Schema: investment
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE investment.accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Propietario
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Producto PAMM
product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE RESTRICT,
-- Identificación
account_number VARCHAR(20) NOT NULL UNIQUE, -- INV-202512-00001
-- Balance
initial_balance DECIMAL(15,2) NOT NULL,
current_balance DECIMAL(15,2) NOT NULL,
total_deposits DECIMAL(15,2) DEFAULT 0.00,
total_withdrawals DECIMAL(15,2) DEFAULT 0.00,
total_distributions DECIMAL(15,2) DEFAULT 0.00,
-- Rentabilidad
total_return_percent DECIMAL(10,4) DEFAULT 0.00,
total_return_amount DECIMAL(15,2) DEFAULT 0.00,
-- Perfil de riesgo del usuario
user_risk_profile investment.risk_profile NOT NULL,
questionnaire_id UUID REFERENCES investment.risk_questionnaire(id),
-- Estado
status investment.account_status DEFAULT 'pending_kyc',
-- KYC/Compliance
kyc_verified BOOLEAN DEFAULT false,
kyc_verified_at TIMESTAMPTZ,
kyc_verified_by VARCHAR(100),
-- Fechas importantes
opened_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
last_distribution_at TIMESTAMPTZ,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_accounts_user ON investment.accounts(user_id);
CREATE INDEX idx_accounts_product ON investment.accounts(product_id);
CREATE INDEX idx_accounts_status ON investment.accounts(status);
CREATE INDEX idx_accounts_active ON investment.accounts(status) WHERE status = 'active';
CREATE INDEX idx_accounts_number ON investment.accounts(account_number);
-- Comentarios
COMMENT ON TABLE investment.accounts IS 'Individual investor PAMM accounts';
COMMENT ON COLUMN investment.accounts.account_number IS 'Unique account identifier (INV-YYYYMM-NNNNN)';
COMMENT ON COLUMN investment.accounts.current_balance IS 'Current account balance including all deposits, withdrawals, and distributions';
COMMENT ON COLUMN investment.accounts.total_return_percent IS 'Cumulative return percentage since account opening';
COMMENT ON COLUMN investment.accounts.user_risk_profile IS 'Risk profile from questionnaire, must match product recommendation';

View File

@ -0,0 +1,69 @@
-- =====================================================
-- INVESTMENT SCHEMA - TRANSACTIONS TABLE
-- =====================================================
-- Description: Deposits, withdrawals, and distributions
-- Schema: investment
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE investment.transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Cuenta asociada
account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE CASCADE,
-- Identificación
transaction_number VARCHAR(30) NOT NULL UNIQUE, -- TXN-202512-00001
-- Tipo y monto
transaction_type investment.transaction_type NOT NULL,
amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
-- Estado
status investment.transaction_status DEFAULT 'pending',
-- Detalles de pago (para deposits/withdrawals)
payment_method VARCHAR(50), -- bank_transfer, card, crypto
payment_reference VARCHAR(100),
payment_metadata JSONB,
-- Distribución (para transaction_type = 'distribution')
distribution_id UUID REFERENCES investment.distributions(id),
-- Balance después de transacción
balance_before DECIMAL(15,2),
balance_after DECIMAL(15,2),
-- Procesamiento
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failure_reason TEXT,
-- Aprobación (para withdrawals)
requires_approval BOOLEAN DEFAULT false,
approved_by VARCHAR(100),
approved_at TIMESTAMPTZ,
-- Metadata
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_transactions_account ON investment.transactions(account_id);
CREATE INDEX idx_transactions_type ON investment.transactions(transaction_type);
CREATE INDEX idx_transactions_status ON investment.transactions(status);
CREATE INDEX idx_transactions_number ON investment.transactions(transaction_number);
CREATE INDEX idx_transactions_distribution ON investment.transactions(distribution_id);
CREATE INDEX idx_transactions_requested ON investment.transactions(requested_at DESC);
-- Comentarios
COMMENT ON TABLE investment.transactions IS 'All account transactions: deposits, withdrawals, and distributions';
COMMENT ON COLUMN investment.transactions.transaction_number IS 'Unique transaction identifier (TXN-YYYYMM-NNNNN)';
COMMENT ON COLUMN investment.transactions.payment_method IS 'Payment method for deposits/withdrawals';
COMMENT ON COLUMN investment.transactions.distribution_id IS 'Link to distribution record if transaction_type is distribution';
COMMENT ON COLUMN investment.transactions.requires_approval IS 'Whether withdrawal requires manual approval';

View File

@ -0,0 +1,69 @@
-- =====================================================
-- INVESTMENT SCHEMA - DISTRIBUTIONS TABLE
-- =====================================================
-- Description: Profit distributions (80/20 split)
-- Schema: investment
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE investment.distributions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Producto PAMM
product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE RESTRICT,
-- Periodo
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
period_label VARCHAR(20) NOT NULL, -- 2025-12, 2025-Q4
-- Performance del agente de trading
total_profit_amount DECIMAL(15,2) NOT NULL, -- Ganancia total generada
total_profit_percent DECIMAL(10,4) NOT NULL, -- % de retorno
-- Distribución 80/20
investor_total_amount DECIMAL(15,2) NOT NULL, -- 80% para inversores
platform_total_amount DECIMAL(15,2) NOT NULL, -- 20% para plataforma
-- Cuentas participantes
participating_accounts INTEGER NOT NULL,
total_aum_at_period_start DECIMAL(15,2) NOT NULL,
total_aum_at_period_end DECIMAL(15,2) NOT NULL,
-- Estado
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed
-- Procesamiento
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
approved_by VARCHAR(100),
approved_at TIMESTAMPTZ,
distributed_at TIMESTAMPTZ,
-- Metadata
notes TEXT,
distribution_metadata JSONB, -- Detalles adicionales
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Validación
CONSTRAINT valid_period CHECK (period_end > period_start),
CONSTRAINT valid_split CHECK (
investor_total_amount + platform_total_amount = total_profit_amount
)
);
-- Índices
CREATE INDEX idx_distributions_product ON investment.distributions(product_id);
CREATE INDEX idx_distributions_period ON investment.distributions(period_start, period_end);
CREATE INDEX idx_distributions_status ON investment.distributions(status);
CREATE UNIQUE INDEX idx_distributions_product_period ON investment.distributions(product_id, period_label);
-- Comentarios
COMMENT ON TABLE investment.distributions IS 'Periodic profit distributions with 80/20 split';
COMMENT ON COLUMN investment.distributions.period_label IS 'Human-readable period identifier (YYYY-MM or YYYY-QN)';
COMMENT ON COLUMN investment.distributions.total_profit_amount IS 'Total profit generated by trading agent during period';
COMMENT ON COLUMN investment.distributions.investor_total_amount IS '80% of total profit distributed to all investors';
COMMENT ON COLUMN investment.distributions.platform_total_amount IS '20% of total profit retained by platform';
COMMENT ON COLUMN investment.distributions.total_aum_at_period_start IS 'Total Assets Under Management at period start';

View File

@ -0,0 +1,63 @@
-- =====================================================
-- INVESTMENT SCHEMA - RISK QUESTIONNAIRE TABLE
-- =====================================================
-- Description: Risk assessment questionnaire (15 questions)
-- Schema: investment
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE investment.risk_questionnaire (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Usuario
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Respuestas (15 preguntas)
responses JSONB NOT NULL, -- [{question_id, answer, score}]
-- Resultado
total_score INTEGER NOT NULL CHECK (total_score >= 0 AND total_score <= 100),
calculated_profile investment.risk_profile NOT NULL,
-- Recomendación de agente
recommended_agent investment.trading_agent,
-- Validez
completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, -- Válido por 1 año
is_expired BOOLEAN GENERATED ALWAYS AS (expires_at < NOW()) STORED,
-- Metadata
ip_address INET,
user_agent TEXT,
completion_time_seconds INTEGER, -- Tiempo que tardó en completar
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_questionnaire_user ON investment.risk_questionnaire(user_id);
CREATE INDEX idx_questionnaire_profile ON investment.risk_questionnaire(calculated_profile);
CREATE INDEX idx_questionnaire_valid ON investment.risk_questionnaire(user_id, expires_at DESC)
WHERE expires_at > NOW();
-- Comentarios
COMMENT ON TABLE investment.risk_questionnaire IS 'Risk assessment questionnaire responses (valid for 1 year)';
COMMENT ON COLUMN investment.risk_questionnaire.responses IS 'Array of question responses with scores: [{question_id, answer, score}]';
COMMENT ON COLUMN investment.risk_questionnaire.total_score IS 'Sum of all question scores (0-100)';
COMMENT ON COLUMN investment.risk_questionnaire.calculated_profile IS 'Risk profile calculated from total_score';
COMMENT ON COLUMN investment.risk_questionnaire.recommended_agent IS 'Trading agent recommendation based on risk profile';
COMMENT ON COLUMN investment.risk_questionnaire.expires_at IS 'Questionnaire expires after 1 year, user must retake';
-- Ejemplo de estructura de responses JSONB:
COMMENT ON COLUMN investment.risk_questionnaire.responses IS
'Example: [
{"question_id": "Q1", "answer": "A", "score": 5},
{"question_id": "Q2", "answer": "B", "score": 10},
...
]
Scoring logic:
- Conservative (0-40): Atlas agent recommended
- Moderate (41-70): Orion agent recommended
- Aggressive (71-100): Nova agent recommended';

View File

@ -0,0 +1,119 @@
-- ============================================================================
-- INVESTMENT SCHEMA - Tabla: withdrawal_requests
-- ============================================================================
-- Solicitudes de retiro de cuentas PAMM
-- Requiere aprobacion manual para montos grandes
-- ============================================================================
-- Enum para estado de solicitud
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'withdrawal_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'investment')) THEN
CREATE TYPE investment.withdrawal_status AS ENUM (
'pending',
'under_review',
'approved',
'processing',
'completed',
'rejected',
'cancelled'
);
END IF;
END$$;
CREATE TABLE IF NOT EXISTS investment.withdrawal_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE RESTRICT,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
-- Solicitud
request_number VARCHAR(20) NOT NULL UNIQUE,
amount DECIMAL(20, 8) NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'USD',
-- Estado
status investment.withdrawal_status NOT NULL DEFAULT 'pending',
-- Destino del retiro
destination_type VARCHAR(20) NOT NULL, -- 'wallet', 'bank', 'crypto'
destination_details JSONB NOT NULL DEFAULT '{}',
-- Fees
fee_amount DECIMAL(20, 8) NOT NULL DEFAULT 0,
fee_percentage DECIMAL(5, 2) NOT NULL DEFAULT 0,
net_amount DECIMAL(20, 8) GENERATED ALWAYS AS (amount - fee_amount) STORED,
-- Aprobacion
requires_approval BOOLEAN NOT NULL DEFAULT FALSE,
reviewed_by UUID REFERENCES auth.users(id),
reviewed_at TIMESTAMPTZ,
review_notes TEXT,
-- Procesamiento
processed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
transaction_reference VARCHAR(100),
-- Rechazo/Cancelacion
rejection_reason TEXT,
cancelled_at TIMESTAMPTZ,
cancellation_reason TEXT,
-- Metadata
ip_address INET,
user_agent TEXT,
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT chk_positive_amount CHECK (amount > 0),
CONSTRAINT chk_valid_fee CHECK (fee_amount >= 0 AND fee_amount <= amount),
CONSTRAINT chk_fee_percentage CHECK (fee_percentage >= 0 AND fee_percentage <= 100),
CONSTRAINT chk_destination_type CHECK (destination_type IN ('wallet', 'bank', 'crypto'))
);
-- Indices
CREATE INDEX idx_withdrawal_requests_account ON investment.withdrawal_requests(account_id);
CREATE INDEX idx_withdrawal_requests_user ON investment.withdrawal_requests(user_id);
CREATE INDEX idx_withdrawal_requests_status ON investment.withdrawal_requests(status);
CREATE INDEX idx_withdrawal_requests_created ON investment.withdrawal_requests(created_at DESC);
CREATE INDEX idx_withdrawal_requests_pending ON investment.withdrawal_requests(status, created_at)
WHERE status IN ('pending', 'under_review');
-- Comentarios
COMMENT ON TABLE investment.withdrawal_requests IS 'Solicitudes de retiro de cuentas PAMM';
COMMENT ON COLUMN investment.withdrawal_requests.request_number IS 'Numero unico de solicitud (WR-YYYYMMDD-XXXX)';
COMMENT ON COLUMN investment.withdrawal_requests.requires_approval IS 'True si el monto requiere aprobacion manual';
COMMENT ON COLUMN investment.withdrawal_requests.destination_details IS 'Detalles del destino (IBAN, wallet address, etc.)';
-- Funcion para generar numero de solicitud
CREATE OR REPLACE FUNCTION investment.generate_withdrawal_request_number()
RETURNS TRIGGER AS $$
DECLARE
v_date TEXT;
v_seq INTEGER;
BEGIN
v_date := TO_CHAR(NOW(), 'YYYYMMDD');
SELECT COALESCE(MAX(
CAST(SUBSTRING(request_number FROM 'WR-[0-9]{8}-([0-9]+)') AS INTEGER)
), 0) + 1
INTO v_seq
FROM investment.withdrawal_requests
WHERE request_number LIKE 'WR-' || v_date || '-%';
NEW.request_number := 'WR-' || v_date || '-' || LPAD(v_seq::TEXT, 4, '0');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_generate_withdrawal_request_number
BEFORE INSERT ON investment.withdrawal_requests
FOR EACH ROW
WHEN (NEW.request_number IS NULL)
EXECUTE FUNCTION investment.generate_withdrawal_request_number();

View File

@ -0,0 +1,115 @@
-- ============================================================================
-- INVESTMENT SCHEMA - Tabla: daily_performance
-- ============================================================================
-- Snapshots diarios de rendimiento de cuentas PAMM
-- Usado para graficos, reportes y calculo de metricas
-- ============================================================================
CREATE TABLE IF NOT EXISTS investment.daily_performance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE CASCADE,
-- Fecha del snapshot
snapshot_date DATE NOT NULL,
-- Balance
opening_balance DECIMAL(20, 8) NOT NULL,
closing_balance DECIMAL(20, 8) NOT NULL,
-- Rendimiento del dia
daily_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0,
daily_return_percentage DECIMAL(10, 6) NOT NULL DEFAULT 0,
-- Rendimiento acumulado
cumulative_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0,
cumulative_return_percentage DECIMAL(10, 6) NOT NULL DEFAULT 0,
-- Movimientos del dia
deposits DECIMAL(20, 8) NOT NULL DEFAULT 0,
withdrawals DECIMAL(20, 8) NOT NULL DEFAULT 0,
distributions_received DECIMAL(20, 8) NOT NULL DEFAULT 0,
-- Metricas del agente de trading
trades_executed INTEGER NOT NULL DEFAULT 0,
winning_trades INTEGER NOT NULL DEFAULT 0,
losing_trades INTEGER NOT NULL DEFAULT 0,
win_rate DECIMAL(5, 2),
-- Volatilidad y riesgo
max_drawdown DECIMAL(10, 6),
sharpe_ratio DECIMAL(10, 6),
volatility DECIMAL(10, 6),
-- High/Low del dia
high_water_mark DECIMAL(20, 8),
lowest_point DECIMAL(20, 8),
-- Metadata del snapshot
snapshot_source VARCHAR(50) DEFAULT 'cron', -- 'cron', 'manual', 'system'
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT uq_daily_performance_account_date UNIQUE(account_id, snapshot_date),
CONSTRAINT chk_valid_balances CHECK (opening_balance >= 0 AND closing_balance >= 0),
CONSTRAINT chk_valid_movements CHECK (deposits >= 0 AND withdrawals >= 0),
CONSTRAINT chk_valid_trades CHECK (
trades_executed >= 0 AND
winning_trades >= 0 AND
losing_trades >= 0 AND
winning_trades + losing_trades <= trades_executed
),
CONSTRAINT chk_valid_win_rate CHECK (win_rate IS NULL OR (win_rate >= 0 AND win_rate <= 100))
);
-- Indices
CREATE INDEX idx_daily_performance_account ON investment.daily_performance(account_id);
CREATE INDEX idx_daily_performance_product ON investment.daily_performance(product_id);
CREATE INDEX idx_daily_performance_date ON investment.daily_performance(snapshot_date DESC);
CREATE INDEX idx_daily_performance_account_date ON investment.daily_performance(account_id, snapshot_date DESC);
-- Indice parcial para ultimos 30 dias (hot data)
CREATE INDEX idx_daily_performance_recent ON investment.daily_performance(account_id, snapshot_date)
WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days';
-- Comentarios
COMMENT ON TABLE investment.daily_performance IS 'Snapshots diarios de rendimiento de cuentas PAMM';
COMMENT ON COLUMN investment.daily_performance.snapshot_date IS 'Fecha del snapshot (una entrada por dia por cuenta)';
COMMENT ON COLUMN investment.daily_performance.daily_return_percentage IS 'Retorno del dia como porcentaje';
COMMENT ON COLUMN investment.daily_performance.cumulative_return_percentage IS 'Retorno acumulado desde apertura de cuenta';
COMMENT ON COLUMN investment.daily_performance.max_drawdown IS 'Maximo drawdown del dia';
COMMENT ON COLUMN investment.daily_performance.high_water_mark IS 'Punto mas alto alcanzado';
-- Vista para resumen mensual
CREATE OR REPLACE VIEW investment.v_monthly_performance AS
SELECT
account_id,
product_id,
DATE_TRUNC('month', snapshot_date) AS month,
MIN(opening_balance) AS month_opening,
MAX(closing_balance) AS month_closing,
SUM(daily_pnl) AS total_pnl,
AVG(daily_return_percentage) AS avg_daily_return,
SUM(deposits) AS total_deposits,
SUM(withdrawals) AS total_withdrawals,
SUM(distributions_received) AS total_distributions,
SUM(trades_executed) AS total_trades,
SUM(winning_trades) AS total_winning,
SUM(losing_trades) AS total_losing,
CASE
WHEN SUM(winning_trades) + SUM(losing_trades) > 0
THEN ROUND(SUM(winning_trades)::DECIMAL / (SUM(winning_trades) + SUM(losing_trades)) * 100, 2)
ELSE NULL
END AS monthly_win_rate,
MIN(lowest_point) AS monthly_low,
MAX(high_water_mark) AS monthly_high,
COUNT(*) AS trading_days
FROM investment.daily_performance
GROUP BY account_id, product_id, DATE_TRUNC('month', snapshot_date);
COMMENT ON VIEW investment.v_monthly_performance IS 'Resumen mensual de rendimiento agregado';

View File

@ -0,0 +1,63 @@
-- =====================================================
-- LLM SCHEMA - ENUMS
-- =====================================================
-- Description: Enumerations for LLM agent system
-- Schema: llm
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
-- Rol del mensaje
CREATE TYPE llm.message_role AS ENUM (
'user',
'assistant',
'system',
'tool'
);
-- Estado de la conversación
CREATE TYPE llm.conversation_status AS ENUM (
'active',
'archived',
'deleted'
);
-- Tipo de conversación
CREATE TYPE llm.conversation_type AS ENUM (
'general', -- Conversación general
'trading_advice', -- Consulta sobre trading
'education', -- Preguntas educativas
'market_analysis', -- Análisis de mercado
'support', -- Soporte técnico
'onboarding' -- Onboarding de usuario
);
-- Tono de comunicación
CREATE TYPE llm.communication_tone AS ENUM (
'casual',
'professional',
'technical'
);
-- Nivel de verbosidad
CREATE TYPE llm.verbosity_level AS ENUM (
'brief',
'normal',
'detailed'
);
-- Frecuencia de alertas
CREATE TYPE llm.alert_frequency AS ENUM (
'low',
'normal',
'high'
);
-- Tipo de memoria
CREATE TYPE llm.memory_type AS ENUM (
'fact', -- Hecho sobre el usuario
'preference', -- Preferencia del usuario
'context', -- Contexto de conversaciones
'goal', -- Objetivo del usuario
'constraint' -- Restricción o límite
);

View File

@ -0,0 +1,63 @@
-- =====================================================
-- LLM SCHEMA - CONVERSATIONS TABLE
-- =====================================================
-- Description: Chat conversations with LLM agent
-- Schema: llm
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE llm.conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Usuario
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Identificación
title VARCHAR(200), -- Auto-generado o definido por usuario
-- Tipo y contexto
conversation_type llm.conversation_type DEFAULT 'general',
-- Estado
status llm.conversation_status DEFAULT 'active',
-- Resumen de conversación (generado por LLM)
summary TEXT,
-- Metadata
total_messages INTEGER DEFAULT 0,
total_tokens_used INTEGER DEFAULT 0,
-- Tags para búsqueda
tags TEXT[] DEFAULT '{}',
-- Contexto de negocio
related_symbols VARCHAR(20)[] DEFAULT '{}', -- Símbolos discutidos
related_topics TEXT[] DEFAULT '{}', -- Temas discutidos
-- Fechas
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_message_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_conversations_user ON llm.conversations(user_id);
CREATE INDEX idx_conversations_status ON llm.conversations(status);
CREATE INDEX idx_conversations_type ON llm.conversations(conversation_type);
CREATE INDEX idx_conversations_active ON llm.conversations(user_id, last_message_at DESC)
WHERE status = 'active';
CREATE INDEX idx_conversations_tags ON llm.conversations USING GIN(tags);
CREATE INDEX idx_conversations_symbols ON llm.conversations USING GIN(related_symbols);
-- Comentarios
COMMENT ON TABLE llm.conversations IS 'Chat conversations between users and LLM agent';
COMMENT ON COLUMN llm.conversations.title IS 'Conversation title (auto-generated from first messages or user-defined)';
COMMENT ON COLUMN llm.conversations.summary IS 'AI-generated summary of conversation content';
COMMENT ON COLUMN llm.conversations.total_tokens_used IS 'Cumulative token count for cost tracking';
COMMENT ON COLUMN llm.conversations.related_symbols IS 'Trading symbols mentioned in conversation';
COMMENT ON COLUMN llm.conversations.related_topics IS 'Topics discussed (e.g., technical_analysis, risk_management)';

View File

@ -0,0 +1,98 @@
-- =====================================================
-- LLM SCHEMA - MESSAGES TABLE
-- =====================================================
-- Description: Individual messages in conversations
-- Schema: llm
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE llm.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Conversación
conversation_id UUID NOT NULL REFERENCES llm.conversations(id) ON DELETE CASCADE,
-- Rol y contenido
role llm.message_role NOT NULL,
content TEXT NOT NULL,
-- Metadata de LLM
model_name VARCHAR(100), -- claude-opus-4-5, gpt-4, etc.
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
-- Contexto utilizado
context_used JSONB, -- RAG context, market data, user profile, etc.
-- Tools/Functions llamadas
tool_calls JSONB, -- Function calls realizadas
tool_results JSONB, -- Resultados de tool calls
-- Metadata de procesamiento
response_time_ms INTEGER,
temperature DECIMAL(3,2),
-- Feedback del usuario
user_rating INTEGER CHECK (user_rating >= 1 AND user_rating <= 5),
user_feedback TEXT,
-- Referencias
references_symbols VARCHAR(20)[] DEFAULT '{}',
references_concepts TEXT[] DEFAULT '{}',
-- Metadata
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_messages_conversation ON llm.messages(conversation_id);
CREATE INDEX idx_messages_role ON llm.messages(role);
CREATE INDEX idx_messages_created ON llm.messages(created_at DESC);
CREATE INDEX idx_messages_conversation_created ON llm.messages(conversation_id, created_at ASC);
CREATE INDEX idx_messages_rated ON llm.messages(user_rating) WHERE user_rating IS NOT NULL;
-- Comentarios
COMMENT ON TABLE llm.messages IS 'Individual messages in LLM conversations';
COMMENT ON COLUMN llm.messages.role IS 'Message sender: user, assistant, system, or tool';
COMMENT ON COLUMN llm.messages.content IS 'Message text content';
COMMENT ON COLUMN llm.messages.model_name IS 'LLM model used to generate response';
COMMENT ON COLUMN llm.messages.context_used IS 'Context provided to LLM (RAG docs, market data, user profile)';
COMMENT ON COLUMN llm.messages.tool_calls IS 'Functions/tools called by LLM during response generation';
COMMENT ON COLUMN llm.messages.user_rating IS 'User satisfaction rating (1-5 stars)';
-- Ejemplo de context_used JSONB:
COMMENT ON COLUMN llm.messages.context_used IS
'Example: {
"market_data": {
"symbol": "BTCUSDT",
"price": 45234.12,
"change_24h": 0.0234
},
"user_profile": {
"risk_profile": "moderate",
"preferred_symbols": ["BTCUSDT", "ETHUSDT"]
},
"rag_documents": [
{"doc_id": "123", "relevance": 0.89, "snippet": "..."},
{"doc_id": "456", "relevance": 0.76, "snippet": "..."}
]
}';
-- Ejemplo de tool_calls JSONB:
COMMENT ON COLUMN llm.messages.tool_calls IS
'Example: [
{
"tool": "get_market_data",
"params": {"symbol": "BTCUSDT", "timeframe": "1h"},
"result": {...}
},
{
"tool": "calculate_indicator",
"params": {"indicator": "rsi", "period": 14},
"result": {"rsi": 65.42}
}
]';

View File

@ -0,0 +1,68 @@
-- =====================================================
-- LLM SCHEMA - USER PREFERENCES TABLE
-- =====================================================
-- Description: User preferences for LLM agent interactions
-- Schema: llm
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE llm.user_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
-- Preferencias de comunicación
language VARCHAR(5) DEFAULT 'es', -- ISO 639-1
tone llm.communication_tone DEFAULT 'professional',
verbosity llm.verbosity_level DEFAULT 'normal',
-- Preferencias de trading
preferred_symbols VARCHAR(20)[] DEFAULT '{}',
preferred_timeframe VARCHAR(10) DEFAULT '1h',
risk_tolerance VARCHAR(20) DEFAULT 'moderate', -- conservative, moderate, aggressive
-- Preferencias de notificación
proactive_alerts BOOLEAN DEFAULT true,
alert_frequency llm.alert_frequency DEFAULT 'normal',
notification_hours_start TIME DEFAULT '08:00:00',
notification_hours_end TIME DEFAULT '22:00:00',
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
-- Intereses
topics_of_interest TEXT[] DEFAULT '{}', -- trading, education, news, market_analysis
-- Nivel de experiencia (para personalizar explicaciones)
trading_experience_level VARCHAR(20) DEFAULT 'beginner', -- beginner, intermediate, advanced, expert
-- Preferencias de análisis
preferred_analysis_types TEXT[] DEFAULT '{}', -- technical, fundamental, sentiment, onchain
-- Formato de respuestas
include_charts BOOLEAN DEFAULT true,
include_data_tables BOOLEAN DEFAULT true,
include_explanations BOOLEAN DEFAULT true,
-- Metadata
onboarding_completed BOOLEAN DEFAULT false,
onboarding_completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE UNIQUE INDEX idx_user_preferences_user ON llm.user_preferences(user_id);
CREATE INDEX idx_user_preferences_language ON llm.user_preferences(language);
-- Comentarios
COMMENT ON TABLE llm.user_preferences IS 'User preferences for personalized LLM agent interactions';
COMMENT ON COLUMN llm.user_preferences.language IS 'Preferred language for responses (ISO 639-1 code)';
COMMENT ON COLUMN llm.user_preferences.tone IS 'Communication style: casual, professional, or technical';
COMMENT ON COLUMN llm.user_preferences.verbosity IS 'Response length preference: brief, normal, or detailed';
COMMENT ON COLUMN llm.user_preferences.preferred_symbols IS 'Trading pairs user is most interested in';
COMMENT ON COLUMN llm.user_preferences.proactive_alerts IS 'Whether agent should send proactive notifications';
COMMENT ON COLUMN llm.user_preferences.alert_frequency IS 'How often to receive alerts: low, normal, or high';
COMMENT ON COLUMN llm.user_preferences.notification_hours_start IS 'Start of notification window in user timezone';
COMMENT ON COLUMN llm.user_preferences.notification_hours_end IS 'End of notification window in user timezone';
COMMENT ON COLUMN llm.user_preferences.topics_of_interest IS 'Topics user wants to learn about or discuss';
COMMENT ON COLUMN llm.user_preferences.trading_experience_level IS 'User experience level for tailoring explanations';

View File

@ -0,0 +1,82 @@
-- =====================================================
-- LLM SCHEMA - USER MEMORY TABLE
-- =====================================================
-- Description: Persistent memory about users for personalization
-- Schema: llm
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE llm.user_memory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Tipo de memoria
memory_type llm.memory_type NOT NULL,
-- Contenido
key VARCHAR(200) NOT NULL,
value TEXT NOT NULL,
-- Importancia
importance_score DECIMAL(3,2) DEFAULT 0.50 CHECK (importance_score >= 0.00 AND importance_score <= 1.00),
-- Fuente
source_conversation_id UUID REFERENCES llm.conversations(id) ON DELETE SET NULL,
extracted_from TEXT, -- Fragmento del que se extrajo la memoria
extraction_method VARCHAR(50) DEFAULT 'llm', -- llm, manual, system
-- Validez
is_active BOOLEAN DEFAULT true,
expires_at TIMESTAMPTZ,
-- Confirmación (algunas memorias pueden requerir confirmación del usuario)
requires_confirmation BOOLEAN DEFAULT false,
confirmed_by_user BOOLEAN DEFAULT false,
confirmed_at TIMESTAMPTZ,
-- Metadata
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT unique_user_memory_key UNIQUE(user_id, memory_type, key)
);
-- Índices
CREATE INDEX idx_memory_user ON llm.user_memory(user_id);
CREATE INDEX idx_memory_type ON llm.user_memory(memory_type);
CREATE INDEX idx_memory_active ON llm.user_memory(is_active) WHERE is_active = true;
CREATE INDEX idx_memory_importance ON llm.user_memory(importance_score DESC);
CREATE INDEX idx_memory_conversation ON llm.user_memory(source_conversation_id);
CREATE INDEX idx_memory_expires ON llm.user_memory(expires_at) WHERE expires_at IS NOT NULL;
-- Comentarios
COMMENT ON TABLE llm.user_memory IS 'Persistent memory about users for LLM personalization and context';
COMMENT ON COLUMN llm.user_memory.memory_type IS 'Type of memory: fact, preference, context, goal, or constraint';
COMMENT ON COLUMN llm.user_memory.key IS 'Memory identifier (e.g., "favorite_symbol", "trading_goal", "risk_limit")';
COMMENT ON COLUMN llm.user_memory.value IS 'Memory content (e.g., "BTCUSDT", "save for house", "max 5% per trade")';
COMMENT ON COLUMN llm.user_memory.importance_score IS 'Importance weight (0.00-1.00) for retrieval prioritization';
COMMENT ON COLUMN llm.user_memory.extracted_from IS 'Original text from which memory was extracted';
COMMENT ON COLUMN llm.user_memory.requires_confirmation IS 'Whether this memory needs explicit user confirmation';
-- Ejemplos de memorias por tipo:
COMMENT ON COLUMN llm.user_memory.memory_type IS
'Memory type examples:
- fact: "trading_since" = "2020", "max_loss_experienced" = "15%"
- preference: "favorite_indicator" = "RSI", "avoids_margin_trading" = "true"
- context: "recent_portfolio_loss" = "trying to recover", "learning_focus" = "risk management"
- goal: "monthly_target" = "5% return", "learning_goal" = "master technical analysis"
- constraint: "max_risk_per_trade" = "2%", "no_trading_during_work" = "9am-5pm"';
-- Ejemplo de metadata JSONB:
COMMENT ON COLUMN llm.user_memory.metadata IS
'Example: {
"confidence": 0.85,
"last_mentioned": "2025-12-05T10:30:00Z",
"mention_count": 5,
"related_memories": ["mem_123", "mem_456"],
"tags": ["trading_style", "risk_management"]
}';

View File

@ -0,0 +1,122 @@
-- =====================================================
-- LLM SCHEMA - EMBEDDINGS TABLE
-- =====================================================
-- Description: Vector embeddings for RAG and semantic search
-- Schema: llm
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
-- NOTA: Requiere extensión pgvector
-- CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE llm.embeddings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Tipo de contenido
content_type VARCHAR(50) NOT NULL, -- message, document, faq, tutorial, article
-- Referencia al contenido original
content_id UUID, -- ID del mensaje, documento, etc.
-- Contenido
content TEXT NOT NULL,
content_hash VARCHAR(64), -- SHA-256 para deduplicación
-- Metadata del contenido
title VARCHAR(500),
description TEXT,
-- Vector embedding (dimensión depende del modelo)
-- OpenAI text-embedding-3-small: 1536 dims
-- OpenAI text-embedding-3-large: 3072 dims
-- Voyage AI: 1024 dims
embedding vector(1536), -- Ajustar según modelo usado
-- Modelo usado para generar embedding
embedding_model VARCHAR(100) NOT NULL, -- text-embedding-3-small, voyage-2, etc.
-- Metadata para filtrado
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- Si es contenido específico de usuario
is_public BOOLEAN DEFAULT true,
-- Categorización
category VARCHAR(100), -- education, trading, market_news, platform_help
subcategory VARCHAR(100),
tags TEXT[] DEFAULT '{}',
-- Relevancia
importance_score DECIMAL(3,2) DEFAULT 0.50,
-- Contexto adicional
context_metadata JSONB, -- Metadata adicional para mejorar recuperación
-- Fuente
source_url VARCHAR(500),
source_type VARCHAR(50), -- internal, external, generated
-- Validez
is_active BOOLEAN DEFAULT true,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_embeddings_type ON llm.embeddings(content_type);
CREATE INDEX idx_embeddings_user ON llm.embeddings(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_embeddings_category ON llm.embeddings(category);
CREATE INDEX idx_embeddings_tags ON llm.embeddings USING GIN(tags);
CREATE INDEX idx_embeddings_active ON llm.embeddings(is_active) WHERE is_active = true;
CREATE INDEX idx_embeddings_hash ON llm.embeddings(content_hash);
-- Índice para búsqueda vectorial (HNSW para mejor performance)
-- Requiere pgvector
CREATE INDEX idx_embeddings_vector_hnsw ON llm.embeddings
USING hnsw (embedding vector_cosine_ops);
-- Índice alternativo: IVFFlat (más rápido de construir, menos preciso)
-- CREATE INDEX idx_embeddings_vector_ivfflat ON llm.embeddings
-- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- Comentarios
COMMENT ON TABLE llm.embeddings IS 'Vector embeddings for RAG and semantic search using pgvector';
COMMENT ON COLUMN llm.embeddings.content_type IS 'Type of content: message, document, faq, tutorial, article';
COMMENT ON COLUMN llm.embeddings.content_id IS 'Reference to original content (e.g., message ID)';
COMMENT ON COLUMN llm.embeddings.content IS 'Text content that was embedded';
COMMENT ON COLUMN llm.embeddings.content_hash IS 'SHA-256 hash for deduplication';
COMMENT ON COLUMN llm.embeddings.embedding IS 'Vector embedding (dimension depends on model)';
COMMENT ON COLUMN llm.embeddings.embedding_model IS 'Model used to generate embedding';
COMMENT ON COLUMN llm.embeddings.is_public IS 'Whether embedding is accessible to all users or user-specific';
COMMENT ON COLUMN llm.embeddings.importance_score IS 'Relevance score for retrieval prioritization';
-- Ejemplo de uso para búsqueda semántica:
COMMENT ON TABLE llm.embeddings IS
'Vector search example:
SELECT
content,
title,
1 - (embedding <=> query_embedding) AS similarity
FROM llm.embeddings
WHERE is_active = true
AND category = ''education''
ORDER BY embedding <=> query_embedding
LIMIT 5;
Operators:
- <-> : L2 distance
- <#> : inner product
- <=> : cosine distance (recommended)';
-- Ejemplo de context_metadata JSONB:
COMMENT ON COLUMN llm.embeddings.context_metadata IS
'Example: {
"language": "es",
"difficulty_level": "beginner",
"reading_time_minutes": 5,
"author": "system",
"last_updated": "2025-12-01",
"related_symbols": ["BTCUSDT"],
"related_topics": ["technical_analysis", "rsi"]
}';

View File

@ -0,0 +1,68 @@
-- =====================================================
-- ML SCHEMA - ENUMS
-- =====================================================
-- Description: Enumerations for ML signals system
-- Schema: ml
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
-- Tipo de modelo ML
CREATE TYPE ml.model_type AS ENUM (
'classification',
'regression',
'time_series',
'clustering',
'anomaly_detection',
'reinforcement_learning'
);
-- Framework de ML
CREATE TYPE ml.framework AS ENUM (
'sklearn',
'tensorflow',
'pytorch',
'xgboost',
'lightgbm',
'prophet',
'custom'
);
-- Estado del modelo
CREATE TYPE ml.model_status AS ENUM (
'development',
'testing',
'staging',
'production',
'deprecated',
'archived'
);
-- Tipo de predicción
CREATE TYPE ml.prediction_type AS ENUM (
'price_direction', -- UP/DOWN/NEUTRAL
'price_target', -- Precio objetivo
'volatility', -- Alta/Media/Baja
'trend', -- Tendencia
'signal', -- BUY/SELL/HOLD
'risk_score' -- Score de riesgo
);
-- Resultado de predicción
CREATE TYPE ml.prediction_result AS ENUM (
'buy',
'sell',
'hold',
'up',
'down',
'neutral'
);
-- Estado de outcome
CREATE TYPE ml.outcome_status AS ENUM (
'pending',
'correct',
'incorrect',
'partially_correct',
'expired'
);

View File

@ -0,0 +1,65 @@
-- =====================================================
-- ML SCHEMA - MODELS TABLE
-- =====================================================
-- Description: ML models registry
-- Schema: ml
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE ml.models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
name VARCHAR(100) NOT NULL UNIQUE,
display_name VARCHAR(200) NOT NULL,
description TEXT,
-- Tipo y framework
model_type ml.model_type NOT NULL,
framework ml.framework NOT NULL,
-- Categoría
category VARCHAR(50) NOT NULL, -- sentiment, technical, fundamental, hybrid
-- Alcance
applies_to_symbols VARCHAR(20)[] DEFAULT '{}', -- ['BTCUSDT', 'ETHUSDT'] o [] para todos
applies_to_timeframes VARCHAR(10)[] DEFAULT '{}', -- ['1h', '4h', '1d'] o [] para todos
-- Estado
status ml.model_status DEFAULT 'development',
-- Versión actual en producción
current_version_id UUID,
-- Metadata
owner VARCHAR(100) NOT NULL,
repository_url VARCHAR(500),
documentation_url VARCHAR(500),
-- Métricas agregadas (de todas las versiones)
total_predictions INTEGER DEFAULT 0,
total_correct_predictions INTEGER DEFAULT 0,
overall_accuracy DECIMAL(5,4),
-- Fechas
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deployed_at TIMESTAMPTZ,
deprecated_at TIMESTAMPTZ
);
-- Índices
CREATE INDEX idx_models_name ON ml.models(name);
CREATE INDEX idx_models_status ON ml.models(status);
CREATE INDEX idx_models_type ON ml.models(model_type);
CREATE INDEX idx_models_category ON ml.models(category);
CREATE INDEX idx_models_production ON ml.models(status) WHERE status = 'production';
-- Comentarios
COMMENT ON TABLE ml.models IS 'Registry of ML models for trading signals';
COMMENT ON COLUMN ml.models.name IS 'Unique technical name (e.g., sentiment_analyzer_v1)';
COMMENT ON COLUMN ml.models.applies_to_symbols IS 'Symbols this model can analyze. Empty array = all symbols';
COMMENT ON COLUMN ml.models.applies_to_timeframes IS 'Timeframes this model supports. Empty array = all timeframes';
COMMENT ON COLUMN ml.models.current_version_id IS 'Reference to current production version';
COMMENT ON COLUMN ml.models.overall_accuracy IS 'Aggregated accuracy across all versions and predictions';

View File

@ -0,0 +1,102 @@
-- =====================================================
-- ML SCHEMA - MODEL VERSIONS TABLE
-- =====================================================
-- Description: Versioned ML model artifacts and metadata
-- Schema: ml
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE ml.model_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Modelo padre
model_id UUID NOT NULL REFERENCES ml.models(id) ON DELETE CASCADE,
-- Versión
version VARCHAR(50) NOT NULL, -- Semantic versioning: 1.0.0, 1.1.0, 2.0.0
-- Artefacto
artifact_path VARCHAR(500) NOT NULL, -- S3 path, local path, registry URL
artifact_size_bytes BIGINT,
checksum VARCHAR(64), -- SHA-256
-- Métricas de entrenamiento
training_metrics JSONB, -- {accuracy, precision, recall, f1, loss}
validation_metrics JSONB, -- {accuracy, precision, recall, f1, loss}
test_metrics JSONB, -- {accuracy, precision, recall, f1, loss}
-- Features
feature_set JSONB NOT NULL, -- Lista de features usadas
feature_importance JSONB, -- Importancia de cada feature
-- Hiperparámetros
hyperparameters JSONB, -- Parámetros del modelo
-- Dataset info
training_dataset_size INTEGER,
training_dataset_path VARCHAR(500),
data_version VARCHAR(50),
-- Estado
is_production BOOLEAN DEFAULT false,
deployed_at TIMESTAMPTZ,
deployment_metadata JSONB,
-- Metadata de entrenamiento
training_started_at TIMESTAMPTZ,
training_completed_at TIMESTAMPTZ,
training_duration_seconds INTEGER,
trained_by VARCHAR(100),
training_environment JSONB, -- Python version, library versions, etc.
-- Performance en producción
production_predictions INTEGER DEFAULT 0,
production_accuracy DECIMAL(5,4),
-- Notas
release_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT unique_model_version UNIQUE(model_id, version)
);
-- Índices
CREATE INDEX idx_model_versions_model ON ml.model_versions(model_id);
CREATE INDEX idx_model_versions_production ON ml.model_versions(is_production)
WHERE is_production = true;
CREATE INDEX idx_model_versions_version ON ml.model_versions(version);
CREATE INDEX idx_model_versions_deployed ON ml.model_versions(deployed_at DESC);
-- Comentarios
COMMENT ON TABLE ml.model_versions IS 'Versioned artifacts and metadata for ML models';
COMMENT ON COLUMN ml.model_versions.version IS 'Semantic version (major.minor.patch)';
COMMENT ON COLUMN ml.model_versions.artifact_path IS 'Location of serialized model file';
COMMENT ON COLUMN ml.model_versions.checksum IS 'SHA-256 hash for artifact integrity verification';
COMMENT ON COLUMN ml.model_versions.feature_set IS 'Array of feature names used by this version';
COMMENT ON COLUMN ml.model_versions.hyperparameters IS 'Model hyperparameters for reproducibility';
COMMENT ON COLUMN ml.model_versions.is_production IS 'Whether this version is currently deployed in production';
-- Ejemplo de training_metrics JSONB:
COMMENT ON COLUMN ml.model_versions.training_metrics IS
'Example: {
"accuracy": 0.8542,
"precision": 0.8234,
"recall": 0.7891,
"f1_score": 0.8058,
"loss": 0.3421,
"auc_roc": 0.9123
}';
-- Ejemplo de feature_set JSONB:
COMMENT ON COLUMN ml.model_versions.feature_set IS
'Example: [
"rsi_14",
"macd_signal",
"volume_sma_20",
"price_change_1h",
"sentiment_score"
]';

View File

@ -0,0 +1,93 @@
-- =====================================================
-- ML SCHEMA - PREDICTIONS TABLE
-- =====================================================
-- Description: ML model predictions and signals
-- Schema: ml
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE ml.predictions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Modelo y versión
model_id UUID NOT NULL REFERENCES ml.models(id) ON DELETE CASCADE,
model_version_id UUID NOT NULL REFERENCES ml.model_versions(id) ON DELETE CASCADE,
-- Símbolo y timeframe
symbol VARCHAR(20) NOT NULL,
timeframe VARCHAR(10) NOT NULL,
-- Tipo de predicción
prediction_type ml.prediction_type NOT NULL,
-- Resultado de predicción
prediction_result ml.prediction_result,
prediction_value DECIMAL(20,8), -- Para predicciones numéricas
-- Confianza
confidence_score DECIMAL(5,4) NOT NULL CHECK (confidence_score >= 0 AND confidence_score <= 1),
-- Input features utilizados
input_features JSONB NOT NULL,
-- Output completo del modelo
model_output JSONB, -- Raw output del modelo
-- Contexto de mercado al momento de predicción
market_price DECIMAL(20,8),
market_timestamp TIMESTAMPTZ NOT NULL,
-- Horizonte temporal
prediction_horizon VARCHAR(20), -- 1h, 4h, 1d, 1w
valid_until TIMESTAMPTZ,
-- Metadata
prediction_metadata JSONB,
-- Procesamiento
inference_time_ms INTEGER, -- Tiempo de inferencia en milisegundos
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_predictions_model ON ml.predictions(model_id);
CREATE INDEX idx_predictions_version ON ml.predictions(model_version_id);
CREATE INDEX idx_predictions_symbol ON ml.predictions(symbol);
CREATE INDEX idx_predictions_symbol_time ON ml.predictions(symbol, market_timestamp DESC);
CREATE INDEX idx_predictions_type ON ml.predictions(prediction_type);
CREATE INDEX idx_predictions_created ON ml.predictions(created_at DESC);
CREATE INDEX idx_predictions_valid ON ml.predictions(valid_until)
WHERE valid_until IS NOT NULL AND valid_until > NOW();
-- Particionamiento por fecha (opcional, para alto volumen)
-- CREATE INDEX idx_predictions_timestamp ON ml.predictions(market_timestamp DESC);
-- Comentarios
COMMENT ON TABLE ml.predictions IS 'ML model predictions and trading signals';
COMMENT ON COLUMN ml.predictions.prediction_type IS 'Type of prediction being made';
COMMENT ON COLUMN ml.predictions.prediction_result IS 'Categorical result (buy/sell/hold/up/down/neutral)';
COMMENT ON COLUMN ml.predictions.prediction_value IS 'Numeric prediction value (e.g., target price, probability)';
COMMENT ON COLUMN ml.predictions.confidence_score IS 'Model confidence in prediction (0.0 to 1.0)';
COMMENT ON COLUMN ml.predictions.input_features IS 'Feature values used for this prediction';
COMMENT ON COLUMN ml.predictions.prediction_horizon IS 'Time horizon for prediction validity';
COMMENT ON COLUMN ml.predictions.inference_time_ms IS 'Model inference latency in milliseconds';
-- Ejemplo de input_features JSONB:
COMMENT ON COLUMN ml.predictions.input_features IS
'Example: {
"rsi_14": 65.42,
"macd_signal": 0.0234,
"volume_sma_20": 1234567.89,
"price_change_1h": 0.0145,
"sentiment_score": 0.72
}';
-- Ejemplo de model_output JSONB:
COMMENT ON COLUMN ml.predictions.model_output IS
'Example: {
"probabilities": {"buy": 0.72, "sell": 0.15, "hold": 0.13},
"raw_score": 0.5823,
"feature_contributions": {...}
}';

View File

@ -0,0 +1,68 @@
-- =====================================================
-- ML SCHEMA - PREDICTION OUTCOMES TABLE
-- =====================================================
-- Description: Actual outcomes vs predictions for model evaluation
-- Schema: ml
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE ml.prediction_outcomes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Predicción asociada
prediction_id UUID NOT NULL REFERENCES ml.predictions(id) ON DELETE CASCADE,
-- Resultado real
actual_result ml.prediction_result,
actual_value DECIMAL(20,8),
-- Evaluación
outcome_status ml.outcome_status NOT NULL DEFAULT 'pending',
is_correct BOOLEAN,
-- Métricas de error
absolute_error DECIMAL(20,8), -- |predicted - actual|
relative_error DECIMAL(10,6), -- (predicted - actual) / actual
-- Contexto de mercado en verificación
actual_price DECIMAL(20,8),
price_change_percent DECIMAL(10,6),
-- Timing
outcome_timestamp TIMESTAMPTZ NOT NULL, -- Cuando se verificó el outcome
time_to_outcome_hours DECIMAL(10,2), -- Horas desde predicción hasta outcome
-- Metadata
outcome_metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT unique_prediction_outcome UNIQUE(prediction_id)
);
-- Índices
CREATE INDEX idx_outcomes_prediction ON ml.prediction_outcomes(prediction_id);
CREATE INDEX idx_outcomes_status ON ml.prediction_outcomes(outcome_status);
CREATE INDEX idx_outcomes_correct ON ml.prediction_outcomes(is_correct)
WHERE is_correct IS NOT NULL;
CREATE INDEX idx_outcomes_timestamp ON ml.prediction_outcomes(outcome_timestamp DESC);
-- Comentarios
COMMENT ON TABLE ml.prediction_outcomes IS 'Actual results for model predictions, used for performance tracking';
COMMENT ON COLUMN ml.prediction_outcomes.actual_result IS 'What actually happened (buy/sell/hold/up/down/neutral)';
COMMENT ON COLUMN ml.prediction_outcomes.actual_value IS 'Actual numeric value (e.g., actual price reached)';
COMMENT ON COLUMN ml.prediction_outcomes.is_correct IS 'Whether prediction matched actual outcome';
COMMENT ON COLUMN ml.prediction_outcomes.absolute_error IS 'Absolute difference between predicted and actual';
COMMENT ON COLUMN ml.prediction_outcomes.relative_error IS 'Percentage error relative to actual value';
COMMENT ON COLUMN ml.prediction_outcomes.time_to_outcome_hours IS 'Time elapsed from prediction to outcome verification';
-- Ejemplo de outcome_metadata JSONB:
COMMENT ON COLUMN ml.prediction_outcomes.outcome_metadata IS
'Example: {
"verification_method": "automated",
"market_volatility": "high",
"external_events": ["fed_announcement"],
"notes": "Prediction incorrect due to unexpected news"
}';

View File

@ -0,0 +1,120 @@
-- =====================================================
-- ML SCHEMA - FEATURE STORE TABLE
-- =====================================================
-- Description: Pre-calculated features for ML models
-- Schema: ml
-- Author: Database Agent
-- Date: 2025-12-06
-- =====================================================
CREATE TABLE ml.feature_store (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación del feature set
symbol VARCHAR(20) NOT NULL,
timeframe VARCHAR(10) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
-- Features técnicos
technical_features JSONB, -- RSI, MACD, Bollinger, SMA, EMA, etc.
-- Features de volumen
volume_features JSONB, -- Volume profiles, OBV, VWAP, etc.
-- Features de precio
price_features JSONB, -- Price changes, returns, volatility, etc.
-- Features de sentimiento (si disponible)
sentiment_features JSONB, -- Social sentiment, news sentiment, etc.
-- Features on-chain (para crypto)
onchain_features JSONB, -- Network metrics, whale activity, etc.
-- Features derivados
derived_features JSONB, -- Features calculados de otros features
-- Metadata
feature_version VARCHAR(20), -- Versión del cálculo de features
calculation_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
calculation_duration_ms INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT unique_feature_set UNIQUE(symbol, timeframe, timestamp)
);
-- Índices
CREATE INDEX idx_feature_store_symbol ON ml.feature_store(symbol);
CREATE INDEX idx_feature_store_symbol_time ON ml.feature_store(symbol, timeframe, timestamp DESC);
CREATE INDEX idx_feature_store_timestamp ON ml.feature_store(timestamp DESC);
CREATE INDEX idx_feature_store_version ON ml.feature_store(feature_version);
-- Particionamiento por fecha (recomendado para alto volumen)
-- Se puede implementar particionamiento mensual o trimestral
-- Comentarios
COMMENT ON TABLE ml.feature_store IS 'Pre-calculated features for ML model inference and training';
COMMENT ON COLUMN ml.feature_store.symbol IS 'Trading symbol (e.g., BTCUSDT)';
COMMENT ON COLUMN ml.feature_store.timeframe IS 'Timeframe for features (1m, 5m, 15m, 1h, 4h, 1d)';
COMMENT ON COLUMN ml.feature_store.timestamp IS 'Timestamp of the candle/bar these features represent';
COMMENT ON COLUMN ml.feature_store.feature_version IS 'Version of feature calculation logic';
-- Ejemplos de features JSONB:
COMMENT ON COLUMN ml.feature_store.technical_features IS
'Example: {
"rsi_14": 65.42,
"rsi_9": 68.21,
"macd": 0.0234,
"macd_signal": 0.0189,
"macd_histogram": 0.0045,
"bb_upper": 45678.90,
"bb_middle": 45234.12,
"bb_lower": 44789.34,
"sma_20": 45123.45,
"ema_12": 45234.56,
"ema_26": 45012.34
}';
COMMENT ON COLUMN ml.feature_store.volume_features IS
'Example: {
"volume": 1234567.89,
"volume_sma_20": 1123456.78,
"volume_ratio": 1.098,
"obv": 98765432.10,
"vwap": 45234.12,
"buy_volume": 678901.23,
"sell_volume": 555666.66
}';
COMMENT ON COLUMN ml.feature_store.price_features IS
'Example: {
"open": 45100.00,
"high": 45500.00,
"low": 44900.00,
"close": 45234.12,
"price_change_1h": 0.0145,
"price_change_4h": 0.0234,
"price_change_24h": 0.0567,
"volatility_1h": 0.0089,
"atr_14": 234.56
}';
COMMENT ON COLUMN ml.feature_store.sentiment_features IS
'Example: {
"social_sentiment": 0.72,
"news_sentiment": 0.65,
"fear_greed_index": 68,
"twitter_volume": 12345,
"reddit_mentions": 678
}';
COMMENT ON COLUMN ml.feature_store.onchain_features IS
'Example: {
"active_addresses": 123456,
"transaction_volume": 98765.43,
"exchange_inflow": 1234.56,
"exchange_outflow": 2345.67,
"whale_transactions": 23,
"network_growth": 0.023
}';

View File

@ -0,0 +1,78 @@
-- ============================================================================
-- Schema: trading
-- File: 00-enums.sql
-- Description: Enumeraciones para el módulo de trading
-- Dependencies: None
-- ============================================================================
-- Tipo de orden
CREATE TYPE trading.order_type AS ENUM (
'market',
'limit',
'stop',
'stop_limit',
'trailing_stop'
);
-- Estado de orden (CON 'partially_filled' que faltaba!)
CREATE TYPE trading.order_status AS ENUM (
'pending',
'open',
'partially_filled', -- AGREGADO - faltaba en análisis
'filled',
'cancelled',
'rejected',
'expired'
);
-- Lado de la orden
CREATE TYPE trading.order_side AS ENUM (
'buy',
'sell'
);
-- Estado de posición
CREATE TYPE trading.position_status AS ENUM (
'open',
'closed',
'liquidated'
);
-- Tipo de señal (interfaz con ML)
CREATE TYPE trading.signal_type AS ENUM (
'entry_long',
'entry_short',
'exit_long',
'exit_short',
'hold'
);
-- Nivel de confianza
CREATE TYPE trading.confidence_level AS ENUM (
'low',
'medium',
'high',
'very_high'
);
-- Timeframes soportados
CREATE TYPE trading.timeframe AS ENUM (
'1m', '5m', '15m', '30m',
'1h', '4h',
'1d', '1w', '1M'
);
-- Tipo de bot
CREATE TYPE trading.bot_type AS ENUM (
'paper', -- Paper trading (simulación)
'live', -- Trading real
'backtest' -- Backtesting
);
-- Estado de bot
CREATE TYPE trading.bot_status AS ENUM (
'active',
'paused',
'stopped',
'error'
);

View File

@ -0,0 +1,96 @@
-- ============================================================================
-- Schema: trading
-- Function: calculate_position_pnl
-- Description: Calcula el PnL (realized y unrealized) de una posición
-- Parameters:
-- p_position_id: ID de la posición
-- p_current_price: Precio actual del mercado (para unrealized PnL)
-- Returns: JSONB con realized_pnl, unrealized_pnl, pnl_percentage
-- ============================================================================
CREATE OR REPLACE FUNCTION trading.calculate_position_pnl(
p_position_id UUID,
p_current_price DECIMAL(20,8) DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
AS $$
DECLARE
v_position RECORD;
v_realized_pnl DECIMAL(20,8) := 0;
v_unrealized_pnl DECIMAL(20,8) := 0;
v_pnl_percentage DECIMAL(10,4) := 0;
v_total_commission DECIMAL(20,8) := 0;
v_current_value DECIMAL(20,8);
BEGIN
-- Obtener datos de la posición
SELECT
p.position_side,
p.status,
p.entry_price,
p.entry_quantity,
p.entry_value,
p.entry_commission,
p.exit_price,
p.exit_quantity,
p.exit_value,
p.exit_commission
INTO v_position
FROM trading.positions p
WHERE p.id = p_position_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Position % not found', p_position_id;
END IF;
-- Calcular comisiones totales
v_total_commission := COALESCE(v_position.entry_commission, 0) + COALESCE(v_position.exit_commission, 0);
-- Calcular PnL según el estado
IF v_position.status = 'closed' OR v_position.status = 'liquidated' THEN
-- Posición cerrada: calcular realized PnL
IF v_position.position_side = 'buy' THEN
-- Long position: profit = (exit_price - entry_price) * quantity - commissions
v_realized_pnl := (v_position.exit_value - v_position.entry_value) - v_total_commission;
ELSE
-- Short position: profit = (entry_price - exit_price) * quantity - commissions
v_realized_pnl := (v_position.entry_value - v_position.exit_value) - v_total_commission;
END IF;
-- Calcular porcentaje
IF v_position.entry_value > 0 THEN
v_pnl_percentage := (v_realized_pnl / v_position.entry_value) * 100;
END IF;
ELSIF v_position.status = 'open' AND p_current_price IS NOT NULL THEN
-- Posición abierta: calcular unrealized PnL
v_current_value := p_current_price * v_position.entry_quantity;
IF v_position.position_side = 'buy' THEN
-- Long position
v_unrealized_pnl := (v_current_value - v_position.entry_value) - v_position.entry_commission;
ELSE
-- Short position
v_unrealized_pnl := (v_position.entry_value - v_current_value) - v_position.entry_commission;
END IF;
-- Calcular porcentaje
IF v_position.entry_value > 0 THEN
v_pnl_percentage := (v_unrealized_pnl / v_position.entry_value) * 100;
END IF;
END IF;
-- Retornar resultado
RETURN jsonb_build_object(
'position_id', p_position_id,
'status', v_position.status,
'realized_pnl', v_realized_pnl,
'unrealized_pnl', v_unrealized_pnl,
'pnl_percentage', v_pnl_percentage,
'total_commission', v_total_commission
);
END;
$$;
-- Comentarios
COMMENT ON FUNCTION trading.calculate_position_pnl IS 'Calcula el PnL realizado y no realizado de una posición';

View File

@ -0,0 +1,88 @@
-- ============================================================================
-- Schema: trading
-- Function: update_bot_stats
-- Description: Actualiza las estadísticas de un bot basándose en sus posiciones
-- Parameters:
-- p_bot_id: ID del bot
-- Returns: JSONB con las estadísticas actualizadas
-- ============================================================================
CREATE OR REPLACE FUNCTION trading.update_bot_stats(
p_bot_id UUID
)
RETURNS JSONB
LANGUAGE plpgsql
AS $$
DECLARE
v_total_trades INTEGER := 0;
v_winning_trades INTEGER := 0;
v_losing_trades INTEGER := 0;
v_total_pnl DECIMAL(20,8) := 0;
v_win_rate DECIMAL(5,2) := 0;
v_current_capital DECIMAL(20,8);
v_initial_capital DECIMAL(20,8);
BEGIN
-- Obtener capital inicial
SELECT initial_capital INTO v_initial_capital
FROM trading.bots
WHERE id = p_bot_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Bot % not found', p_bot_id;
END IF;
-- Contar trades cerrados
SELECT
COUNT(*)::INTEGER,
COUNT(*) FILTER (WHERE realized_pnl > 0)::INTEGER,
COUNT(*) FILTER (WHERE realized_pnl < 0)::INTEGER,
COALESCE(SUM(realized_pnl), 0)
INTO
v_total_trades,
v_winning_trades,
v_losing_trades,
v_total_pnl
FROM trading.positions
WHERE bot_id = p_bot_id
AND status IN ('closed', 'liquidated');
-- Calcular win rate
IF v_total_trades > 0 THEN
v_win_rate := (v_winning_trades::DECIMAL / v_total_trades::DECIMAL) * 100;
END IF;
-- Calcular capital actual
v_current_capital := v_initial_capital + v_total_pnl;
-- Actualizar bot
UPDATE trading.bots
SET
total_trades = v_total_trades,
winning_trades = v_winning_trades,
total_profit_loss = v_total_pnl,
win_rate = v_win_rate,
current_capital = v_current_capital,
updated_at = NOW()
WHERE id = p_bot_id;
-- Retornar estadísticas
RETURN jsonb_build_object(
'bot_id', p_bot_id,
'total_trades', v_total_trades,
'winning_trades', v_winning_trades,
'losing_trades', v_losing_trades,
'win_rate', v_win_rate,
'total_profit_loss', v_total_pnl,
'initial_capital', v_initial_capital,
'current_capital', v_current_capital,
'roi_percentage', CASE
WHEN v_initial_capital > 0
THEN ((v_current_capital - v_initial_capital) / v_initial_capital * 100)
ELSE 0
END
);
END;
$$;
-- Comentarios
COMMENT ON FUNCTION trading.update_bot_stats IS 'Actualiza las estadísticas agregadas de un trading bot';

View File

@ -0,0 +1,165 @@
-- ============================================================================
-- TRADING SCHEMA - Funcion: initialize_paper_balance
-- ============================================================================
-- Inicializa el balance de paper trading para un usuario
-- ============================================================================
CREATE OR REPLACE FUNCTION trading.initialize_paper_balance(
p_user_id UUID,
p_initial_amount DECIMAL(20, 8) DEFAULT 10000.00,
p_asset VARCHAR(10) DEFAULT 'USDT'
)
RETURNS UUID AS $$
DECLARE
v_balance_id UUID;
BEGIN
INSERT INTO trading.paper_balances (
user_id,
asset,
total,
available,
locked,
initial_balance
)
VALUES (
p_user_id,
p_asset,
p_initial_amount,
p_initial_amount,
0,
p_initial_amount
)
ON CONFLICT (user_id, asset) DO NOTHING
RETURNING id INTO v_balance_id;
-- Si ya existia, obtener el ID
IF v_balance_id IS NULL THEN
SELECT id INTO v_balance_id
FROM trading.paper_balances
WHERE user_id = p_user_id AND asset = p_asset;
END IF;
RETURN v_balance_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION trading.initialize_paper_balance IS 'Inicializa balance de paper trading para un usuario (default $10,000 USDT)';
-- ============================================================================
-- Funcion: reset_paper_balance
-- ============================================================================
-- Resetea el balance de paper trading a su valor inicial
-- ============================================================================
CREATE OR REPLACE FUNCTION trading.reset_paper_balance(
p_user_id UUID,
p_asset VARCHAR(10) DEFAULT 'USDT',
p_new_amount DECIMAL(20, 8) DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_initial DECIMAL(20, 8);
BEGIN
-- Obtener balance inicial original o usar el nuevo monto
IF p_new_amount IS NOT NULL THEN
v_initial := p_new_amount;
ELSE
SELECT initial_balance INTO v_initial
FROM trading.paper_balances
WHERE user_id = p_user_id AND asset = p_asset;
END IF;
-- Resetear balance
UPDATE trading.paper_balances
SET
total = v_initial,
available = v_initial,
locked = 0,
initial_balance = v_initial,
total_deposits = 0,
total_withdrawals = 0,
total_pnl = 0,
last_reset_at = NOW(),
reset_count = reset_count + 1,
updated_at = NOW()
WHERE user_id = p_user_id AND asset = p_asset;
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION trading.reset_paper_balance IS 'Resetea el balance de paper trading al valor inicial';
-- ============================================================================
-- Funcion: update_paper_balance
-- ============================================================================
-- Actualiza el balance de paper trading (lock/unlock/pnl)
-- ============================================================================
CREATE OR REPLACE FUNCTION trading.update_paper_balance(
p_user_id UUID,
p_asset VARCHAR(10),
p_amount DECIMAL(20, 8),
p_operation VARCHAR(20) -- 'lock', 'unlock', 'pnl', 'deposit', 'withdrawal'
)
RETURNS BOOLEAN AS $$
BEGIN
CASE p_operation
WHEN 'lock' THEN
UPDATE trading.paper_balances
SET
available = available - p_amount,
locked = locked + p_amount,
updated_at = NOW()
WHERE user_id = p_user_id
AND asset = p_asset
AND available >= p_amount;
WHEN 'unlock' THEN
UPDATE trading.paper_balances
SET
available = available + p_amount,
locked = locked - p_amount,
updated_at = NOW()
WHERE user_id = p_user_id
AND asset = p_asset
AND locked >= p_amount;
WHEN 'pnl' THEN
UPDATE trading.paper_balances
SET
total = total + p_amount,
available = available + p_amount,
total_pnl = total_pnl + p_amount,
updated_at = NOW()
WHERE user_id = p_user_id AND asset = p_asset;
WHEN 'deposit' THEN
UPDATE trading.paper_balances
SET
total = total + p_amount,
available = available + p_amount,
total_deposits = total_deposits + p_amount,
updated_at = NOW()
WHERE user_id = p_user_id AND asset = p_asset;
WHEN 'withdrawal' THEN
UPDATE trading.paper_balances
SET
total = total - p_amount,
available = available - p_amount,
total_withdrawals = total_withdrawals + p_amount,
updated_at = NOW()
WHERE user_id = p_user_id
AND asset = p_asset
AND available >= p_amount;
ELSE
RETURN FALSE;
END CASE;
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION trading.update_paper_balance IS 'Actualiza balance de paper trading (lock/unlock/pnl/deposit/withdrawal)';

View File

@ -0,0 +1,80 @@
-- ============================================================================
-- TRADING SCHEMA - Funcion: create_default_watchlist
-- ============================================================================
-- Crea watchlist default y balance inicial para nuevos usuarios
-- Se ejecuta como trigger en auth.users
-- ============================================================================
CREATE OR REPLACE FUNCTION trading.create_user_trading_defaults()
RETURNS TRIGGER AS $$
DECLARE
v_watchlist_id UUID;
v_btc_id UUID;
v_eth_id UUID;
v_bnb_id UUID;
BEGIN
-- 1. Crear watchlist default "My Favorites"
INSERT INTO trading.watchlists (
user_id,
name,
description,
is_default,
display_order
)
VALUES (
NEW.id,
'My Favorites',
'Default watchlist with popular trading pairs',
true,
0
)
RETURNING id INTO v_watchlist_id;
-- 2. Agregar simbolos populares si existen en la tabla symbols
SELECT id INTO v_btc_id FROM trading.symbols WHERE symbol = 'BTCUSDT' LIMIT 1;
SELECT id INTO v_eth_id FROM trading.symbols WHERE symbol = 'ETHUSDT' LIMIT 1;
SELECT id INTO v_bnb_id FROM trading.symbols WHERE symbol = 'BNBUSDT' LIMIT 1;
IF v_btc_id IS NOT NULL THEN
INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order)
VALUES (v_watchlist_id, v_btc_id, 0)
ON CONFLICT DO NOTHING;
END IF;
IF v_eth_id IS NOT NULL THEN
INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order)
VALUES (v_watchlist_id, v_eth_id, 1)
ON CONFLICT DO NOTHING;
END IF;
IF v_bnb_id IS NOT NULL THEN
INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order)
VALUES (v_watchlist_id, v_bnb_id, 2)
ON CONFLICT DO NOTHING;
END IF;
-- 3. Inicializar balance de paper trading ($10,000 USDT)
PERFORM trading.initialize_paper_balance(NEW.id, 10000.00, 'USDT');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION trading.create_user_trading_defaults IS 'Trigger function: crea watchlist default y balance inicial para nuevos usuarios';
-- ============================================================================
-- Trigger en auth.users
-- ============================================================================
-- Nota: Este trigger debe crearse despues de que exista la tabla auth.users
-- Se recomienda ejecutarlo en un script de post-instalacion o manualmente
-- ============================================================================
-- DROP TRIGGER IF EXISTS tr_create_user_trading_defaults ON auth.users;
-- CREATE TRIGGER tr_create_user_trading_defaults
-- AFTER INSERT ON auth.users
-- FOR EACH ROW
-- EXECUTE FUNCTION trading.create_user_trading_defaults();
-- COMMENT ON TRIGGER tr_create_user_trading_defaults ON auth.users IS
-- 'Crea watchlist default y balance de paper trading para nuevos usuarios';

View File

@ -0,0 +1,49 @@
-- ============================================================================
-- Schema: trading
-- Table: symbols
-- Description: Catálogo de símbolos/instrumentos financieros operables
-- Dependencies: None
-- ============================================================================
CREATE TABLE trading.symbols (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
symbol VARCHAR(20) NOT NULL UNIQUE, -- BTC/USDT, EUR/USD
name VARCHAR(100) NOT NULL,
base_asset VARCHAR(10) NOT NULL, -- BTC, EUR
quote_asset VARCHAR(10) NOT NULL, -- USDT, USD
-- Tipo
asset_class VARCHAR(50) NOT NULL, -- crypto, forex, stocks
exchange VARCHAR(50), -- binance, coinbase
-- Precisión
price_precision INTEGER NOT NULL DEFAULT 8,
quantity_precision INTEGER NOT NULL DEFAULT 8,
-- Límites
min_quantity DECIMAL(20,8),
max_quantity DECIMAL(20,8),
min_notional DECIMAL(20,8),
-- Estado
is_active BOOLEAN DEFAULT true,
is_tradeable BOOLEAN DEFAULT true,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_symbols_symbol ON trading.symbols(symbol);
CREATE INDEX idx_symbols_active ON trading.symbols(is_active) WHERE is_active = true;
CREATE INDEX idx_symbols_asset_class ON trading.symbols(asset_class);
CREATE INDEX idx_symbols_exchange ON trading.symbols(exchange);
-- Comentarios
COMMENT ON TABLE trading.symbols IS 'Catálogo de símbolos/instrumentos financieros operables';
COMMENT ON COLUMN trading.symbols.symbol IS 'Símbolo del instrumento (e.g., BTC/USDT)';
COMMENT ON COLUMN trading.symbols.asset_class IS 'Clase de activo: crypto, forex, stocks, commodities';
COMMENT ON COLUMN trading.symbols.is_tradeable IS 'Indica si el símbolo está disponible para trading';

View File

@ -0,0 +1,40 @@
-- ============================================================================
-- Schema: trading
-- Table: watchlists
-- Description: Listas de seguimiento personalizadas de usuarios
-- Dependencies: auth.users
-- ============================================================================
CREATE TABLE trading.watchlists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Propietario
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Configuración
name VARCHAR(100) NOT NULL,
description TEXT,
-- Ordenamiento
display_order INTEGER DEFAULT 0,
-- Estado
is_default BOOLEAN DEFAULT false,
is_public BOOLEAN DEFAULT false,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraint: nombre único por usuario
CONSTRAINT uq_watchlists_user_name UNIQUE (user_id, name)
);
-- Índices
CREATE INDEX idx_watchlists_user ON trading.watchlists(user_id);
CREATE INDEX idx_watchlists_user_default ON trading.watchlists(user_id, is_default) WHERE is_default = true;
-- Comentarios
COMMENT ON TABLE trading.watchlists IS 'Listas de seguimiento personalizadas de símbolos';
COMMENT ON COLUMN trading.watchlists.is_default IS 'Indica si es la watchlist por defecto del usuario';
COMMENT ON COLUMN trading.watchlists.is_public IS 'Indica si la watchlist es visible públicamente';

View File

@ -0,0 +1,38 @@
-- ============================================================================
-- Schema: trading
-- Table: watchlist_items
-- Description: Items individuales dentro de watchlists
-- Dependencies: trading.watchlists, trading.symbols
-- ============================================================================
CREATE TABLE trading.watchlist_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Referencias
watchlist_id UUID NOT NULL REFERENCES trading.watchlists(id) ON DELETE CASCADE,
symbol_id UUID NOT NULL REFERENCES trading.symbols(id) ON DELETE CASCADE,
-- Configuración personalizada
notes TEXT,
alert_price_high DECIMAL(20,8),
alert_price_low DECIMAL(20,8),
-- Ordenamiento
display_order INTEGER DEFAULT 0,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraint: un símbolo solo una vez por watchlist
CONSTRAINT uq_watchlist_items_watchlist_symbol UNIQUE (watchlist_id, symbol_id)
);
-- Índices
CREATE INDEX idx_watchlist_items_watchlist ON trading.watchlist_items(watchlist_id);
CREATE INDEX idx_watchlist_items_symbol ON trading.watchlist_items(symbol_id);
-- Comentarios
COMMENT ON TABLE trading.watchlist_items IS 'Símbolos individuales dentro de una watchlist';
COMMENT ON COLUMN trading.watchlist_items.alert_price_high IS 'Precio superior para alertas';
COMMENT ON COLUMN trading.watchlist_items.alert_price_low IS 'Precio inferior para alertas';

View File

@ -0,0 +1,64 @@
-- ============================================================================
-- Schema: trading
-- Table: bots
-- Description: Trading bots configurados por usuarios
-- Dependencies: auth.users, trading.bot_type, trading.bot_status, trading.timeframe
-- ============================================================================
CREATE TABLE trading.bots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Propietario
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Configuración básica
name VARCHAR(100) NOT NULL,
bot_type trading.bot_type NOT NULL DEFAULT 'paper',
status trading.bot_status NOT NULL DEFAULT 'paused',
-- Símbolos a operar
symbols VARCHAR(20)[] NOT NULL,
timeframe trading.timeframe NOT NULL DEFAULT '1h',
-- Capital
initial_capital DECIMAL(20,8) NOT NULL,
current_capital DECIMAL(20,8) NOT NULL,
-- Risk management
max_position_size_pct DECIMAL(5,2) DEFAULT 10.00, -- % del capital
max_daily_loss_pct DECIMAL(5,2) DEFAULT 5.00,
max_drawdown_pct DECIMAL(5,2) DEFAULT 20.00,
-- Estrategia (referencia a ML signal provider)
strategy_type VARCHAR(50), -- 'atlas', 'orion', 'nova', 'custom'
strategy_config JSONB DEFAULT '{}',
-- Estadísticas
total_trades INTEGER DEFAULT 0,
winning_trades INTEGER DEFAULT 0,
total_profit_loss DECIMAL(20,8) DEFAULT 0,
win_rate DECIMAL(5,2) DEFAULT 0,
-- Metadata
started_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
last_trade_at TIMESTAMPTZ,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_bots_user ON trading.bots(user_id);
CREATE INDEX idx_bots_status ON trading.bots(status);
CREATE INDEX idx_bots_type ON trading.bots(bot_type);
CREATE INDEX idx_bots_user_status ON trading.bots(user_id, status);
-- Comentarios
COMMENT ON TABLE trading.bots IS 'Trading bots automatizados configurados por usuarios';
COMMENT ON COLUMN trading.bots.bot_type IS 'Tipo: paper (simulación), live (real), backtest';
COMMENT ON COLUMN trading.bots.strategy_type IS 'Estrategia ML: atlas, orion, nova, custom';
COMMENT ON COLUMN trading.bots.max_position_size_pct IS 'Máximo % del capital por posición';
COMMENT ON COLUMN trading.bots.max_daily_loss_pct IS 'Máxima pérdida diaria permitida (%)';
COMMENT ON COLUMN trading.bots.max_drawdown_pct IS 'Máximo drawdown permitido (%)';

Some files were not shown because too many files have changed in this diff Show More