workspace/projects/gamilit/docs/01-fase-alcance-inicial/EAI-003-gamificacion/especificaciones/ET-GAM-002-comodines.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- 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>
2025-12-08 10:44:23 -06:00

37 KiB

ET-GAM-002: Implementación del Sistema de Comodines

📋 Metadata

Campo Valor
ID ET-GAM-002
Módulo 02 - Gamificación
Título Implementación del Sistema de Comodines (Power-ups)
Prioridad Alta
Estado Implementado
Versión 2.3.0
Fecha Creación 2025-11-07
Última Actualización 2025-11-28
Sistema Actual docs/sistema-recompensas/ v2.3.0
Autor Database Team
Reviewers Backend Lead, Frontend Lead, QA Lead

🔗 Referencias

Requerimiento Funcional

📘 Documento RF:

Implementación DDL

🗄️ ENUM:

  • gamification_system.comodin_type - apps/database/ddl/00-prerequisites.sql:55-58

🗄️ Tablas:

  • gamification_system.comodines_inventory - Inventario por usuario
  • gamification_system.comodin_usage_log - Log de usos
  • gamification_system.comodin_usage_tracking - Tracking de límites por ejercicio

🗄️ Funciones:

  • purchase_comodin() - Comprar comodín con ML Coins
  • use_comodin() - Usar comodín en ejercicio
  • get_comodin_inventory() - Obtener inventario completo

🏗️ Arquitectura

Diagrama de Capas

┌────────────────────────────────────────────────────┐
│                 FRONTEND (React)                   │
│  - ComodinShop (tienda)                            │
│  - ComodinButton (botones en ejercicio)           │
│  - ComodinInventory (inventario del usuario)      │
│  - HintDisplay (mostrar pistas)                   │
└─────────────────┬──────────────────────────────────┘
                  │ REST API
┌─────────────────▼──────────────────────────────────┐
│              BACKEND (NestJS)                      │
│  - ComodinService                                  │
│    · purchaseComodin()                             │
│    · useComodin()                                  │
│    · getInventory()                                │
│  - ComodinController                               │
│  - DTOs: PurchaseComodinDto, UseComodinDto        │
└─────────────────┬──────────────────────────────────┘
                  │ SQL Queries + Transactions
┌─────────────────▼──────────────────────────────────┐
│            DATABASE (PostgreSQL)                   │
│  - comodines_inventory (inventario)                │
│  - comodin_usage_log (historial)                   │
│  - comodin_usage_tracking (límites por ejercicio)  │
│  - purchase_comodin() (transacción atómica)        │
│  - use_comodin() (validaciones + logging)          │
└────────────────────────────────────────────────────┘

Flujo de Compra

Usuario en ComodinShop
        ↓
Selecciona tipo (pistas, visión, segunda)
        ↓
Click "Comprar"
        ↓
Frontend → POST /comodines/purchase
        ↓
    ┌────────────────────────────────────┐
    │ ComodinService.purchaseComodin()  │
    │ - Validar balance ML Coins         │
    │ - Llamar purchase_comodin()        │
    └────────────┬───────────────────────┘
                 ↓
    ┌────────────────────────────────────┐
    │ purchase_comodin() SQL             │
    │ BEGIN TRANSACTION                  │
    │   - Verificar balance              │
    │   - Deducir ML Coins               │
    │   - Incrementar inventario         │
    │   - Registrar en audit_log         │
    │ COMMIT                             │
    └────────────┬───────────────────────┘
                 ↓
Frontend muestra confirmación
+ Actualiza inventario UI

Flujo de Uso en Ejercicio

Usuario intenta ejercicio
        ↓
Click "Usar Pista"
        ↓
