# πŸ—„οΈ 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 ```sql 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:** ```sql 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 correcto - `hints_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 ```sql 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):** ```sql 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 correcto - `xp_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 ```sql 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:** ```sql 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:** 1. βœ… Usa `NEW.ml_coins_earned` (NO `coins_earned`) 2. βœ… Actualiza `ml_coins` (NO `ml_coins_balance`) 3. βœ… Trackea total ganado en `ml_coins_earned_total` 4. βœ… Balance inicial de 100 ML Coins al crear user 5. βœ… UPSERT pattern (UPDATE + INSERT si no existe) 6. βœ… 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:** ```sql 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 ```sql 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 ```sql 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 ```sql 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