- 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>
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 usuariogamification_system.comodin_usage_log- Log de usosgamification_system.comodin_usage_tracking- Tracking de límites por ejercicio
🗄️ Funciones:
purchase_comodin()- Comprar comodín con ML Coinsuse_comodin()- Usar comodín en ejercicioget_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