Frontend → POST /comodines/use
        ↓
    ┌────────────────────────────────────┐
    │ ComodinService.useComodin()        │
    │ - Validar inventario               │
    │ - Validar límites por ejercicio    │
    │ - Llamar use_comodin()             │
    │ - Generar contenido (ej: pista #1) │
    └────────────┬───────────────────────┘
                 ↓
    ┌────────────────────────────────────┐
    │ use_comodin() SQL                  │
    │ BEGIN TRANSACTION                  │
    │   - Verificar disponibilidad       │
    │   - Decrementar inventario         │
    │   - Registrar uso en log           │
    │   - Actualizar tracking            │
    │ COMMIT                             │
    └────────────┬───────────────────────┘
                 ↓
Frontend muestra efecto del comodín
(pista, highlight, reset attempt)

💾 Implementación de Base de Datos

1. ENUM: comodin_type

Ubicación: apps/database/ddl/00-prerequisites.sql:55-58

-- Comodin Types (Power-ups)
CREATE TYPE gamification_system.comodin_type AS ENUM (
    'pistas',               -- Hints (hasta 3 por ejercicio)
    'vision_lectora',       -- Reading Vision (1 por ejercicio)
    'segunda_oportunidad'   -- Second Chance (1 por ejercicio)
);

COMMENT ON TYPE gamification_system.comodin_type IS '
Tipos de comodines (power-ups):
- pistas: Revelan pistas progresivas (máx 3 por ejercicio)
- vision_lectora: Resaltan oraciones clave en textos largos (1 por ejercicio)
- segunda_oportunidad: Reintentar sin penalización (1 por ejercicio)
';

2. Tabla: comodines_inventory

Ubicación: apps/database/ddl/schemas/gamification_system/tables/comodines_inventory.sql

CREATE TABLE IF NOT EXISTS gamification_system.comodines_inventory (
    -- No hay PK individual, la combinación user_id + comodin_type es única
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    comodin_type gamification_system.comodin_type NOT NULL,

    -- Cantidades
    quantity INTEGER NOT NULL DEFAULT 0 CHECK (quantity >= 0),
    total_purchased INTEGER NOT NULL DEFAULT 0 CHECK (total_purchased >= 0),
    total_used INTEGER NOT NULL DEFAULT 0 CHECK (total_used >= 0),

    -- Timestamps
    last_purchased_at TIMESTAMPTZ,
    last_used_at TIMESTAMPTZ,

    -- Metadata
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),

    -- Constraint
    PRIMARY KEY (user_id, comodin_type)
);

-- Índices
CREATE INDEX idx_comodines_inventory_user ON gamification_system.comodines_inventory(user_id);

-- Trigger para actualizar updated_at
CREATE TRIGGER trg_comodines_inventory_updated_at
    BEFORE UPDATE ON gamification_system.comodines_inventory
    FOR EACH ROW
    EXECUTE FUNCTION public.update_updated_at_column();

-- Comentarios
COMMENT ON TABLE gamification_system.comodines_inventory IS 'Inventario de comodines disponibles por usuario';
COMMENT ON COLUMN gamification_system.comodines_inventory.quantity IS 'Cantidad disponible actual';
COMMENT ON COLUMN gamification_system.comodines_inventory.total_purchased IS 'Total comprado históricamente';
COMMENT ON COLUMN gamification_system.comodines_inventory.total_used IS 'Total usado históricamente';

-- Constraint adicional: máximo 99 unidades por tipo
ALTER TABLE gamification_system.comodines_inventory
    ADD CONSTRAINT chk_max_inventory CHECK (quantity <= 99);

3. Tabla: comodin_usage_log

Ubicación: apps/database/ddl/schemas/gamification_system/tables/comodin_usage_log.sql

CREATE TABLE IF NOT EXISTS gamification_system.comodin_usage_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Relaciones
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    exercise_id UUID NOT NULL REFERENCES educational_content.exercises(id) ON DELETE CASCADE,
    attempt_id UUID REFERENCES progress_tracking.exercise_attempts(id) ON DELETE SET NULL,

    -- Tipo de comodín usado
    comodin_type gamification_system.comodin_type NOT NULL,

    -- Contexto
    hint_number INTEGER, -- Si es pista, cuál número (1, 2, 3)
    was_successful BOOLEAN, -- Si el intento fue exitoso después de usar comodín
    exercise_difficulty VARCHAR(20), -- Dificultad del ejercicio (para análisis)

    -- Timestamp
    used_at TIMESTAMPTZ DEFAULT NOW()
);

-- Índices
CREATE INDEX idx_comodin_usage_log_user ON gamification_system.comodin_usage_log(user_id);
CREATE INDEX idx_comodin_usage_log_exercise ON gamification_system.comodin_usage_log(exercise_id);
CREATE INDEX idx_comodin_usage_log_used_at ON gamification_system.comodin_usage_log(used_at DESC);
CREATE INDEX idx_comodin_usage_log_type ON gamification_system.comodin_usage_log(comodin_type);

-- Comentarios
COMMENT ON TABLE gamification_system.comodin_usage_log IS 'Log inmutable de cada uso de comodín';
COMMENT ON COLUMN gamification_system.comodin_usage_log.hint_number IS 'Para pistas: 1, 2 o 3';
COMMENT ON COLUMN gamification_system.comodin_usage_log.was_successful IS 'Si el ejercicio se completó exitosamente después de usar comodín';

4. Tabla: comodin_usage_tracking

Ubicación: apps/database/ddl/schemas/gamification_system/tables/comodin_usage_tracking.sql

