- Configure workspace Git repository with comprehensive .gitignore - Add Odoo as submodule for ERP reference code - Include documentation: SETUP.md, GIT-STRUCTURE.md - Add gitignore templates for projects (backend, frontend, database) - Structure supports independent repos per project/subproject level Workspace includes: - core/ - Reusable patterns, modules, orchestration system - projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.) - knowledge-base/ - Reference code and patterns (includes Odoo submodule) - devtools/ - Development tools and templates - customers/ - Client implementations template 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
25 KiB
🗄️ DATABASE SCHEMA - SISTEMA DE RECOMPENSAS
Versión: v2.8.0 Fecha: 2025-11-29
📊 Esquema General
┌─────────────────────────────────────────────────────────────────────┐
│ educational_content (schema) │
│ ┌──────────────────────────┐ ┌───────────────────────────────┐ │
│ │ modules │ │ exercises │ │
│ │ - id (PK) │ │ - id (PK) │ │
│ │ - title │ │ - module_id (FK) │ │
│ │ - description │ │ - title │ │
│ │ - xp_reward │ │ - xp_reward │ │
│ │ - ml_coins_reward │ │ - ml_coins_reward │ │
│ └──────────────────────────┘ └───────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ progress_tracking (schema) │
│ ┌──────────────────────────┐ ┌───────────────────────────────┐ │
│ │ exercise_submissions │ │ exercise_attempts │ │
│ │ (WORKFLOW TABLE) │ │ (REWARDS TABLE) │ │
│ │ - id (PK) │ │ - id (PK) │ │
│ │ - user_id (FK) │ │ - user_id (FK) │ │
│ │ - exercise_id (FK) │ │ - exercise_id (FK) │ │
│ │ - status VARCHAR(20) │ │ - submission_id (FK) │ │
│ │ • draft │ │ - score INTEGER │ │
│ │ • submitted │ │ - is_correct BOOLEAN │ │
│ │ • graded │ │ - xp_earned INTEGER │ │
│ │ - answers JSONB │ │ - ml_coins_earned INTEGER │ │
│ │ - submitted_at │ │ - hints_used INTEGER │ │
│ │ - graded_at │ │ - powerups_used JSONB │ │
│ │ - feedback TEXT │ │ - attempt_number INTEGER │ │
│ │ - created_at │ │ - time_spent INTEGER │ │
│ │ - updated_at │ │ - created_at │ │
│ └──────────────────────────┘ └───────────┬───────────────────┘ │
│ │ │
│ │ TRIGGER │
│ │ │
└─────────────────────────────────────────────┼───────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ gamilit (schema) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ update_user_stats_on_exercise_complete() - TRIGGER FUNCTION │ │
│ │ │ │
│ │ DECLARE │ │
│ │ v_is_correct BOOLEAN; │ │
│ │ v_xp_earned INTEGER; │ │
│ │ v_coins_earned INTEGER; │ │
│ │ BEGIN │ │
│ │ v_is_correct := (NEW.is_correct OR NEW.score >= 60); │ │
│ │ v_xp_earned := COALESCE(NEW.xp_earned, 10); │ │
│ │ v_coins_earned := COALESCE(NEW.ml_coins_earned, 5); │ │
│ │ │ │
│ │ UPDATE gamification_system.user_stats │ │
│ │ SET exercises_completed = exercises_completed + 1, │ │
│ │ total_xp = total_xp + v_xp_earned, │ │
│ │ ml_coins = ml_coins + v_coins_earned, │ │
│ │ ml_coins_earned_total = ml_coins_earned_total + ... │ │
│ │ WHERE user_id = NEW.user_id; │ │
│ │ │ │
│ │ IF NOT FOUND THEN │ │
│ │ INSERT INTO user_stats (...) VALUES (...); │ │
│ │ END IF; │ │
│ │ END; │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
│ UPDATE
▼
┌─────────────────────────────────────────────────────────────────────┐
│ gamification_system (schema) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ user_stats │ │
│ │ - id (PK) │ │
│ │ - user_id (FK) UNIQUE │ │
│ │ - tenant_id (FK) │ │
│ │ - level INTEGER │ │
│ │ - total_xp INTEGER │ │
│ │ - xp_to_next_level INTEGER │ │
│ │ - current_rank VARCHAR(50) │ │
│ │ - rank_progress DECIMAL │ │
│ │ - ml_coins INTEGER ⬅️ SALDO ACTUAL │ │
│ │ - ml_coins_earned_total INTEGER ⬅️ TOTAL GANADO (HISTORY) │ │
│ │ - ml_coins_spent_total INTEGER ⬅️ TOTAL GASTADO │ │
│ │ - exercises_completed INTEGER │ │
│ │ - modules_completed INTEGER │ │
│ │ - perfect_scores INTEGER │ │
│ │ - achievements_earned INTEGER │ │
│ │ - current_streak INTEGER │ │
│ │ - max_streak INTEGER │ │
│ │ - last_activity_at TIMESTAMP │ │
│ │ - created_at TIMESTAMP │ │
│ │ - updated_at TIMESTAMP │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
📋 Tablas Detalladas
1. progress_tracking.exercise_attempts
Propósito: Almacena cada intento de ejercicio con las recompensas calculadas
CREATE TABLE progress_tracking.exercise_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id),
tenant_id UUID REFERENCES auth.tenants(id),
exercise_id UUID NOT NULL REFERENCES educational_content.exercises(id),
submission_id UUID REFERENCES progress_tracking.exercise_submissions(id),
-- SCORING
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
is_correct BOOLEAN DEFAULT false,
attempt_number INTEGER DEFAULT 1,
-- REWARDS (precalculados por ExerciseAttemptService)
xp_earned INTEGER NOT NULL DEFAULT 0,
ml_coins_earned INTEGER NOT NULL DEFAULT 0,
-- GAMEPLAY
hints_used INTEGER DEFAULT 0,
powerups_used JSONB DEFAULT '[]'::JSONB,
time_spent INTEGER, -- segundos
-- METADATA
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- CONSTRAINTS
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES auth.users(id),
CONSTRAINT fk_exercise FOREIGN KEY (exercise_id) REFERENCES educational_content.exercises(id),
CONSTRAINT fk_submission FOREIGN KEY (submission_id) REFERENCES progress_tracking.exercise_submissions(id)
);
-- ÍNDICES
CREATE INDEX idx_exercise_attempts_user ON exercise_attempts(user_id);
CREATE INDEX idx_exercise_attempts_exercise ON exercise_attempts(exercise_id);
CREATE INDEX idx_exercise_attempts_submission ON exercise_attempts(submission_id);
CREATE INDEX idx_exercise_attempts_created ON exercise_attempts(created_at DESC);
Trigger Asociado:
CREATE TRIGGER trg_update_user_stats_on_exercise
AFTER INSERT ON progress_tracking.exercise_attempts
FOR EACH ROW
EXECUTE FUNCTION gamilit.update_user_stats_on_exercise_complete();
Columnas Clave:
xp_earned: XP calculado después de penalties (0-200+)ml_coins_earned: ML Coins calculados después de penalties (0-100+)is_correct: true si score >= 60 o resultado correctohints_used: Número de pistas usadas (afecta rewards)powerups_used: Array de IDs de powerups usados
2. progress_tracking.exercise_submissions
Propósito: Gestión del workflow de evaluación manual
CREATE TABLE progress_tracking.exercise_submissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id),
tenant_id UUID REFERENCES auth.tenants(id),
exercise_id UUID NOT NULL REFERENCES educational_content.exercises(id),
-- WORKFLOW
status VARCHAR(20) NOT NULL DEFAULT 'draft',
-- 'draft' | 'submitted' | 'graded' | 'reviewed'
-- ANSWERS
answers JSONB,
answer_data JSONB,
-- SCORING (calculado al calificar)
is_correct BOOLEAN DEFAULT false,
xp_earned INTEGER DEFAULT 0,
ml_coins_earned INTEGER DEFAULT 0,
-- TIMING
started_at TIMESTAMP WITH TIME ZONE,
submitted_at TIMESTAMP WITH TIME ZONE,
graded_at TIMESTAMP WITH TIME ZONE,
-- EVALUATION
feedback TEXT,
grader_id UUID REFERENCES auth.users(id),
-- METADATA
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- CONSTRAINTS
CHECK (status IN ('draft', 'submitted', 'graded', 'reviewed'))
);
-- ÍNDICES
CREATE INDEX idx_exercise_submissions_user ON exercise_submissions(user_id);
CREATE INDEX idx_exercise_submissions_exercise ON exercise_submissions(exercise_id);
CREATE INDEX idx_exercise_submissions_status ON exercise_submissions(status);
CREATE INDEX idx_exercise_submissions_graded ON exercise_submissions(graded_at DESC);
Estados del Workflow:
draft ──────▶ submitted ──────▶ graded ──────▶ reviewed
(Save) (Submit) (Teacher) (Final)
Trigger Asociado (v2.8.0):
CREATE TRIGGER trg_update_user_stats_on_submission
AFTER UPDATE ON progress_tracking.exercise_submissions
FOR EACH ROW
WHEN (
NEW.status IN ('graded', 'reviewed')
AND NEW.is_correct = true
AND (OLD.status IS DISTINCT FROM NEW.status
OR OLD.is_correct IS DISTINCT FROM NEW.is_correct)
)
EXECUTE FUNCTION gamilit.update_user_stats_on_submission_graded();
Columnas Clave para Recompensas:
is_correct: true si el maestro califica como correctoxp_earned: XP otorgado (calculado por servicio)ml_coins_earned: ML Coins otorgados (calculado por servicio)
3. gamification_system.user_stats
Propósito: Estadísticas acumuladas del usuario
CREATE TABLE gamification_system.user_stats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
-- LEVEL & XP
level INTEGER DEFAULT 1,
total_xp INTEGER DEFAULT 0,
xp_to_next_level INTEGER DEFAULT 100,
-- RANK
current_rank VARCHAR(50) DEFAULT 'Ajaw',
rank_progress DECIMAL(5,2) DEFAULT 0.00,
-- ML COINS
ml_coins INTEGER DEFAULT 100, -- Saldo actual
ml_coins_earned_total INTEGER DEFAULT 0, -- Total histórico ganado
ml_coins_spent_total INTEGER DEFAULT 0, -- Total histórico gastado
ml_coins_earned_today INTEGER DEFAULT 0,
last_ml_coins_reset TIMESTAMP WITH TIME ZONE,
-- STREAKS
current_streak INTEGER DEFAULT 0,
max_streak INTEGER DEFAULT 0,
streak_started_at TIMESTAMP WITH TIME ZONE,
days_active_total INTEGER DEFAULT 0,
-- EXERCISES
exercises_completed INTEGER DEFAULT 0,
modules_completed INTEGER DEFAULT 0,
perfect_scores INTEGER DEFAULT 0,
total_score INTEGER DEFAULT 0,
average_score DECIMAL(5,2),
-- ACHIEVEMENTS
achievements_earned INTEGER DEFAULT 0,
certificates_earned INTEGER DEFAULT 0,
-- TIME TRACKING
total_time_spent JSONB DEFAULT '{}'::JSONB,
weekly_time_spent JSONB DEFAULT '{}'::JSONB,
sessions_count INTEGER DEFAULT 0,
-- LEADERBOARD
weekly_xp INTEGER DEFAULT 0,
monthly_xp INTEGER DEFAULT 0,
weekly_exercises INTEGER DEFAULT 0,
global_rank_position INTEGER,
class_rank_position INTEGER,
school_rank_position INTEGER,
-- TIMESTAMPS
last_activity_at TIMESTAMP WITH TIME ZONE,
last_login_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- METADATA
metadata JSONB DEFAULT '{}'::JSONB,
-- CONSTRAINTS
CONSTRAINT uq_user_stats_user UNIQUE (user_id),
CHECK (ml_coins >= 0),
CHECK (level >= 1)
);
-- ÍNDICES
CREATE INDEX idx_user_stats_user ON user_stats(user_id);
CREATE INDEX idx_user_stats_tenant ON user_stats(tenant_id);
CREATE INDEX idx_user_stats_level ON user_stats(level DESC);
CREATE INDEX idx_user_stats_xp ON user_stats(total_xp DESC);
CREATE INDEX idx_user_stats_coins ON user_stats(ml_coins DESC);
CREATE INDEX idx_user_stats_global_rank ON user_stats(global_rank_position);
⚙️ Función Trigger
gamilit.update_user_stats_on_exercise_complete()
Archivo: apps/database/ddl/schemas/gamilit/functions/14-update_user_stats_on_exercise_complete.sql
Código Completo:
CREATE OR REPLACE FUNCTION gamilit.update_user_stats_on_exercise_complete()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_is_correct BOOLEAN;
v_xp_earned INTEGER;
v_coins_earned INTEGER;
BEGIN
-- Determinar si el ejercicio fue completado correctamente
v_is_correct := (NEW.is_correct = true OR NEW.score >= 60);
-- Calcular XP y monedas ganadas
IF v_is_correct THEN
v_xp_earned := COALESCE(NEW.xp_earned, 10);
v_coins_earned := COALESCE(NEW.ml_coins_earned, 5);
ELSE
v_xp_earned := 0;
v_coins_earned := 0;
END IF;
-- Actualizar estadísticas del usuario
UPDATE gamification_system.user_stats
SET
exercises_completed = exercises_completed + 1,
total_xp = total_xp + v_xp_earned,
ml_coins = ml_coins + v_coins_earned,
ml_coins_earned_total = ml_coins_earned_total + v_coins_earned,
last_activity_at = gamilit.now_mexico(),
updated_at = gamilit.now_mexico()
WHERE user_id = NEW.user_id;
-- Si no existe el registro de estadísticas, crearlo
IF NOT FOUND THEN
INSERT INTO gamification_system.user_stats (
user_id,
tenant_id,
exercises_completed,
total_xp,
ml_coins,
ml_coins_earned_total,
last_activity_at
) VALUES (
NEW.user_id,
'00000000-0000-0000-0000-000000000000'::UUID,
1,
v_xp_earned,
100 + v_coins_earned, -- Balance inicial de 100
v_coins_earned,
gamilit.now_mexico()
);
END IF;
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
RAISE WARNING 'Error al actualizar estadísticas de usuario %: %', NEW.user_id, SQLERRM;
RETURN NEW;
END;
$$;
Puntos Clave:
- ✅ Usa
NEW.ml_coins_earned(NOcoins_earned) - ✅ Actualiza
ml_coins(NOml_coins_balance) - ✅ Trackea total ganado en
ml_coins_earned_total - ✅ Balance inicial de 100 ML Coins al crear user
- ✅ UPSERT pattern (UPDATE + INSERT si no existe)
- ✅ Exception handler para no bloquear inserts
gamilit.update_user_stats_on_submission_graded() (v2.8.0)
Archivo: apps/database/ddl/schemas/gamilit/functions/27-update_user_stats_on_submission_graded.sql
Propósito: Actualiza estadísticas de usuario cuando una submission es calificada (complementa función 14 para el flujo de submissions)
Código Completo:
CREATE OR REPLACE FUNCTION gamilit.update_user_stats_on_submission_graded()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
-- Validar que solo procesemos calificaciones nuevas
IF NEW.status NOT IN ('graded', 'reviewed') THEN
RETURN NEW;
END IF;
IF NEW.is_correct IS NOT TRUE THEN
RETURN NEW;
END IF;
IF NEW.xp_earned <= 0 THEN
RETURN NEW;
END IF;
-- Evitar re-disparos cuando el status o is_correct no cambiaron
IF OLD.status IS NOT DISTINCT FROM NEW.status
AND OLD.is_correct IS NOT DISTINCT FROM NEW.is_correct THEN
RETURN NEW;
END IF;
-- Actualizar estadísticas del usuario (patrón UPSERT)
UPDATE gamification_system.user_stats
SET
exercises_completed = exercises_completed + 1,
total_xp = total_xp + NEW.xp_earned,
ml_coins = ml_coins + NEW.ml_coins_earned,
ml_coins_earned_total = ml_coins_earned_total + NEW.ml_coins_earned,
last_activity_at = gamilit.now_mexico(),
updated_at = gamilit.now_mexico()
WHERE user_id = NEW.user_id;
-- Si no existe el registro de estadísticas, crearlo
IF NOT FOUND THEN
INSERT INTO gamification_system.user_stats (
user_id, tenant_id, exercises_completed,
total_xp, ml_coins, ml_coins_earned_total, last_activity_at
) VALUES (
NEW.user_id,
'00000000-0000-0000-0000-000000000000'::UUID,
1, NEW.xp_earned, 100 + NEW.ml_coins_earned,
NEW.ml_coins_earned, gamilit.now_mexico()
);
END IF;
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
RAISE WARNING 'Error al actualizar estadísticas de usuario % desde submission %: %',
NEW.user_id, NEW.id, SQLERRM;
RETURN NEW;
END;
$$;
Diferencias con Función 14 (exercise_attempts):
| Aspecto | Función 14 (attempts) | Función 27 (submissions) |
|---|---|---|
| Evento | AFTER INSERT | AFTER UPDATE |
| Tabla | exercise_attempts | exercise_submissions |
| Validación | is_correct OR score>=60 | status IN ('graded','reviewed') AND is_correct |
| Anti-refire | No necesario (INSERT) | Valida cambio en status/is_correct |
Cadena de Triggers:
┌─────────────────────────────────────────────────────────────────┐
│ Teacher califica submission (UPDATE exercise_submissions) │
│ status = 'graded', is_correct = true, xp_earned = 200 │
└────────────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ trg_update_user_stats_on_submission (AFTER UPDATE) │
│ WHEN: status IN ('graded','reviewed') AND is_correct = true │
└────────────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ update_user_stats_on_submission_graded() │
│ UPDATE user_stats SET total_xp = total_xp + 200 │
└────────────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ trg_update_missions_on_earn_xp (AFTER UPDATE on user_stats) │
│ WHEN: NEW.total_xp != OLD.total_xp │
│ → Actualiza misiones earn_xp automáticamente │
└─────────────────────────────────────────────────────────────────┘
🔍 Queries de Verificación
Ver Attempts de un Usuario
SELECT
id,
exercise_id,
score,
is_correct,
xp_earned,
ml_coins_earned,
hints_used,
created_at
FROM progress_tracking.exercise_attempts
WHERE user_id = 'cccccccc-cccc-cccc-cccc-cccccccccccc'
ORDER BY created_at DESC
LIMIT 10;
Ver Stats Actualizados
SELECT
user_id,
level,
total_xp,
ml_coins,
ml_coins_earned_total,
exercises_completed,
last_activity_at
FROM gamification_system.user_stats
WHERE user_id = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
Ver Submissions de un Usuario
SELECT
id,
exercise_id,
status,
submitted_at,
graded_at
FROM progress_tracking.exercise_submissions
WHERE user_id = 'cccccccc-cccc-cccc-cccc-cccccccccccc'
ORDER BY created_at DESC;
📊 Relaciones Entre Tablas
auth.users (1)
│
├──▶ exercise_submissions (N)
│ │
│ └──▶ exercise_attempts (N)
│ │
│ └──▶ TRIGGER ──▶ user_stats (1)
│
└──▶ user_stats (1)
Cardinalidades:
- 1 User → N Submissions
- 1 Submission → N Attempts (reintent os permitidos)
- 1 User → 1 User Stats (UNIQUE)
- 1 Attempt → 1 Trigger Execution → 1 Stats Update
Última actualización: 2025-11-29 Autor: Sistema Gamilit Versión: 2.8.0