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