CREATE TABLE IF NOT EXISTS gamification_system.comodin_usage_tracking (
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    exercise_id UUID NOT NULL REFERENCES educational_content.exercises(id) ON DELETE CASCADE,
    attempt_id UUID NOT NULL REFERENCES progress_tracking.exercise_attempts(id) ON DELETE CASCADE,

    -- Contadores por tipo
    pistas_used INTEGER NOT NULL DEFAULT 0 CHECK (pistas_used >= 0 AND pistas_used <= 3),
    vision_lectora_used BOOLEAN NOT NULL DEFAULT false,
    segunda_oportunidad_used BOOLEAN NOT NULL DEFAULT false,

    -- Timestamps
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),

    -- PK compuesta
    PRIMARY KEY (user_id, exercise_id, attempt_id)
);

-- Índices
CREATE INDEX idx_comodin_tracking_user_exercise ON gamification_system.comodin_usage_tracking(user_id, exercise_id);

-- Comentarios
COMMENT ON TABLE gamification_system.comodin_usage_tracking IS 'Tracking de límites de comodines por ejercicio y attempt';
COMMENT ON COLUMN gamification_system.comodin_usage_tracking.pistas_used IS 'Contador de pistas usadas (máx 3)';

5. Función: purchase_comodin

Ubicación: apps/database/ddl/schemas/gamification_system/functions/purchase_comodin.sql

CREATE OR REPLACE FUNCTION gamification_system.purchase_comodin(
    p_user_id UUID,
    p_comodin_type gamification_system.comodin_type,
    p_quantity INTEGER DEFAULT 1
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
    v_cost_per_unit INTEGER;
    v_total_cost INTEGER;
    v_current_balance INTEGER;
    v_current_quantity INTEGER;
    v_result JSONB;
BEGIN
    -- 1. Determinar costo por unidad
    CASE p_comodin_type
        WHEN 'pistas' THEN v_cost_per_unit := 15;
        WHEN 'vision_lectora' THEN v_cost_per_unit := 25;
        WHEN 'segunda_oportunidad' THEN v_cost_per_unit := 40;
        ELSE RAISE EXCEPTION 'Invalid comodin type: %', p_comodin_type;
    END CASE;

    v_total_cost := v_cost_per_unit * p_quantity;

    -- 2. Obtener balance actual
    SELECT ml_coins INTO v_current_balance
    FROM gamification_system.user_stats
    WHERE user_id = p_user_id;

    IF v_current_balance IS NULL THEN
        RAISE EXCEPTION 'User stats not found for user %', p_user_id;
    END IF;

    -- 3. Validar balance suficiente
    IF v_current_balance < v_total_cost THEN
        RAISE EXCEPTION 'Insufficient ML Coins. Required: %, Available: %', v_total_cost, v_current_balance;
    END IF;

    -- 4. Obtener cantidad actual en inventario
    SELECT quantity INTO v_current_quantity
    FROM gamification_system.comodines_inventory
    WHERE user_id = p_user_id AND comodin_type = p_comodin_type;

    -- Si no existe registro, crear uno
    IF v_current_quantity IS NULL THEN
        INSERT INTO gamification_system.comodines_inventory (
            user_id,
            comodin_type,
            quantity,
            total_purchased,
            last_purchased_at
        ) VALUES (
            p_user_id,
            p_comodin_type,
            p_quantity,
            p_quantity,
            NOW()
        );
        v_current_quantity := 0;
    ELSE
        -- 5. Validar que no exceda límite de inventario (99)
        IF v_current_quantity + p_quantity > 99 THEN
            RAISE EXCEPTION 'Max inventory limit reached. Current: %, Trying to add: %', v_current_quantity, p_quantity;
        END IF;

        -- 6. Actualizar inventario
        UPDATE gamification_system.comodines_inventory
        SET
            quantity = quantity + p_quantity,
            total_purchased = total_purchased + p_quantity,
            last_purchased_at = NOW(),
            updated_at = NOW()
        WHERE user_id = p_user_id AND comodin_type = p_comodin_type;
    END IF;

    -- 7. Deducir ML Coins
    UPDATE gamification_system.user_stats
    SET
        ml_coins = ml_coins - v_total_cost,
        updated_at = NOW()
    WHERE user_id = p_user_id;

    -- 8. Registrar en audit log
    INSERT INTO audit_logging.audit_logs (
        user_id,
        action,
        resource_type,
        resource_id,
        details,
        severity
    ) VALUES (
        p_user_id,
        'comodin_purchased',
        'comodin',
        p_comodin_type::TEXT,
        jsonb_build_object(
            'quantity', p_quantity,
            'cost_per_unit', v_cost_per_unit,
            'total_cost', v_total_cost,
            'balance_before', v_current_balance,
            'balance_after', v_current_balance - v_total_cost
        ),
        'info'
    );

    -- 9. Construir resultado
    v_result := jsonb_build_object(
        'success', true,
        'comodin_type', p_comodin_type,
        'quantity_purchased', p_quantity,
        'total_cost', v_total_cost,
        'new_balance', v_current_balance - v_total_cost,
        'new_quantity', v_current_quantity + p_quantity
    );

    RAISE NOTICE 'User % purchased % x %', p_user_id, p_quantity, p_comodin_type;

    RETURN v_result;
END;
$$;

COMMENT ON FUNCTION gamification_system.purchase_comodin IS 'Comprar comodín con ML Coins (transacción atómica)';

6. Función: use_comodin

Ubicación: apps/database/ddl/schemas/gamification_system/functions/use_comodin.sql

CREATE OR REPLACE FUNCTION gamification_system.use_comodin(
    p_user_id UUID,
    p_exercise_id UUID,
    p_attempt_id UUID,
    p_comodin_type gamification_system.comodin_type
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
    v_current_quantity INTEGER;
    v_pistas_used INTEGER := 0;
    v_vision_used BOOLEAN := false;
    v_segunda_used BOOLEAN := false;
    v_hint_number INTEGER := NULL;
    v_exercise_difficulty VARCHAR(20);
    v_is_exam BOOLEAN;
    v_result JSONB;
BEGIN
    -- 1. Validar que ejercicio no sea examen
    SELECT difficulty, is_exam INTO v_exercise_difficulty, v_is_exam
    FROM educational_content.exercises
    WHERE id = p_exercise_id;

    IF v_is_exam THEN
        RAISE EXCEPTION 'Power-ups are not allowed in exam mode';
    END IF;

    -- 2. Verificar inventario
    SELECT quantity INTO v_current_quantity
    FROM gamification_system.comodines_inventory
    WHERE user_id = p_user_id AND comodin_type = p_comodin_type;

    IF v_current_quantity IS NULL OR v_current_quantity <= 0 THEN
        RAISE EXCEPTION 'No % available in inventory', p_comodin_type;
    END IF;

    -- 3. Obtener tracking actual para este ejercicio + attempt
    SELECT pistas_used, vision_lectora_used, segunda_oportunidad_used
    INTO v_pistas_used, v_vision_used, v_segunda_used
    FROM gamification_system.comodin_usage_tracking
    WHERE user_id = p_user_id
        AND exercise_id = p_exercise_id
        AND attempt_id = p_attempt_id;

    -- Si no existe, crear registro
    IF NOT FOUND THEN
        INSERT INTO gamification_system.comodin_usage_tracking (
            user_id,
            exercise_id,
            attempt_id,
            pistas_used,
            vision_lectora_used,
            segunda_oportunidad_used
        ) VALUES (
            p_user_id,
            p_exercise_id,
            p_attempt_id,
            0,
            false,
            false
        );
        v_pistas_used := 0;
        v_vision_used := false;
        v_segunda_used := false;
    END IF;

    -- 4. Validar límites por tipo
    IF p_comodin_type = 'pistas' THEN
        IF v_pistas_used >= 3 THEN
            RAISE EXCEPTION 'Max hints reached for this exercise (3)';
        END IF;
        v_hint_number := v_pistas_used + 1;

    ELSIF p_comodin_type = 'vision_lectora' THEN
        IF v_vision_used THEN
            RAISE EXCEPTION 'Reading Vision already used in this exercise';
        END IF;

    ELSIF p_comodin_type = 'segunda_oportunidad' THEN
        IF v_segunda_used THEN
            RAISE EXCEPTION 'Second Chance already used in this exercise';
        END IF;
    END IF;

    -- 5. Decrementar inventario
    UPDATE gamification_system.comodines_inventory
    SET
        quantity = quantity - 1,
        total_used = total_used + 1,
        last_used_at = NOW(),
        updated_at = NOW()
    WHERE user_id = p_user_id AND comodin_type = p_comodin_type;

    -- 6. Actualizar tracking
    IF p_comodin_type = 'pistas' THEN
        UPDATE gamification_system.comodin_usage_tracking
        SET pistas_used = pistas_used + 1, updated_at = NOW()
        WHERE user_id = p_user_id AND exercise_id = p_exercise_id AND attempt_id = p_attempt_id;

    ELSIF p_comodin_type = 'vision_lectora' THEN
        UPDATE gamification_system.comodin_usage_tracking
        SET vision_lectora_used = true, updated_at = NOW()
        WHERE user_id = p_user_id AND exercise_id = p_exercise_id AND attempt_id = p_attempt_id;

    ELSIF p_comodin_type = 'segunda_oportunidad' THEN
        UPDATE gamification_system.comodin_usage_tracking
        SET segunda_oportunidad_used = true, updated_at = NOW()
        WHERE user_id = p_user_id AND exercise_id = p_exercise_id AND attempt_id = p_attempt_id;
    END IF;

    -- 7. Registrar en log
    INSERT INTO gamification_system.comodin_usage_log (
        user_id,
        exercise_id,
        attempt_id,
        comodin_type,
        hint_number,
        exercise_difficulty,
        used_at
    ) VALUES (
        p_user_id,
        p_exercise_id,
        p_attempt_id,
        p_comodin_type,
        v_hint_number,
        v_exercise_difficulty,
        NOW()
    );

    -- 8. Construir resultado
    v_result := jsonb_build_object(
        'success', true,
        'comodin_type', p_comodin_type,
        'hint_number', v_hint_number,
        'remaining_quantity', v_current_quantity - 1
    );

    RAISE NOTICE 'User % used % in exercise %', p_user_id, p_comodin_type, p_exercise_id;

    RETURN v_result;
END;
$$;

COMMENT ON FUNCTION gamification_system.use_comodin IS 'Usar comodín en ejercicio (validaciones + logging)';

7. Función: get_comodin_inventory

Ubicación: apps/database/ddl/schemas/gamification_system/functions/get_comodin_inventory.sql

CREATE OR REPLACE FUNCTION gamification_system.get_comodin_inventory(
    p_user_id UUID
)
RETURNS TABLE (
    comodin_type gamification_system.comodin_type,
    quantity INTEGER,
    total_purchased INTEGER,
    total_used INTEGER,
    last_purchased_at TIMESTAMPTZ,
    last_used_at TIMESTAMPTZ
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
    RETURN QUERY
    SELECT
        ci.comodin_type,
        ci.quantity,
        ci.total_purchased,
        ci.total_used,
        ci.last_purchased_at,
        ci.last_used_at
    FROM gamification_system.comodines_inventory ci
    WHERE ci.user_id = p_user_id
    ORDER BY
        CASE ci.comodin_type
            WHEN 'pistas' THEN 1
            WHEN 'vision_lectora' THEN 2
            WHEN 'segunda_oportunidad' THEN 3
        END;
END;
$$;

COMMENT ON FUNCTION gamification_system.get_comodin_inventory IS 'Obtener inventario completo de comodines del usuario';

🔧 Implementación Backend (NestJS)

1. Enum TypeScript

Ubicación: apps/backend/src/gamification/enums/comodin-type.enum.ts

export enum ComodinTypeEnum {
  PISTAS = 'pistas',
  VISION_LECTORA = 'vision_lectora',
  SEGUNDA_OPORTUNIDAD = 'segunda_oportunidad',
}

export const COMODIN_COSTS: Record<ComodinTypeEnum, number> = {
  [ComodinTypeEnum.PISTAS]: 15,
  [ComodinTypeEnum.VISION_LECTORA]: 25,
  [ComodinTypeEnum.SEGUNDA_OPORTUNIDAD]: 40,
};

export const COMODIN_LIMITS: Record<ComodinTypeEnum, number> = {
  [ComodinTypeEnum.PISTAS]: 3,
  [ComodinTypeEnum.VISION_LECTORA]: 1,
  [ComodinTypeEnum.SEGUNDA_OPORTUNIDAD]: 1,
};

2. Entities

Ubicación: apps/backend/src/gamification/entities/comodin-inventory.entity.ts

import { Entity, Column, PrimaryColumn } from 'typeorm';
import { ComodinTypeEnum } from '../enums/comodin-type.enum';

@Entity({ schema: 'gamification_system', name: 'comodines_inventory' })
export class ComodinInventory {
  @PrimaryColumn({ type: 'uuid', name: 'user_id' })
  userId: string;

  @PrimaryColumn({ type: 'enum', enum: ComodinTypeEnum, name: 'comodin_type' })
  comodinType: ComodinTypeEnum;

  @Column({ type: 'integer', default: 0 })
  quantity: number;

  @Column({ type: 'integer', default: 0, name: 'total_purchased' })
  totalPurchased: number;

  @Column({ type: 'integer', default: 0, name: 'total_used' })
  totalUsed: number;

  @Column({ type: 'timestamptz', nullable: true, name: 'last_purchased_at' })
  lastPurchasedAt?: Date;

  @Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' })
  lastUsedAt?: Date;

  @Column({ type: 'timestamptz', default: () => 'NOW()', name: 'created_at' })
  createdAt: Date;

  @Column({ type: 'timestamptz', default: () => 'NOW()', name: 'updated_at' })
  updatedAt: Date;
}

3. DTOs

Ubicación: apps/backend/src/gamification/dto/comodin.dto.ts

import { IsEnum, IsInt, Min, Max, IsUUID, IsOptional } from 'class-validator';
import { ComodinTypeEnum } from '../enums/comodin-type.enum';

export class PurchaseComodinDto {
  @IsEnum(ComodinTypeEnum)
  comodinType: ComodinTypeEnum;

  @IsInt()
  @Min(1)
  @Max(10) // Máximo 10 unidades por compra
  quantity: number;
}

export class UseComodinDto {
  @IsUUID()
  exerciseId: string;

  @IsUUID()
  attemptId: string;

  @IsEnum(ComodinTypeEnum)
  comodinType: ComodinTypeEnum;
}

export class ComodinInventoryDto {
  comodinType: ComodinTypeEnum;
  quantity: number;
  totalPurchased: number;
  totalUsed: number;
  lastPurchasedAt?: Date;
  lastUsedAt?: Date;
  cost: number; // Costo unitario
}

4. ComodinService

Ubicación: apps/backend/src/gamification/services/comodin.service.ts

import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ComodinInventory } from '../entities/comodin-inventory.entity';
import { ComodinTypeEnum, COMODIN_COSTS } from '../enums/comodin-type.enum';
import { PurchaseComodinDto, UseComodinDto, ComodinInventoryDto } from '../dto/comodin.dto';

@Injectable()
export class ComodinService {
  constructor(
    @InjectRepository(ComodinInventory)
    private comodinInventoryRepo: Repository<ComodinInventory>,
  ) {}

  /**
   * Comprar comodín con ML Coins
   */
  async purchase(userId: string, dto: PurchaseComodinDto): Promise<{
    success: boolean;
    comodinType: ComodinTypeEnum;
    quantityPurchased: number;
    totalCost: number;
    newBalance: number;
    newQuantity: number;
  }> {
    const result = await this.comodinInventoryRepo.query(
      'SELECT * FROM gamification_system.purchase_comodin($1, $2, $3)',
      [userId, dto.comodinType, dto.quantity]
    );

    return result[0].purchase_comodin;
  }

  /**
   * Usar comodín en ejercicio
   */
  async use(userId: string, dto: UseComodinDto): Promise<{
    success: boolean;
    comodinType: ComodinTypeEnum;
    hintNumber?: number;
    remainingQuantity: number;
    effect?: any; // Contenido específico del comodín
  }> {
    const result = await this.comodinInventoryRepo.query(
      'SELECT * FROM gamification_system.use_comodin($1, $2, $3, $4)',
      [userId, dto.exerciseId, dto.attemptId, dto.comodinType]
    );

    const usageResult = result[0].use_comodin;

    // Generar efecto según tipo
    let effect = null;

    if (dto.comodinType === ComodinTypeEnum.PISTAS) {
      effect = await this.generateHint(dto.exerciseId, usageResult.hint_number);
    } else if (dto.comodinType === ComodinTypeEnum.VISION_LECTORA) {
      effect = await this.generateReadingVision(dto.exerciseId);
    }

    return {
      ...usageResult,
      effect,
    };
  }

  /**
   * Obtener inventario completo
   */
  async getInventory(userId: string): Promise<ComodinInventoryDto[]> {
    const result = await this.comodinInventoryRepo.query(
      'SELECT * FROM gamification_system.get_comodin_inventory($1)',
      [userId]
    );

    return result.map((row) => ({
      comodinType: row.comodin_type,
      quantity: row.quantity,
      totalPurchased: row.total_purchased,
      totalUsed: row.total_used,
      lastPurchasedAt: row.last_purchased_at,
      lastUsedAt: row.last_used_at,
      cost: COMODIN_COSTS[row.comodin_type],
    }));
  }

  /**
   * Verificar disponibilidad de comodín
   */
  async hasAvailable(userId: string, comodinType: ComodinTypeEnum): Promise<boolean> {
    const inventory = await this.comodinInventoryRepo.findOne({
      where: { userId, comodinType },
    });

    return inventory ? inventory.quantity > 0 : false;
  }

  /**
   * Generar contenido de pista
   */
  private async generateHint(exerciseId: string, hintNumber: number): Promise<string> {
    // Obtener ejercicio
    const exercise = await this.comodinInventoryRepo.query(
      'SELECT hints FROM educational_content.exercises WHERE id = $1',
      [exerciseId]
    );

    if (!exercise[0] || !exercise[0].hints) {
      throw new BadRequestException('No hints available for this exercise');
    }

    const hints = exercise[0].hints; // JSONB array: ["hint1", "hint2", "hint3"]

    if (hints.length < hintNumber) {
      throw new BadRequestException(`Hint ${hintNumber} not available`);
    }

    return hints[hintNumber - 1];
  }

  /**
   * Generar efecto de Visión Lectora
   */
  private async generateReadingVision(exerciseId: string): Promise<{
    highlightedSentences: number[]; // Índices de oraciones a resaltar
  }> {
    // Obtener ejercicio y su contenido
    const exercise = await this.comodinInventoryRepo.query(
      'SELECT content, answer_key FROM educational_content.exercises WHERE id = $1',
      [exerciseId]
    );

    if (!exercise[0]) {
      throw new BadRequestException('Exercise not found');
    }

    // Algoritmo simple: resaltar oraciones que contengan palabras clave de la respuesta
    // En producción, usar NLP más sofisticado
    const content = exercise[0].content;
    const answerKey = exercise[0].answer_key;

    // Simplificación: devolver índices de oraciones relevantes
    // TODO: Implementar algoritmo de NLP real
    const highlightedSentences = [1, 3, 5]; // Placeholder

    return { highlightedSentences };
  }
}

5. Controller

Ubicación: apps/backend/src/gamification/controllers/comodin.controller.ts

import { Controller, Get, Post, Body, UseGuards, Req } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { ComodinService } from '../services/comodin.service';
import { PurchaseComodinDto, UseComodinDto } from '../dto/comodin.dto';

@Controller('comodines')
@UseGuards(JwtAuthGuard)
export class ComodinController {
  constructor(private comodinService: ComodinService) {}

  /**
   * POST /comodines/purchase
   * Comprar comodín con ML Coins
   */
  @Post('purchase')
  async purchase(@Req() req, @Body() dto: PurchaseComodinDto) {
    return await this.comodinService.purchase(req.user.id, dto);
  }

  /**
   * POST /comodines/use
   * Usar comodín en ejercicio
   */
  @Post('use')
  async use(@Req() req, @Body() dto: UseComodinDto) {
    return await this.comodinService.use(req.user.id, dto);
  }

  /**
   * GET /comodines/inventory
   * Obtener inventario del usuario
   */
  @Get('inventory')
  async getInventory(@Req() req) {
    return await this.comodinService.getInventory(req.user.id);
  }
}

🎨 Implementación Frontend (React)

1. Component: ComodinShop

Ubicación: apps/frontend/src/components/gamification/ComodinShop.tsx

import React, { useState, useEffect } from 'react';
import { comodinService } from '../../services/comodin.service';
import { ComodinTypeEnum, COMODIN_COSTS } from '../../types/comodin.types';
import { toast } from 'react-toastify';

export const ComodinShop: React.FC = () => {
  const [inventory, setInventory] = useState([]);
  const [mlCoins, setMlCoins] = useState(0);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    loadData();
  }, []);

  const loadData = async () => {
    const inv = await comodinService.getInventory();
    setInventory(inv);

    // Obtener ML Coins del usuario
    const userStats = await comodinService.getUserStats();
    setMlCoins(userStats.mlCoins);
  };

  const handlePurchase = async (type: ComodinTypeEnum, quantity: number) => {
    try {
      setLoading(true);

      const result = await comodinService.purchase({ comodinType: type, quantity });

      toast.success(`Compraste ${quantity} ${type}!`);
      setMlCoins(result.newBalance);

      await loadData(); // Recargar inventario
    } catch (error) {
      toast.error(error.response?.data?.message || 'Error al comprar');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-4">Tienda de Comodines</h1>

      {/* Balance */}
      <div className="mb-6 p-4 bg-yellow-100 rounded-lg">
        <p className="text-lg">Tu balance: <span className="font-bold">{mlCoins} ML Coins</span></p>
      </div>

      {/* Grid de productos */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {/* Pistas */}
        <div className="bg-white p-6 rounded-lg shadow-lg border-2 border-blue-500">
          <div className="text-center mb-4">
            <div className="text-6xl mb-2">💡</div>
            <h3 className="text-xl font-bold">Pistas</h3>
          </div>
          <p className="text-gray-600 text-sm mb-4">
            Recibe hasta 3 pistas progresivas por ejercicio
          </p>
          <div className="text-center mb-4">
            <span className="text-2xl font-bold text-blue-600">15 ML Coins</span>
          </div>
          <button
            onClick={() => handlePurchase(ComodinTypeEnum.PISTAS, 1)}
            disabled={loading || mlCoins < 15}
            className="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400"
          >
            Comprar 1
          </button>
          <button
            onClick={() => handlePurchase(ComodinTypeEnum.PISTAS, 5)}
            disabled={loading || mlCoins < 75}
            className="w-full mt-2 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400"
          >
            Comprar 5 (75 coins)
          </button>
        </div>

        {/* Visión Lectora */}
        <div className="bg-white p-6 rounded-lg shadow-lg border-2 border-purple-500">
          <div className="text-center mb-4">
            <div className="text-6xl mb-2">👁️</div>
            <h3 className="text-xl font-bold">Visión Lectora</h3>
          </div>
          <p className="text-gray-600 text-sm mb-4">
            Resalta oraciones clave en textos largos
          </p>
          <div className="text-center mb-4">
            <span className="text-2xl font-bold text-purple-600">25 ML Coins</span>
          </div>
          <button
            onClick={() => handlePurchase(ComodinTypeEnum.VISION_LECTORA, 1)}
            disabled={loading || mlCoins < 25}
            className="w-full py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:bg-gray-400"
          >
            Comprar 1
          </button>
        </div>

        {/* Segunda Oportunidad */}
        <div className="bg-white p-6 rounded-lg shadow-lg border-2 border-green-500">
          <div className="text-center mb-4">
            <div className="text-6xl mb-2">♻️</div>
            <h3 className="text-xl font-bold">Segunda Oportunidad</h3>
          </div>
          <p className="text-gray-600 text-sm mb-4">
            Reintenta sin perder racha ni XP
          </p>
          <div className="text-center mb-4">
            <span className="text-2xl font-bold text-green-600">40 ML Coins</span>
          </div>
          <button
            onClick={() => handlePurchase(ComodinTypeEnum.SEGUNDA_OPORTUNIDAD, 1)}
            disabled={loading || mlCoins < 40}
            className="w-full py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400"
          >
            Comprar 1
          </button>
        </div>
      </div>

      {/* Inventario actual */}
      <div className="mt-8 p-6 bg-gray-100 rounded-lg">
        <h2 className="text-2xl font-bold mb-4">Tu Inventario</h2>
        <div className="grid grid-cols-3 gap-4">
          {inventory.map((item) => (
            <div key={item.comodinType} className="text-center">
              <p className="font-semibold">{item.comodinType}</p>
              <p className="text-3xl font-bold text-blue-600">{item.quantity}</p>
              <p className="text-xs text-gray-500">Comprados: {item.totalPurchased}</p>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

2. Component: ComodinButton (en ejercicio)

Ubicación: apps/frontend/src/components/exercises/ComodinButton.tsx

import React from 'react';
import { ComodinTypeEnum } from '../../types/comodin.types';

interface ComodinButtonProps {
  type: ComodinTypeEnum;
  available: number;
  onClick: () => void;
  disabled?: boolean;
}

const COMODIN_ICONS = {
  [ComodinTypeEnum.PISTAS]: '💡',
  [ComodinTypeEnum.VISION_LECTORA]: '👁️',
  [ComodinTypeEnum.SEGUNDA_OPORTUNIDAD]: '♻️',
};

const COMODIN_LABELS = {
  [ComodinTypeEnum.PISTAS]: 'Usar Pista',
  [ComodinTypeEnum.VISION_LECTORA]: 'Visión Lectora',
  [ComodinTypeEnum.SEGUNDA_OPORTUNIDAD]: 'Segunda Oportunidad',
};

export const ComodinButton: React.FC<ComodinButtonProps> = ({ type, available, onClick, disabled }) => {
  return (
    <button
      onClick={onClick}
      disabled={disabled || available <= 0}
      className={`px-4 py-2 rounded-lg font-semibold flex items-center gap-2 transition-all ${
        disabled || available <= 0
          ? 'bg-gray-300 text-gray-500 cursor-not-allowed'
          : 'bg-blue-600 text-white hover:bg-blue-700 shadow-lg hover:shadow-xl'
      }`}
    >
      <span className="text-2xl">{COMODIN_ICONS[type]}</span>
      <span>{COMODIN_LABELS[type]}</span>
      <span className="ml-2 px-2 py-1 bg-white text-blue-600 rounded text-sm">{available}</span>
    </button>
  );
};

🧪 Testing

Test Case 1: Compra Exitosa

test('User can purchase comodin with sufficient ML Coins', async () => {
  const user = await createUser({ ml_coins: 100 });

  const result = await comodinService.purchase(user.id, {
    comodinType: ComodinTypeEnum.PISTAS,
    quantity: 3,
  });

  expect(result.success).toBe(true);
  expect(result.totalCost).toBe(45); // 15 * 3
  expect(result.newBalance).toBe(55); // 100 - 45

  const inventory = await getComodinInventory(user.id);
  expect(inventory[ComodinTypeEnum.PISTAS].quantity).toBe(3);
});

📊 Performance

Índices Críticos

CREATE INDEX idx_comodines_inventory_user ON gamification_system.comodines_inventory(user_id);
CREATE INDEX idx_comodin_usage_log_user ON gamification_system.comodin_usage_log(user_id);

📅 Historial de Cambios

Versión Fecha Autor Cambios
1.0 2025-11-07 Database Team Creación del documento
1.1 2025-11-11 Database Team Actualización de costos a valores DDL implementados (pistas: 15, vision_lectora: 25, segunda_oportunidad: 40)

Documento: docs/02-especificaciones-tecnicas/02-gamificacion/ET-GAM-002-comodines.md Propósito: Especificación técnica completa del sistema de comodines Audiencia: Backend Developers, Frontend Developers, QA Team