Compare commits
9 Commits
c4b4b9cd89
...
289c5a4ee5
| Author | SHA1 | Date | |
|---|---|---|---|
| 289c5a4ee5 | |||
| 9660dfbe07 | |||
| 9a18f6cd2a | |||
| 94dc2ca560 | |||
| 0e99b5c02f | |||
| 44c3b5ee09 | |||
| a23f31ce8f | |||
| 8b12d7f231 | |||
| d0d5699cd5 |
353
projects/gamilit/.gitignore
vendored
353
projects/gamilit/.gitignore
vendored
@ -1,148 +1,106 @@
|
||||
# GAMILIT Monorepo - .gitignore
|
||||
# Generado: 2025-11-01 (RFC-0001)
|
||||
# Actualizado: 2025-12-05
|
||||
|
||||
# ============================================
|
||||
# NODE.JS - DEPENDENCIAS (GLOBAL)
|
||||
# ============================================
|
||||
# Ignorar node_modules en CUALQUIER nivel de anidación
|
||||
**/node_modules/
|
||||
|
||||
# Lock files de otros package managers (mantener solo package-lock.json)
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
|
||||
# Logs de npm/yarn/pnpm
|
||||
# === NODE.JS ===
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Directorios de dependencias alternativas
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
bower_components/
|
||||
|
||||
# Cache de npm
|
||||
.npm/
|
||||
.npmrc.local
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Cache de eslint/stylelint
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# ============================================
|
||||
# TYPESCRIPT / BUILD ARTIFACTS (GLOBAL)
|
||||
# ============================================
|
||||
# Ignorar dist/build en CUALQUIER nivel
|
||||
**/dist/
|
||||
**/build/
|
||||
**/out/
|
||||
**/.next/
|
||||
**/.nuxt/
|
||||
**/.turbo/
|
||||
|
||||
# TypeScript
|
||||
# === TYPESCRIPT ===
|
||||
*.tsbuildinfo
|
||||
**/*.tsbuildinfo
|
||||
dist/
|
||||
build/
|
||||
*.js.map
|
||||
|
||||
# ============================================
|
||||
# FRAMEWORKS ESPECÍFICOS
|
||||
# ============================================
|
||||
# Angular / NX
|
||||
# === ANGULAR / NX ===
|
||||
.angular/
|
||||
.nx/
|
||||
**/.nx/
|
||||
.nx/cache/
|
||||
.nx/workspace-data/
|
||||
|
||||
# Vite
|
||||
**/.vite/
|
||||
# === NESTJS ===
|
||||
/apps/backend/dist/
|
||||
/apps/backend/build/
|
||||
|
||||
# Webpack
|
||||
.webpack/
|
||||
**/.webpack/
|
||||
|
||||
# ============================================
|
||||
# ENVIRONMENT FILES - SECRETS
|
||||
# ============================================
|
||||
# CRÍTICO: Nunca commitear secrets
|
||||
# === ENVIRONMENT FILES ===
|
||||
# IMPORTANTE: Nunca commitear secrets reales
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
.env.development
|
||||
.env.test
|
||||
.env.staging
|
||||
# Permitir archivos de ejemplo (sin secrets)
|
||||
!.env.*.example
|
||||
!.env.example
|
||||
|
||||
# Archivos con secrets
|
||||
**/secrets.json
|
||||
**/credentials.json
|
||||
# Archivos de configuración con secrets
|
||||
config/secrets.json
|
||||
config/credentials.json
|
||||
**/*secrets*.json
|
||||
**/*credentials*.json
|
||||
**/*.secret
|
||||
**/*.secrets
|
||||
|
||||
# Configuración de base de datos con credenciales
|
||||
**/database.config.ts
|
||||
!**/database.config.example.ts
|
||||
**/ormconfig.json
|
||||
!**/ormconfig.example.json
|
||||
|
||||
# ============================================
|
||||
# DATABASES
|
||||
# ============================================
|
||||
# Backups y dumps
|
||||
# === DATABASES ===
|
||||
# PostgreSQL
|
||||
*.sql.backup
|
||||
*.dump
|
||||
*.pgdata
|
||||
*.sql.gz
|
||||
|
||||
# Bases de datos locales
|
||||
# Local database files
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Redis
|
||||
dump.rdb
|
||||
# Database connection strings (excepción: ejemplos con .example)
|
||||
database.config.ts
|
||||
!database.config.example.ts
|
||||
|
||||
# ============================================
|
||||
# LOGS (GLOBAL)
|
||||
# ============================================
|
||||
**/logs/
|
||||
**/*.log
|
||||
**/pm2-logs/
|
||||
**/*.pm2.log
|
||||
# === LOGS ===
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# ============================================
|
||||
# TESTING (GLOBAL)
|
||||
# ============================================
|
||||
**/coverage/
|
||||
**/.nyc_output/
|
||||
# PM2 logs
|
||||
pm2-logs/
|
||||
*.pm2.log
|
||||
|
||||
# === TESTING ===
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.lcov
|
||||
|
||||
# Jest
|
||||
**/jest-cache/
|
||||
.jest/
|
||||
jest-cache/
|
||||
|
||||
# Cypress
|
||||
**/cypress/screenshots/
|
||||
**/cypress/videos/
|
||||
**/cypress/downloads/
|
||||
|
||||
# Playwright
|
||||
**/playwright-report/
|
||||
**/playwright/.cache/
|
||||
**/test-results/
|
||||
cypress/screenshots/
|
||||
cypress/videos/
|
||||
cypress/downloads/
|
||||
|
||||
# E2E reports
|
||||
**/e2e-reports/
|
||||
e2e-reports/
|
||||
test-results/
|
||||
|
||||
# ============================================
|
||||
# IDEs and EDITORS
|
||||
# ============================================
|
||||
# VSCode - mantener configuración compartida
|
||||
# === IDEs and EDITORS ===
|
||||
# VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
@ -161,117 +119,92 @@ dump.rdb
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Vim/Neovim
|
||||
# Vim
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.netrwhist
|
||||
Session.vim
|
||||
|
||||
# Emacs
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
*.elc
|
||||
auto-save-list/
|
||||
|
||||
# ============================================
|
||||
# OS FILES
|
||||
# ============================================
|
||||
# === OS FILES ===
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
|
||||
# Linux
|
||||
*~
|
||||
.directory
|
||||
.Trash-*
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
*.lnk
|
||||
|
||||
# ============================================
|
||||
# DOCKER
|
||||
# ============================================
|
||||
# === DOCKER ===
|
||||
# No ignorar Dockerfiles, solo archivos temporales
|
||||
docker-compose.override.yml
|
||||
docker-compose.local.yml
|
||||
.dockerignore.local
|
||||
**/*.dockerignore.local
|
||||
|
||||
# ============================================
|
||||
# DEPLOYMENT & SECURITY
|
||||
# ============================================
|
||||
# === BUILD ARTIFACTS ===
|
||||
/apps/*/dist/
|
||||
/apps/*/build/
|
||||
/libs/*/dist/
|
||||
|
||||
# Webpack
|
||||
.webpack/
|
||||
|
||||
# === DEPLOYMENT ===
|
||||
# PM2
|
||||
ecosystem.config.js.local
|
||||
pm2.config.js.local
|
||||
|
||||
# Claves y certificados
|
||||
# Deploy keys (excepción: .example files)
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
!*.example.pem
|
||||
!*.example.key
|
||||
|
||||
# SSL certificates
|
||||
# SSL certificates (excepción: self-signed para dev)
|
||||
*.crt
|
||||
*.cer
|
||||
!dev-cert.crt
|
||||
!*.example.crt
|
||||
|
||||
# SSH keys
|
||||
id_rsa*
|
||||
id_ed25519*
|
||||
*.pub
|
||||
!*.example.pub
|
||||
# === TEMP FILES ===
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.temp
|
||||
*.cache
|
||||
|
||||
# ============================================
|
||||
# TEMP FILES (GLOBAL)
|
||||
# ============================================
|
||||
**/tmp/
|
||||
**/temp/
|
||||
**/.tmp/
|
||||
**/.temp/
|
||||
**/*.tmp
|
||||
**/*.temp
|
||||
**/*.cache
|
||||
**/.cache/
|
||||
|
||||
# ============================================
|
||||
# ARTIFACTS
|
||||
# ============================================
|
||||
# === ARTIFACTS (parcial) ===
|
||||
# Mantener reportes importantes, ignorar temporales
|
||||
/artifacts/temp/
|
||||
/artifacts/cache/
|
||||
/artifacts/**/*.tmp
|
||||
/artifacts/**/*.cache
|
||||
|
||||
# ============================================
|
||||
# CLAUDE CODE / AI
|
||||
# ============================================
|
||||
# Configuración local de Claude Code
|
||||
# === CLAUDE CODE ===
|
||||
# Excluir toda la carpeta .claude (configuración local de IA)
|
||||
.claude/
|
||||
|
||||
# Pero NO ignorar orchestration (necesario para Claude Code cloud)
|
||||
# Solo ignorar temporales dentro de orchestration
|
||||
# === ORCHESTRATION ===
|
||||
# IMPORTANTE: orchestration/ DEBE estar en el repo para Claude Code cloud
|
||||
# Contiene: prompts, directivas, trazas, inventarios, templates
|
||||
# Solo ignorar subcarpetas temporales específicas y archivos comprimidos
|
||||
orchestration/.archive/
|
||||
orchestration/.tmp/
|
||||
orchestration/**/*.tmp
|
||||
orchestration/**/*.cache
|
||||
|
||||
# ============================================
|
||||
# REFERENCE (Código de Referencia)
|
||||
# ============================================
|
||||
# reference/ DEBE estar en el repo
|
||||
# Solo ignorar build/dependencias dentro
|
||||
# === REFERENCE (Código de Referencia) ===
|
||||
# IMPORTANTE: reference/ DEBE estar en el repo para Claude Code cloud
|
||||
# Contiene: proyectos de referencia para análisis y desarrollo
|
||||
# Ignorar solo carpetas de build/dependencias dentro de reference/
|
||||
reference/**/node_modules/
|
||||
reference/**/dist/
|
||||
reference/**/build/
|
||||
@ -286,9 +219,16 @@ reference/**/*.tmp
|
||||
reference/**/*.cache
|
||||
reference/**/.DS_Store
|
||||
|
||||
# ============================================
|
||||
# PACKAGE MANAGERS
|
||||
# ============================================
|
||||
# === MIGRATION (temporal) ===
|
||||
# Durante migración, mantener docs de análisis
|
||||
# Descomentar después de migración completa:
|
||||
# /docs-analysis/
|
||||
|
||||
# === ARCHIVOS ESPECÍFICOS GRANDES ===
|
||||
# Si hay archivos markdown extremadamente grandes (>50MB), considerarlos para LFS
|
||||
# *.large.md
|
||||
|
||||
# === PACKAGE MANAGERS ===
|
||||
# Yarn
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
@ -301,102 +241,33 @@ reference/**/.DS_Store
|
||||
# PNPM
|
||||
.pnpm-store/
|
||||
|
||||
# ============================================
|
||||
# MONITORING & OBSERVABILITY
|
||||
# ============================================
|
||||
# === MONITORING ===
|
||||
# Application monitoring
|
||||
newrelic_agent.log
|
||||
.monitors/
|
||||
**/.sentry/
|
||||
**/sentry-debug.log
|
||||
|
||||
# ============================================
|
||||
# BACKUPS (GLOBAL)
|
||||
# ============================================
|
||||
# Archivos
|
||||
# === MISC ===
|
||||
# Backups - Archivos
|
||||
*.backup
|
||||
*.bak
|
||||
*.old
|
||||
*.orig
|
||||
|
||||
# Carpetas
|
||||
**/*_old/
|
||||
**/*_bckp/
|
||||
**/*_bkp/
|
||||
**/*_backup/
|
||||
**/*.old/
|
||||
**/*.bak/
|
||||
**/*.backup/
|
||||
# Backups - Carpetas
|
||||
*_old/
|
||||
*_bckp/
|
||||
*_bkp/
|
||||
*_backup/
|
||||
*.old/
|
||||
*.bak/
|
||||
*.backup/
|
||||
|
||||
# Específicos del proyecto
|
||||
# Backups específicos (carpetas identificadas en workspace)
|
||||
orchestration_old/
|
||||
orchestration_bckp/
|
||||
docs_bkp/
|
||||
|
||||
# ============================================
|
||||
# COMPRESSED FILES
|
||||
# ============================================
|
||||
# Compressed files (si no son assets del proyecto)
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.tar
|
||||
*.rar
|
||||
*.7z
|
||||
# Excepto assets
|
||||
!assets/**/*.zip
|
||||
!public/**/*.zip
|
||||
|
||||
# ============================================
|
||||
# MISCELÁNEOS
|
||||
# ============================================
|
||||
# Archivos de debug
|
||||
**/*.debug
|
||||
**/debug.log
|
||||
|
||||
# Archivos de error
|
||||
**/*.error
|
||||
**/error.log
|
||||
|
||||
# Storybook
|
||||
**/storybook-static/
|
||||
|
||||
# Parcel
|
||||
.parcel-cache/
|
||||
|
||||
# Serverless
|
||||
.serverless/
|
||||
|
||||
# FuseBox
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local
|
||||
.dynamodb/
|
||||
|
||||
# TernJS
|
||||
.tern-port
|
||||
|
||||
# Archivos generados por IDEs
|
||||
*.code-workspace
|
||||
|
||||
# Archivos de licencia generados
|
||||
LICENSE.txt.bak
|
||||
|
||||
# ============================================
|
||||
# APPS ESPECÍFICAS DEL MONOREPO
|
||||
# ============================================
|
||||
# Backend NestJS
|
||||
apps/backend/dist/
|
||||
apps/backend/build/
|
||||
apps/backend/.nest/
|
||||
|
||||
# Frontend React/Vite
|
||||
apps/frontend/dist/
|
||||
apps/frontend/build/
|
||||
apps/frontend/.vite/
|
||||
|
||||
# Database - mantener DDL, ignorar generados
|
||||
apps/database/*.dump
|
||||
apps/database/*.backup
|
||||
apps/database/data/
|
||||
|
||||
# DevOps - ignorar logs y temp
|
||||
apps/devops/logs/
|
||||
apps/devops/tmp/
|
||||
|
||||
214
projects/gamilit/INSTRUCCIONES-DEPLOYMENT.md
Normal file
214
projects/gamilit/INSTRUCCIONES-DEPLOYMENT.md
Normal file
@ -0,0 +1,214 @@
|
||||
# INSTRUCCIONES DE DEPLOYMENT - GAMILIT
|
||||
|
||||
**Version:** 1.0
|
||||
**Fecha:** 2025-12-18
|
||||
|
||||
---
|
||||
|
||||
Este archivo contiene las instrucciones de deployment que el agente ejecuta DESPUES de hacer backup y pull.
|
||||
|
||||
**Prerequisitos:**
|
||||
- Ya ejecutaste backup de BD y configuraciones
|
||||
- Ya hiciste pull del repositorio
|
||||
- Los servicios PM2 estan detenidos
|
||||
- Tienes las variables DB_PASSWORD y DATABASE_URL configuradas
|
||||
|
||||
---
|
||||
|
||||
## FASE 1: RESTAURAR CONFIGURACIONES
|
||||
|
||||
Restaurar los .env de produccion desde el backup:
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit
|
||||
BACKUP_DIR="/home/gamilit/backups/latest"
|
||||
|
||||
# Restaurar .env.production
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/backend/.env.production 2>/dev/null || echo "WARN: No habia .env.production de backend"
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/frontend/.env.production 2>/dev/null || echo "WARN: No habia .env.production de frontend"
|
||||
|
||||
# Crear symlinks .env -> .env.production
|
||||
cd apps/backend && ln -sf .env.production .env && cd ../..
|
||||
cd apps/frontend && ln -sf .env.production .env && cd ../..
|
||||
|
||||
# Verificar
|
||||
echo "=== Configuraciones restauradas ==="
|
||||
ls -la apps/backend/.env* 2>/dev/null
|
||||
ls -la apps/frontend/.env* 2>/dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASE 2: RECREAR BASE DE DATOS
|
||||
|
||||
La BD se recrea completamente (politica de CARGA LIMPIA):
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit/apps/database
|
||||
|
||||
# Verificar que el script existe
|
||||
ls -la drop-and-recreate-database.sh
|
||||
|
||||
# Ejecutar recreacion completa
|
||||
# Este script: DROP BD -> CREATE BD -> DDL (16 fases) -> Seeds (38+ archivos)
|
||||
./drop-and-recreate-database.sh "$DATABASE_URL"
|
||||
```
|
||||
|
||||
**Si el script falla**, alternativas:
|
||||
|
||||
```bash
|
||||
# Opcion A: Si la BD existe pero esta vacia
|
||||
./create-database.sh
|
||||
|
||||
# Opcion B: Si necesitas crear desde cero (usuario no existe)
|
||||
cd scripts && ./init-database.sh --env prod --password "$DB_PASSWORD"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASE 3: BUILD
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit
|
||||
|
||||
# Backend
|
||||
echo "=== Building Backend ==="
|
||||
cd apps/backend
|
||||
npm install
|
||||
npm run build
|
||||
cd ../..
|
||||
|
||||
# Frontend
|
||||
echo "=== Building Frontend ==="
|
||||
cd apps/frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ../..
|
||||
|
||||
echo "=== Build completado ==="
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASE 4: INICIAR SERVICIOS
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit
|
||||
|
||||
# Iniciar con PM2
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# Guardar configuracion
|
||||
pm2 save
|
||||
|
||||
# Mostrar estado
|
||||
pm2 list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASE 5: VALIDAR DEPLOYMENT
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit
|
||||
|
||||
echo "=== 5.1 Estado de PM2 ==="
|
||||
pm2 list
|
||||
|
||||
echo "=== 5.2 Health del Backend ==="
|
||||
curl -s http://localhost:3006/api/health | head -20
|
||||
|
||||
echo "=== 5.3 Frontend accesible ==="
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3005)
|
||||
echo "HTTP Status: $HTTP_CODE"
|
||||
|
||||
echo "=== 5.4 Validacion de Base de Datos ==="
|
||||
psql "$DATABASE_URL" -c "SELECT 'tenants' as tabla, COUNT(*) as total FROM auth_management.tenants
|
||||
UNION ALL SELECT 'users', COUNT(*) FROM auth.users
|
||||
UNION ALL SELECT 'modules', COUNT(*) FROM educational_content.modules
|
||||
UNION ALL SELECT 'maya_ranks', COUNT(*) FROM gamification_system.maya_ranks
|
||||
UNION ALL SELECT 'feature_flags', COUNT(*) FROM system_configuration.feature_flags;"
|
||||
|
||||
echo "=== 5.5 Logs recientes ==="
|
||||
pm2 logs --lines 10 --nostream
|
||||
```
|
||||
|
||||
**Valores esperados:**
|
||||
- tenants: 14+
|
||||
- users: 20+
|
||||
- modules: 5
|
||||
- maya_ranks: 5
|
||||
- feature_flags: 26+
|
||||
|
||||
---
|
||||
|
||||
## ROLLBACK (Si algo falla)
|
||||
|
||||
```bash
|
||||
BACKUP_DIR="/home/gamilit/backups/latest"
|
||||
|
||||
# Restaurar BD
|
||||
echo "=== Restaurando Base de Datos ==="
|
||||
gunzip -c "$BACKUP_DIR/database/gamilit_*.sql.gz" | psql "$DATABASE_URL"
|
||||
|
||||
# Restaurar configs
|
||||
echo "=== Restaurando Configuraciones ==="
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/backend/ 2>/dev/null || true
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/frontend/ 2>/dev/null || true
|
||||
|
||||
# Reiniciar servicios
|
||||
pm2 restart all
|
||||
pm2 list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## REPORTE DE ERRORES
|
||||
|
||||
Si algo falla, reporta:
|
||||
1. Numero de FASE donde fallo (1-5)
|
||||
2. Comando exacto que fallo
|
||||
3. Mensaje de error completo
|
||||
4. Output de: `pm2 list` y `pm2 logs --lines 50 --nostream`
|
||||
|
||||
---
|
||||
|
||||
## INFORMACION ADICIONAL
|
||||
|
||||
### Estructura del proyecto
|
||||
|
||||
```
|
||||
/home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API (puerto 3006, 2 instancias PM2)
|
||||
│ ├── frontend/ # React App (puerto 3005, 1 instancia PM2)
|
||||
│ └── database/ # DDL, Seeds, Scripts
|
||||
├── scripts/ # Scripts de produccion
|
||||
├── ecosystem.config.js # Configuracion PM2
|
||||
└── logs/ # Logs de aplicacion
|
||||
```
|
||||
|
||||
### Scripts disponibles
|
||||
|
||||
| Script | Ubicacion | Proposito |
|
||||
|--------|-----------|-----------|
|
||||
| drop-and-recreate-database.sh | apps/database/ | Recrear BD completa |
|
||||
| create-database.sh | apps/database/ | Solo DDL + Seeds |
|
||||
| init-database.sh | apps/database/scripts/ | Crear usuario + BD |
|
||||
| build-production.sh | scripts/ | Solo build |
|
||||
| deploy-production.sh | scripts/ | Solo deploy PM2 |
|
||||
| pre-deploy-check.sh | scripts/ | Validacion pre-deploy |
|
||||
| diagnose-production.sh | scripts/ | Diagnostico del sistema |
|
||||
|
||||
### Documentacion adicional
|
||||
|
||||
| Archivo | Proposito |
|
||||
|---------|-----------|
|
||||
| docs/95-guias-desarrollo/DIRECTIVA-DEPLOYMENT.md | Checklist |
|
||||
| docs/95-guias-desarrollo/GUIA-DESPLIEGUE-PRODUCCION-COMPLETA.md | Guia detallada |
|
||||
| docs/95-guias-desarrollo/GUIA-VALIDACION-PRODUCCION.md | Troubleshooting |
|
||||
| apps/database/FLUJO-CARGA-LIMPIA.md | Recreacion de BD |
|
||||
|
||||
---
|
||||
|
||||
*Ultima actualizacion: 2025-12-18 v1.0*
|
||||
94
projects/gamilit/PRODUCTION-UPDATE.md
Normal file
94
projects/gamilit/PRODUCTION-UPDATE.md
Normal file
@ -0,0 +1,94 @@
|
||||
# ACTUALIZACION DE PRODUCCION - GAMILIT
|
||||
|
||||
**LEER ESTE ARCHIVO DESPUES DE HACER `git pull`**
|
||||
|
||||
---
|
||||
|
||||
## INSTRUCCIONES RAPIDAS
|
||||
|
||||
```bash
|
||||
# 1. Configurar password de base de datos
|
||||
export DB_PASSWORD="tu_password"
|
||||
|
||||
# 2. Hacer scripts ejecutables
|
||||
chmod +x scripts/*.sh
|
||||
|
||||
# 3. Ejecutar actualizacion completa
|
||||
./scripts/update-production.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## QUE HACE EL SCRIPT
|
||||
|
||||
El script `update-production.sh` ejecuta automaticamente:
|
||||
|
||||
1. Detiene PM2
|
||||
2. Respalda configuraciones (.env) a `/home/gamilit/backups/`
|
||||
3. Respalda base de datos completa (pg_dump)
|
||||
4. Restaura configuraciones despues del pull
|
||||
5. Recrea base de datos limpia con todos los seeds
|
||||
6. Instala dependencias (npm install)
|
||||
7. Build de aplicaciones (npm run build)
|
||||
8. Inicia servicios (pm2 start)
|
||||
9. Valida el deployment
|
||||
|
||||
---
|
||||
|
||||
## DOCUMENTACION COMPLETA
|
||||
|
||||
Si necesitas mas detalles o algo falla, lee estas guias:
|
||||
|
||||
| Guia | Path |
|
||||
|------|------|
|
||||
| Actualizacion paso a paso | `docs/95-guias-desarrollo/GUIA-ACTUALIZACION-PRODUCCION.md` |
|
||||
| Validacion y troubleshooting | `docs/95-guias-desarrollo/GUIA-VALIDACION-PRODUCCION.md` |
|
||||
| Despliegue completo | `docs/95-guias-desarrollo/GUIA-DESPLIEGUE-PRODUCCION-COMPLETA.md` |
|
||||
|
||||
---
|
||||
|
||||
## SI ALGO FALLA
|
||||
|
||||
### Diagnostico rapido
|
||||
```bash
|
||||
./scripts/diagnose-production.sh
|
||||
```
|
||||
|
||||
### Reparar datos faltantes
|
||||
```bash
|
||||
./scripts/repair-missing-data.sh
|
||||
```
|
||||
|
||||
### Rollback
|
||||
Los backups estan en `/home/gamilit/backups/YYYYMMDD_HHMMSS/`
|
||||
|
||||
```bash
|
||||
# Ver backups disponibles
|
||||
ls -la /home/gamilit/backups/
|
||||
|
||||
# Restaurar base de datos
|
||||
gunzip -c /home/gamilit/backups/YYYYMMDD_HHMMSS/database/gamilit_*.sql.gz | psql "$DATABASE_URL"
|
||||
|
||||
# Restaurar configuraciones
|
||||
cp /home/gamilit/backups/YYYYMMDD_HHMMSS/config/* apps/backend/
|
||||
cp /home/gamilit/backups/YYYYMMDD_HHMMSS/config/* apps/frontend/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COMANDOS PM2
|
||||
|
||||
```bash
|
||||
pm2 list # Ver procesos
|
||||
pm2 logs # Ver logs
|
||||
pm2 restart all # Reiniciar todo
|
||||
pm2 monit # Monitor en tiempo real
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Fecha:** 2025-12-18
|
||||
**Servidor:** 74.208.126.102
|
||||
**Backend:** Puerto 3006
|
||||
**Frontend:** Puerto 3005
|
||||
**Base de datos:** PostgreSQL puerto 5432, database gamilit_platform
|
||||
131
projects/gamilit/PROMPT-AGENTE-PRODUCCION.md
Normal file
131
projects/gamilit/PROMPT-AGENTE-PRODUCCION.md
Normal file
@ -0,0 +1,131 @@
|
||||
# PROMPT PARA AGENTE DE PRODUCCION - GAMILIT
|
||||
|
||||
**Version:** 4.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Servidor:** 74.208.126.102
|
||||
|
||||
---
|
||||
|
||||
## INSTRUCCIONES DE USO
|
||||
|
||||
Este es el prompt INICIAL que se le da al agente de produccion.
|
||||
**Copiar TODO el contenido entre `=== INICIO PROMPT ===` y `=== FIN PROMPT ===`**
|
||||
|
||||
El agente ejecutara:
|
||||
1. Backup de BD y configuraciones
|
||||
2. Pull del repositorio
|
||||
3. Leera las instrucciones de deployment del repositorio
|
||||
|
||||
---
|
||||
|
||||
=== INICIO PROMPT ===
|
||||
|
||||
Eres el agente de deployment de GAMILIT en el servidor de produccion (74.208.126.102).
|
||||
|
||||
CONTEXTO:
|
||||
- Workspace: /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit
|
||||
- Base de datos: PostgreSQL gamilit_platform, usuario gamilit_user
|
||||
|
||||
Tu trabajo tiene 2 ETAPAS:
|
||||
|
||||
## ETAPA 1: BACKUP Y PULL (Ejecutar ahora)
|
||||
|
||||
### Paso 1.1: Configurar variables
|
||||
|
||||
```bash
|
||||
# IMPORTANTE: Reemplaza [PASSWORD] con el password real de la BD
|
||||
export DB_PASSWORD="[PASSWORD]"
|
||||
export DATABASE_URL="postgresql://gamilit_user:$DB_PASSWORD@localhost:5432/gamilit_platform"
|
||||
|
||||
# Verificar conexion
|
||||
psql "$DATABASE_URL" -c "SELECT 1;" && echo "Conexion OK" || echo "ERROR: No se puede conectar"
|
||||
```
|
||||
|
||||
### Paso 1.2: Backup de Base de Datos
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="/home/gamilit/backups/$TIMESTAMP"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"/{database,config,logs}
|
||||
|
||||
echo "=== Backup de Base de Datos ==="
|
||||
pg_dump "$DATABASE_URL" | gzip > "$BACKUP_DIR/database/gamilit_$TIMESTAMP.sql.gz"
|
||||
ls -lh "$BACKUP_DIR/database/"
|
||||
```
|
||||
|
||||
### Paso 1.3: Backup de Configuraciones
|
||||
|
||||
```bash
|
||||
echo "=== Backup de Configuraciones ==="
|
||||
cp apps/backend/.env.production "$BACKUP_DIR/config/" 2>/dev/null || true
|
||||
cp apps/backend/.env "$BACKUP_DIR/config/backend.env" 2>/dev/null || true
|
||||
cp apps/frontend/.env.production "$BACKUP_DIR/config/" 2>/dev/null || true
|
||||
cp apps/frontend/.env "$BACKUP_DIR/config/frontend.env" 2>/dev/null || true
|
||||
cp ecosystem.config.js "$BACKUP_DIR/config/" 2>/dev/null || true
|
||||
|
||||
echo "=== Backup de Logs ==="
|
||||
cp logs/*.log "$BACKUP_DIR/logs/" 2>/dev/null || true
|
||||
|
||||
# Crear symlink a ultimo backup
|
||||
ln -sfn "$BACKUP_DIR" "/home/gamilit/backups/latest"
|
||||
|
||||
echo "=== BACKUP COMPLETADO: $BACKUP_DIR ==="
|
||||
ls -la "$BACKUP_DIR/"
|
||||
```
|
||||
|
||||
### Paso 1.4: Detener servicios
|
||||
|
||||
```bash
|
||||
pm2 stop all
|
||||
pm2 list
|
||||
```
|
||||
|
||||
### Paso 1.5: Pull del repositorio
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit
|
||||
|
||||
git fetch origin
|
||||
git status
|
||||
git reset --hard origin/main
|
||||
git log --oneline -5
|
||||
|
||||
echo "=== Repositorio actualizado ==="
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ETAPA 2: DEPLOYMENT (Leer del repositorio)
|
||||
|
||||
Ahora que tienes el repositorio actualizado, LEE el archivo:
|
||||
|
||||
**INSTRUCCIONES-DEPLOYMENT.md**
|
||||
|
||||
ubicado en la raiz del proyecto:
|
||||
/home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit/INSTRUCCIONES-DEPLOYMENT.md
|
||||
|
||||
Ese archivo contiene las instrucciones completas para:
|
||||
- Restaurar configuraciones
|
||||
- Recrear base de datos
|
||||
- Build de backend y frontend
|
||||
- Iniciar servicios PM2
|
||||
- Validar deployment
|
||||
- Rollback si algo falla
|
||||
|
||||
EJECUTA las instrucciones de ese archivo.
|
||||
|
||||
---
|
||||
|
||||
Si algo falla en la ETAPA 1, reporta:
|
||||
1. El paso donde fallo (1.1, 1.2, etc.)
|
||||
2. El comando exacto
|
||||
3. El mensaje de error completo
|
||||
|
||||
=== FIN PROMPT ===
|
||||
|
||||
---
|
||||
|
||||
*Ultima actualizacion: 2025-12-18 v4.0*
|
||||
137
projects/gamilit/apps/backend/.env.production.example
Normal file
137
projects/gamilit/apps/backend/.env.production.example
Normal file
@ -0,0 +1,137 @@
|
||||
# ============================================================================
|
||||
# GAMILIT Backend - Production Environment Variables (EXAMPLE)
|
||||
# ============================================================================
|
||||
# INSTRUCCIONES:
|
||||
# 1. Copiar este archivo a .env.production
|
||||
# 2. Reemplazar todos los valores <...> con valores reales
|
||||
# 3. Configurar como variables de entorno del sistema (recomendado)
|
||||
# O usar archivo .env.production (asegurar permisos restrictivos)
|
||||
|
||||
# ==================== SERVER ====================
|
||||
NODE_ENV=production
|
||||
PORT=3006
|
||||
API_PREFIX=api
|
||||
|
||||
# ==================== DATABASE ====================
|
||||
# IMPORTANTE: Usar credenciales seguras en producción
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=gamilit_platform
|
||||
DB_USER=gamilit_user
|
||||
DB_PASSWORD=<PASSWORD_SEGURO_AQUI>
|
||||
DB_SYNCHRONIZE=false
|
||||
DB_LOGGING=false
|
||||
|
||||
# ==================== JWT ====================
|
||||
# IMPORTANTE: NUNCA usar valores de ejemplo en producción
|
||||
# Generar secret seguro con: openssl rand -base64 32
|
||||
JWT_SECRET=<GENERAR_SECRET_SEGURO_AQUI>
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# ==================== CORS ====================
|
||||
# CRÍTICO: Configurar orígenes permitidos correctamente
|
||||
#
|
||||
# ============================================================================
|
||||
# ADVERTENCIA: HEADERS CORS DUPLICADOS
|
||||
# ============================================================================
|
||||
# Si usas Nginx como proxy SSL, NO agregar headers CORS en Nginx.
|
||||
# NestJS maneja CORS internamente en main.ts.
|
||||
# Headers duplicados causan error:
|
||||
# "The 'Access-Control-Allow-Origin' header contains multiple values"
|
||||
#
|
||||
# SOLUCION: Solo NestJS maneja CORS, Nginx solo hace proxy sin headers CORS.
|
||||
# Ver: docs/95-guias-desarrollo/GUIA-CORS-PRODUCCION.md
|
||||
# ============================================================================
|
||||
#
|
||||
# Servidor actual: 74.208.126.102
|
||||
# Frontend puerto: 3005
|
||||
#
|
||||
# CONFIGURACION RECOMENDADA CON SSL:
|
||||
# Incluye HTTPS y HTTP para compatibilidad durante transición
|
||||
CORS_ORIGIN=https://74.208.126.102:3005,https://74.208.126.102,http://74.208.126.102:3005,http://74.208.126.102
|
||||
ENABLE_CORS=true
|
||||
ENABLE_SWAGGER=false
|
||||
|
||||
# ==================== LOGGING ====================
|
||||
LOG_LEVEL=warn
|
||||
LOG_TO_FILE=true
|
||||
|
||||
# ==================== RATE LIMITING ====================
|
||||
RATE_LIMIT_TTL=60
|
||||
RATE_LIMIT_MAX=100
|
||||
|
||||
# ==================== SESSION ====================
|
||||
# Generar secret con: openssl rand -base64 32
|
||||
SESSION_SECRET=<GENERAR_SECRET_SEGURO_AQUI>
|
||||
SESSION_MAX_AGE=86400000
|
||||
|
||||
# ==================== EMAIL ====================
|
||||
# Configurar si se usa envío de emails
|
||||
EMAIL_FROM=noreply@gamilit.com
|
||||
EMAIL_REPLY_TO=support@gamilit.com
|
||||
|
||||
# ==================== FRONTEND URL ====================
|
||||
# URL del frontend para redirects, links en emails, etc.
|
||||
# IMPORTANTE: Usar HTTPS si el servidor tiene SSL configurado
|
||||
FRONTEND_URL=https://74.208.126.102:3005
|
||||
|
||||
# ==================== WEB PUSH NOTIFICATIONS (VAPID) ====================
|
||||
# Web Push API nativo - No requiere servicios externos (Firebase, etc.)
|
||||
# IMPORTANTE: Generar claves VAPID con el comando:
|
||||
# npx web-push generate-vapid-keys
|
||||
#
|
||||
# VAPID (Voluntary Application Server Identification):
|
||||
# - Public Key: Se comparte con el frontend para crear subscripciones
|
||||
# - Private Key: Se mantiene secreta en backend para firmar notificaciones
|
||||
# - Subject: Email de contacto o URL del sitio (mailto: o https:)
|
||||
#
|
||||
# Ejemplo de generación:
|
||||
# $ npx web-push generate-vapid-keys
|
||||
# Public Key: BN4GvZtEZiZuqaaObWga7lEP-S1WCv7L1c...
|
||||
# Private Key: aB3cDefGh4IjKlM5nOpQr6StUvWxYz...
|
||||
#
|
||||
# Compatible con: Chrome, Firefox, Edge, Safari 16.4+
|
||||
# NOTA: Si no se configuran, push notifications quedarán deshabilitadas (graceful degradation)
|
||||
#
|
||||
VAPID_PUBLIC_KEY=<GENERAR_CON_WEB_PUSH_GENERATE_VAPID_KEYS>
|
||||
VAPID_PRIVATE_KEY=<GENERAR_CON_WEB_PUSH_GENERATE_VAPID_KEYS>
|
||||
VAPID_SUBJECT=mailto:admin@gamilit.com
|
||||
|
||||
# ==================== OPCIONALES ====================
|
||||
# Descomentar y configurar si se usan
|
||||
|
||||
# Uploads
|
||||
# MAX_FILE_SIZE=5242880
|
||||
# UPLOAD_DESTINATION=./uploads
|
||||
# ALLOWED_MIME_TYPES=image/jpeg,image/png,image/gif,application/pdf
|
||||
|
||||
# Mantenimiento
|
||||
# MAINTENANCE_MODE=false
|
||||
|
||||
# ============================================================================
|
||||
# NOTAS DE SEGURIDAD
|
||||
# ============================================================================
|
||||
#
|
||||
# 1. JWT_SECRET: DEBE ser diferente al de desarrollo, usar 32+ caracteres aleatorios
|
||||
# 2. DB_PASSWORD: NUNCA commitear credenciales reales al repositorio
|
||||
# 3. SESSION_SECRET: Generar valor único y seguro
|
||||
# 4. CORS_ORIGIN: Solo incluir orígenes confiables, NUNCA usar "*"
|
||||
# 5. ENABLE_SWAGGER: DEBE estar en false en producción
|
||||
# 6. Permisos archivo: chmod 600 .env.production (solo owner puede leer)
|
||||
# 7. Logs: Verificar que LOG_TO_FILE no exponga datos sensibles
|
||||
#
|
||||
# ============================================================================
|
||||
# VALIDACIÓN PRE-DEPLOY
|
||||
# ============================================================================
|
||||
#
|
||||
# Antes de deploy, verificar:
|
||||
# [ ] JWT_SECRET cambiado de valor de ejemplo
|
||||
# [ ] DB_PASSWORD configurado correctamente
|
||||
# [ ] CORS_ORIGIN incluye origen del frontend en producción
|
||||
# [ ] ENABLE_SWAGGER=false
|
||||
# [ ] FRONTEND_URL apunta a URL correcta
|
||||
# [ ] Todas las variables <...> reemplazadas
|
||||
# [ ] VAPID_* configuradas si se requieren push notifications (generar con npx web-push generate-vapid-keys)
|
||||
#
|
||||
# ============================================================================
|
||||
6
projects/gamilit/apps/backend/.gitignore
vendored
6
projects/gamilit/apps/backend/.gitignore
vendored
@ -33,13 +33,11 @@ coverage/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Uploads (keep directory structure but ignore files)
|
||||
uploads/exercises/*
|
||||
!uploads/exercises/.gitkeep
|
||||
|
||||
.env*
|
||||
.flaskenv*
|
||||
!.env.project
|
||||
!.env.vault
|
||||
!.env.*.example
|
||||
!.env.example
|
||||
# Backups de scripts automáticos
|
||||
**/*.backup-*
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, ConflictException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import * as crypto from 'crypto';
|
||||
@ -6,6 +6,7 @@ import { User, EmailVerificationToken } from '../entities';
|
||||
import {
|
||||
VerifyEmailDto,
|
||||
} from '../dto';
|
||||
import { MailService } from '@/modules/mail/mail.service';
|
||||
|
||||
/**
|
||||
* EmailVerificationService
|
||||
@ -33,6 +34,8 @@ import {
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmailVerificationService {
|
||||
private readonly logger = new Logger(EmailVerificationService.name);
|
||||
|
||||
private readonly TOKEN_LENGTH_BYTES = 32;
|
||||
|
||||
private readonly TOKEN_EXPIRATION_HOURS = 24;
|
||||
@ -44,8 +47,7 @@ export class EmailVerificationService {
|
||||
@InjectRepository(EmailVerificationToken, 'auth')
|
||||
private readonly tokenRepository: Repository<EmailVerificationToken>,
|
||||
|
||||
// TODO: Inject MailerService
|
||||
// private readonly mailerService: MailerService,
|
||||
private readonly mailService: MailService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -89,9 +91,17 @@ export class EmailVerificationService {
|
||||
await this.tokenRepository.save(verificationToken);
|
||||
|
||||
// 8. Enviar email con token plaintext
|
||||
// TODO: Implementar envío de email
|
||||
// await this.mailerService.sendEmailVerification(email, plainToken);
|
||||
console.log(`[DEV] Email verification token for ${email}: ${plainToken}`);
|
||||
try {
|
||||
await this.mailService.sendVerificationEmail(email, plainToken);
|
||||
this.logger.log(`Verification email sent to: ${email}`);
|
||||
} catch (error) {
|
||||
// Log error pero no fallar (el token ya está creado)
|
||||
this.logger.error(`Failed to send verification email to ${email}:`, error);
|
||||
// En desarrollo, mostrar el token para testing
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.logger.debug(`[DEV] Verification token for ${email}: ${plainToken}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { message: 'Email de verificación enviado' };
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import * as crypto from 'crypto';
|
||||
@ -9,6 +9,7 @@ import {
|
||||
ResetPasswordDto,
|
||||
} from '../dto';
|
||||
import { MailService } from '@/modules/mail/mail.service';
|
||||
import { SessionManagementService } from './session-management.service';
|
||||
|
||||
/**
|
||||
* PasswordRecoveryService
|
||||
@ -33,6 +34,8 @@ import { MailService } from '@/modules/mail/mail.service';
|
||||
*/
|
||||
@Injectable()
|
||||
export class PasswordRecoveryService {
|
||||
private readonly logger = new Logger(PasswordRecoveryService.name);
|
||||
|
||||
private readonly TOKEN_LENGTH_BYTES = 32;
|
||||
|
||||
private readonly TOKEN_EXPIRATION_HOURS = 1;
|
||||
@ -46,8 +49,7 @@ export class PasswordRecoveryService {
|
||||
|
||||
private readonly mailService: MailService,
|
||||
|
||||
// TODO: Inject SessionManagementService for logout
|
||||
// private readonly sessionService: SessionManagementService,
|
||||
private readonly sessionManagementService: SessionManagementService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -90,14 +92,16 @@ export class PasswordRecoveryService {
|
||||
// 7. Enviar email con token plaintext
|
||||
try {
|
||||
await this.mailService.sendPasswordResetEmail(user.email, plainToken);
|
||||
this.logger.log(`Password reset email sent to: ${user.email}`);
|
||||
} catch (error) {
|
||||
// Log error pero NO fallar (por seguridad, no revelar errores)
|
||||
console.error(`Failed to send password reset email to ${user.email}:`, error);
|
||||
this.logger.error(`Failed to send password reset email to ${user.email}:`, error);
|
||||
// En desarrollo, mostrar el token para testing
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.logger.debug(`[DEV] Password reset token for ${user.email}: ${plainToken}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback para desarrollo (si SMTP no configurado)
|
||||
console.log(`[DEV] Password reset token for ${user.email}: ${plainToken}`);
|
||||
|
||||
return { message: genericMessage };
|
||||
}
|
||||
|
||||
@ -159,10 +163,16 @@ export class PasswordRecoveryService {
|
||||
{ used_at: new Date() },
|
||||
);
|
||||
|
||||
// 6. Invalidar todas las sesiones (logout global)
|
||||
// TODO: Implementar con SessionManagementService
|
||||
// await this.sessionService.revokeAllSessions(user.id);
|
||||
console.log(`[DEV] Should revoke all sessions for user ${user.id}`);
|
||||
// 6. Invalidar todas las sesiones (logout global de seguridad)
|
||||
try {
|
||||
// Revocar todas las sesiones excepto ninguna (currentSessionId vacío = revocar todas)
|
||||
// Usamos un UUID inexistente para asegurar que se cierren TODAS las sesiones
|
||||
const result = await this.sessionManagementService.revokeAllSessions(user.id, '00000000-0000-0000-0000-000000000000');
|
||||
this.logger.log(`Revoked ${result.count} sessions for user ${user.id} after password reset`);
|
||||
} catch (error) {
|
||||
// Log pero no fallar - la contraseña ya fue cambiada
|
||||
this.logger.error(`Failed to revoke sessions for user ${user.id}:`, error);
|
||||
}
|
||||
|
||||
return { message: 'Contraseña actualizada exitosamente' };
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
|
||||
import { Repository, EntityManager } from 'typeorm';
|
||||
import { ExerciseSubmission } from '../entities';
|
||||
@ -79,6 +79,8 @@ interface FragmentState {
|
||||
*/
|
||||
@Injectable()
|
||||
export class ExerciseSubmissionService {
|
||||
private readonly logger = new Logger(ExerciseSubmissionService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ExerciseSubmission, 'progress')
|
||||
private readonly submissionRepo: Repository<ExerciseSubmission>,
|
||||
@ -242,7 +244,7 @@ export class ExerciseSubmissionService {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[BE-P2-009] Diario multimedia validation passed: ${wordCount} palabras`);
|
||||
this.logger.log(`[BE-P2-009] Diario multimedia validation passed: ${wordCount} palabras`);
|
||||
}
|
||||
|
||||
if (exercise.exercise_type === 'comic_digital') {
|
||||
@ -269,7 +271,7 @@ export class ExerciseSubmissionService {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[BE-P2-009] Comic digital validation passed: ${panels.length} paneles`);
|
||||
this.logger.log(`[BE-P2-009] Comic digital validation passed: ${panels.length} paneles`);
|
||||
}
|
||||
|
||||
if (exercise.exercise_type === 'video_carta') {
|
||||
@ -290,11 +292,11 @@ export class ExerciseSubmissionService {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[BE-P2-009] Video carta validation passed: ${videoUrl}`);
|
||||
this.logger.log(`[BE-P2-009] Video carta validation passed: ${videoUrl}`);
|
||||
}
|
||||
|
||||
// FE-059: Validate answer structure BEFORE saving to database
|
||||
console.log(`[FE-059] Validating answer structure for exercise type: ${exercise.exercise_type}`);
|
||||
this.logger.log(`[FE-059] Validating answer structure for exercise type: ${exercise.exercise_type}`);
|
||||
await ExerciseAnswerValidator.validate(exercise.exercise_type, answers);
|
||||
|
||||
// Verificar si ya existe un envío previo
|
||||
@ -326,7 +328,7 @@ export class ExerciseSubmissionService {
|
||||
|
||||
// ✅ FIX BUG-001: Auto-claim rewards después de calificar
|
||||
if (submission.is_correct && submission.status === 'graded') {
|
||||
console.log(`[BUG-001 FIX] Auto-claiming rewards for submission ${submission.id}`);
|
||||
this.logger.log(`[BUG-001 FIX] Auto-claiming rewards for submission ${submission.id}`);
|
||||
const rewards = await this.claimRewards(submission.id);
|
||||
|
||||
// Los campos ya están persistidos en la submission por claimRewards()
|
||||
@ -336,13 +338,13 @@ export class ExerciseSubmissionService {
|
||||
|
||||
// BE-P2-008: Notificar al docente si el ejercicio requiere revisión manual
|
||||
if (exercise.requires_manual_grading) {
|
||||
console.log(`[BE-P2-008] Exercise ${exerciseId} requires manual grading - notifying teacher`);
|
||||
this.logger.log(`[BE-P2-008] Exercise ${exerciseId} requires manual grading - notifying teacher`);
|
||||
try {
|
||||
await this.notifyTeacherOfSubmission(submission, exercise, profileId);
|
||||
} catch (error) {
|
||||
// Log error but don't fail submission
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[BE-P2-008] Failed to notify teacher: ${errorMessage}`);
|
||||
this.logger.error(`[BE-P2-008] Failed to notify teacher: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -371,7 +373,7 @@ export class ExerciseSubmissionService {
|
||||
|
||||
// P1-003: Check if manual grading is requested
|
||||
if (manualGrade?.final_score !== undefined) {
|
||||
console.log(`[P1-003] Manual grading requested: score=${manualGrade.final_score}, grader=${manualGrade.grader_id}`);
|
||||
this.logger.log(`[P1-003] Manual grading requested: score=${manualGrade.final_score}, grader=${manualGrade.grader_id}`);
|
||||
|
||||
// Validate manual score range
|
||||
if (manualGrade.final_score < 0 || manualGrade.final_score > submission.max_score) {
|
||||
@ -398,7 +400,7 @@ export class ExerciseSubmissionService {
|
||||
submission.feedback = `Calificación manual: ${manualGrade.final_score}/${submission.max_score}`;
|
||||
}
|
||||
|
||||
console.log(`[P1-003] Manual grading applied: ${submission.score}/${submission.max_score}, correct=${submission.is_correct}`);
|
||||
this.logger.log(`[P1-003] Manual grading applied: ${submission.score}/${submission.max_score}, correct=${submission.is_correct}`);
|
||||
|
||||
const savedSubmission = await this.submissionRepo.save(submission);
|
||||
|
||||
@ -406,17 +408,17 @@ export class ExerciseSubmissionService {
|
||||
try {
|
||||
const earned = await this.achievementsService.detectAndGrantEarned(submission.user_id);
|
||||
if (earned.length > 0) {
|
||||
console.log(`[IMPL-004] ✅ Granted ${earned.length} achievements to user ${submission.user_id} after manual grading`);
|
||||
this.logger.log(`[IMPL-004] ✅ Granted ${earned.length} achievements to user ${submission.user_id} after manual grading`);
|
||||
}
|
||||
} catch (achievementError) {
|
||||
console.error(`[IMPL-004] ❌ Error detecting achievements: ${achievementError instanceof Error ? achievementError.message : String(achievementError)}`);
|
||||
this.logger.error(`[IMPL-004] ❌ Error detecting achievements: ${achievementError instanceof Error ? achievementError.message : String(achievementError)}`);
|
||||
}
|
||||
|
||||
return savedSubmission;
|
||||
}
|
||||
|
||||
// Default: Auto-grading using SQL validate_and_audit()
|
||||
console.log('[P1-003] No manual score provided - executing auto-grading');
|
||||
this.logger.log('[P1-003] No manual score provided - executing auto-grading');
|
||||
|
||||
const { score, isCorrect, correctAnswers, totalQuestions, feedback, details, auditId } = await this.autoGrade(
|
||||
submission.user_id, // userId (profiles.id)
|
||||
@ -433,7 +435,7 @@ export class ExerciseSubmissionService {
|
||||
|
||||
// FE-059: Audit ID is stored in educational_content.exercise_validation_audit
|
||||
// Can be queried using: exercise_id + user_id + attempt_number
|
||||
console.log(`[FE-059] Validation audit saved with ID: ${auditId}`);
|
||||
this.logger.log(`[FE-059] Validation audit saved with ID: ${auditId}`);
|
||||
|
||||
// Store validation results in submission
|
||||
(submission as any).correctAnswers = correctAnswers;
|
||||
@ -466,10 +468,10 @@ export class ExerciseSubmissionService {
|
||||
try {
|
||||
const earned = await this.achievementsService.detectAndGrantEarned(submission.user_id);
|
||||
if (earned.length > 0) {
|
||||
console.log(`[IMPL-004] ✅ Granted ${earned.length} achievements to user ${submission.user_id} after auto-grading`);
|
||||
this.logger.log(`[IMPL-004] ✅ Granted ${earned.length} achievements to user ${submission.user_id} after auto-grading`);
|
||||
}
|
||||
} catch (achievementError) {
|
||||
console.error(`[IMPL-004] ❌ Error detecting achievements: ${achievementError instanceof Error ? achievementError.message : String(achievementError)}`);
|
||||
this.logger.error(`[IMPL-004] ❌ Error detecting achievements: ${achievementError instanceof Error ? achievementError.message : String(achievementError)}`);
|
||||
}
|
||||
|
||||
return savedSubmission;
|
||||
@ -512,7 +514,7 @@ export class ExerciseSubmissionService {
|
||||
|
||||
// SPECIAL CASE: Completar Espacios - Anti-redundancy validation (Exercise 1.3)
|
||||
if (exercise.exercise_type === 'completar_espacios') {
|
||||
console.log('[autoGrade] Checking anti-redundancy for Completar Espacios (Exercise 1.3)');
|
||||
this.logger.log('[autoGrade] Checking anti-redundancy for Completar Espacios (Exercise 1.3)');
|
||||
|
||||
// Check if blanks.5 and blanks.6 exist and are identical (case-insensitive)
|
||||
const blanks = (answerData.blanks || {}) as Record<string, unknown>;
|
||||
@ -521,7 +523,7 @@ export class ExerciseSubmissionService {
|
||||
const space6 = String(blanks['6']).toLowerCase().trim();
|
||||
|
||||
if (space5 === space6) {
|
||||
console.log(`[autoGrade] REDUNDANCY DETECTED: space5="${space5}" === space6="${space6}"`);
|
||||
this.logger.log(`[autoGrade] REDUNDANCY DETECTED: space5="${space5}" === space6="${space6}"`);
|
||||
|
||||
// Create audit record for failed validation
|
||||
const auditId = 'redundancy-' + Date.now();
|
||||
@ -545,12 +547,12 @@ export class ExerciseSubmissionService {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[autoGrade] Anti-redundancy check passed, proceeding with normal validation');
|
||||
this.logger.log('[autoGrade] Anti-redundancy check passed, proceeding with normal validation');
|
||||
}
|
||||
|
||||
// SPECIAL CASE: Rueda de Inferencias custom validation
|
||||
if (exercise.exercise_type === 'rueda_inferencias') {
|
||||
console.log('[autoGrade] Using custom validation for Rueda de Inferencias');
|
||||
this.logger.log('[autoGrade] Using custom validation for Rueda de Inferencias');
|
||||
|
||||
// Extract fragmentStates from answerData if available
|
||||
const fragmentStates = answerData.fragmentStates as FragmentState[] | undefined;
|
||||
@ -583,7 +585,7 @@ export class ExerciseSubmissionService {
|
||||
}
|
||||
|
||||
// DEFAULT CASE: Use SQL validate_and_audit() for other exercise types
|
||||
console.log(`[FE-059] Validating exercise ${exerciseId} using SQL validate_and_audit()`);
|
||||
this.logger.log(`[FE-059] Validating exercise ${exerciseId} using SQL validate_and_audit()`);
|
||||
|
||||
// Call PostgreSQL validate_and_audit() function
|
||||
const query = `
|
||||
@ -611,7 +613,7 @@ export class ExerciseSubmissionService {
|
||||
|
||||
const validation = result[0];
|
||||
|
||||
console.log(`[FE-059] Validation result: score=${validation.score}/${validation.max_score}, correct=${validation.is_correct}, audit_id=${validation.audit_id}`);
|
||||
this.logger.log(`[FE-059] Validation result: score=${validation.score}/${validation.max_score}, correct=${validation.is_correct}, audit_id=${validation.audit_id}`);
|
||||
|
||||
return {
|
||||
score: validation.score,
|
||||
@ -623,7 +625,7 @@ export class ExerciseSubmissionService {
|
||||
auditId: validation.audit_id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[FE-059] Error calling validate_and_audit():', error);
|
||||
this.logger.error('[FE-059] Error calling validate_and_audit():', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new InternalServerErrorException(`Exercise validation failed: ${errorMessage}`);
|
||||
}
|
||||
@ -780,7 +782,7 @@ export class ExerciseSubmissionService {
|
||||
}>;
|
||||
};
|
||||
} {
|
||||
console.log('[validateRuedaInferencias] Starting validation for Rueda de Inferencias exercise');
|
||||
this.logger.log('[validateRuedaInferencias] Starting validation for Rueda de Inferencias exercise');
|
||||
|
||||
// Cast solution to ExerciseSolution interface
|
||||
const solution = exercise.solution as unknown as ExerciseSolution;
|
||||
@ -816,7 +818,7 @@ export class ExerciseSubmissionService {
|
||||
|
||||
// Skip if no answer provided
|
||||
if (!userAnswer) {
|
||||
console.log(`[validateRuedaInferencias] No answer provided for fragment ${fragment.id}, skipping`);
|
||||
this.logger.log(`[validateRuedaInferencias] No answer provided for fragment ${fragment.id}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -830,14 +832,14 @@ export class ExerciseSubmissionService {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[validateRuedaInferencias] Fragment ${fragment.id} using category: ${categoryId}`);
|
||||
this.logger.log(`[validateRuedaInferencias] Fragment ${fragment.id} using category: ${categoryId}`);
|
||||
|
||||
// Get expectations for this category (with type safety)
|
||||
type CategoryId = 'cat-literal' | 'cat-inferencial' | 'cat-critico' | 'cat-creativo';
|
||||
let categoryExpectation = fragment.categoryExpectations?.[categoryId as CategoryId];
|
||||
|
||||
if (!categoryExpectation) {
|
||||
console.warn(`[validateRuedaInferencias] No expectations found for category ${categoryId} in fragment ${fragment.id}, using default`);
|
||||
this.logger.warn(`[validateRuedaInferencias] No expectations found for category ${categoryId} in fragment ${fragment.id}, using default`);
|
||||
// Fallback: use literal category if available
|
||||
categoryExpectation = fragment.categoryExpectations?.['cat-literal'];
|
||||
if (!categoryExpectation) {
|
||||
@ -847,7 +849,7 @@ export class ExerciseSubmissionService {
|
||||
|
||||
// Validate categoryExpectation structure
|
||||
if (!categoryExpectation.keywords || !Array.isArray(categoryExpectation.keywords)) {
|
||||
console.warn(`[validateRuedaInferencias] Invalid category expectation for ${categoryId} in fragment ${fragment.id}`);
|
||||
this.logger.warn(`[validateRuedaInferencias] Invalid category expectation for ${categoryId} in fragment ${fragment.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -861,7 +863,7 @@ export class ExerciseSubmissionService {
|
||||
userAnswerLower.includes(keyword.toLowerCase()),
|
||||
);
|
||||
|
||||
console.log(`[validateRuedaInferencias] Fragment ${fragment.id}: Found ${foundKeywords.length}/${expectedKeywords.length} keywords`);
|
||||
this.logger.log(`[validateRuedaInferencias] Fragment ${fragment.id}: Found ${foundKeywords.length}/${expectedKeywords.length} keywords`);
|
||||
|
||||
// Calculate score based on keywords found
|
||||
let fragmentScore = 0;
|
||||
@ -910,7 +912,7 @@ export class ExerciseSubmissionService {
|
||||
overallFeedback = 'Necesitas practicar más. Revisa las categorías de inferencias y los ejemplos proporcionados.';
|
||||
}
|
||||
|
||||
console.log(`[validateRuedaInferencias] Validation complete: ${totalScore}/${maxScore} points (${overallPercentage.toFixed(1)}%)`);
|
||||
this.logger.log(`[validateRuedaInferencias] Validation complete: ${totalScore}/${maxScore} points (${overallPercentage.toFixed(1)}%)`);
|
||||
|
||||
return {
|
||||
score: totalScore,
|
||||
@ -973,7 +975,7 @@ export class ExerciseSubmissionService {
|
||||
let xpEarned = Math.floor(baseXpReward * scoreMultiplier * rankMultiplier);
|
||||
let mlCoinsEarned = Math.floor(baseMlCoinsReward * scoreMultiplier);
|
||||
|
||||
console.log(`[claimRewards] XP calculation: base=${baseXpReward}, score=${scoreMultiplier.toFixed(2)}, rank=${rankMultiplier}x, total=${xpEarned}`);
|
||||
this.logger.log(`[claimRewards] XP calculation: base=${baseXpReward}, score=${scoreMultiplier.toFixed(2)}, rank=${rankMultiplier}x, total=${xpEarned}`);
|
||||
|
||||
// Bonificación por perfect score
|
||||
if (submission.score === submission.max_score && !submission.hint_used) {
|
||||
@ -989,7 +991,7 @@ export class ExerciseSubmissionService {
|
||||
mlCoinsEarned = Math.max(0, mlCoinsEarned - submission.ml_coins_spent);
|
||||
|
||||
// ✅ FIX BUG-001: Actualizar user_stats con XP y ML Coins
|
||||
console.log(`[BUG-001 FIX] Claiming rewards for user ${submission.user_id}: +${xpEarned} XP, +${mlCoinsEarned} ML Coins`);
|
||||
this.logger.log(`[BUG-001 FIX] Claiming rewards for user ${submission.user_id}: +${xpEarned} XP, +${mlCoinsEarned} ML Coins`);
|
||||
|
||||
// Obtener rango ANTES de agregar XP
|
||||
const userStatsBefore = await this.userStatsService.findByUserId(submission.user_id);
|
||||
@ -1048,7 +1050,7 @@ export class ExerciseSubmissionService {
|
||||
newMultiplier: rankMultipliers[newRank] || 1.0,
|
||||
};
|
||||
|
||||
console.log(`[RANK UP] User ${submission.user_id} promoted from ${previousRank} to ${newRank}`);
|
||||
this.logger.log(`[RANK UP] User ${submission.user_id} promoted from ${previousRank} to ${newRank}`);
|
||||
}
|
||||
|
||||
// ✅ FIX BUG-002: Actualizar module_progress después de completar ejercicio
|
||||
@ -1107,7 +1109,7 @@ export class ExerciseSubmissionService {
|
||||
|
||||
return 1.00; // Default si no encuentra
|
||||
} catch {
|
||||
console.warn(`[getRankXpMultiplier] Error getting multiplier for user ${userId}, using 1.00`);
|
||||
this.logger.warn(`[getRankXpMultiplier] Error getting multiplier for user ${userId}, using 1.00`);
|
||||
return 1.00;
|
||||
}
|
||||
}
|
||||
@ -1133,12 +1135,12 @@ export class ExerciseSubmissionService {
|
||||
// Obtener module_id del ejercicio
|
||||
const exercise = await this.exerciseRepo.findOne({ where: { id: exerciseId } });
|
||||
if (!exercise?.module_id) {
|
||||
console.warn(`[BUG-002 FIX] Exercise ${exerciseId} has no module_id - skipping progress update`);
|
||||
this.logger.warn(`[BUG-002 FIX] Exercise ${exerciseId} has no module_id - skipping progress update`);
|
||||
return;
|
||||
}
|
||||
|
||||
const moduleId = exercise.module_id;
|
||||
console.log(`[BUG-002 FIX] Updating module progress for user ${userId}, module ${moduleId}`);
|
||||
this.logger.log(`[BUG-002 FIX] Updating module progress for user ${userId}, module ${moduleId}`);
|
||||
|
||||
// Verificar si es el primer envío correcto para ESTE ejercicio específico
|
||||
const previousCorrectSubmissions = await this.submissionRepo.count({
|
||||
@ -1156,7 +1158,7 @@ export class ExerciseSubmissionService {
|
||||
SET last_accessed_at = NOW(), updated_at = NOW()
|
||||
WHERE user_id = $1 AND module_id = $2
|
||||
`, [userId, moduleId]);
|
||||
console.log('[BUG-002 FIX] Not first correct submission - only updated timestamps');
|
||||
this.logger.log('[BUG-002 FIX] Not first correct submission - only updated timestamps');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1192,7 +1194,7 @@ export class ExerciseSubmissionService {
|
||||
newStatus = 'not_started';
|
||||
}
|
||||
|
||||
console.log(`[BUG-002 FIX] Module progress: ${completedExercises}/${totalExercises} (${progressPercentage}%) - Status: ${newStatus}`);
|
||||
this.logger.log(`[BUG-002 FIX] Module progress: ${completedExercises}/${totalExercises} (${progressPercentage}%) - Status: ${newStatus}`);
|
||||
|
||||
// Actualizar o insertar module_progress usando UPSERT
|
||||
await this.entityManager.query(`
|
||||
@ -1228,12 +1230,12 @@ export class ExerciseSubmissionService {
|
||||
updated_at = NOW()
|
||||
`, [userId, moduleId, newStatus, progressPercentage, completedExercises, totalExercises, xpEarned, mlCoinsEarned]);
|
||||
|
||||
console.log('[BUG-002 FIX] ✅ Module progress updated successfully');
|
||||
this.logger.log('[BUG-002 FIX] ✅ Module progress updated successfully');
|
||||
|
||||
} catch (error) {
|
||||
// Log error pero no bloquear el claim de rewards
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[BUG-002 FIX] ❌ Error updating module progress: ${errorMessage}`);
|
||||
this.logger.error(`[BUG-002 FIX] ❌ Error updating module progress: ${errorMessage}`);
|
||||
// No throw - la actualización de progreso no debe bloquear la respuesta
|
||||
}
|
||||
}
|
||||
@ -1252,7 +1254,7 @@ export class ExerciseSubmissionService {
|
||||
*/
|
||||
private async updateMissionsProgressAfterCompletion(userId: string, xpEarned: number = 0): Promise<void> {
|
||||
try {
|
||||
console.log(`[BUG-003 FIX] Updating missions progress for user ${userId}`);
|
||||
this.logger.log(`[BUG-003 FIX] Updating missions progress for user ${userId}`);
|
||||
|
||||
// Buscar misiones activas del usuario con objetivo 'complete_exercises'
|
||||
const missions = await this.missionsService.findByTypeAndUser(userId, MissionTypeEnum.DAILY);
|
||||
@ -1267,7 +1269,7 @@ export class ExerciseSubmissionService {
|
||||
);
|
||||
|
||||
if (activeMissions.length === 0) {
|
||||
console.log('[BUG-003 FIX] No active missions with \'complete_exercises\' objective found');
|
||||
this.logger.log('[BUG-003 FIX] No active missions with \'complete_exercises\' objective found');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1280,11 +1282,11 @@ export class ExerciseSubmissionService {
|
||||
'complete_exercises',
|
||||
1, // Incrementar en 1 por cada ejercicio completado
|
||||
);
|
||||
console.log(`[BUG-003 FIX] ✅ Mission ${mission.id} (complete_exercises) updated`);
|
||||
this.logger.log(`[BUG-003 FIX] ✅ Mission ${mission.id} (complete_exercises) updated`);
|
||||
} catch (missionError) {
|
||||
// Log pero continuar con otras misiones
|
||||
const errorMessage = missionError instanceof Error ? missionError.message : String(missionError);
|
||||
console.warn(`[BUG-003 FIX] ⚠️ Error updating mission ${mission.id}: ${errorMessage}`);
|
||||
this.logger.warn(`[BUG-003 FIX] ⚠️ Error updating mission ${mission.id}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1304,20 +1306,20 @@ export class ExerciseSubmissionService {
|
||||
'earn_xp',
|
||||
xpEarned, // Incrementar por cantidad de XP ganado
|
||||
);
|
||||
console.log(`[BUG-003 FIX] ✅ Mission ${mission.id} (earn_xp) updated with +${xpEarned} XP`);
|
||||
this.logger.log(`[BUG-003 FIX] ✅ Mission ${mission.id} (earn_xp) updated with +${xpEarned} XP`);
|
||||
} catch (missionError) {
|
||||
const errorMessage = missionError instanceof Error ? missionError.message : String(missionError);
|
||||
console.warn(`[BUG-003 FIX] ⚠️ Error updating earn_xp mission ${mission.id}: ${errorMessage}`);
|
||||
this.logger.warn(`[BUG-003 FIX] ⚠️ Error updating earn_xp mission ${mission.id}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[BUG-003 FIX] ✅ Missions progress update completed');
|
||||
this.logger.log('[BUG-003 FIX] ✅ Missions progress update completed');
|
||||
|
||||
} catch (error) {
|
||||
// Log error pero no bloquear el claim de rewards
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[BUG-003 FIX] ❌ Error updating missions progress: ${errorMessage}`);
|
||||
this.logger.error(`[BUG-003 FIX] ❌ Error updating missions progress: ${errorMessage}`);
|
||||
// No throw - la actualización de misiones no debe bloquear la respuesta
|
||||
}
|
||||
}
|
||||
@ -1486,7 +1488,7 @@ export class ExerciseSubmissionService {
|
||||
exercise: Exercise,
|
||||
studentProfileId: string,
|
||||
): Promise<void> {
|
||||
console.log(`[BE-P2-008] Notifying teacher about submission ${submission.id}`);
|
||||
this.logger.log(`[BE-P2-008] Notifying teacher about submission ${submission.id}`);
|
||||
|
||||
// 1. Obtener datos del estudiante
|
||||
const studentProfile = await this.profileRepo.findOne({
|
||||
@ -1495,7 +1497,7 @@ export class ExerciseSubmissionService {
|
||||
});
|
||||
|
||||
if (!studentProfile) {
|
||||
console.warn(`[BE-P2-008] Student profile ${studentProfileId} not found - skipping notification`);
|
||||
this.logger.warn(`[BE-P2-008] Student profile ${studentProfileId} not found - skipping notification`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1524,7 +1526,7 @@ export class ExerciseSubmissionService {
|
||||
const teacherResult = await this.entityManager.query(teacherQuery, [studentProfileId]);
|
||||
|
||||
if (!teacherResult || teacherResult.length === 0) {
|
||||
console.warn(`[BE-P2-008] No active teacher found for student ${studentProfileId} - skipping notification`);
|
||||
this.logger.warn(`[BE-P2-008] No active teacher found for student ${studentProfileId} - skipping notification`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1535,7 +1537,7 @@ export class ExerciseSubmissionService {
|
||||
const classroomName = teacher.classroom_name || 'tu aula';
|
||||
const teacherPreferences = teacher.teacher_preferences || {};
|
||||
|
||||
console.log(`[BE-P2-008] Found teacher ${teacherId} (${teacherEmail}) for student ${studentProfileId}`);
|
||||
this.logger.log(`[BE-P2-008] Found teacher ${teacherId} (${teacherEmail}) for student ${studentProfileId}`);
|
||||
|
||||
// 3. Construir URL de revisión
|
||||
const reviewUrl = `/teacher/reviews/${submission.id}`;
|
||||
@ -1565,10 +1567,10 @@ export class ExerciseSubmissionService {
|
||||
priority: 'high',
|
||||
});
|
||||
|
||||
console.log(`[BE-P2-008] ✅ In-app notification sent to teacher ${teacherId}`);
|
||||
this.logger.log(`[BE-P2-008] ✅ In-app notification sent to teacher ${teacherId}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[BE-P2-008] ❌ Failed to send in-app notification: ${errorMessage}`);
|
||||
this.logger.error(`[BE-P2-008] ❌ Failed to send in-app notification: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// 5. Enviar email si está habilitado en preferencias
|
||||
@ -1576,7 +1578,7 @@ export class ExerciseSubmissionService {
|
||||
const exerciseFeedbackEmailEnabled = teacherPreferences?.email_notifications?.exercise_feedback !== false;
|
||||
|
||||
if (emailNotificationsEnabled && exerciseFeedbackEmailEnabled && teacherEmail) {
|
||||
console.log(`[BE-P2-008] Email notifications enabled for teacher ${teacherId} - sending email to ${teacherEmail}`);
|
||||
this.logger.log(`[BE-P2-008] Email notifications enabled for teacher ${teacherId} - sending email to ${teacherEmail}`);
|
||||
|
||||
const emailSubject = `Nuevo ejercicio para revisar: ${exercise.title}`;
|
||||
const emailMessage = `
|
||||
@ -1597,19 +1599,19 @@ export class ExerciseSubmissionService {
|
||||
);
|
||||
|
||||
if (emailSent) {
|
||||
console.log(`[BE-P2-008] ✅ Email notification sent to teacher ${teacherEmail}`);
|
||||
this.logger.log(`[BE-P2-008] ✅ Email notification sent to teacher ${teacherEmail}`);
|
||||
} else {
|
||||
console.warn(`[BE-P2-008] ⚠️ Email notification logged but not sent (SMTP not configured)`);
|
||||
this.logger.warn(`[BE-P2-008] ⚠️ Email notification logged but not sent (SMTP not configured)`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[BE-P2-008] ❌ Failed to send email notification: ${errorMessage}`);
|
||||
this.logger.error(`[BE-P2-008] ❌ Failed to send email notification: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[BE-P2-008] Email notifications disabled for teacher ${teacherId} - skipping email`);
|
||||
this.logger.log(`[BE-P2-008] Email notifications disabled for teacher ${teacherId} - skipping email`);
|
||||
}
|
||||
|
||||
console.log(`[BE-P2-008] ✅ Teacher notification process completed for submission ${submission.id}`);
|
||||
this.logger.log(`[BE-P2-008] ✅ Teacher notification process completed for submission ${submission.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -578,6 +578,158 @@ export class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// P1-08: CACHE INVALIDATION METHODS (2025-12-18)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Invalidate economy analytics cache for a teacher
|
||||
*
|
||||
* Call this when:
|
||||
* - Student earns/spends ML Coins
|
||||
* - New student joins classroom
|
||||
* - Student removed from classroom
|
||||
*
|
||||
* @param teacherId - Teacher's user ID
|
||||
* @param classroomId - Optional specific classroom ID
|
||||
*/
|
||||
async invalidateEconomyAnalyticsCache(teacherId: string, classroomId?: string): Promise<void> {
|
||||
const cacheKeys = [
|
||||
`economy-analytics:${teacherId}:all`,
|
||||
`students-economy:${teacherId}:all`,
|
||||
];
|
||||
|
||||
if (classroomId) {
|
||||
cacheKeys.push(`economy-analytics:${teacherId}:${classroomId}`);
|
||||
cacheKeys.push(`students-economy:${teacherId}:${classroomId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(cacheKeys.map(key => this.cacheManager.del(key)));
|
||||
this.logger.debug(`Invalidated economy cache for teacher ${teacherId}${classroomId ? `, classroom ${classroomId}` : ''}`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.warn(`Error invalidating economy cache: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate achievements stats cache for a teacher
|
||||
*
|
||||
* Call this when:
|
||||
* - Student unlocks achievement
|
||||
* - Achievement configuration changes
|
||||
* - Student joins/leaves classroom
|
||||
*
|
||||
* @param teacherId - Teacher's user ID
|
||||
* @param classroomId - Optional specific classroom ID
|
||||
*/
|
||||
async invalidateAchievementsStatsCache(teacherId: string, classroomId?: string): Promise<void> {
|
||||
const cacheKeys = [
|
||||
`achievements-stats:${teacherId}:all`,
|
||||
];
|
||||
|
||||
if (classroomId) {
|
||||
cacheKeys.push(`achievements-stats:${teacherId}:${classroomId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(cacheKeys.map(key => this.cacheManager.del(key)));
|
||||
this.logger.debug(`Invalidated achievements cache for teacher ${teacherId}${classroomId ? `, classroom ${classroomId}` : ''}`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.warn(`Error invalidating achievements cache: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all analytics caches for a teacher
|
||||
*
|
||||
* Call this on major data changes that affect multiple analytics:
|
||||
* - New submission completed
|
||||
* - Score/grade updated
|
||||
* - Bulk student changes
|
||||
*
|
||||
* @param teacherId - Teacher's user ID
|
||||
* @param studentId - Optional student whose insights should be invalidated
|
||||
* @param classroomId - Optional specific classroom ID
|
||||
*/
|
||||
async invalidateAllAnalyticsCache(
|
||||
teacherId: string,
|
||||
studentId?: string,
|
||||
classroomId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const invalidations = [
|
||||
this.invalidateEconomyAnalyticsCache(teacherId, classroomId),
|
||||
this.invalidateAchievementsStatsCache(teacherId, classroomId),
|
||||
];
|
||||
|
||||
if (studentId) {
|
||||
invalidations.push(this.invalidateStudentInsightsCache(studentId));
|
||||
}
|
||||
|
||||
await Promise.all(invalidations);
|
||||
this.logger.debug(
|
||||
`Invalidated all analytics caches for teacher ${teacherId}` +
|
||||
(studentId ? `, student ${studentId}` : '') +
|
||||
(classroomId ? `, classroom ${classroomId}` : ''),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.warn(`Error invalidating all analytics cache: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for ExerciseSubmissionService to call when a submission is created/updated
|
||||
*
|
||||
* @param studentId - Student who made the submission
|
||||
* @param teacherIds - Array of teacher IDs who teach this student
|
||||
*/
|
||||
async onSubmissionChange(studentId: string, teacherIds: string[]): Promise<void> {
|
||||
try {
|
||||
// Invalidate student's insights
|
||||
await this.invalidateStudentInsightsCache(studentId);
|
||||
|
||||
// Invalidate each teacher's analytics
|
||||
await Promise.all(
|
||||
teacherIds.map(teacherId =>
|
||||
this.invalidateAllAnalyticsCache(teacherId, studentId),
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.debug(`Invalidated caches after submission change for student ${studentId}`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.warn(`Error in onSubmissionChange cache invalidation: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for ClassroomMemberService to call when membership changes
|
||||
*
|
||||
* @param classroomId - Classroom where change occurred
|
||||
* @param teacherId - Teacher who owns the classroom
|
||||
* @param studentId - Student whose membership changed
|
||||
*/
|
||||
async onMembershipChange(classroomId: string, teacherId: string, studentId: string): Promise<void> {
|
||||
try {
|
||||
await Promise.all([
|
||||
this.invalidateStudentInsightsCache(studentId),
|
||||
this.invalidateEconomyAnalyticsCache(teacherId, classroomId),
|
||||
this.invalidateAchievementsStatsCache(teacherId, classroomId),
|
||||
]);
|
||||
|
||||
this.logger.debug(
|
||||
`Invalidated caches after membership change in classroom ${classroomId} for student ${studentId}`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.warn(`Error in onMembershipChange cache invalidation: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ECONOMY ANALYTICS (GAP-ST-005)
|
||||
// =========================================================================
|
||||
|
||||
@ -14,6 +14,9 @@ import { ClassroomMember } from '@/modules/social/entities/classroom-member.enti
|
||||
import { Classroom } from '@/modules/social/entities/classroom.entity';
|
||||
import { User } from '@/modules/auth/entities/user.entity';
|
||||
import { UserStats } from '@/modules/gamification/entities/user-stats.entity';
|
||||
// P1-05: Added 2025-12-18 - Educational entities for data enrichment
|
||||
import { Module as EducationalModule } from '@/modules/educational/entities/module.entity';
|
||||
import { Exercise } from '@/modules/educational/entities/exercise.entity';
|
||||
import { GetStudentProgressQueryDto, AddTeacherNoteDto, StudentNoteResponseDto } from '../dto';
|
||||
|
||||
export interface StudentOverview {
|
||||
@ -101,6 +104,11 @@ export class StudentProgressService {
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(UserStats, 'gamification')
|
||||
private readonly userStatsRepository: Repository<UserStats>,
|
||||
// P1-05: Added 2025-12-18 - Educational repositories for data enrichment
|
||||
@InjectRepository(EducationalModule, 'educational')
|
||||
private readonly moduleRepository: Repository<EducationalModule>,
|
||||
@InjectRepository(Exercise, 'educational')
|
||||
private readonly exerciseRepository: Repository<Exercise>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -255,20 +263,45 @@ export class StudentProgressService {
|
||||
where: { user_id: profile.id },
|
||||
});
|
||||
|
||||
// TODO: Join with actual module data to get names and details
|
||||
return moduleProgresses.map((mp, index) => ({
|
||||
module_id: mp.module_id,
|
||||
module_name: `Módulo ${index + 1}`, // TODO: Get from modules table
|
||||
module_order: index + 1,
|
||||
total_activities: 15, // TODO: Get from module
|
||||
completed_activities: Math.round(
|
||||
(mp.progress_percentage / 100) * 15,
|
||||
),
|
||||
average_score: Math.round(mp.progress_percentage * 0.8), // Estimate
|
||||
time_spent_minutes: 0, // TODO: Calculate from submissions
|
||||
last_activity_date: mp.updated_at, // Using updated_at as proxy for last_activity
|
||||
status: this.calculateModuleStatus(mp.progress_percentage),
|
||||
}));
|
||||
// P1-05: Get module data for enrichment
|
||||
const moduleIds = moduleProgresses.map(mp => mp.module_id);
|
||||
const modules = moduleIds.length > 0
|
||||
? await this.moduleRepository.find({ where: { id: In(moduleIds) } })
|
||||
: [];
|
||||
const moduleMap = new Map(modules.map(m => [m.id, m]));
|
||||
|
||||
// P1-05: Get submissions for time calculation
|
||||
const submissions = await this.submissionRepository.find({
|
||||
where: { user_id: profile.id },
|
||||
});
|
||||
const timeByModule = new Map<string, number>();
|
||||
for (const sub of submissions) {
|
||||
// Get exercise to find module
|
||||
const exercise = await this.exerciseRepository.findOne({ where: { id: sub.exercise_id } });
|
||||
if (exercise) {
|
||||
const currentTime = timeByModule.get(exercise.module_id) || 0;
|
||||
timeByModule.set(exercise.module_id, currentTime + (sub.time_spent_seconds || 0));
|
||||
}
|
||||
}
|
||||
|
||||
// P1-05: Enriched response with real module data
|
||||
return moduleProgresses.map((mp) => {
|
||||
const moduleData = moduleMap.get(mp.module_id);
|
||||
const timeSpentSeconds = timeByModule.get(mp.module_id) || 0;
|
||||
const totalActivities = moduleData?.total_exercises || 15;
|
||||
|
||||
return {
|
||||
module_id: mp.module_id,
|
||||
module_name: moduleData?.title || `Módulo ${moduleData?.order_index || 1}`,
|
||||
module_order: moduleData?.order_index || 1,
|
||||
total_activities: totalActivities,
|
||||
completed_activities: Math.round((mp.progress_percentage / 100) * totalActivities),
|
||||
average_score: Math.round(mp.average_score || mp.progress_percentage * 0.8),
|
||||
time_spent_minutes: Math.round(timeSpentSeconds / 60),
|
||||
last_activity_date: mp.updated_at,
|
||||
status: this.calculateModuleStatus(mp.progress_percentage),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -316,19 +349,37 @@ export class StudentProgressService {
|
||||
order: { submitted_at: 'DESC' },
|
||||
});
|
||||
|
||||
// TODO: Join with exercise data to get titles and types
|
||||
return submissions.map((sub) => ({
|
||||
id: sub.id,
|
||||
exercise_title: 'Ejercicio', // TODO: Get from exercises table
|
||||
module_name: 'Módulo', // TODO: Get from modules table
|
||||
exercise_type: 'multiple_choice', // TODO: Get from exercises table
|
||||
is_correct: sub.is_correct || false,
|
||||
// Protect against division by zero
|
||||
score_percentage: Math.round((sub.score / (sub.max_score || 1)) * 100),
|
||||
time_spent_seconds: sub.time_spent_seconds || 0,
|
||||
hints_used: sub.hints_count || 0,
|
||||
submitted_at: sub.submitted_at,
|
||||
}));
|
||||
// P1-05: Get exercise and module data for enrichment
|
||||
const exerciseIds = [...new Set(submissions.map(s => s.exercise_id))];
|
||||
const exercises = exerciseIds.length > 0
|
||||
? await this.exerciseRepository.find({ where: { id: In(exerciseIds) } })
|
||||
: [];
|
||||
const exerciseMap = new Map(exercises.map(e => [e.id, e]));
|
||||
|
||||
// Get module data for exercise modules
|
||||
const moduleIds = [...new Set(exercises.map(e => e.module_id))];
|
||||
const modules = moduleIds.length > 0
|
||||
? await this.moduleRepository.find({ where: { id: In(moduleIds) } })
|
||||
: [];
|
||||
const moduleMap = new Map(modules.map(m => [m.id, m]));
|
||||
|
||||
// P1-05: Enriched response with real exercise/module data
|
||||
return submissions.map((sub) => {
|
||||
const exercise = exerciseMap.get(sub.exercise_id);
|
||||
const moduleData = exercise ? moduleMap.get(exercise.module_id) : undefined;
|
||||
|
||||
return {
|
||||
id: sub.id,
|
||||
exercise_title: exercise?.title || 'Ejercicio',
|
||||
module_name: moduleData?.title || 'Módulo',
|
||||
exercise_type: exercise?.exercise_type || 'multiple_choice',
|
||||
is_correct: sub.is_correct || false,
|
||||
score_percentage: Math.round((sub.score / (sub.max_score || 1)) * 100),
|
||||
time_spent_seconds: sub.time_spent_seconds || 0,
|
||||
hints_used: sub.hints_count || 0,
|
||||
submitted_at: sub.submitted_at,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -351,32 +402,48 @@ export class StudentProgressService {
|
||||
});
|
||||
|
||||
// Group by exercise to find struggles
|
||||
const exerciseMap = new Map<string, ExerciseSubmission[]>();
|
||||
const submissionsByExercise = new Map<string, ExerciseSubmission[]>();
|
||||
submissions.forEach((sub) => {
|
||||
const key = sub.exercise_id;
|
||||
if (!exerciseMap.has(key)) {
|
||||
exerciseMap.set(key, []);
|
||||
if (!submissionsByExercise.has(key)) {
|
||||
submissionsByExercise.set(key, []);
|
||||
}
|
||||
exerciseMap.get(key)!.push(sub);
|
||||
submissionsByExercise.get(key)!.push(sub);
|
||||
});
|
||||
|
||||
// P1-05: Get exercise and module data for enrichment
|
||||
const exerciseIds = [...submissionsByExercise.keys()];
|
||||
const exercises = exerciseIds.length > 0
|
||||
? await this.exerciseRepository.find({ where: { id: In(exerciseIds) } })
|
||||
: [];
|
||||
const exerciseDataMap = new Map(exercises.map(e => [e.id, e]));
|
||||
|
||||
const moduleIds = [...new Set(exercises.map(e => e.module_id))];
|
||||
const modules = moduleIds.length > 0
|
||||
? await this.moduleRepository.find({ where: { id: In(moduleIds) } })
|
||||
: [];
|
||||
const moduleDataMap = new Map(modules.map(m => [m.id, m]));
|
||||
|
||||
const struggles: StruggleArea[] = [];
|
||||
|
||||
exerciseMap.forEach((subs) => {
|
||||
submissionsByExercise.forEach((subs, exerciseId) => {
|
||||
const attempts = subs.length;
|
||||
const correctAttempts = subs.filter((s) => s.is_correct).length;
|
||||
const successRate = (correctAttempts / attempts) * 100;
|
||||
|
||||
// Consider it a struggle if success rate < 70% and multiple attempts
|
||||
if (successRate < 70 && attempts >= 2) {
|
||||
// Protect against division by zero in score calculation
|
||||
const avgScore =
|
||||
subs.reduce((sum, s) => sum + (s.score / (s.max_score || 1)) * 100, 0) /
|
||||
attempts;
|
||||
|
||||
// P1-05: Get real exercise/module names
|
||||
const exercise = exerciseDataMap.get(exerciseId);
|
||||
const moduleData = exercise ? moduleDataMap.get(exercise.module_id) : undefined;
|
||||
|
||||
struggles.push({
|
||||
topic: 'Tema del ejercicio', // TODO: Get from exercise data
|
||||
module_name: 'Módulo', // TODO: Get from module data
|
||||
topic: exercise?.title || 'Tema del ejercicio',
|
||||
module_name: moduleData?.title || 'Módulo',
|
||||
attempts,
|
||||
success_rate: Math.round(successRate),
|
||||
average_score: Math.round(avgScore),
|
||||
@ -390,6 +457,7 @@ export class StudentProgressService {
|
||||
|
||||
/**
|
||||
* Compare student with class averages
|
||||
* P1-05: Updated 2025-12-18 - Calculate real class averages
|
||||
*/
|
||||
async getClassComparison(studentId: string): Promise<ClassComparison[]> {
|
||||
const studentStats = await this.getStudentStats(studentId);
|
||||
@ -398,12 +466,14 @@ export class StudentProgressService {
|
||||
const allProfiles = await this.profileRepository.find();
|
||||
const allSubmissions = await this.submissionRepository.find();
|
||||
|
||||
// P1-05: Get all user stats for real averages
|
||||
const allUserStats = await this.userStatsRepository.find();
|
||||
|
||||
// Calculate class averages (with division by zero protection)
|
||||
const classAvgScore = allSubmissions.length > 0
|
||||
? Math.round(
|
||||
allSubmissions.reduce(
|
||||
(sum, sub) => {
|
||||
// Protect against division by zero in score calculation
|
||||
const maxScore = sub.max_score || 1;
|
||||
return sum + (sub.score / maxScore) * 100;
|
||||
},
|
||||
@ -417,6 +487,24 @@ export class StudentProgressService {
|
||||
? allSubmissions.length / allProfiles.length
|
||||
: 0;
|
||||
|
||||
// P1-05: Calculate real time average from submissions
|
||||
const totalTimeAllStudents = allSubmissions.reduce(
|
||||
(sum, sub) => sum + (sub.time_spent_seconds || 0),
|
||||
0,
|
||||
);
|
||||
const classAvgTimeMinutes = allProfiles.length > 0
|
||||
? Math.round(totalTimeAllStudents / 60 / allProfiles.length)
|
||||
: 0;
|
||||
|
||||
// P1-05: Calculate real streak average from user_stats
|
||||
const totalStreaks = allUserStats.reduce(
|
||||
(sum, stats) => sum + (stats.current_streak || 0),
|
||||
0,
|
||||
);
|
||||
const classAvgStreak = allUserStats.length > 0
|
||||
? Math.round(totalStreaks / allUserStats.length)
|
||||
: 0;
|
||||
|
||||
return [
|
||||
{
|
||||
metric: 'Puntuación Promedio',
|
||||
@ -439,19 +527,19 @@ export class StudentProgressService {
|
||||
{
|
||||
metric: 'Tiempo de Estudio (min)',
|
||||
student_value: studentStats.total_time_spent_minutes,
|
||||
class_average: 1100, // TODO: Calculate actual class average
|
||||
class_average: classAvgTimeMinutes,
|
||||
percentile: this.calculatePercentile(
|
||||
studentStats.total_time_spent_minutes,
|
||||
1100,
|
||||
classAvgTimeMinutes,
|
||||
),
|
||||
},
|
||||
{
|
||||
metric: 'Racha Actual (días)',
|
||||
student_value: studentStats.current_streak_days,
|
||||
class_average: 5, // TODO: Calculate actual class average
|
||||
class_average: classAvgStreak,
|
||||
percentile: this.calculatePercentile(
|
||||
studentStats.current_streak_days,
|
||||
5,
|
||||
classAvgStreak,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@ -13,7 +13,9 @@ import { Profile } from '@/modules/auth/entities/profile.entity';
|
||||
import { Classroom } from '@/modules/social/entities/classroom.entity';
|
||||
import { ClassroomMember } from '@/modules/social/entities/classroom-member.entity';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { GamilityRoleEnum } from '@/shared/constants/enums.constants';
|
||||
// P0-04: Added 2025-12-18 - NotificationsService integration
|
||||
import { NotificationsService } from '@/modules/notifications/services/notifications.service';
|
||||
import { GamilityRoleEnum, NotificationTypeEnum } from '@/shared/constants/enums.constants';
|
||||
|
||||
export interface RiskAlert {
|
||||
student_id: string;
|
||||
@ -49,6 +51,8 @@ export class StudentRiskAlertService {
|
||||
@InjectRepository(ClassroomMember, 'social')
|
||||
private readonly classroomMemberRepository: Repository<ClassroomMember>,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
// P0-04: Added 2025-12-18 - NotificationsService injection
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -205,29 +209,45 @@ export class StudentRiskAlertService {
|
||||
/**
|
||||
* Send alert notification to teacher about at-risk students
|
||||
*
|
||||
* @TODO: Replace with actual notification service call
|
||||
* P0-04: Implemented 2025-12-18 - NotificationsService integration
|
||||
*/
|
||||
private async sendTeacherAlert(teacherId: string, alerts: RiskAlert[]): Promise<void> {
|
||||
const highRiskCount = alerts.filter(a => a.risk_level === 'high').length;
|
||||
const mediumRiskCount = alerts.filter(a => a.risk_level === 'medium').length;
|
||||
const totalAlerts = highRiskCount + mediumRiskCount;
|
||||
|
||||
this.logger.log(
|
||||
`[NOTIFICATION] Teacher ${teacherId}: ${highRiskCount} high-risk, ${mediumRiskCount} medium-risk students`,
|
||||
);
|
||||
|
||||
// TODO: Integrate with NotificationService
|
||||
// Example:
|
||||
// await this.notificationService.create({
|
||||
// recipient_id: teacherId,
|
||||
// type: 'student_risk_alert',
|
||||
// title: `${highRiskCount + mediumRiskCount} estudiantes requieren atención`,
|
||||
// message: this.formatAlertMessage(alerts),
|
||||
// priority: highRiskCount > 0 ? 'high' : 'medium',
|
||||
// action_url: '/teacher/alerts',
|
||||
// metadata: { alerts }
|
||||
// });
|
||||
try {
|
||||
// P0-04: Send notification via NotificationsService
|
||||
await this.notificationsService.sendNotification({
|
||||
userId: teacherId,
|
||||
type: NotificationTypeEnum.SYSTEM_ANNOUNCEMENT,
|
||||
title: highRiskCount > 0
|
||||
? `⚠️ Alerta: ${totalAlerts} estudiantes requieren atención urgente`
|
||||
: `📊 ${totalAlerts} estudiantes requieren seguimiento`,
|
||||
message: this.formatAlertMessage(alerts),
|
||||
data: {
|
||||
alertType: 'student_risk',
|
||||
highRiskCount,
|
||||
mediumRiskCount,
|
||||
studentIds: alerts.map(a => a.student_id),
|
||||
action: {
|
||||
type: 'navigate',
|
||||
url: '/teacher/alerts',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// For now, just log detailed info
|
||||
this.logger.log(`✅ Notification sent to teacher ${teacherId}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`Failed to send notification to teacher ${teacherId}: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Log detailed info for debugging
|
||||
for (const alert of alerts) {
|
||||
this.logger.debug(
|
||||
` - ${alert.student_name}: ${alert.risk_level} risk, ${alert.overall_score}% score, ${alert.dropout_risk * 100}% dropout risk`,
|
||||
@ -237,21 +257,70 @@ export class StudentRiskAlertService {
|
||||
|
||||
/**
|
||||
* Send summary to admins about high-risk students across the platform
|
||||
*
|
||||
* P0-04: Implemented 2025-12-18 - NotificationsService integration
|
||||
*/
|
||||
private async sendAdminSummary(highRiskAlerts: RiskAlert[]): Promise<void> {
|
||||
this.logger.log(
|
||||
`[ADMIN SUMMARY] ${highRiskAlerts.length} high-risk students detected across platform`,
|
||||
);
|
||||
|
||||
// TODO: Integrate with NotificationService for admins
|
||||
// Example:
|
||||
// await this.notificationService.createForRole({
|
||||
// role: GamilityRoleEnum.SUPER_ADMIN,
|
||||
// type: 'platform_risk_summary',
|
||||
// title: `Alerta: ${highRiskAlerts.length} estudiantes en alto riesgo`,
|
||||
// message: this.formatAdminSummary(highRiskAlerts),
|
||||
// priority: 'high'
|
||||
// });
|
||||
try {
|
||||
// Get all super admins
|
||||
const admins = await this.profileRepository.find({
|
||||
where: { role: GamilityRoleEnum.SUPER_ADMIN },
|
||||
});
|
||||
|
||||
if (admins.length === 0) {
|
||||
this.logger.warn('No super admins found to receive risk summary');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare summary message
|
||||
const summaryLines = [
|
||||
`🚨 Resumen de Riesgo de Plataforma`,
|
||||
``,
|
||||
`Se han detectado ${highRiskAlerts.length} estudiantes en ALTO RIESGO:`,
|
||||
``,
|
||||
];
|
||||
|
||||
for (const alert of highRiskAlerts.slice(0, 10)) { // Limit to first 10
|
||||
summaryLines.push(`• ${alert.student_name} - Riesgo abandono: ${Math.round(alert.dropout_risk * 100)}%`);
|
||||
}
|
||||
|
||||
if (highRiskAlerts.length > 10) {
|
||||
summaryLines.push(`... y ${highRiskAlerts.length - 10} más`);
|
||||
}
|
||||
|
||||
summaryLines.push(``);
|
||||
summaryLines.push(`Revisa el panel de administración para acciones.`);
|
||||
|
||||
const summaryMessage = summaryLines.join('\n');
|
||||
|
||||
// P0-04: Send notification to each admin
|
||||
const notifications = admins.map(admin => ({
|
||||
userId: admin.id,
|
||||
type: NotificationTypeEnum.SYSTEM_ANNOUNCEMENT,
|
||||
title: `🚨 Alerta Crítica: ${highRiskAlerts.length} estudiantes en alto riesgo`,
|
||||
message: summaryMessage,
|
||||
data: {
|
||||
alertType: 'platform_risk_summary',
|
||||
highRiskCount: highRiskAlerts.length,
|
||||
studentIds: highRiskAlerts.map(a => a.student_id),
|
||||
action: {
|
||||
type: 'navigate',
|
||||
url: '/admin/alerts',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
await this.notificationsService.sendBulkNotifications(notifications);
|
||||
|
||||
this.logger.log(`✅ Admin summary sent to ${admins.length} administrators`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`Failed to send admin summary: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -72,6 +72,8 @@ import { TeacherGuard, ClassroomOwnershipGuard } from './guards';
|
||||
|
||||
// External modules
|
||||
import { ProgressModule } from '@modules/progress/progress.module';
|
||||
// P0-04: Added 2025-12-18 - NotificationsModule for StudentRiskAlertService
|
||||
import { NotificationsModule } from '@modules/notifications/notifications.module';
|
||||
|
||||
/**
|
||||
* TeacherModule
|
||||
@ -124,6 +126,9 @@ import { ProgressModule } from '@modules/progress/progress.module';
|
||||
// Import ProgressModule for ExerciseSubmissionService (needed for reward distribution)
|
||||
ProgressModule,
|
||||
|
||||
// P0-04: Import NotificationsModule for StudentRiskAlertService
|
||||
NotificationsModule,
|
||||
|
||||
// Entities from 'auth' datasource
|
||||
TypeOrmModule.forFeature([Profile, User], 'auth'),
|
||||
|
||||
|
||||
@ -17,7 +17,14 @@ import {
|
||||
import { Logger, UseGuards } from '@nestjs/common';
|
||||
import { Server } from 'socket.io';
|
||||
import { WsJwtGuard, AuthenticatedSocket } from './guards/ws-jwt.guard';
|
||||
import { SocketEvent } from './types/websocket.types';
|
||||
import {
|
||||
SocketEvent,
|
||||
StudentActivityPayload,
|
||||
ClassroomUpdatePayload,
|
||||
NewSubmissionPayload,
|
||||
AlertTriggeredPayload,
|
||||
StudentOnlineStatusPayload,
|
||||
} from './types/websocket.types';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
@ -175,4 +182,166 @@ implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||
getUserSocketCount(userId: string): number {
|
||||
return this.userSockets.get(userId)?.size || 0;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// TEACHER PORTAL METHODS (P2-01: 2025-12-18)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe teacher to classroom updates
|
||||
*/
|
||||
@UseGuards(WsJwtGuard)
|
||||
@SubscribeMessage('teacher:subscribe_classroom')
|
||||
async handleSubscribeClassroom(
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
@MessageBody() data: { classroomId: string },
|
||||
) {
|
||||
try {
|
||||
const userId = client.userData!.userId;
|
||||
const { classroomId } = data;
|
||||
const room = `classroom:${classroomId}`;
|
||||
|
||||
await client.join(room);
|
||||
this.logger.debug(`Teacher ${userId} subscribed to classroom ${classroomId}`);
|
||||
|
||||
return { success: true, room };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error('Error subscribing to classroom:', error);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe teacher from classroom updates
|
||||
*/
|
||||
@UseGuards(WsJwtGuard)
|
||||
@SubscribeMessage('teacher:unsubscribe_classroom')
|
||||
async handleUnsubscribeClassroom(
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
@MessageBody() data: { classroomId: string },
|
||||
) {
|
||||
try {
|
||||
const userId = client.userData!.userId;
|
||||
const { classroomId } = data;
|
||||
const room = `classroom:${classroomId}`;
|
||||
|
||||
await client.leave(room);
|
||||
this.logger.debug(`Teacher ${userId} unsubscribed from classroom ${classroomId}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error('Error unsubscribing from classroom:', error);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit student activity to classroom teachers
|
||||
*/
|
||||
emitStudentActivity(classroomId: string, payload: Omit<StudentActivityPayload, 'timestamp'>) {
|
||||
const room = `classroom:${classroomId}`;
|
||||
this.server.to(room).emit(SocketEvent.STUDENT_ACTIVITY, {
|
||||
...payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.debug(`Student activity emitted to classroom ${classroomId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit classroom update to subscribed teachers
|
||||
*/
|
||||
emitClassroomUpdate(classroomId: string, payload: Omit<ClassroomUpdatePayload, 'timestamp'>) {
|
||||
const room = `classroom:${classroomId}`;
|
||||
this.server.to(room).emit(SocketEvent.CLASSROOM_UPDATE, {
|
||||
...payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.debug(`Classroom update emitted to ${classroomId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit new submission notification to classroom teachers
|
||||
*/
|
||||
emitNewSubmission(classroomId: string, payload: Omit<NewSubmissionPayload, 'timestamp'>) {
|
||||
const room = `classroom:${classroomId}`;
|
||||
this.server.to(room).emit(SocketEvent.NEW_SUBMISSION, {
|
||||
...payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.debug(`New submission emitted to classroom ${classroomId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit alert to specific teacher and classroom
|
||||
*/
|
||||
emitAlertTriggered(
|
||||
teacherId: string,
|
||||
classroomId: string,
|
||||
payload: Omit<AlertTriggeredPayload, 'timestamp'>,
|
||||
) {
|
||||
// Emit to teacher's personal room
|
||||
this.emitToUser(teacherId, SocketEvent.ALERT_TRIGGERED, payload);
|
||||
// Also emit to classroom room for other subscribed teachers
|
||||
const room = `classroom:${classroomId}`;
|
||||
this.server.to(room).emit(SocketEvent.ALERT_TRIGGERED, {
|
||||
...payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.debug(`Alert triggered for teacher ${teacherId} in classroom ${classroomId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit student online/offline status to classroom
|
||||
*/
|
||||
emitStudentOnlineStatus(classroomId: string, payload: Omit<StudentOnlineStatusPayload, 'timestamp'>) {
|
||||
const room = `classroom:${classroomId}`;
|
||||
const event = payload.isOnline ? SocketEvent.STUDENT_ONLINE : SocketEvent.STUDENT_OFFLINE;
|
||||
this.server.to(room).emit(event, {
|
||||
...payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.debug(`Student ${payload.studentId} is now ${payload.isOnline ? 'online' : 'offline'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit progress update for a student
|
||||
*/
|
||||
emitProgressUpdate(
|
||||
teacherIds: string[],
|
||||
classroomId: string,
|
||||
data: {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
progressType: 'module_complete' | 'exercise_complete' | 'level_up' | 'achievement';
|
||||
details: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const payload = {
|
||||
...data,
|
||||
classroomId,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Emit to all relevant teachers
|
||||
teacherIds.forEach((teacherId) => {
|
||||
this.emitToUser(teacherId, SocketEvent.PROGRESS_UPDATE, payload);
|
||||
});
|
||||
|
||||
// Also emit to classroom room
|
||||
const room = `classroom:${classroomId}`;
|
||||
this.server.to(room).emit(SocketEvent.PROGRESS_UPDATE, payload);
|
||||
|
||||
this.logger.debug(`Progress update emitted for student ${data.studentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of teachers subscribed to a classroom
|
||||
*/
|
||||
async getClassroomSubscriberCount(classroomId: string): Promise<number> {
|
||||
const room = `classroom:${classroomId}`;
|
||||
const sockets = await this.server.in(room).fetchSockets();
|
||||
return sockets.length;
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,15 @@ export enum SocketEvent {
|
||||
// Missions
|
||||
MISSION_COMPLETED = 'mission:completed',
|
||||
MISSION_PROGRESS = 'mission:progress',
|
||||
|
||||
// Teacher Portal (P2-01: 2025-12-18)
|
||||
STUDENT_ACTIVITY = 'teacher:student_activity',
|
||||
CLASSROOM_UPDATE = 'teacher:classroom_update',
|
||||
NEW_SUBMISSION = 'teacher:new_submission',
|
||||
ALERT_TRIGGERED = 'teacher:alert_triggered',
|
||||
STUDENT_ONLINE = 'teacher:student_online',
|
||||
STUDENT_OFFLINE = 'teacher:student_offline',
|
||||
PROGRESS_UPDATE = 'teacher:progress_update',
|
||||
}
|
||||
|
||||
export interface SocketUserData {
|
||||
@ -51,3 +60,60 @@ export interface LeaderboardPayload {
|
||||
leaderboard: any[]; // Will be typed from gamification module
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Teacher Portal Payloads (P2-01: 2025-12-18)
|
||||
|
||||
export interface StudentActivityPayload {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
classroomId: string;
|
||||
activityType: 'exercise_start' | 'exercise_complete' | 'hint_used' | 'comodin_used' | 'module_start';
|
||||
exerciseId?: string;
|
||||
exerciseTitle?: string;
|
||||
moduleId?: string;
|
||||
moduleTitle?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ClassroomUpdatePayload {
|
||||
classroomId: string;
|
||||
classroomName: string;
|
||||
updateType: 'student_joined' | 'student_left' | 'stats_changed';
|
||||
data: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NewSubmissionPayload {
|
||||
submissionId: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
exerciseId: string;
|
||||
exerciseTitle: string;
|
||||
classroomId: string;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
requiresReview: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AlertTriggeredPayload {
|
||||
alertId: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
classroomId: string;
|
||||
alertType: 'at_risk' | 'low_performance' | 'inactive' | 'struggling';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface StudentOnlineStatusPayload {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
classroomId: string;
|
||||
isOnline: boolean;
|
||||
lastActivity?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
208
projects/gamilit/apps/database/FLUJO-CARGA-LIMPIA.md
Normal file
208
projects/gamilit/apps/database/FLUJO-CARGA-LIMPIA.md
Normal file
@ -0,0 +1,208 @@
|
||||
# FLUJO DE CARGA LIMPIA - GAMILIT DATABASE
|
||||
|
||||
**Fecha:** 2025-12-18
|
||||
**Version:** 1.0
|
||||
**Cumple con:** DIRECTIVA-POLITICA-CARGA-LIMPIA.md
|
||||
|
||||
---
|
||||
|
||||
## RESUMEN
|
||||
|
||||
Este documento describe los 3 escenarios de inicializacion de base de datos y que script usar en cada caso.
|
||||
|
||||
---
|
||||
|
||||
## ESCENARIOS Y SCRIPTS
|
||||
|
||||
### Escenario 1: INSTALACION NUEVA (Usuario y BD no existen)
|
||||
|
||||
**Usar:** `init-database.sh` o `init-database-v3.sh`
|
||||
|
||||
**Ubicacion:** `apps/database/scripts/`
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/projects/gamilit/apps/database/scripts
|
||||
|
||||
# Opcion A: Con password manual
|
||||
./init-database.sh --env dev --password "tu_password_seguro"
|
||||
|
||||
# Opcion B: Con dotenv-vault (recomendado para produccion)
|
||||
./manage-secrets.sh generate --env prod
|
||||
./manage-secrets.sh sync --env prod
|
||||
./init-database-v3.sh --env prod
|
||||
```
|
||||
|
||||
**Que hace:**
|
||||
1. Crea usuario PostgreSQL `gamilit_user`
|
||||
2. Crea base de datos `gamilit_platform`
|
||||
3. Ejecuta todos los DDL (16 fases)
|
||||
4. Carga todos los Seeds (38+ archivos)
|
||||
5. Genera archivo de credenciales
|
||||
6. Actualiza .env de backend/frontend
|
||||
|
||||
---
|
||||
|
||||
### Escenario 2: RECREACION COMPLETA (Usuario existe, BD se resetea)
|
||||
|
||||
**Usar:** `drop-and-recreate-database.sh`
|
||||
|
||||
**Ubicacion:** `apps/database/`
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/projects/gamilit/apps/database
|
||||
|
||||
# Con DATABASE_URL
|
||||
export DATABASE_URL="postgresql://gamilit_user:password@localhost:5432/gamilit_platform"
|
||||
./drop-and-recreate-database.sh
|
||||
|
||||
# O pasando como argumento
|
||||
./drop-and-recreate-database.sh "postgresql://gamilit_user:password@localhost:5432/gamilit_platform"
|
||||
```
|
||||
|
||||
**Que hace:**
|
||||
1. Desconecta usuarios activos
|
||||
2. DROP DATABASE gamilit_platform
|
||||
3. CREATE DATABASE gamilit_platform
|
||||
4. Llama a `create-database.sh` automaticamente
|
||||
|
||||
---
|
||||
|
||||
### Escenario 3: SOLO DDL + SEEDS (BD limpia ya existe)
|
||||
|
||||
**Usar:** `create-database.sh`
|
||||
|
||||
**Ubicacion:** `apps/database/`
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/projects/gamilit/apps/database
|
||||
|
||||
export DATABASE_URL="postgresql://gamilit_user:password@localhost:5432/gamilit_platform"
|
||||
./create-database.sh
|
||||
```
|
||||
|
||||
**Que hace:**
|
||||
1. Habilita extensiones (pgcrypto, uuid-ossp)
|
||||
2. Ejecuta DDL en 16 fases ordenadas
|
||||
3. Carga Seeds de produccion (38+ archivos)
|
||||
4. Genera reporte de objetos creados
|
||||
|
||||
---
|
||||
|
||||
## DIAGRAMA DE DECISION
|
||||
|
||||
```
|
||||
¿Existe el usuario gamilit_user?
|
||||
│
|
||||
├── NO ──► Escenario 1: ./scripts/init-database.sh --env dev
|
||||
│
|
||||
└── SI ──► ¿Necesitas eliminar TODOS los datos?
|
||||
│
|
||||
├── SI ──► Escenario 2: ./drop-and-recreate-database.sh
|
||||
│
|
||||
└── NO ──► ¿La BD esta vacia (recien creada)?
|
||||
│
|
||||
├── SI ──► Escenario 3: ./create-database.sh
|
||||
│
|
||||
└── NO ──► Escenario 2: ./drop-and-recreate-database.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COMANDOS RAPIDOS POR AMBIENTE
|
||||
|
||||
### Desarrollo (primera vez)
|
||||
|
||||
```bash
|
||||
cd apps/database/scripts
|
||||
./init-database.sh --env dev --password "dev_password_123"
|
||||
```
|
||||
|
||||
### Desarrollo (recrear BD)
|
||||
|
||||
```bash
|
||||
cd apps/database
|
||||
export DATABASE_URL="postgresql://gamilit_user:dev_password_123@localhost:5432/gamilit_platform"
|
||||
./drop-and-recreate-database.sh
|
||||
```
|
||||
|
||||
### Produccion (primera vez)
|
||||
|
||||
```bash
|
||||
cd apps/database/scripts
|
||||
./manage-secrets.sh generate --env prod
|
||||
./manage-secrets.sh sync --env prod
|
||||
./init-database-v3.sh --env prod
|
||||
```
|
||||
|
||||
### Produccion (recrear BD)
|
||||
|
||||
```bash
|
||||
cd apps/database
|
||||
export DATABASE_URL="postgresql://gamilit_user:$DB_PASSWORD@localhost:5432/gamilit_platform"
|
||||
./drop-and-recreate-database.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VALIDACION POST-CARGA
|
||||
|
||||
Despues de cualquier escenario, validar con:
|
||||
|
||||
```bash
|
||||
# Verificar conteo de objetos
|
||||
psql "$DATABASE_URL" -c "
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name NOT IN ('pg_catalog','information_schema','pg_toast')) as schemas,
|
||||
(SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema')) as tables,
|
||||
(SELECT COUNT(*) FROM pg_type WHERE typcategory = 'E') as enums
|
||||
;"
|
||||
|
||||
# Verificar datos criticos
|
||||
psql "$DATABASE_URL" -c "
|
||||
SELECT 'tenants' as tabla, COUNT(*) FROM auth_management.tenants
|
||||
UNION ALL SELECT 'users', COUNT(*) FROM auth.users
|
||||
UNION ALL SELECT 'modules', COUNT(*) FROM educational_content.modules
|
||||
UNION ALL SELECT 'maya_ranks', COUNT(*) FROM gamification_system.maya_ranks
|
||||
UNION ALL SELECT 'feature_flags', COUNT(*) FROM system_configuration.feature_flags;
|
||||
"
|
||||
```
|
||||
|
||||
**Valores esperados:**
|
||||
- Schemas: 15+
|
||||
- Tablas: 60+
|
||||
- ENUMs: 35+
|
||||
- Tenants: 14+
|
||||
- Users: 20+
|
||||
- Modules: 5
|
||||
- Maya Ranks: 5
|
||||
- Feature Flags: 26+
|
||||
|
||||
---
|
||||
|
||||
## SCRIPTS DISPONIBLES
|
||||
|
||||
| Script | Ubicacion | Proposito |
|
||||
|--------|-----------|-----------|
|
||||
| `init-database.sh` | scripts/ | Crear usuario + BD + DDL + Seeds |
|
||||
| `init-database-v3.sh` | scripts/ | Igual pero con dotenv-vault |
|
||||
| `drop-and-recreate-database.sh` | ./ | Drop BD + Recrear + DDL + Seeds |
|
||||
| `create-database.sh` | ./ | Solo DDL + Seeds (BD debe existir) |
|
||||
| `reset-database.sh` | scripts/ | Reset BD (mantiene usuario) |
|
||||
| `recreate-database.sh` | scripts/ | Drop completo (usuario + BD) |
|
||||
| `manage-secrets.sh` | scripts/ | Gestionar passwords con vault |
|
||||
|
||||
---
|
||||
|
||||
## CUMPLIMIENTO DE DIRECTIVA
|
||||
|
||||
Este flujo cumple con DIRECTIVA-POLITICA-CARGA-LIMPIA.md:
|
||||
|
||||
- ✅ DDL es fuente de verdad
|
||||
- ✅ BD es resultado de ejecutar DDL
|
||||
- ✅ No se usan migrations
|
||||
- ✅ Recreacion completa en cualquier momento
|
||||
- ✅ Un comando = BD lista
|
||||
|
||||
---
|
||||
|
||||
**Ultima actualizacion:** 2025-12-18
|
||||
@ -72,7 +72,6 @@ INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, e
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES ('00000000-0000-0000-0000-000000000000', '5ae21325-7450-4c37-82f1-3f9bcd7b6f45', 'authenticated', NULL, 'omarcitogonzalezzavaleta@gmail.com', '$2b$10$RRk3DAgQdiikxVImFIMqquqB.TNpKs3E.RNFtt1rwwTzO24uShri.', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '{"provider": "email", "providers": ["email"]}', '{"last_name": "", "first_name": ""}', false, '2025-11-25 08:17:07.610076+00', '2025-11-25 08:17:07.610076+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES ('00000000-0000-0000-0000-000000000000', 'a4d27774-8a51-4660-ad2f-81d0dfd3a5a7', 'authenticated', NULL, 'gustavobm2024cbtis@gmail.com', '$2b$10$lg7KRUTPofcx4Rtyey8J7.XO0gmdBLCFIfK5uP08mqT0qUIl1aTJq', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '{"provider": "email", "providers": ["email"]}', '{"last_name": "", "first_name": ""}', false, '2025-11-25 08:20:49.649184+00', '2025-11-25 08:20:49.649184+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES ('00000000-0000-0000-0000-000000000000', '6e30164a-78b0-49b0-bd21-23d7c6c03349', 'authenticated', NULL, 'marianaxsotoxt22@gmail.com', '$2b$10$GQC9yTWiP2vP9GUp0gnhUeLjmw70EI4JQhfJBZbMOlCNXGXb/bt5O', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '{"provider": "email", "providers": ["email"]}', '{"last_name": "", "first_name": ""}', false, '2025-11-25 08:33:18.150784+00', '2025-11-25 08:33:18.150784+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES (NULL, '0ae1bf21-39e3-4168-9632-457418c7a07d', 'authenticated', NULL, 'rckrdmrd@gmail.com', '$2b$10$LiDdaJLA.ZvdFleamkMuvOcIrW0PQMEh5aVZ5Wg5pzhm7gwc5s.1C', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-09 01:22:42.784+00', NULL, '{}', false, '2025-11-29 13:37:09.271457+00', '2025-12-09 01:22:42.785367+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES ('00000000-0000-0000-0000-000000000000', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'authenticated', NULL, 'admin@gamilit.com', '$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga', '2025-11-29 13:26:50.289631+00', NULL, '', NULL, '', NULL, '', '', NULL, '2025-12-01 00:54:19.615+00', '{"provider": "email", "providers": ["email"]}', '{"name": "Admin GAMILIT", "role": "super_admin", "description": "Usuario administrador de testing"}', false, '2025-11-29 13:26:50.289631+00', '2025-12-01 00:54:19.617766+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'super_admin', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES (NULL, '69681b09-5077-4f77-84cc-67606abd9755', 'authenticated', NULL, 'javiermar06@hotmail.com', '$2b$10$3RHyXnR4BG3NaxP8Ez82FuiGDMNCG7GhNaOsMFigy3BpIVOzCqHMW', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-14 03:51:04.122+00', NULL, '{}', false, '2025-12-08 19:24:06.266895+00', '2025-12-14 03:51:04.123886+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES (NULL, 'f929d6df-8c29-461f-88f5-264facd879e9', 'authenticated', NULL, 'ju188an@gmail.com', '$2b$10$9vUERFnXApdfXuAI7DFve.aa8uDjI5bfm4CI75/EZ2cUre83RytKe', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-17 23:51:43.553+00', NULL, '{}', false, '2025-12-17 17:51:43.530434+00', '2025-12-17 23:51:43.55475+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
@ -127,7 +126,6 @@ INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, fi
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('c0aecfcc-3b2f-4117-9f20-e0920df97dc0', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, '', '', 'segurauriel235@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-11-29 13:30:54.277737+00', '2025-11-29 13:30:54.277737+00', '5d1839f6-b03f-4e12-b236-eca43f4674f2', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('3dfcdc9d-de8a-45b3-a05f-b83b51097ef5', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, '', '', 'omarcitogonzalezzavaleta@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-11-29 13:30:54.277737+00', '2025-11-29 13:30:54.277737+00', '5ae21325-7450-4c37-82f1-3f9bcd7b6f45', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('bb74b280-db90-4240-ab09-b8c6cf63d553', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, '', '', 'erickfranco462@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-11-29 13:30:54.277737+00', '2025-11-29 13:30:54.277737+00', '2d9f05d4-44dd-42cd-97aa-d57bd06fecd0', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('0ae1bf21-39e3-4168-9632-457418c7a07d', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, 'rckrdmrd@gmail.com', NULL, 'rckrdmrd@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-11-29 13:37:09.278078+00', '2025-11-29 13:37:09.278078+00', '0ae1bf21-39e3-4168-9632-457418c7a07d', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('69681b09-5077-4f77-84cc-67606abd9755', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, 'Javier', ' Mar', 'javiermar06@hotmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-12-08 19:24:06.272257+00', '2025-12-08 19:24:06.272257+00', '69681b09-5077-4f77-84cc-67606abd9755', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('f929d6df-8c29-461f-88f5-264facd879e9', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, 'Juan', 'pa', 'ju188an@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-12-17 17:51:43.536295+00', '2025-12-17 17:51:43.536295+00', 'f929d6df-8c29-461f-88f5-264facd879e9', NULL);
|
||||
|
||||
|
||||
@ -45,6 +45,5 @@ instance_id,id,aud,role,email,encrypted_password,email_confirmed_at,invited_at,c
|
||||
00000000-0000-0000-0000-000000000000,cccccccc-cccc-cccc-cccc-cccccccccccc,authenticated,,student@gamilit.com,$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga,2025-11-29 13:26:50.289631+00,,"",,"",,"","",,2025-12-07 03:42:02.528+00,"{""provider"": ""email"", ""providers"": [""email""]}","{""name"": ""Estudiante Testing"", ""role"": ""student"", ""description"": ""Usuario estudiante de testing""}",f,2025-11-29 13:26:50.289631+00,2025-12-07 03:42:02.529507+00,,,,,,,,0,,,,f,,student,active
|
||||
00000000-0000-0000-0000-000000000000,bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb,authenticated,,teacher@gamilit.com,$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga,2025-11-29 13:26:50.289631+00,,"",,"",,"","",,,"{""provider"": ""email"", ""providers"": [""email""]}","{""name"": ""Profesor Testing"", ""role"": ""admin_teacher"", ""description"": ""Usuario profesor de testing""}",f,2025-11-29 13:26:50.289631+00,2025-11-29 13:26:50.289631+00,,,,,,,,0,,,,f,,admin_teacher,active
|
||||
00000000-0000-0000-0000-000000000000,aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa,authenticated,,admin@gamilit.com,$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga,2025-11-29 13:26:50.289631+00,,"",,"",,"","",,2025-12-01 00:54:19.615+00,"{""provider"": ""email"", ""providers"": [""email""]}","{""name"": ""Admin GAMILIT"", ""role"": ""super_admin"", ""description"": ""Usuario administrador de testing""}",f,2025-11-29 13:26:50.289631+00,2025-12-01 00:54:19.617766+00,,,,,,,,0,,,,f,,super_admin,active
|
||||
,0ae1bf21-39e3-4168-9632-457418c7a07d,authenticated,,rckrdmrd@gmail.com,$2b$10$LiDdaJLA.ZvdFleamkMuvOcIrW0PQMEh5aVZ5Wg5pzhm7gwc5s.1C,,,,,,,,,,2025-12-09 01:22:42.784+00,,{},f,2025-11-29 13:37:09.271457+00,2025-12-09 01:22:42.785367+00,,,,,,,,0,,,,f,,student,active
|
||||
,69681b09-5077-4f77-84cc-67606abd9755,authenticated,,javiermar06@hotmail.com,$2b$10$3RHyXnR4BG3NaxP8Ez82FuiGDMNCG7GhNaOsMFigy3BpIVOzCqHMW,,,,,,,,,,2025-12-14 03:51:04.122+00,,{},f,2025-12-08 19:24:06.266895+00,2025-12-14 03:51:04.123886+00,,,,,,,,0,,,,f,,student,active
|
||||
,f929d6df-8c29-461f-88f5-264facd879e9,authenticated,,ju188an@gmail.com,$2b$10$9vUERFnXApdfXuAI7DFve.aa8uDjI5bfm4CI75/EZ2cUre83RytKe,,,,,,,,,,2025-12-17 23:51:43.553+00,,{},f,2025-12-17 17:51:43.530434+00,2025-12-17 23:51:43.55475+00,,,,,,,,0,,,,f,,student,active
|
||||
|
||||
|
@ -45,6 +45,5 @@ de1511df-f963-4ff6-8e3f-2225ba493879,a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11,,,"","
|
||||
26168044-3b5c-43f6-a757-833ba1485d41,a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11,,,"","",enriquecuevascbtis136@gmail.com,,,,,,,,student,active,f,f,"{""theme"": ""detective"", ""language"": ""es"", ""timezone"": ""America/Mexico_City"", ""sound_enabled"": true, ""notifications_enabled"": true}",,,{},2025-11-29 13:30:54.277737+00,2025-11-29 13:30:54.277737+00,1efe491d-98ef-4c02-acd1-3135f7289072,
|
||||
e742724a-0ff6-4760-884b-866835460045,a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11,,,"","",fl432025@gmail.com,,,,,,,,student,active,f,f,"{""theme"": ""detective"", ""language"": ""es"", ""timezone"": ""America/Mexico_City"", ""sound_enabled"": true, ""notifications_enabled"": true}",,,{},2025-11-29 13:30:54.277737+00,2025-11-29 13:30:54.277737+00,547eb778-4782-4681-b198-c731bba36147,
|
||||
3ce354c8-bcac-44c6-9a94-5274e5f9b389,a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11,,,"","",abdallahxelhaneriavega@gmail.com,,,,,,,,student,active,f,f,"{""theme"": ""detective"", ""language"": ""es"", ""timezone"": ""America/Mexico_City"", ""sound_enabled"": true, ""notifications_enabled"": true}",,,{},2025-11-29 13:30:54.277737+00,2025-11-29 13:30:54.277737+00,f4c46f46-3fb9-40bf-a52b-a8ad2e6a92e1,
|
||||
0ae1bf21-39e3-4168-9632-457418c7a07d,a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11,,,rckrdmrd@gmail.com,,rckrdmrd@gmail.com,,,,,,,,student,active,f,f,"{""theme"": ""detective"", ""language"": ""es"", ""timezone"": ""America/Mexico_City"", ""sound_enabled"": true, ""notifications_enabled"": true}",,,{},2025-11-29 13:37:09.278078+00,2025-11-29 13:37:09.278078+00,0ae1bf21-39e3-4168-9632-457418c7a07d,
|
||||
69681b09-5077-4f77-84cc-67606abd9755,a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11,,,Javier, Mar,javiermar06@hotmail.com,,,,,,,,student,active,f,f,"{""theme"": ""detective"", ""language"": ""es"", ""timezone"": ""America/Mexico_City"", ""sound_enabled"": true, ""notifications_enabled"": true}",,,{},2025-12-08 19:24:06.272257+00,2025-12-08 19:24:06.272257+00,69681b09-5077-4f77-84cc-67606abd9755,
|
||||
f929d6df-8c29-461f-88f5-264facd879e9,a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11,,,Juan,pa,ju188an@gmail.com,,,,,,,,student,active,f,f,"{""theme"": ""detective"", ""language"": ""es"", ""timezone"": ""America/Mexico_City"", ""sound_enabled"": true, ""notifications_enabled"": true}",,,{},2025-12-17 17:51:43.536295+00,2025-12-17 17:51:43.536295+00,f929d6df-8c29-461f-88f5-264facd879e9,
|
||||
|
||||
|
@ -67,7 +67,6 @@ INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, e
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES ('00000000-0000-0000-0000-000000000000', '5ae21325-7450-4c37-82f1-3f9bcd7b6f45', 'authenticated', NULL, 'omarcitogonzalezzavaleta@gmail.com', '$2b$10$RRk3DAgQdiikxVImFIMqquqB.TNpKs3E.RNFtt1rwwTzO24uShri.', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '{"provider": "email", "providers": ["email"]}', '{"last_name": "", "first_name": ""}', false, '2025-11-25 08:17:07.610076+00', '2025-11-25 08:17:07.610076+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES ('00000000-0000-0000-0000-000000000000', 'a4d27774-8a51-4660-ad2f-81d0dfd3a5a7', 'authenticated', NULL, 'gustavobm2024cbtis@gmail.com', '$2b$10$lg7KRUTPofcx4Rtyey8J7.XO0gmdBLCFIfK5uP08mqT0qUIl1aTJq', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '{"provider": "email", "providers": ["email"]}', '{"last_name": "", "first_name": ""}', false, '2025-11-25 08:20:49.649184+00', '2025-11-25 08:20:49.649184+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES ('00000000-0000-0000-0000-000000000000', '6e30164a-78b0-49b0-bd21-23d7c6c03349', 'authenticated', NULL, 'marianaxsotoxt22@gmail.com', '$2b$10$GQC9yTWiP2vP9GUp0gnhUeLjmw70EI4JQhfJBZbMOlCNXGXb/bt5O', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '{"provider": "email", "providers": ["email"]}', '{"last_name": "", "first_name": ""}', false, '2025-11-25 08:33:18.150784+00', '2025-11-25 08:33:18.150784+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES (NULL, '0ae1bf21-39e3-4168-9632-457418c7a07d', 'authenticated', NULL, 'rckrdmrd@gmail.com', '$2b$10$LiDdaJLA.ZvdFleamkMuvOcIrW0PQMEh5aVZ5Wg5pzhm7gwc5s.1C', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-09 01:22:42.784+00', NULL, '{}', false, '2025-11-29 13:37:09.271457+00', '2025-12-09 01:22:42.785367+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES ('00000000-0000-0000-0000-000000000000', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'authenticated', NULL, 'admin@gamilit.com', '$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga', '2025-11-29 13:26:50.289631+00', NULL, '', NULL, '', NULL, '', '', NULL, '2025-12-01 00:54:19.615+00', '{"provider": "email", "providers": ["email"]}', '{"name": "Admin GAMILIT", "role": "super_admin", "description": "Usuario administrador de testing"}', false, '2025-11-29 13:26:50.289631+00', '2025-12-01 00:54:19.617766+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'super_admin', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES (NULL, '69681b09-5077-4f77-84cc-67606abd9755', 'authenticated', NULL, 'javiermar06@hotmail.com', '$2b$10$3RHyXnR4BG3NaxP8Ez82FuiGDMNCG7GhNaOsMFigy3BpIVOzCqHMW', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-14 03:51:04.122+00', NULL, '{}', false, '2025-12-08 19:24:06.266895+00', '2025-12-14 03:51:04.123886+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at, confirmation_token, confirmation_sent_at, recovery_token, recovery_sent_at, email_change_token_new, email_change, email_change_sent_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at, phone, phone_confirmed_at, phone_change, phone_change_token, phone_change_sent_at, confirmed_at, email_change_token_current, email_change_confirm_status, banned_until, reauthentication_token, reauthentication_sent_at, is_sso_user, deleted_at, gamilit_role, status) VALUES (NULL, 'f929d6df-8c29-461f-88f5-264facd879e9', 'authenticated', NULL, 'ju188an@gmail.com', '$2b$10$9vUERFnXApdfXuAI7DFve.aa8uDjI5bfm4CI75/EZ2cUre83RytKe', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-17 23:51:43.553+00', NULL, '{}', false, '2025-12-17 17:51:43.530434+00', '2025-12-17 23:51:43.55475+00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, false, NULL, 'student', 'active');
|
||||
@ -123,7 +122,6 @@ INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, fi
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('c0aecfcc-3b2f-4117-9f20-e0920df97dc0', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, '', '', 'segurauriel235@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-11-29 13:30:54.277737+00', '2025-11-29 13:30:54.277737+00', '5d1839f6-b03f-4e12-b236-eca43f4674f2', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('3dfcdc9d-de8a-45b3-a05f-b83b51097ef5', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, '', '', 'omarcitogonzalezzavaleta@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-11-29 13:30:54.277737+00', '2025-11-29 13:30:54.277737+00', '5ae21325-7450-4c37-82f1-3f9bcd7b6f45', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('bb74b280-db90-4240-ab09-b8c6cf63d553', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, '', '', 'erickfranco462@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-11-29 13:30:54.277737+00', '2025-11-29 13:30:54.277737+00', '2d9f05d4-44dd-42cd-97aa-d57bd06fecd0', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('0ae1bf21-39e3-4168-9632-457418c7a07d', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, 'rckrdmrd@gmail.com', NULL, 'rckrdmrd@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-11-29 13:37:09.278078+00', '2025-11-29 13:37:09.278078+00', '0ae1bf21-39e3-4168-9632-457418c7a07d', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('69681b09-5077-4f77-84cc-67606abd9755', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, 'Javier', ' Mar', 'javiermar06@hotmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-12-08 19:24:06.272257+00', '2025-12-08 19:24:06.272257+00', '69681b09-5077-4f77-84cc-67606abd9755', NULL);
|
||||
INSERT INTO auth_management.profiles (id, tenant_id, display_name, full_name, first_name, last_name, email, avatar_url, bio, phone, date_of_birth, grade_level, student_id, school_id, role, status, email_verified, phone_verified, preferences, last_sign_in_at, last_activity_at, metadata, created_at, updated_at, user_id, deleted_at) VALUES ('f929d6df-8c29-461f-88f5-264facd879e9', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', NULL, NULL, 'Juan', 'pa', 'ju188an@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'student', 'active', false, false, '{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "sound_enabled": true, "notifications_enabled": true}', NULL, NULL, '{}', '2025-12-17 17:51:43.536295+00', '2025-12-17 17:51:43.536295+00', 'f929d6df-8c29-461f-88f5-264facd879e9', NULL);
|
||||
|
||||
|
||||
@ -77,5 +77,15 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA gamilit GRANT EXECUTE ON FUNCTIONS TO gamilit
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT EXECUTE ON FUNCTIONS TO gamilit_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO gamilit_user;
|
||||
|
||||
-- =====================================================
|
||||
-- BYPASS RLS for gamilit_user
|
||||
-- =====================================================
|
||||
-- Added: 2025-12-18 (FIX: Application user needs to bypass RLS)
|
||||
-- Reason: The application manages RLS context via app.current_user_id
|
||||
-- but needs BYPASSRLS to perform operations on behalf of users
|
||||
-- =====================================================
|
||||
|
||||
ALTER ROLE gamilit_user BYPASSRLS;
|
||||
|
||||
-- Verification
|
||||
SELECT 'Permisos otorgados exitosamente a gamilit_user' as status;
|
||||
SELECT 'Permisos otorgados exitosamente a gamilit_user (incluyendo BYPASSRLS)' as status;
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
-- =====================================================
|
||||
-- Indexes for Teacher Portal Optimization
|
||||
-- Schema: progress_tracking
|
||||
-- Created: 2025-12-18 (P1-02 - FASE 5 Implementation)
|
||||
-- =====================================================
|
||||
|
||||
-- Index: module_progress by classroom and status
|
||||
-- Purpose: Fast lookup of progress data for classroom analytics
|
||||
CREATE INDEX IF NOT EXISTS idx_module_progress_classroom_status
|
||||
ON progress_tracking.module_progress(classroom_id, status);
|
||||
|
||||
COMMENT ON INDEX progress_tracking.idx_module_progress_classroom_status IS
|
||||
'P1-02: Optimizes classroom progress overview queries';
|
||||
|
||||
-- Index: intervention_alerts by teacher and status
|
||||
-- Purpose: Fast lookup of pending alerts for teacher dashboard
|
||||
CREATE INDEX IF NOT EXISTS idx_intervention_alerts_teacher_status
|
||||
ON progress_tracking.student_intervention_alerts(teacher_id, status)
|
||||
WHERE status IN ('pending', 'acknowledged');
|
||||
|
||||
COMMENT ON INDEX progress_tracking.idx_intervention_alerts_teacher_status IS
|
||||
'P1-02: Optimizes teacher alerts panel queries';
|
||||
|
||||
-- Index: exercise_submissions by student and date
|
||||
-- Purpose: Fast lookup of recent submissions for progress tracking
|
||||
CREATE INDEX IF NOT EXISTS idx_exercise_submissions_student_date
|
||||
ON progress_tracking.exercise_submissions(student_id, submitted_at DESC);
|
||||
|
||||
COMMENT ON INDEX progress_tracking.idx_exercise_submissions_student_date IS
|
||||
'P1-02: Optimizes student timeline and recent activity queries';
|
||||
|
||||
-- Index: exercise_submissions needing review
|
||||
-- Purpose: Fast lookup of submissions pending manual review
|
||||
CREATE INDEX IF NOT EXISTS idx_exercise_submissions_needs_review
|
||||
ON progress_tracking.exercise_submissions(needs_review, submitted_at DESC)
|
||||
WHERE needs_review = true;
|
||||
|
||||
COMMENT ON INDEX progress_tracking.idx_exercise_submissions_needs_review IS
|
||||
'P1-02: Optimizes teacher review queue';
|
||||
@ -14,9 +14,12 @@ ALTER TABLE progress_tracking.module_progress ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE progress_tracking.exercise_attempts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE progress_tracking.exercise_submissions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE progress_tracking.learning_sessions ENABLE ROW LEVEL SECURITY;
|
||||
-- P1-01: Added 2025-12-18 - Teacher notes RLS
|
||||
ALTER TABLE progress_tracking.teacher_notes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE progress_tracking.module_progress IS 'RLS enabled: Progreso de módulos - lectura propia + teacher + admin';
|
||||
COMMENT ON TABLE progress_tracking.exercise_attempts IS 'RLS enabled: Intentos de ejercicios - lectura propia + teacher';
|
||||
COMMENT ON TABLE progress_tracking.exercise_submissions IS 'RLS enabled: Entregas de ejercicios - gestión propia + calificación teacher';
|
||||
COMMENT ON TABLE progress_tracking.learning_sessions IS 'RLS enabled: Sesiones de aprendizaje de usuarios';
|
||||
COMMENT ON TABLE progress_tracking.teacher_notes IS 'RLS enabled: Notas de profesores - lectura/escritura propia';
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
-- =====================================================
|
||||
-- RLS Policies for: progress_tracking.teacher_notes
|
||||
-- Description: Teacher notes with self-access only
|
||||
-- Created: 2025-12-18 (P1-01 - FASE 5 Implementation)
|
||||
-- Policies: 4 (SELECT, INSERT, UPDATE, DELETE)
|
||||
-- =====================================================
|
||||
--
|
||||
-- Security Strategy:
|
||||
-- - Self-service: Teachers can CRUD their own notes
|
||||
-- - No student access: Notes are private to teachers
|
||||
-- - No cross-teacher access: Teachers cannot see other teachers' notes
|
||||
-- =====================================================
|
||||
|
||||
-- =====================================================
|
||||
-- TABLE: progress_tracking.teacher_notes
|
||||
-- Policies: 4 (SELECT: 1, INSERT: 1, UPDATE: 1, DELETE: 1)
|
||||
-- =====================================================
|
||||
|
||||
DROP POLICY IF EXISTS teacher_notes_select_own ON progress_tracking.teacher_notes;
|
||||
DROP POLICY IF EXISTS teacher_notes_insert_own ON progress_tracking.teacher_notes;
|
||||
DROP POLICY IF EXISTS teacher_notes_update_own ON progress_tracking.teacher_notes;
|
||||
DROP POLICY IF EXISTS teacher_notes_delete_own ON progress_tracking.teacher_notes;
|
||||
|
||||
-- Policy: teacher_notes_select_own
|
||||
-- Purpose: Teachers can read their own notes
|
||||
CREATE POLICY teacher_notes_select_own
|
||||
ON progress_tracking.teacher_notes
|
||||
AS PERMISSIVE
|
||||
FOR SELECT
|
||||
TO public
|
||||
USING (teacher_id = current_setting('app.current_user_id', true)::uuid);
|
||||
|
||||
COMMENT ON POLICY teacher_notes_select_own ON progress_tracking.teacher_notes IS
|
||||
'Teachers can see their own notes about students';
|
||||
|
||||
-- Policy: teacher_notes_insert_own
|
||||
-- Purpose: Teachers can create notes for any student
|
||||
CREATE POLICY teacher_notes_insert_own
|
||||
ON progress_tracking.teacher_notes
|
||||
AS PERMISSIVE
|
||||
FOR INSERT
|
||||
TO public
|
||||
WITH CHECK (
|
||||
teacher_id = current_setting('app.current_user_id', true)::uuid
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM auth_management.user_roles ur
|
||||
WHERE ur.user_id = current_setting('app.current_user_id', true)::uuid
|
||||
AND ur.role = 'admin_teacher'
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON POLICY teacher_notes_insert_own ON progress_tracking.teacher_notes IS
|
||||
'Teachers can create notes about students (teacher role required)';
|
||||
|
||||
-- Policy: teacher_notes_update_own
|
||||
-- Purpose: Teachers can update their own notes
|
||||
CREATE POLICY teacher_notes_update_own
|
||||
ON progress_tracking.teacher_notes
|
||||
AS PERMISSIVE
|
||||
FOR UPDATE
|
||||
TO public
|
||||
USING (teacher_id = current_setting('app.current_user_id', true)::uuid)
|
||||
WITH CHECK (teacher_id = current_setting('app.current_user_id', true)::uuid);
|
||||
|
||||
COMMENT ON POLICY teacher_notes_update_own ON progress_tracking.teacher_notes IS
|
||||
'Teachers can update their own notes';
|
||||
|
||||
-- Policy: teacher_notes_delete_own
|
||||
-- Purpose: Teachers can delete their own notes
|
||||
CREATE POLICY teacher_notes_delete_own
|
||||
ON progress_tracking.teacher_notes
|
||||
AS PERMISSIVE
|
||||
FOR DELETE
|
||||
TO public
|
||||
USING (teacher_id = current_setting('app.current_user_id', true)::uuid);
|
||||
|
||||
COMMENT ON POLICY teacher_notes_delete_own ON progress_tracking.teacher_notes IS
|
||||
'Teachers can delete their own notes';
|
||||
|
||||
-- =====================================================
|
||||
-- SUMMARY: teacher_notes RLS
|
||||
-- =====================================================
|
||||
-- Total policies: 4
|
||||
-- - SELECT: 1 (own notes only)
|
||||
-- - INSERT: 1 (teacher role required)
|
||||
-- - UPDATE: 1 (own notes only)
|
||||
-- - DELETE: 1 (own notes only)
|
||||
-- =====================================================
|
||||
@ -0,0 +1,204 @@
|
||||
-- =============================================================================
|
||||
-- TABLE: progress_tracking.teacher_interventions
|
||||
-- =============================================================================
|
||||
-- Purpose: Track teacher actions/interventions in response to student alerts
|
||||
-- Priority: P2-04 - Teacher Portal intervention tracking
|
||||
-- Created: 2025-12-18
|
||||
--
|
||||
-- DESCRIPTION:
|
||||
-- This table records the specific actions teachers take when responding to
|
||||
-- student intervention alerts. Unlike the resolution in student_intervention_alerts
|
||||
-- which only captures the final resolution, this table captures the full history
|
||||
-- of all interventions taken, including follow-ups and multi-step interventions.
|
||||
--
|
||||
-- USE CASES:
|
||||
-- - Parent contact records
|
||||
-- - One-on-one sessions scheduled/completed
|
||||
-- - Resource assignments
|
||||
-- - Peer tutoring arrangements
|
||||
-- - Accommodation adjustments
|
||||
-- - Follow-up tracking
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS progress_tracking.teacher_interventions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
-- Alert reference (optional - can also be standalone intervention)
|
||||
alert_id uuid,
|
||||
|
||||
-- Core identifiers
|
||||
student_id uuid NOT NULL,
|
||||
teacher_id uuid NOT NULL,
|
||||
classroom_id uuid,
|
||||
|
||||
-- Intervention details
|
||||
intervention_type text NOT NULL,
|
||||
title text NOT NULL,
|
||||
description text,
|
||||
|
||||
-- Action tracking
|
||||
action_taken text NOT NULL,
|
||||
outcome text,
|
||||
|
||||
-- Scheduling
|
||||
scheduled_date timestamp with time zone,
|
||||
completed_date timestamp with time zone,
|
||||
|
||||
-- Status tracking
|
||||
status text DEFAULT 'planned'::text NOT NULL,
|
||||
priority text DEFAULT 'medium'::text NOT NULL,
|
||||
|
||||
-- Follow-up
|
||||
follow_up_required boolean DEFAULT false,
|
||||
follow_up_date timestamp with time zone,
|
||||
follow_up_notes text,
|
||||
|
||||
-- Communication records
|
||||
parent_contacted boolean DEFAULT false,
|
||||
parent_contact_date timestamp with time zone,
|
||||
parent_contact_notes text,
|
||||
|
||||
-- Effectiveness tracking
|
||||
effectiveness_rating integer,
|
||||
student_response text,
|
||||
|
||||
-- Metadata
|
||||
notes text,
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Multi-tenant
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Audit
|
||||
created_at timestamp with time zone DEFAULT gamilit.now_mexico() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT gamilit.now_mexico() NOT NULL,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT teacher_interventions_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT teacher_interventions_type_check CHECK (intervention_type IN (
|
||||
'one_on_one_session', -- Individual tutoring session
|
||||
'parent_contact', -- Parent/guardian communication
|
||||
'resource_assignment', -- Additional materials assigned
|
||||
'peer_tutoring', -- Paired with another student
|
||||
'accommodation', -- Learning accommodations
|
||||
'referral', -- Referral to specialist
|
||||
'behavior_plan', -- Behavioral intervention
|
||||
'progress_check', -- Scheduled progress review
|
||||
'encouragement', -- Motivational intervention
|
||||
'schedule_adjustment', -- Modified schedule/deadlines
|
||||
'other' -- Custom intervention
|
||||
)),
|
||||
CONSTRAINT teacher_interventions_status_check CHECK (status IN (
|
||||
'planned', -- Scheduled but not started
|
||||
'in_progress', -- Currently ongoing
|
||||
'completed', -- Successfully completed
|
||||
'cancelled', -- Cancelled before completion
|
||||
'rescheduled' -- Moved to new date
|
||||
)),
|
||||
CONSTRAINT teacher_interventions_priority_check CHECK (priority IN (
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'urgent'
|
||||
)),
|
||||
CONSTRAINT teacher_interventions_effectiveness_check CHECK (
|
||||
effectiveness_rating IS NULL OR
|
||||
(effectiveness_rating >= 1 AND effectiveness_rating <= 5)
|
||||
)
|
||||
);
|
||||
|
||||
-- Set ownership
|
||||
ALTER TABLE progress_tracking.teacher_interventions OWNER TO gamilit_user;
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE progress_tracking.teacher_interventions IS
|
||||
'Records teacher intervention actions for at-risk students. Tracks full intervention history including scheduling, outcomes, parent contact, and effectiveness.';
|
||||
|
||||
COMMENT ON COLUMN progress_tracking.teacher_interventions.intervention_type IS
|
||||
'Type of intervention: one_on_one_session, parent_contact, resource_assignment, peer_tutoring, accommodation, referral, behavior_plan, progress_check, encouragement, schedule_adjustment, other';
|
||||
|
||||
COMMENT ON COLUMN progress_tracking.teacher_interventions.status IS
|
||||
'Current status: planned, in_progress, completed, cancelled, rescheduled';
|
||||
|
||||
COMMENT ON COLUMN progress_tracking.teacher_interventions.effectiveness_rating IS
|
||||
'Teacher rating of intervention effectiveness (1-5 scale). NULL if not yet rated.';
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_teacher_interventions_alert ON progress_tracking.teacher_interventions(alert_id);
|
||||
CREATE INDEX idx_teacher_interventions_student ON progress_tracking.teacher_interventions(student_id);
|
||||
CREATE INDEX idx_teacher_interventions_teacher ON progress_tracking.teacher_interventions(teacher_id);
|
||||
CREATE INDEX idx_teacher_interventions_classroom ON progress_tracking.teacher_interventions(classroom_id);
|
||||
CREATE INDEX idx_teacher_interventions_status ON progress_tracking.teacher_interventions(status);
|
||||
CREATE INDEX idx_teacher_interventions_type ON progress_tracking.teacher_interventions(intervention_type);
|
||||
CREATE INDEX idx_teacher_interventions_tenant ON progress_tracking.teacher_interventions(tenant_id);
|
||||
CREATE INDEX idx_teacher_interventions_scheduled ON progress_tracking.teacher_interventions(scheduled_date)
|
||||
WHERE status IN ('planned', 'in_progress');
|
||||
CREATE INDEX idx_teacher_interventions_follow_up ON progress_tracking.teacher_interventions(follow_up_date)
|
||||
WHERE follow_up_required = true;
|
||||
|
||||
-- Foreign keys
|
||||
ALTER TABLE progress_tracking.teacher_interventions
|
||||
ADD CONSTRAINT teacher_interventions_alert_fkey
|
||||
FOREIGN KEY (alert_id) REFERENCES progress_tracking.student_intervention_alerts(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE progress_tracking.teacher_interventions
|
||||
ADD CONSTRAINT teacher_interventions_student_fkey
|
||||
FOREIGN KEY (student_id) REFERENCES auth_management.profiles(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE progress_tracking.teacher_interventions
|
||||
ADD CONSTRAINT teacher_interventions_teacher_fkey
|
||||
FOREIGN KEY (teacher_id) REFERENCES auth_management.profiles(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE progress_tracking.teacher_interventions
|
||||
ADD CONSTRAINT teacher_interventions_classroom_fkey
|
||||
FOREIGN KEY (classroom_id) REFERENCES social_features.classrooms(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE progress_tracking.teacher_interventions
|
||||
ADD CONSTRAINT teacher_interventions_tenant_fkey
|
||||
FOREIGN KEY (tenant_id) REFERENCES auth_management.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
-- Trigger for updated_at
|
||||
CREATE TRIGGER trg_teacher_interventions_updated_at
|
||||
BEFORE UPDATE ON progress_tracking.teacher_interventions
|
||||
FOR EACH ROW EXECUTE FUNCTION gamilit.update_updated_at_column();
|
||||
|
||||
-- =============================================================================
|
||||
-- ROW LEVEL SECURITY
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE progress_tracking.teacher_interventions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Teachers can view and manage their own interventions
|
||||
CREATE POLICY teacher_manage_own_interventions ON progress_tracking.teacher_interventions
|
||||
FOR ALL
|
||||
USING (teacher_id = gamilit.get_current_user_id())
|
||||
WITH CHECK (teacher_id = gamilit.get_current_user_id());
|
||||
|
||||
-- Teachers can view interventions for students in their classrooms
|
||||
CREATE POLICY teacher_view_classroom_interventions ON progress_tracking.teacher_interventions
|
||||
FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM social_features.teacher_classrooms tc
|
||||
WHERE tc.classroom_id = teacher_interventions.classroom_id
|
||||
AND tc.teacher_id = gamilit.get_current_user_id()
|
||||
AND tc.tenant_id = teacher_interventions.tenant_id
|
||||
));
|
||||
|
||||
-- Admins can view all interventions in their tenant
|
||||
CREATE POLICY admin_view_tenant_interventions ON progress_tracking.teacher_interventions
|
||||
FOR SELECT
|
||||
USING (
|
||||
tenant_id IN (
|
||||
SELECT p.tenant_id FROM auth_management.profiles p
|
||||
WHERE p.id = gamilit.get_current_user_id()
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM auth_management.profiles p
|
||||
WHERE p.id = gamilit.get_current_user_id()
|
||||
AND p.role IN ('SUPER_ADMIN', 'ADMIN_TEACHER')
|
||||
)
|
||||
);
|
||||
|
||||
-- Grants
|
||||
GRANT ALL ON TABLE progress_tracking.teacher_interventions TO gamilit_user;
|
||||
GRANT SELECT ON TABLE progress_tracking.teacher_interventions TO authenticated;
|
||||
@ -0,0 +1,182 @@
|
||||
-- =============================================================================
|
||||
-- VIEW: progress_tracking.teacher_pending_reviews
|
||||
-- =============================================================================
|
||||
-- Purpose: Consolidated view of student submissions requiring teacher review
|
||||
-- Priority: P2-05 - Teacher Portal pending reviews dashboard
|
||||
-- Created: 2025-12-18
|
||||
--
|
||||
-- DESCRIPTION:
|
||||
-- This view aggregates exercise submissions that need manual grading or review,
|
||||
-- providing teachers with a prioritized queue of work items. It includes
|
||||
-- student info, exercise details, submission timestamps, and urgency indicators.
|
||||
--
|
||||
-- USE CASES:
|
||||
-- - Teacher dashboard pending review count
|
||||
-- - Grading queue interface
|
||||
-- - Priority-based review workflow
|
||||
-- - Bulk grading operations
|
||||
-- =============================================================================
|
||||
|
||||
DROP VIEW IF EXISTS progress_tracking.teacher_pending_reviews CASCADE;
|
||||
|
||||
CREATE VIEW progress_tracking.teacher_pending_reviews AS
|
||||
SELECT
|
||||
-- Submission identifiers
|
||||
es.id AS submission_id,
|
||||
es.exercise_id,
|
||||
es.user_id AS student_id,
|
||||
|
||||
-- Student info
|
||||
p.full_name AS student_name,
|
||||
p.username AS student_username,
|
||||
|
||||
-- Exercise info
|
||||
e.title AS exercise_title,
|
||||
e.mechanic_type,
|
||||
e.exercise_type,
|
||||
m.title AS module_title,
|
||||
m.module_order,
|
||||
|
||||
-- Classroom info
|
||||
cm.classroom_id,
|
||||
c.name AS classroom_name,
|
||||
|
||||
-- Submission details
|
||||
es.score,
|
||||
es.time_spent,
|
||||
es.attempts,
|
||||
es.answers,
|
||||
es.feedback,
|
||||
es.submitted_at,
|
||||
es.created_at AS submission_date,
|
||||
|
||||
-- Review status
|
||||
CASE
|
||||
WHEN es.graded_at IS NOT NULL THEN 'graded'
|
||||
WHEN es.submitted_at IS NOT NULL THEN 'pending'
|
||||
ELSE 'in_progress'
|
||||
END AS review_status,
|
||||
es.graded_at,
|
||||
es.graded_by,
|
||||
|
||||
-- Priority calculation
|
||||
CASE
|
||||
WHEN es.submitted_at < NOW() - INTERVAL '7 days' THEN 'urgent'
|
||||
WHEN es.submitted_at < NOW() - INTERVAL '3 days' THEN 'high'
|
||||
WHEN es.submitted_at < NOW() - INTERVAL '1 day' THEN 'medium'
|
||||
ELSE 'normal'
|
||||
END AS priority,
|
||||
|
||||
-- Days waiting
|
||||
EXTRACT(DAY FROM (NOW() - es.submitted_at))::integer AS days_waiting,
|
||||
|
||||
-- Metadata
|
||||
es.metadata,
|
||||
es.tenant_id
|
||||
|
||||
FROM progress_tracking.exercise_submissions es
|
||||
-- Join to get student profile
|
||||
INNER JOIN auth_management.profiles p ON es.user_id = p.id
|
||||
-- Join to get exercise details
|
||||
INNER JOIN educational_content.exercises e ON es.exercise_id = e.id
|
||||
-- Join to get module info
|
||||
INNER JOIN educational_content.modules m ON e.module_id = m.id
|
||||
-- Join to find student's classroom(s)
|
||||
LEFT JOIN social_features.classroom_members cm ON es.user_id = cm.student_id
|
||||
AND cm.is_active = true
|
||||
-- Join to get classroom name
|
||||
LEFT JOIN social_features.classrooms c ON cm.classroom_id = c.id
|
||||
|
||||
WHERE
|
||||
-- Only submissions that need review
|
||||
es.graded_at IS NULL
|
||||
AND es.submitted_at IS NOT NULL
|
||||
-- Only exercises that require manual grading
|
||||
AND (
|
||||
e.requires_manual_grading = true
|
||||
OR e.mechanic_type IN (
|
||||
'respuesta_abierta',
|
||||
'escritura_creativa',
|
||||
'debate_guiado',
|
||||
'mapa_mental',
|
||||
'proyecto_multimedia',
|
||||
'reflexion_metacognitiva',
|
||||
'podcast_educativo',
|
||||
'infografia_interactiva',
|
||||
'creacion_storyboard',
|
||||
'argumentacion_estructurada'
|
||||
)
|
||||
)
|
||||
|
||||
ORDER BY
|
||||
-- Urgent items first
|
||||
CASE
|
||||
WHEN es.submitted_at < NOW() - INTERVAL '7 days' THEN 1
|
||||
WHEN es.submitted_at < NOW() - INTERVAL '3 days' THEN 2
|
||||
WHEN es.submitted_at < NOW() - INTERVAL '1 day' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
-- Then by submission date (oldest first)
|
||||
es.submitted_at ASC;
|
||||
|
||||
-- Set ownership
|
||||
ALTER VIEW progress_tracking.teacher_pending_reviews OWNER TO gamilit_user;
|
||||
|
||||
-- Comments
|
||||
COMMENT ON VIEW progress_tracking.teacher_pending_reviews IS
|
||||
'Consolidated view of student submissions requiring teacher review. Shows pending items with priority based on wait time.';
|
||||
|
||||
COMMENT ON COLUMN progress_tracking.teacher_pending_reviews.priority IS
|
||||
'Priority based on wait time: urgent (>7 days), high (3-7 days), medium (1-3 days), normal (<1 day)';
|
||||
|
||||
COMMENT ON COLUMN progress_tracking.teacher_pending_reviews.days_waiting IS
|
||||
'Number of days the submission has been waiting for review';
|
||||
|
||||
-- Grants
|
||||
GRANT SELECT ON progress_tracking.teacher_pending_reviews TO gamilit_user;
|
||||
GRANT SELECT ON progress_tracking.teacher_pending_reviews TO authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- HELPER FUNCTION: Get pending reviews count for a teacher
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION progress_tracking.get_teacher_pending_reviews_count(
|
||||
p_teacher_id uuid,
|
||||
p_classroom_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
total_pending bigint,
|
||||
urgent_count bigint,
|
||||
high_count bigint,
|
||||
medium_count bigint,
|
||||
normal_count bigint
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_pending,
|
||||
COUNT(*) FILTER (WHERE pr.priority = 'urgent')::bigint AS urgent_count,
|
||||
COUNT(*) FILTER (WHERE pr.priority = 'high')::bigint AS high_count,
|
||||
COUNT(*) FILTER (WHERE pr.priority = 'medium')::bigint AS medium_count,
|
||||
COUNT(*) FILTER (WHERE pr.priority = 'normal')::bigint AS normal_count
|
||||
FROM progress_tracking.teacher_pending_reviews pr
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM social_features.teacher_classrooms tc
|
||||
WHERE tc.teacher_id = p_teacher_id
|
||||
AND tc.classroom_id = pr.classroom_id
|
||||
AND tc.is_active = true
|
||||
)
|
||||
AND (p_classroom_id IS NULL OR pr.classroom_id = p_classroom_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION progress_tracking.get_teacher_pending_reviews_count(uuid, uuid) OWNER TO gamilit_user;
|
||||
|
||||
COMMENT ON FUNCTION progress_tracking.get_teacher_pending_reviews_count IS
|
||||
'Returns count of pending reviews for a teacher, optionally filtered by classroom. Includes breakdown by priority level.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION progress_tracking.get_teacher_pending_reviews_count(uuid, uuid) TO gamilit_user;
|
||||
GRANT EXECUTE ON FUNCTION progress_tracking.get_teacher_pending_reviews_count(uuid, uuid) TO authenticated;
|
||||
@ -0,0 +1,23 @@
|
||||
-- =====================================================
|
||||
-- Indexes for Teacher Portal Optimization
|
||||
-- Schema: social_features
|
||||
-- Created: 2025-12-18 (P1-02 - FASE 5 Implementation)
|
||||
-- =====================================================
|
||||
|
||||
-- Index: classroom_members active by classroom
|
||||
-- Purpose: Fast lookup of active students in a classroom (Teacher Portal monitoring)
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_members_classroom_active
|
||||
ON social_features.classroom_members(classroom_id, status)
|
||||
WHERE status = 'active';
|
||||
|
||||
COMMENT ON INDEX social_features.idx_classroom_members_classroom_active IS
|
||||
'P1-02: Optimizes teacher queries for active students in classrooms';
|
||||
|
||||
-- Index: classrooms by teacher
|
||||
-- Purpose: Fast lookup of classrooms owned by a teacher
|
||||
CREATE INDEX IF NOT EXISTS idx_classrooms_teacher_active
|
||||
ON social_features.classrooms(teacher_id, is_active)
|
||||
WHERE is_active = true;
|
||||
|
||||
COMMENT ON INDEX social_features.idx_classrooms_teacher_active IS
|
||||
'P1-02: Optimizes teacher dashboard classroom listing';
|
||||
@ -0,0 +1,87 @@
|
||||
-- =============================================================================
|
||||
-- VIEW: social_features.classroom_progress_overview
|
||||
-- =============================================================================
|
||||
-- Purpose: Aggregated progress view for Teacher Portal classroom monitoring
|
||||
-- Priority: P1-03 - Teacher Portal optimization
|
||||
-- Created: 2025-12-18
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE VIEW social_features.classroom_progress_overview AS
|
||||
SELECT
|
||||
c.id AS classroom_id,
|
||||
c.name AS classroom_name,
|
||||
c.teacher_id,
|
||||
|
||||
-- Student counts
|
||||
COUNT(DISTINCT cm.student_id) FILTER (WHERE cm.status = 'active') AS total_students,
|
||||
COUNT(DISTINCT mp.student_id) FILTER (WHERE mp.status = 'completed') AS students_completed,
|
||||
|
||||
-- Progress metrics
|
||||
COALESCE(ROUND(AVG(mp.progress_percentage)::numeric, 2), 0) AS avg_progress,
|
||||
COALESCE(ROUND(AVG(mp.score)::numeric, 2), 0) AS avg_score,
|
||||
|
||||
-- Alert counts
|
||||
COUNT(DISTINCT sia.id) FILTER (WHERE sia.status = 'pending') AS pending_alerts,
|
||||
COUNT(DISTINCT sia.id) FILTER (WHERE sia.status = 'acknowledged') AS acknowledged_alerts,
|
||||
|
||||
-- Review counts
|
||||
COUNT(DISTINCT es.id) FILTER (WHERE es.needs_review = true) AS pending_reviews,
|
||||
|
||||
-- Activity metrics
|
||||
COUNT(DISTINCT es.id) AS total_submissions,
|
||||
MAX(es.submitted_at) AS last_activity,
|
||||
|
||||
-- Module completion
|
||||
COUNT(DISTINCT mp.module_id) FILTER (WHERE mp.status = 'completed') AS modules_completed,
|
||||
COUNT(DISTINCT mp.module_id) AS modules_started,
|
||||
|
||||
-- Timestamps
|
||||
c.created_at AS classroom_created_at,
|
||||
c.updated_at AS classroom_updated_at
|
||||
|
||||
FROM social_features.classrooms c
|
||||
LEFT JOIN social_features.classroom_members cm
|
||||
ON c.id = cm.classroom_id AND cm.status = 'active'
|
||||
LEFT JOIN progress_tracking.module_progress mp
|
||||
ON cm.student_id = mp.student_id
|
||||
LEFT JOIN progress_tracking.student_intervention_alerts sia
|
||||
ON cm.student_id = sia.student_id AND sia.teacher_id = c.teacher_id
|
||||
LEFT JOIN progress_tracking.exercise_submissions es
|
||||
ON cm.student_id = es.student_id
|
||||
WHERE
|
||||
c.is_deleted = FALSE
|
||||
GROUP BY
|
||||
c.id, c.name, c.teacher_id, c.created_at, c.updated_at;
|
||||
|
||||
-- Grant permissions
|
||||
GRANT SELECT ON social_features.classroom_progress_overview TO authenticated;
|
||||
|
||||
-- Documentation
|
||||
COMMENT ON VIEW social_features.classroom_progress_overview IS
|
||||
'Teacher Portal view for classroom progress monitoring.
|
||||
|
||||
Columns:
|
||||
- classroom_id/name: Classroom identification
|
||||
- teacher_id: Owner teacher
|
||||
- total_students: Active students in classroom
|
||||
- students_completed: Students who completed at least one module
|
||||
- avg_progress: Average progress percentage (0-100)
|
||||
- avg_score: Average score across all modules
|
||||
- pending_alerts: Count of pending intervention alerts
|
||||
- acknowledged_alerts: Count of acknowledged alerts
|
||||
- pending_reviews: Submissions needing manual review
|
||||
- total_submissions: Total exercise submissions
|
||||
- last_activity: Most recent submission timestamp
|
||||
- modules_completed/started: Module completion stats
|
||||
|
||||
Usage:
|
||||
-- Get all classrooms for a teacher
|
||||
SELECT * FROM social_features.classroom_progress_overview
|
||||
WHERE teacher_id = :teacher_id;
|
||||
|
||||
-- Find classrooms needing attention
|
||||
SELECT * FROM social_features.classroom_progress_overview
|
||||
WHERE pending_alerts > 0 OR pending_reviews > 5
|
||||
ORDER BY pending_alerts DESC;
|
||||
|
||||
Created: 2025-12-18 (P1-03 Teacher Portal Analysis)';
|
||||
69
projects/gamilit/apps/database/scripts/DB-127-validar-gaps.sh
Executable file
69
projects/gamilit/apps/database/scripts/DB-127-validar-gaps.sh
Executable file
@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Script: Validación de Gaps DB-127
|
||||
# Fecha: 2025-11-24
|
||||
# Autor: Database-Agent
|
||||
# ============================================================================
|
||||
#
|
||||
# DESCRIPCIÓN:
|
||||
# Valida que los 3 gaps Database↔Backend estén resueltos
|
||||
#
|
||||
# USO:
|
||||
# ./scripts/DB-127-validar-gaps.sh [DATABASE_URL]
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
set -e # Exit on error
|
||||
set -u # Exit on undefined variable
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get database URL from argument or environment
|
||||
DATABASE_URL="${1:-${DATABASE_URL:-}}"
|
||||
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
echo -e "${RED}ERROR: DATABASE_URL no está configurada${NC}"
|
||||
echo "Uso: ./scripts/DB-127-validar-gaps.sh <DATABASE_URL>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}============================================================================${NC}"
|
||||
echo -e "${BLUE}VALIDACIÓN DE GAPS DB-127${NC}"
|
||||
echo -e "${BLUE}============================================================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Extract database name from URL
|
||||
DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*://[^/]*/\([^?]*\).*|\1|p')
|
||||
echo -e "Base de datos: ${YELLOW}$DB_NAME${NC}"
|
||||
echo ""
|
||||
|
||||
# Run validation SQL script
|
||||
echo -e "${YELLOW}Ejecutando validación...${NC}"
|
||||
echo ""
|
||||
|
||||
psql "$DATABASE_URL" -f "$(dirname "$0")/validate-gap-fixes.sql"
|
||||
|
||||
EXIT_CODE=$?
|
||||
|
||||
echo ""
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ VALIDACIÓN COMPLETADA${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}Próximos pasos:${NC}"
|
||||
echo "1. Verificar que los 3 gaps muestran estado '✅ RESUELTO'"
|
||||
echo "2. Probar endpoints backend:"
|
||||
echo " - GET /api/admin/dashboard/actions/recent"
|
||||
echo " - GET /api/admin/dashboard/alerts"
|
||||
echo " - GET /api/admin/tenants"
|
||||
echo " - GET /api/classrooms?is_deleted=false"
|
||||
echo ""
|
||||
else
|
||||
echo -e "${RED}❌ ERROR EN VALIDACIÓN${NC}"
|
||||
echo "Revisar logs arriba para detalles del error"
|
||||
exit 1
|
||||
fi
|
||||
396
projects/gamilit/apps/database/scripts/INDEX.md
Normal file
396
projects/gamilit/apps/database/scripts/INDEX.md
Normal file
@ -0,0 +1,396 @@
|
||||
# 📚 ÍNDICE MAESTRO - Scripts de Base de Datos GAMILIT
|
||||
|
||||
**Actualizado:** 2025-11-08
|
||||
**Versión:** 3.0
|
||||
**Estado:** ✅ Consolidado y Funcional
|
||||
|
||||
---
|
||||
|
||||
## 🎯 INICIO RÁPIDO
|
||||
|
||||
¿Nuevo en el proyecto? Empieza aquí:
|
||||
|
||||
```bash
|
||||
# 1. Lee la guía rápida
|
||||
cat QUICK-START.md
|
||||
|
||||
# 2. Inicializa la BD
|
||||
./init-database.sh --env dev --force
|
||||
|
||||
# 3. ¡Listo!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 DOCUMENTACIÓN DISPONIBLE
|
||||
|
||||
### Documentos Principales
|
||||
|
||||
| Archivo | Propósito | ¿Cuándo leer? |
|
||||
|---------|-----------|---------------|
|
||||
| **QUICK-START.md** | Guía rápida de uso | ⭐ Primero - Setup inicial |
|
||||
| **README.md** | Documentación completa | Segunda lectura - Detalles |
|
||||
| **ANALISIS-SCRIPTS-2025-11-08.md** | Análisis técnico | Referencia técnica |
|
||||
| **INDEX.md** | Este índice | Navegación general |
|
||||
| **README-SETUP.md** | Guía de setup detallada | Setup avanzado |
|
||||
|
||||
### Orden Recomendado de Lectura
|
||||
|
||||
```
|
||||
1. INDEX.md (este archivo) ← Estás aquí
|
||||
2. QUICK-START.md ← Guía rápida para empezar
|
||||
3. README.md ← Documentación completa
|
||||
4. ANALISIS-SCRIPTS-2025-11-08.md ← Detalles técnicos (opcional)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ SCRIPTS DISPONIBLES
|
||||
|
||||
### Scripts Principales (3) ⭐
|
||||
|
||||
| Script | Tamaño | Estado | Propósito |
|
||||
|--------|--------|--------|-----------|
|
||||
| `init-database.sh` | 36K | ✅ Activo | Inicialización completa (v3.0) |
|
||||
| `reset-database.sh` | 16K | ✅ Activo | Reset rápido (mantiene usuario) |
|
||||
| `recreate-database.sh` | 8.9K | ✅ Activo | Recreación completa (elimina todo) |
|
||||
|
||||
### Scripts de Gestión (3)
|
||||
|
||||
| Script | Tamaño | Estado | Propósito |
|
||||
|--------|--------|--------|-----------|
|
||||
| `manage-secrets.sh` | 18K | ✅ Activo | Gestión de secrets con dotenv-vault |
|
||||
| `update-env-files.sh` | 16K | ✅ Activo | Sincronización de .env |
|
||||
| `cleanup-duplicados.sh` | 12K | ✅ Activo | Limpieza de duplicados |
|
||||
|
||||
### Scripts de Inventario (8)
|
||||
|
||||
Ubicación: `inventory/`
|
||||
|
||||
| Script | Propósito |
|
||||
|--------|-----------|
|
||||
| `list-tables.sh` | Listar todas las tablas |
|
||||
| `list-functions.sh` | Listar todas las funciones |
|
||||
| `list-enums.sh` | Listar todos los ENUMs |
|
||||
| `list-rls.sh` | Listar RLS policies |
|
||||
| `list-indexes.sh` | Listar índices |
|
||||
| `list-views.sh` | Listar vistas |
|
||||
| `list-triggers.sh` | Listar triggers |
|
||||
| `list-seeds.sh` | Listar seeds disponibles |
|
||||
| `generate-all-inventories.sh` | Generar todos los inventarios |
|
||||
|
||||
### Scripts Obsoletos (deprecated/)
|
||||
|
||||
| Script | Estado | Notas |
|
||||
|--------|--------|-------|
|
||||
| `init-database-v1.sh` | 📦 Deprecated | Versión original (21K) |
|
||||
| `init-database-v2.sh` | 📦 Deprecated | Versión intermedia (32K) |
|
||||
| `init-database.sh.backup-*` | 📦 Deprecated | Backup de v1.0 |
|
||||
|
||||
⚠️ **NO eliminar archivos en deprecated/** - Son históricos y de referencia
|
||||
|
||||
---
|
||||
|
||||
## 📊 COMPARACIÓN RÁPIDA DE SCRIPTS PRINCIPALES
|
||||
|
||||
| Característica | init-database.sh | reset-database.sh | recreate-database.sh |
|
||||
|----------------|------------------|-------------------|----------------------|
|
||||
| **Elimina usuario** | ❌ | ❌ | ✅ |
|
||||
| **Elimina BD** | ⚠️ Si existe | ✅ | ✅ |
|
||||
| **Crea usuario** | ✅ Si no existe | ❌ | ✅ |
|
||||
| **Genera password** | ✅ | ❌ | ✅ |
|
||||
| **Requiere password** | ❌ | ✅ | ❌ |
|
||||
| **Actualiza .env** | ✅ | ❌ | ✅ |
|
||||
| **Soporta dotenv-vault** | ✅ | ❌ | ✅ (vía init) |
|
||||
| **Tiempo ejecución** | 30-60s | 20-30s | 40-70s |
|
||||
| **Riesgo de pérdida datos** | Bajo | Medio | Alto |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 GUÍA DE DECISIÓN RÁPIDA
|
||||
|
||||
### ¿Qué script debo usar?
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ ¿Es la primera vez en el proyecto? │
|
||||
└──────────┬──────────────────────────┘
|
||||
│
|
||||
├─ SÍ ──> init-database.sh --env dev --force
|
||||
│
|
||||
└─ NO ──┐
|
||||
│
|
||||
┌──────────┴────────────────────────┐
|
||||
│ ¿Conoces el password del usuario? │
|
||||
└──────────┬────────────────────────┘
|
||||
│
|
||||
├─ SÍ ──> reset-database.sh --env dev --password "pass"
|
||||
│
|
||||
└─ NO ──> recreate-database.sh --env dev
|
||||
```
|
||||
|
||||
### Casos de Uso Específicos
|
||||
|
||||
| Situación | Script Recomendado | Comando |
|
||||
|-----------|-------------------|---------|
|
||||
| **Primera vez** | init-database.sh | `./init-database.sh --env dev --force` |
|
||||
| **Aplicar cambios DDL** | reset-database.sh | `./reset-database.sh --env dev --password "pass"` |
|
||||
| **Olvidé password** | recreate-database.sh | `./recreate-database.sh --env dev` |
|
||||
| **Deployment producción** | init-database.sh + vault | `./manage-secrets.sh generate --env prod && ./init-database.sh --env prod` |
|
||||
| **Desarrollo diario** | reset-database.sh | `./reset-database.sh --env dev --password "$(grep Password ../database-credentials-dev.txt | cut -d: -f2 | xargs)"` |
|
||||
|
||||
---
|
||||
|
||||
## 📁 ESTRUCTURA DEL DIRECTORIO
|
||||
|
||||
```
|
||||
/apps/database/scripts/
|
||||
│
|
||||
├── 📖 Documentación
|
||||
│ ├── INDEX.md ← Estás aquí
|
||||
│ ├── QUICK-START.md ⭐ Guía rápida
|
||||
│ ├── README.md 📚 Documentación completa
|
||||
│ ├── README-SETUP.md 🔧 Setup avanzado
|
||||
│ └── ANALISIS-SCRIPTS-2025-11-08.md 📊 Análisis técnico
|
||||
│
|
||||
├── 🛠️ Scripts Principales
|
||||
│ ├── init-database.sh ⭐ Inicialización (v3.0)
|
||||
│ ├── reset-database.sh 🔄 Reset rápido
|
||||
│ └── recreate-database.sh ⚠️ Recreación completa
|
||||
│
|
||||
├── 🔐 Scripts de Gestión
|
||||
│ ├── manage-secrets.sh 🔑 Gestión de secrets
|
||||
│ ├── update-env-files.sh 🔧 Sincronización .env
|
||||
│ └── cleanup-duplicados.sh 🧹 Limpieza
|
||||
│
|
||||
├── ⚙️ Configuración
|
||||
│ └── config/
|
||||
│ ├── dev.conf 🛠️ Config desarrollo
|
||||
│ └── prod.conf 🚀 Config producción
|
||||
│
|
||||
├── 📊 Inventario
|
||||
│ └── inventory/
|
||||
│ ├── list-tables.sh 📋 Listar tablas
|
||||
│ ├── list-functions.sh ⚙️ Listar funciones
|
||||
│ ├── list-enums.sh 🏷️ Listar ENUMs
|
||||
│ ├── list-rls.sh 🔒 Listar RLS
|
||||
│ ├── list-indexes.sh 📈 Listar índices
|
||||
│ ├── list-views.sh 👁️ Listar vistas
|
||||
│ ├── list-triggers.sh ⚡ Listar triggers
|
||||
│ ├── list-seeds.sh 🌱 Listar seeds
|
||||
│ └── generate-all-inventories.sh 📊 Generar todos
|
||||
│
|
||||
├── 🔄 Migraciones
|
||||
│ └── migrations/
|
||||
│ └── *.sql 📝 Migraciones SQL
|
||||
│
|
||||
├── 💾 Backup y Restore
|
||||
│ ├── backup/ 💾 Scripts de backup
|
||||
│ └── restore/ ♻️ Scripts de restore
|
||||
│
|
||||
├── 🛠️ Utilidades
|
||||
│ └── utilities/ 🔧 Herramientas varias
|
||||
│
|
||||
└── 📦 Obsoletos
|
||||
└── deprecated/
|
||||
├── init-database-v1.sh 📦 Versión 1.0
|
||||
├── init-database-v2.sh 📦 Versión 2.0
|
||||
└── init-database.sh.backup-* 📦 Backups
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 BÚSQUEDA RÁPIDA
|
||||
|
||||
### ¿Cómo hacer...?
|
||||
|
||||
**Inicializar BD por primera vez:**
|
||||
```bash
|
||||
./init-database.sh --env dev --force
|
||||
```
|
||||
|
||||
**Resetear datos rápidamente:**
|
||||
```bash
|
||||
PASSWORD=$(grep 'Database Password' ../database-credentials-dev.txt | cut -d: -f2 | xargs)
|
||||
./reset-database.sh --env dev --password "$PASSWORD"
|
||||
```
|
||||
|
||||
**Ver credenciales actuales:**
|
||||
```bash
|
||||
cat ../database-credentials-dev.txt
|
||||
```
|
||||
|
||||
**Listar todos los objetos de BD:**
|
||||
```bash
|
||||
cd inventory/
|
||||
./generate-all-inventories.sh
|
||||
```
|
||||
|
||||
**Aplicar migración SQL:**
|
||||
```bash
|
||||
# Agregar migración a migrations/
|
||||
# Luego resetear BD
|
||||
./reset-database.sh --env dev --password "pass"
|
||||
```
|
||||
|
||||
**Verificar estado de BD:**
|
||||
```bash
|
||||
# Verificar conexión
|
||||
psql -U gamilit_user -d gamilit_platform -c "SELECT version();"
|
||||
|
||||
# Contar objetos
|
||||
psql -U gamilit_user -d gamilit_platform -c "\dt *.*" | wc -l # Tablas
|
||||
psql -U gamilit_user -d gamilit_platform -c "\df *.*" | wc -l # Funciones
|
||||
psql -U gamilit_user -d gamilit_platform -c "\dn" | wc -l # Schemas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 ESTADO DE LA BASE DE DATOS
|
||||
|
||||
### Objetos Implementados (según INVENTARIO-COMPLETO-BD-2025-11-07.md)
|
||||
|
||||
| Tipo de Objeto | Cantidad | Estado |
|
||||
|----------------|----------|--------|
|
||||
| **Schemas** | 13 | ✅ Completo |
|
||||
| **Tablas** | 61 | ✅ Completo |
|
||||
| **Funciones** | 61 | ✅ Completo |
|
||||
| **Vistas** | 12 | ✅ Completo |
|
||||
| **Vistas Materializadas** | 4 | ✅ Completo |
|
||||
| **Triggers** | 49 | ✅ Completo |
|
||||
| **Índices** | 74 archivos | ✅ Completo |
|
||||
| **RLS Policies** | 24 archivos | ✅ Completo |
|
||||
| **ENUMs** | 36 | ✅ Completo |
|
||||
|
||||
**Total:** 285 archivos SQL
|
||||
|
||||
**Calidad:** A+ (98.8%)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ADVERTENCIAS IMPORTANTES
|
||||
|
||||
### Desarrollo (dev)
|
||||
|
||||
✅ **Puedes:**
|
||||
- Usar `--force` libremente
|
||||
- Recrear BD frecuentemente
|
||||
- Experimentar con scripts
|
||||
|
||||
❌ **Evita:**
|
||||
- Usar secrets de producción
|
||||
- Omitir logs de errores
|
||||
|
||||
### Producción (prod)
|
||||
|
||||
✅ **Debes:**
|
||||
- SIEMPRE hacer backup primero
|
||||
- Usar dotenv-vault
|
||||
- Validar dos veces
|
||||
- Notificar al equipo
|
||||
|
||||
❌ **NUNCA:**
|
||||
- Usar `--force` sin validación
|
||||
- Recrear sin backup
|
||||
- Ejecutar sin pruebas previas
|
||||
|
||||
---
|
||||
|
||||
## 🐛 TROUBLESHOOTING RÁPIDO
|
||||
|
||||
| Error | Solución Rápida |
|
||||
|-------|----------------|
|
||||
| "psql no encontrado" | `sudo apt install postgresql-client` |
|
||||
| "No se puede conectar" | `sudo systemctl start postgresql` |
|
||||
| "Usuario ya existe" | `./recreate-database.sh --env dev` |
|
||||
| "Permisos denegados" | `chmod +x *.sh` |
|
||||
| "BD en uso" | `sudo -u postgres psql -c "SELECT pg_terminate_backend..."` |
|
||||
|
||||
Para más detalles: `cat QUICK-START.md | grep -A 10 "Troubleshooting"`
|
||||
|
||||
---
|
||||
|
||||
## 📞 OBTENER AYUDA
|
||||
|
||||
### Orden de consulta
|
||||
|
||||
1. **QUICK-START.md** - Casos de uso comunes
|
||||
2. **README.md** - Documentación detallada
|
||||
3. **ANALISIS-SCRIPTS-2025-11-08.md** - Detalles técnicos
|
||||
4. **Logs del script** - Revisa el output del comando
|
||||
5. **Equipo de BD** - Si todo falla
|
||||
|
||||
### Comandos de ayuda
|
||||
|
||||
```bash
|
||||
# Ver ayuda de cualquier script
|
||||
./init-database.sh --help
|
||||
./reset-database.sh --help
|
||||
./recreate-database.sh --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ CHECKLIST RÁPIDO
|
||||
|
||||
### Primera Vez en el Proyecto
|
||||
|
||||
- [ ] Leí QUICK-START.md
|
||||
- [ ] PostgreSQL está instalado y corriendo
|
||||
- [ ] Ejecuté `./init-database.sh --env dev --force`
|
||||
- [ ] Verifiqué credenciales en `../database-credentials-dev.txt`
|
||||
- [ ] Backend puede conectarse a la BD
|
||||
|
||||
### Antes de Deployment Producción
|
||||
|
||||
- [ ] Leí README.md completo
|
||||
- [ ] Tengo backup completo de BD actual
|
||||
- [ ] Generé secrets con `manage-secrets.sh`
|
||||
- [ ] Probé en staging
|
||||
- [ ] Tengo plan de rollback
|
||||
- [ ] Notifiqué al equipo
|
||||
|
||||
---
|
||||
|
||||
## 📈 HISTORIAL DE CAMBIOS
|
||||
|
||||
### 2025-11-08 - Consolidación v3.0
|
||||
|
||||
- ✅ Unificadas versiones múltiples de init-database.sh
|
||||
- ✅ Movidos scripts obsoletos a deprecated/
|
||||
- ✅ Creado QUICK-START.md
|
||||
- ✅ Creado ANALISIS-SCRIPTS-2025-11-08.md
|
||||
- ✅ Creado INDEX.md (este archivo)
|
||||
- ✅ Actualizada documentación completa
|
||||
|
||||
### Versiones Anteriores
|
||||
|
||||
- v2.0 (2025-11-02) - Integración con update-env-files
|
||||
- v1.0 (Original) - Scripts base
|
||||
|
||||
---
|
||||
|
||||
## 🎓 RECURSOS ADICIONALES
|
||||
|
||||
### Documentación de BD
|
||||
|
||||
- `INVENTARIO-COMPLETO-BD-2025-11-07.md` - Inventario exhaustivo
|
||||
- `REPORTE-VALIDACION-BD-COMPLETO-2025-11-08.md` - Validación completa
|
||||
- `MATRIZ-COBERTURA-MODULOS-PLATAFORMA-2025-11-07.md` - Cobertura
|
||||
|
||||
### Validaciones Cruzadas
|
||||
|
||||
- `VALIDACION-CRUZADA-INFORME-MIGRACION-2025-11-08.md` - Validación de migración
|
||||
|
||||
---
|
||||
|
||||
**Última actualización:** 2025-11-08
|
||||
**Mantenido por:** Equipo de Base de Datos GAMILIT
|
||||
**Versión:** 3.0
|
||||
**Estado:** ✅ Consolidado y Funcional
|
||||
|
||||
---
|
||||
|
||||
🎉 **¡Bienvenido a los Scripts de Base de Datos GAMILIT!** 🎉
|
||||
|
||||
**Próximo paso:** Lee `QUICK-START.md` para empezar
|
||||
317
projects/gamilit/apps/database/scripts/QUICK-START.md
Normal file
317
projects/gamilit/apps/database/scripts/QUICK-START.md
Normal file
@ -0,0 +1,317 @@
|
||||
# 🚀 GUÍA RÁPIDA - Scripts de Base de Datos GAMILIT
|
||||
|
||||
**Actualizado:** 2025-11-08
|
||||
**Versión:** 3.0
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Inicio Rápido
|
||||
|
||||
### Para Desarrollo (Primera Vez)
|
||||
|
||||
```bash
|
||||
cd /home/isem/workspace/projects/gamilit/apps/database/scripts
|
||||
|
||||
# Inicializar BD completa (crea usuario + BD + DDL + seeds)
|
||||
./init-database.sh --env dev --force
|
||||
```
|
||||
|
||||
### Para Producción (Primera Vez)
|
||||
|
||||
```bash
|
||||
# Con dotenv-vault (RECOMENDADO)
|
||||
./manage-secrets.sh generate --env prod
|
||||
./manage-secrets.sh sync --env prod
|
||||
./init-database.sh --env prod
|
||||
|
||||
# O con password manual
|
||||
./init-database.sh --env prod --password "tu_password_seguro_32chars"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Scripts Disponibles (3 principales)
|
||||
|
||||
### 1. `init-database.sh` - Inicialización Completa ⭐
|
||||
|
||||
**¿Cuándo usar?** Primera vez, o cuando el usuario NO existe
|
||||
|
||||
```bash
|
||||
./init-database.sh --env dev # Desarrollo
|
||||
./init-database.sh --env prod # Producción
|
||||
./init-database.sh --env dev --force # Sin confirmación
|
||||
```
|
||||
|
||||
**¿Qué hace?**
|
||||
- ✅ Crea usuario `gamilit_user` (si no existe)
|
||||
- ✅ Genera password seguro de 32 caracteres
|
||||
- ✅ Crea base de datos `gamilit_platform`
|
||||
- ✅ Ejecuta DDL (13 schemas, 61 tablas, 61 funciones, 288 índices, 114 RLS policies)
|
||||
- ✅ Carga seeds del ambiente
|
||||
- ✅ Actualiza archivos .env automáticamente
|
||||
|
||||
---
|
||||
|
||||
### 2. `reset-database.sh` - Reset Rápido (Mantiene Usuario)
|
||||
|
||||
**¿Cuándo usar?** Usuario ya existe, solo quieres resetear datos
|
||||
|
||||
```bash
|
||||
./reset-database.sh --env dev --password "password_existente"
|
||||
./reset-database.sh --env prod --password "prod_pass"
|
||||
```
|
||||
|
||||
**¿Qué hace?**
|
||||
- ⚠️ Elimina la BD `gamilit_platform`
|
||||
- ✅ Mantiene el usuario `gamilit_user` (NO cambia password)
|
||||
- ✅ Recrea BD con DDL y seeds
|
||||
- ℹ️ NO actualiza .env (credenciales no cambian)
|
||||
|
||||
---
|
||||
|
||||
### 3. `recreate-database.sh` - Recreación Completa (DESTRUYE TODO)
|
||||
|
||||
**¿Cuándo usar?** Cuando quieres empezar desde cero COMPLETAMENTE
|
||||
|
||||
⚠️ **ADVERTENCIA: ELIMINA USUARIO Y TODOS LOS DATOS**
|
||||
|
||||
```bash
|
||||
./recreate-database.sh --env dev
|
||||
./recreate-database.sh --env prod # Requiere confirmación adicional
|
||||
```
|
||||
|
||||
**¿Qué hace?**
|
||||
- ⚠️ Termina todas las conexiones
|
||||
- ⚠️ Elimina completamente la BD
|
||||
- ⚠️ Elimina el usuario
|
||||
- ✅ Ejecuta `init-database.sh` para recrear todo
|
||||
- ✅ Actualiza archivos .env automáticamente
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Casos de Uso Comunes
|
||||
|
||||
### Caso 1: Primera vez en proyecto (Setup inicial)
|
||||
|
||||
```bash
|
||||
./init-database.sh --env dev --force
|
||||
```
|
||||
|
||||
### Caso 2: Resetear datos pero mantener usuario
|
||||
|
||||
```bash
|
||||
# Si conoces el password
|
||||
./reset-database.sh --env dev --password "mi_password"
|
||||
|
||||
# Si no conoces el password, usa recreate
|
||||
./recreate-database.sh --env dev
|
||||
```
|
||||
|
||||
### Caso 3: Actualizar estructura de BD (nueva migración)
|
||||
|
||||
```bash
|
||||
# Opción A: Reset rápido (si tienes password)
|
||||
./reset-database.sh --env dev --password "password"
|
||||
|
||||
# Opción B: Recrear completo (genera nuevo password)
|
||||
./recreate-database.sh --env dev
|
||||
```
|
||||
|
||||
### Caso 4: Aplicar cambios de DDL
|
||||
|
||||
```bash
|
||||
# Si solo cambiaron DDL/seeds (sin cambios de usuario)
|
||||
./reset-database.sh --env dev --password "password_actual"
|
||||
```
|
||||
|
||||
### Caso 5: Olvidé el password del usuario
|
||||
|
||||
```bash
|
||||
# Única opción: recrear todo
|
||||
./recreate-database.sh --env dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Comparación Rápida
|
||||
|
||||
| Acción | init-database.sh | reset-database.sh | recreate-database.sh |
|
||||
|--------|------------------|-------------------|----------------------|
|
||||
| **Elimina usuario** | ❌ | ❌ | ✅ |
|
||||
| **Elimina BD** | ⚠️ Si existe | ✅ | ✅ |
|
||||
| **Crea usuario** | ✅ Si no existe | ❌ | ✅ |
|
||||
| **Genera password** | ✅ | ❌ | ✅ |
|
||||
| **Requiere password** | ❌ | ✅ | ❌ |
|
||||
| **Actualiza .env** | ✅ | ❌ | ✅ |
|
||||
| **Tiempo aprox** | 30-60s | 20-30s | 40-70s |
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Gestión de Credenciales
|
||||
|
||||
### ¿Dónde están las credenciales?
|
||||
|
||||
Después de ejecutar `init-database.sh` o `recreate-database.sh`:
|
||||
|
||||
```
|
||||
apps/database/database-credentials-{env}.txt ← Credenciales guardadas aquí
|
||||
```
|
||||
|
||||
Ejemplo de contenido:
|
||||
```
|
||||
Database Host: localhost
|
||||
Database Port: 5432
|
||||
Database Name: gamilit_platform
|
||||
Database User: gamilit_user
|
||||
Database Password: xB9k2mN...Zp8Q
|
||||
Connection String: postgresql://gamilit_user:xB9k2mN...@localhost:5432/gamilit_platform
|
||||
```
|
||||
|
||||
### Archivos .env actualizados automáticamente
|
||||
|
||||
- `apps/backend/.env.{env}`
|
||||
- `apps/database/.env.{env}`
|
||||
- `../../gamilit-deployment-scripts/.env.{env}` (si existe)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Advertencias de Seguridad
|
||||
|
||||
### Desarrollo
|
||||
|
||||
- ✅ OK usar `--force` para automatización
|
||||
- ✅ OK regenerar passwords
|
||||
- ✅ OK recrear BD frecuentemente
|
||||
|
||||
### Producción
|
||||
|
||||
- ⚠️ NUNCA usar `--force` sin validación
|
||||
- ⚠️ SIEMPRE hacer backup antes de `recreate-database.sh`
|
||||
- ⚠️ Confirmar que tienes backup antes de eliminar
|
||||
- ✅ Usar dotenv-vault para gestión de secrets
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Error: "No se puede conectar a PostgreSQL"
|
||||
|
||||
```bash
|
||||
# Verificar que PostgreSQL está corriendo
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# O verificar proceso
|
||||
ps aux | grep postgres
|
||||
|
||||
# Iniciar si está detenido
|
||||
sudo systemctl start postgresql
|
||||
```
|
||||
|
||||
### Error: "Usuario ya existe"
|
||||
|
||||
```bash
|
||||
# Opción A: Usar reset (si conoces password)
|
||||
./reset-database.sh --env dev --password "password_existente"
|
||||
|
||||
# Opción B: Recrear todo
|
||||
./recreate-database.sh --env dev
|
||||
```
|
||||
|
||||
### Error: "Base de datos no se puede eliminar (conexiones activas)"
|
||||
|
||||
```bash
|
||||
# El script ya maneja esto, pero si falla:
|
||||
sudo -u postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='gamilit_platform';"
|
||||
```
|
||||
|
||||
### Error: "Permisos denegados"
|
||||
|
||||
```bash
|
||||
# Dar permisos de ejecución
|
||||
chmod +x *.sh
|
||||
|
||||
# Verificar permisos de PostgreSQL
|
||||
sudo -u postgres psql -c "SELECT version();"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estructura de Archivos
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── init-database.sh ⭐ Script principal (v3.0)
|
||||
├── reset-database.sh 🔄 Reset rápido
|
||||
├── recreate-database.sh ⚠️ Recreación completa
|
||||
├── manage-secrets.sh 🔐 Gestión de secrets
|
||||
├── update-env-files.sh 🔧 Sincronización .env
|
||||
├── cleanup-duplicados.sh 🧹 Limpieza
|
||||
├── QUICK-START.md 📖 Esta guía
|
||||
├── README.md 📚 Documentación completa
|
||||
├── deprecated/ 📦 Scripts antiguos
|
||||
│ ├── init-database-v1.sh
|
||||
│ ├── init-database-v2.sh
|
||||
│ └── init-database.sh.backup-*
|
||||
├── config/ ⚙️ Configuraciones
|
||||
│ ├── dev.conf
|
||||
│ └── prod.conf
|
||||
├── inventory/ 📊 Scripts de inventario
|
||||
└── utilities/ 🛠️ Utilidades
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Flujo Recomendado para Nuevos Desarrolladores
|
||||
|
||||
### Día 1 - Setup Inicial
|
||||
|
||||
```bash
|
||||
# 1. Clonar repositorio
|
||||
git clone <repo-url>
|
||||
cd gamilit/projects/gamilit/apps/database/scripts
|
||||
|
||||
# 2. Inicializar BD
|
||||
./init-database.sh --env dev --force
|
||||
|
||||
# 3. Verificar credenciales
|
||||
cat ../database-credentials-dev.txt
|
||||
|
||||
# 4. ¡Listo! Backend puede conectarse
|
||||
```
|
||||
|
||||
### Día a Día - Desarrollo
|
||||
|
||||
```bash
|
||||
# Aplicar cambios de DDL
|
||||
./reset-database.sh --env dev --password "$(grep 'Database Password' ../database-credentials-dev.txt | cut -d: -f2 | xargs)"
|
||||
|
||||
# O más simple: recrear todo
|
||||
./recreate-database.sh --env dev --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist Pre-Deployment Producción
|
||||
|
||||
- [ ] Backup completo de BD actual
|
||||
- [ ] Verificar que `manage-secrets.sh` tiene secrets generados
|
||||
- [ ] Probar script en staging primero
|
||||
- [ ] Tener plan de rollback
|
||||
- [ ] Notificar al equipo del deployment
|
||||
- [ ] Ejecutar con --env prod (SIN --force)
|
||||
- [ ] Validar conexiones post-deployment
|
||||
- [ ] Verificar que seeds de producción se cargaron
|
||||
|
||||
---
|
||||
|
||||
## 📞 Soporte
|
||||
|
||||
- **Documentación completa:** `README.md`
|
||||
- **Scripts de inventario:** `inventory/`
|
||||
- **Logs:** Revisa output del script (se muestra en consola)
|
||||
|
||||
---
|
||||
|
||||
**Última actualización:** 2025-11-08
|
||||
**Versión de scripts:** 3.0
|
||||
**Mantenido por:** Equipo de Base de Datos GAMILIT
|
||||
289
projects/gamilit/apps/database/scripts/cleanup-duplicados.sh
Executable file
289
projects/gamilit/apps/database/scripts/cleanup-duplicados.sh
Executable file
@ -0,0 +1,289 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# Script: cleanup-duplicados.sh
|
||||
# Propósito: Eliminar duplicados detectados en análisis de dependencias
|
||||
# Generado: 2025-11-07
|
||||
# Autor: NEXUS-DATABASE-AVANZADO
|
||||
# Documentación: /gamilit/orchestration/05-validaciones/database/ANALISIS-DEPENDENCIAS-DUPLICADOS-2025-11-07.md
|
||||
# ==============================================================================
|
||||
|
||||
set -e # Exit on error
|
||||
set -u # Exit on undefined variable
|
||||
|
||||
# Colores para output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuración
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
BACKUP_DIR="$PROJECT_ROOT/apps/database/backups/duplicados/2025-11-07"
|
||||
DDL_DIR="$PROJECT_ROOT/apps/database/ddl"
|
||||
|
||||
echo -e "${BLUE}================================${NC}"
|
||||
echo -e "${BLUE} CLEANUP DE DUPLICADOS - DATABASE${NC}"
|
||||
echo -e "${BLUE}================================${NC}"
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
# PASO 0: Verificar ubicación
|
||||
# ==============================================================================
|
||||
echo -e "${YELLOW}📍 Verificando ubicación...${NC}"
|
||||
if [ ! -d "$PROJECT_ROOT/apps/database" ]; then
|
||||
echo -e "${RED}❌ Error: No se encuentra el directorio de database${NC}"
|
||||
echo -e "${RED} Ejecutar desde: /gamilit/apps/database/scripts/${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✅ Ubicación correcta${NC}"
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
# PASO 1: Crear estructura de backups
|
||||
# ==============================================================================
|
||||
echo -e "${YELLOW}📦 PASO 1: Creando estructura de backups...${NC}"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
cat > "$BACKUP_DIR/README.md" << 'EOF'
|
||||
# Backups de Archivos Duplicados - 2025-11-07
|
||||
|
||||
## Razón del Backup
|
||||
Archivos duplicados detectados por análisis de dependencias.
|
||||
Estos archivos fueron eliminados tras confirmar que no tienen referencias activas.
|
||||
|
||||
## Archivos en este backup
|
||||
1. `auth_get_current_user_id.sql` - Duplicado de gamilit/functions/02-get_current_user_id.sql (0 referencias)
|
||||
2. `public_trg_feature_flags_updated_at.sql` - Duplicado en schema incorrecto
|
||||
3. `public_trg_system_settings_updated_at.sql` - Duplicado en schema incorrecto
|
||||
|
||||
## Análisis Completo
|
||||
Ver: `/gamilit/orchestration/05-validaciones/database/ANALISIS-DEPENDENCIAS-DUPLICADOS-2025-11-07.md`
|
||||
|
||||
## Versiones Canónicas (MANTENER)
|
||||
1. `gamilit/functions/02-get_current_user_id.sql` - 73 referencias en DDL
|
||||
2. `system_configuration/triggers/29-trg_feature_flags_updated_at.sql` - Ubicación correcta
|
||||
3. `system_configuration/triggers/30-trg_system_settings_updated_at.sql` - Ubicación correcta
|
||||
|
||||
## Restauración (solo si es necesario)
|
||||
```bash
|
||||
# Restaurar función (NO RECOMENDADO - 0 referencias)
|
||||
cp auth_get_current_user_id.sql ../../ddl/schemas/auth/functions/get_current_user_id.sql
|
||||
|
||||
# Restaurar triggers (NO RECOMENDADO - schema incorrecto)
|
||||
cp public_trg_feature_flags_updated_at.sql ../../ddl/schemas/public/triggers/29-trg_feature_flags_updated_at.sql
|
||||
cp public_trg_system_settings_updated_at.sql ../../ddl/schemas/public/triggers/30-trg_system_settings_updated_at.sql
|
||||
```
|
||||
|
||||
**IMPORTANTE:** Los archivos eliminados NO tienen referencias activas o están en ubicación incorrecta.
|
||||
La restauración solo debe hacerse si se detecta un error específico.
|
||||
|
||||
## Timestamp
|
||||
- **Fecha backup:** 2025-11-07T18:45:00Z
|
||||
- **Análisis basado en:** 73 referencias medidas en DDL, Backend, Frontend y Docs
|
||||
- **Decisión:** Data-driven
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}✅ Estructura de backups creada${NC}"
|
||||
echo -e " Ubicación: $BACKUP_DIR"
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
# PASO 2: Realizar backups
|
||||
# ==============================================================================
|
||||
echo -e "${YELLOW}💾 PASO 2: Creando backups de duplicados...${NC}"
|
||||
|
||||
DUPLICADOS=(
|
||||
"schemas/auth/functions/get_current_user_id.sql:auth_get_current_user_id.sql"
|
||||
"schemas/public/triggers/29-trg_feature_flags_updated_at.sql:public_trg_feature_flags_updated_at.sql"
|
||||
"schemas/public/triggers/30-trg_system_settings_updated_at.sql:public_trg_system_settings_updated_at.sql"
|
||||
)
|
||||
|
||||
BACKUP_COUNT=0
|
||||
for DUP in "${DUPLICADOS[@]}"; do
|
||||
SOURCE_PATH="${DUP%%:*}"
|
||||
BACKUP_NAME="${DUP##*:}"
|
||||
FULL_PATH="$DDL_DIR/$SOURCE_PATH"
|
||||
|
||||
if [ -f "$FULL_PATH" ]; then
|
||||
cp "$FULL_PATH" "$BACKUP_DIR/$BACKUP_NAME"
|
||||
echo -e "${GREEN} ✅ Backup: $BACKUP_NAME${NC}"
|
||||
BACKUP_COUNT=$((BACKUP_COUNT + 1))
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ No encontrado: $SOURCE_PATH (posiblemente ya eliminado)${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${GREEN}✅ Backups completados: $BACKUP_COUNT archivos${NC}"
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
# PASO 3: Verificar estado antes de eliminar
|
||||
# ==============================================================================
|
||||
echo -e "${YELLOW}🔍 PASO 3: Verificando estado ANTES de eliminar...${NC}"
|
||||
|
||||
# Contar referencias actuales
|
||||
AUTH_REFS_BEFORE=$(grep -r "auth\.get_current_user_id" "$DDL_DIR" --include="*.sql" 2>/dev/null | wc -l || echo "0")
|
||||
GAMILIT_REFS_BEFORE=$(grep -r "gamilit\.get_current_user_id" "$DDL_DIR" --include="*.sql" 2>/dev/null | wc -l || echo "0")
|
||||
|
||||
echo -e " Referencias auth.get_current_user_id: $AUTH_REFS_BEFORE"
|
||||
echo -e " Referencias gamilit.get_current_user_id: $GAMILIT_REFS_BEFORE"
|
||||
|
||||
# Contar archivos de triggers
|
||||
FEATURE_FLAGS_COUNT=$(find "$DDL_DIR" -name "*trg_feature_flags_updated_at*" 2>/dev/null | wc -l || echo "0")
|
||||
SYSTEM_SETTINGS_COUNT=$(find "$DDL_DIR" -name "*trg_system_settings_updated_at*" 2>/dev/null | wc -l || echo "0")
|
||||
|
||||
echo -e " Archivos trg_feature_flags_updated_at: $FEATURE_FLAGS_COUNT"
|
||||
echo -e " Archivos trg_system_settings_updated_at: $SYSTEM_SETTINGS_COUNT"
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
# PASO 4: Eliminar duplicados
|
||||
# ==============================================================================
|
||||
echo -e "${YELLOW}🗑️ PASO 4: Eliminando duplicados...${NC}"
|
||||
|
||||
DELETED_COUNT=0
|
||||
for DUP in "${DUPLICADOS[@]}"; do
|
||||
SOURCE_PATH="${DUP%%:*}"
|
||||
FULL_PATH="$DDL_DIR/$SOURCE_PATH"
|
||||
|
||||
if [ -f "$FULL_PATH" ]; then
|
||||
rm "$FULL_PATH"
|
||||
echo -e "${GREEN} ✅ Eliminado: $SOURCE_PATH${NC}"
|
||||
DELETED_COUNT=$((DELETED_COUNT + 1))
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ Ya eliminado: $SOURCE_PATH${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${GREEN}✅ Duplicados eliminados: $DELETED_COUNT archivos${NC}"
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
# PASO 5: Verificar integridad POST-eliminación
|
||||
# ==============================================================================
|
||||
echo -e "${YELLOW}✅ PASO 5: Verificando integridad POST-eliminación...${NC}"
|
||||
|
||||
# Verificar referencias
|
||||
AUTH_REFS_AFTER=$(grep -r "auth\.get_current_user_id" "$DDL_DIR" --include="*.sql" 2>/dev/null | wc -l || echo "0")
|
||||
GAMILIT_REFS_AFTER=$(grep -r "gamilit\.get_current_user_id" "$DDL_DIR" --include="*.sql" 2>/dev/null | wc -l || echo "0")
|
||||
|
||||
echo -e " Referencias auth.get_current_user_id: $AUTH_REFS_AFTER (esperado: 0)"
|
||||
echo -e " Referencias gamilit.get_current_user_id: $GAMILIT_REFS_AFTER (esperado: 73)"
|
||||
|
||||
# Verificar archivos de triggers
|
||||
FEATURE_FLAGS_AFTER=$(find "$DDL_DIR" -name "*trg_feature_flags_updated_at*" 2>/dev/null | wc -l || echo "0")
|
||||
SYSTEM_SETTINGS_AFTER=$(find "$DDL_DIR" -name "*trg_system_settings_updated_at*" 2>/dev/null | wc -l || echo "0")
|
||||
|
||||
echo -e " Archivos trg_feature_flags_updated_at: $FEATURE_FLAGS_AFTER (esperado: 1)"
|
||||
echo -e " Archivos trg_system_settings_updated_at: $SYSTEM_SETTINGS_AFTER (esperado: 1)"
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
# PASO 6: Validación de resultados
|
||||
# ==============================================================================
|
||||
echo -e "${YELLOW}🎯 PASO 6: Validando resultados...${NC}"
|
||||
|
||||
ERRORS=0
|
||||
|
||||
# Validar función
|
||||
if [ "$AUTH_REFS_AFTER" -ne 0 ]; then
|
||||
echo -e "${RED} ❌ FALLO: auth.get_current_user_id tiene $AUTH_REFS_AFTER referencias (esperado: 0)${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo -e "${GREEN} ✅ auth.get_current_user_id: 0 referencias${NC}"
|
||||
fi
|
||||
|
||||
if [ "$GAMILIT_REFS_AFTER" -eq 73 ]; then
|
||||
echo -e "${GREEN} ✅ gamilit.get_current_user_id: 73 referencias${NC}"
|
||||
elif [ "$GAMILIT_REFS_AFTER" -gt 70 ]; then
|
||||
echo -e "${YELLOW} ⚠️ gamilit.get_current_user_id: $GAMILIT_REFS_AFTER referencias (esperado: 73, aceptable)${NC}"
|
||||
else
|
||||
echo -e "${RED} ❌ FALLO: gamilit.get_current_user_id tiene $GAMILIT_REFS_AFTER referencias (esperado: 73)${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Validar triggers
|
||||
if [ "$FEATURE_FLAGS_AFTER" -eq 1 ]; then
|
||||
echo -e "${GREEN} ✅ trg_feature_flags_updated_at: 1 archivo${NC}"
|
||||
else
|
||||
echo -e "${RED} ❌ FALLO: trg_feature_flags_updated_at tiene $FEATURE_FLAGS_AFTER archivos (esperado: 1)${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
if [ "$SYSTEM_SETTINGS_AFTER" -eq 1 ]; then
|
||||
echo -e "${GREEN} ✅ trg_system_settings_updated_at: 1 archivo${NC}"
|
||||
else
|
||||
echo -e "${RED} ❌ FALLO: trg_system_settings_updated_at tiene $SYSTEM_SETTINGS_AFTER archivos (esperado: 1)${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
# PASO 7: Listar archivos preservados
|
||||
# ==============================================================================
|
||||
echo -e "${YELLOW}📋 PASO 7: Verificando archivos preservados...${NC}"
|
||||
|
||||
CANONICOS=(
|
||||
"schemas/gamilit/functions/02-get_current_user_id.sql:gamilit.get_current_user_id()"
|
||||
"schemas/system_configuration/triggers/29-trg_feature_flags_updated_at.sql:trg_feature_flags_updated_at"
|
||||
"schemas/system_configuration/triggers/30-trg_system_settings_updated_at.sql:trg_system_settings_updated_at"
|
||||
)
|
||||
|
||||
for CANONICO in "${CANONICOS[@]}"; do
|
||||
FILE_PATH="${CANONICO%%:*}"
|
||||
FUNC_NAME="${CANONICO##*:}"
|
||||
FULL_PATH="$DDL_DIR/$FILE_PATH"
|
||||
|
||||
if [ -f "$FULL_PATH" ]; then
|
||||
echo -e "${GREEN} ✅ $FUNC_NAME${NC}"
|
||||
echo -e " $FILE_PATH"
|
||||
else
|
||||
echo -e "${RED} ❌ FALLO: No se encuentra $FUNC_NAME${NC}"
|
||||
echo -e "${RED} $FILE_PATH${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
# RESUMEN FINAL
|
||||
# ==============================================================================
|
||||
echo -e "${BLUE}================================${NC}"
|
||||
echo -e "${BLUE} RESUMEN FINAL${NC}"
|
||||
echo -e "${BLUE}================================${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "📊 Estadísticas:"
|
||||
echo -e " - Archivos respaldados: $BACKUP_COUNT"
|
||||
echo -e " - Archivos eliminados: $DELETED_COUNT"
|
||||
echo -e " - Archivos preservados: 3"
|
||||
echo -e " - Errores detectados: $ERRORS"
|
||||
echo ""
|
||||
|
||||
echo -e "📁 Backups guardados en:"
|
||||
echo -e " $BACKUP_DIR"
|
||||
echo ""
|
||||
|
||||
if [ $ERRORS -eq 0 ]; then
|
||||
echo -e "${GREEN}🎉 PROCESO COMPLETADO EXITOSAMENTE${NC}"
|
||||
echo -e "${GREEN}✅ 0 duplicados restantes${NC}"
|
||||
echo -e "${GREEN}✅ Integridad verificada${NC}"
|
||||
echo -e ""
|
||||
echo -e "${BLUE}📚 Ver análisis completo:${NC}"
|
||||
echo -e " orchestration/05-validaciones/database/ANALISIS-DEPENDENCIAS-DUPLICADOS-2025-11-07.md"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}❌ PROCESO COMPLETADO CON ERRORES${NC}"
|
||||
echo -e "${RED} Errores encontrados: $ERRORS${NC}"
|
||||
echo -e ""
|
||||
echo -e "${YELLOW}⚠️ Acciones recomendadas:${NC}"
|
||||
echo -e " 1. Revisar archivos preservados"
|
||||
echo -e " 2. Verificar backups en: $BACKUP_DIR"
|
||||
echo -e " 3. Consultar análisis: orchestration/05-validaciones/database/ANALISIS-DEPENDENCIAS-DUPLICADOS-2025-11-07.md"
|
||||
exit 1
|
||||
fi
|
||||
121
projects/gamilit/apps/database/scripts/fix-duplicate-triggers.sh
Executable file
121
projects/gamilit/apps/database/scripts/fix-duplicate-triggers.sh
Executable file
@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# =====================================================
|
||||
# Script: fix-duplicate-triggers.sh
|
||||
# Purpose: Remove duplicate triggers from table files
|
||||
# Date: 2025-11-24
|
||||
# Author: Architecture-Analyst
|
||||
#
|
||||
# This script comments out CREATE TRIGGER statements from
|
||||
# table definition files, as they should only exist in
|
||||
# separate trigger files (ddl/schemas/*/triggers/*.sql)
|
||||
# =====================================================
|
||||
|
||||
set -e
|
||||
|
||||
DDL_PATH="/home/isem/workspace/projects/gamilit/apps/database/ddl/schemas"
|
||||
LOG_FILE="/tmp/fix-duplicate-triggers-$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Fix Duplicate Triggers Script"
|
||||
echo "Date: $(date)"
|
||||
echo "Log: $LOG_FILE"
|
||||
echo "=========================================="
|
||||
|
||||
# List of files to process
|
||||
declare -a TABLE_FILES=(
|
||||
# auth_management
|
||||
"auth_management/tables/01-tenants.sql"
|
||||
"auth_management/tables/04-roles.sql"
|
||||
"auth_management/tables/10-memberships.sql"
|
||||
|
||||
# progress_tracking
|
||||
"progress_tracking/tables/01-module_progress.sql"
|
||||
"progress_tracking/tables/03-exercise_attempts.sql"
|
||||
"progress_tracking/tables/04-exercise_submissions.sql"
|
||||
|
||||
# gamification_system
|
||||
"gamification_system/tables/01-user_stats.sql"
|
||||
"gamification_system/tables/02-user_ranks.sql"
|
||||
"gamification_system/tables/03-achievements.sql"
|
||||
"gamification_system/tables/06-missions.sql"
|
||||
"gamification_system/tables/07-comodines_inventory.sql"
|
||||
"gamification_system/tables/08-notifications.sql"
|
||||
|
||||
# educational_content
|
||||
"educational_content/tables/01-modules.sql"
|
||||
"educational_content/tables/02-exercises.sql"
|
||||
"educational_content/tables/03-assessment_rubrics.sql"
|
||||
"educational_content/tables/04-media_resources.sql"
|
||||
|
||||
# content_management
|
||||
"content_management/tables/01-content_templates.sql"
|
||||
"content_management/tables/02-marie_curie_content.sql"
|
||||
"content_management/tables/03-media_files.sql"
|
||||
|
||||
# social_features
|
||||
"social_features/tables/02-schools.sql"
|
||||
"social_features/tables/03-classrooms.sql"
|
||||
"social_features/tables/04-classroom_members.sql"
|
||||
"social_features/tables/05-teams.sql"
|
||||
|
||||
# audit_logging
|
||||
"audit_logging/tables/03-system_alerts.sql"
|
||||
|
||||
# system_configuration
|
||||
"system_configuration/tables/01-system_settings.sql"
|
||||
"system_configuration/tables/01-feature_flags.sql"
|
||||
)
|
||||
|
||||
process_file() {
|
||||
local file="$DDL_PATH/$1"
|
||||
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "SKIP: $1 (file not found)" | tee -a "$LOG_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check if file has CREATE TRIGGER
|
||||
if ! grep -q "CREATE TRIGGER\|CREATE OR REPLACE TRIGGER" "$file"; then
|
||||
echo "SKIP: $1 (no triggers)" | tee -a "$LOG_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "PROCESSING: $1" | tee -a "$LOG_FILE"
|
||||
|
||||
# Create backup
|
||||
cp "$file" "${file}.bak"
|
||||
|
||||
# Comment out CREATE TRIGGER blocks (from CREATE TRIGGER to ;)
|
||||
# This is a simplified approach - for complex cases, manual review is needed
|
||||
sed -i 's/^CREATE TRIGGER/-- [DUPLICATE] CREATE TRIGGER/g' "$file"
|
||||
sed -i 's/^CREATE OR REPLACE TRIGGER/-- [DUPLICATE] CREATE OR REPLACE TRIGGER/g' "$file"
|
||||
|
||||
# Add note about trigger location
|
||||
if ! grep -q "NOTE: Triggers moved to separate files" "$file"; then
|
||||
# Add note after "-- Triggers" comment if exists
|
||||
sed -i '/^-- Triggers$/a -- NOTE: Triggers moved to separate files in triggers/ directory' "$file"
|
||||
fi
|
||||
|
||||
echo " - Commented out CREATE TRIGGER statements" | tee -a "$LOG_FILE"
|
||||
echo " - Backup created: ${file}.bak" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "Processing ${#TABLE_FILES[@]} files..."
|
||||
echo ""
|
||||
|
||||
for file in "${TABLE_FILES[@]}"; do
|
||||
process_file "$file"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "COMPLETED"
|
||||
echo "Files processed: ${#TABLE_FILES[@]}"
|
||||
echo "Log saved to: $LOG_FILE"
|
||||
echo ""
|
||||
echo "NEXT STEPS:"
|
||||
echo "1. Review changes in git diff"
|
||||
echo "2. Test with: ./drop-and-recreate-database.sh"
|
||||
echo "3. Remove .bak files if successful"
|
||||
echo "=========================================="
|
||||
1080
projects/gamilit/apps/database/scripts/init-database-v3.sh
Executable file
1080
projects/gamilit/apps/database/scripts/init-database-v3.sh
Executable file
File diff suppressed because it is too large
Load Diff
1091
projects/gamilit/apps/database/scripts/init-database.sh
Executable file
1091
projects/gamilit/apps/database/scripts/init-database.sh
Executable file
File diff suppressed because it is too large
Load Diff
172
projects/gamilit/apps/database/scripts/load-users-and-profiles.sh
Executable file
172
projects/gamilit/apps/database/scripts/load-users-and-profiles.sh
Executable file
@ -0,0 +1,172 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Script: load-users-and-profiles.sh
|
||||
# Descripción: Carga usuarios y perfiles correctamente
|
||||
# Versión: 2.0 (con correcciones para tablas faltantes)
|
||||
# Fecha: 2025-11-09
|
||||
# Autor: Claude Code (AI Assistant)
|
||||
# ============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DB_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$DB_DIR"
|
||||
|
||||
# Cargar credenciales
|
||||
if [ ! -f "database-credentials-dev.txt" ]; then
|
||||
echo "❌ Error: database-credentials-dev.txt no encontrado"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DB_PASSWORD=$(grep "^Password:" database-credentials-dev.txt | awk '{print $2}')
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
PSQL="psql -h localhost -p 5432 -U gamilit_user -d gamilit_platform"
|
||||
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " CARGANDO USUARIOS Y PERFILES - GAMILIT PLATFORM"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# PASO 1: Verificar tablas de gamificación
|
||||
echo "📋 PASO 1: Verificando tablas de gamificación..."
|
||||
TABLES_COUNT=$($PSQL -t -c "
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'gamification_system'
|
||||
AND table_name IN ('user_stats', 'user_ranks');
|
||||
" | tr -d ' ')
|
||||
|
||||
if [ "$TABLES_COUNT" -lt 2 ]; then
|
||||
echo "⚠️ Tablas de gamificación faltantes ($TABLES_COUNT/2). Creando..."
|
||||
bash "$SCRIPT_DIR/fix-missing-gamification-tables.sh"
|
||||
else
|
||||
echo "✅ Tablas de gamificación presentes (2/2)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# PASO 2: Cargar usuarios en auth.users
|
||||
echo "👥 PASO 2: Cargando usuarios en auth.users..."
|
||||
|
||||
if [ -f "seeds/dev/auth/01-demo-users.sql" ]; then
|
||||
$PSQL -f seeds/dev/auth/01-demo-users.sql > /dev/null 2>&1
|
||||
echo " ✅ Demo users cargados"
|
||||
else
|
||||
echo " ⚠️ seeds/dev/auth/01-demo-users.sql no encontrado"
|
||||
fi
|
||||
|
||||
if [ -f "seeds/dev/auth/02-test-users.sql" ]; then
|
||||
$PSQL -f seeds/dev/auth/02-test-users.sql > /dev/null 2>&1
|
||||
echo " ✅ Test users cargados"
|
||||
else
|
||||
echo " ⚠️ seeds/dev/auth/02-test-users.sql no encontrado"
|
||||
fi
|
||||
|
||||
USERS_COUNT=$($PSQL -t -c "
|
||||
SELECT COUNT(*) FROM auth.users
|
||||
WHERE email LIKE '%@glit.edu.mx'
|
||||
OR email LIKE '%@demo.glit.edu.mx'
|
||||
OR email LIKE '%@gamilit.com';
|
||||
" | tr -d ' ')
|
||||
echo " 📊 Total usuarios: $USERS_COUNT"
|
||||
|
||||
echo ""
|
||||
|
||||
# PASO 3: Cargar profiles en auth_management.profiles
|
||||
echo "📝 PASO 3: Cargando profiles..."
|
||||
|
||||
if [ -f "seeds/dev/auth_management/03-profiles.sql" ]; then
|
||||
# Ejecutar y capturar errores
|
||||
if $PSQL -f seeds/dev/auth_management/03-profiles.sql 2>&1 | grep -q "ERROR"; then
|
||||
echo " ⚠️ Error al cargar profiles. Intentando método alternativo..."
|
||||
|
||||
# Deshabilitar trigger temporalmente
|
||||
$PSQL -c "ALTER TABLE auth_management.profiles DISABLE TRIGGER trg_initialize_user_stats;" > /dev/null 2>&1
|
||||
|
||||
# Re-intentar carga
|
||||
$PSQL -f seeds/dev/auth_management/03-profiles.sql > /dev/null 2>&1
|
||||
|
||||
# Re-habilitar trigger
|
||||
$PSQL -c "ALTER TABLE auth_management.profiles ENABLE TRIGGER trg_initialize_user_stats;" > /dev/null 2>&1
|
||||
|
||||
echo " ✅ Profiles cargados (método alternativo)"
|
||||
else
|
||||
echo " ✅ Profiles cargados"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ seeds/dev/auth_management/03-profiles.sql no encontrado"
|
||||
fi
|
||||
|
||||
PROFILES_COUNT=$($PSQL -t -c "
|
||||
SELECT COUNT(*) FROM auth_management.profiles
|
||||
WHERE email LIKE '%@glit.edu.mx'
|
||||
OR email LIKE '%@demo.glit.edu.mx'
|
||||
OR email LIKE '%@gamilit.com';
|
||||
" | tr -d ' ')
|
||||
echo " 📊 Total profiles: $PROFILES_COUNT"
|
||||
|
||||
echo ""
|
||||
|
||||
# PASO 4: Verificación final
|
||||
echo "✅ PASO 4: Verificación final..."
|
||||
echo ""
|
||||
|
||||
$PSQL -c "
|
||||
SELECT
|
||||
'auth.users' as tabla,
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE role = 'super_admin') as admins,
|
||||
COUNT(*) FILTER (WHERE role = 'admin_teacher') as teachers,
|
||||
COUNT(*) FILTER (WHERE role = 'student') as students
|
||||
FROM auth.users
|
||||
WHERE email LIKE '%@glit.edu.mx'
|
||||
OR email LIKE '%@demo.glit.edu.mx'
|
||||
OR email LIKE '%@gamilit.com'
|
||||
UNION ALL
|
||||
SELECT
|
||||
'auth_management.profiles' as tabla,
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE role = 'super_admin') as admins,
|
||||
COUNT(*) FILTER (WHERE role = 'admin_teacher') as teachers,
|
||||
COUNT(*) FILTER (WHERE role = 'student') as students
|
||||
FROM auth_management.profiles
|
||||
WHERE email LIKE '%@glit.edu.mx'
|
||||
OR email LIKE '%@demo.glit.edu.mx'
|
||||
OR email LIKE '%@gamilit.com';
|
||||
"
|
||||
|
||||
echo ""
|
||||
|
||||
# Verificar vinculación
|
||||
UNLINKED=$($PSQL -t -c "
|
||||
SELECT COUNT(*)
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth_management.profiles p ON u.id = p.user_id
|
||||
WHERE p.user_id IS NULL
|
||||
AND (u.email LIKE '%@glit.edu.mx'
|
||||
OR u.email LIKE '%@demo.glit.edu.mx'
|
||||
OR u.email LIKE '%@gamilit.com');
|
||||
" | tr -d ' ')
|
||||
|
||||
if [ "$UNLINKED" -gt 0 ]; then
|
||||
echo "⚠️ Advertencia: $UNLINKED usuarios sin perfil"
|
||||
else
|
||||
echo "✅ Todos los usuarios tienen perfil vinculado"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " ✅ CARGA COMPLETADA"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "📊 Resumen:"
|
||||
echo " Usuarios cargados: $USERS_COUNT"
|
||||
echo " Profiles cargados: $PROFILES_COUNT"
|
||||
echo " Sin vincular: $UNLINKED"
|
||||
echo ""
|
||||
echo "📝 Para ver detalles, ejecutar:"
|
||||
echo " bash scripts/verify-users.sh"
|
||||
echo ""
|
||||
329
projects/gamilit/apps/database/scripts/recreate-database.sh
Executable file
329
projects/gamilit/apps/database/scripts/recreate-database.sh
Executable file
@ -0,0 +1,329 @@
|
||||
#!/bin/bash
|
||||
##############################################################################
|
||||
# GAMILIT Platform - Database Recreation Script
|
||||
#
|
||||
# Propósito: ELIMINACIÓN COMPLETA y recreación (usuario + BD)
|
||||
# ⚠️ DESTRUYE TODOS LOS DATOS ⚠️
|
||||
#
|
||||
# Uso:
|
||||
# ./recreate-database.sh # Modo interactivo
|
||||
# ./recreate-database.sh --env dev # Desarrollo
|
||||
# ./recreate-database.sh --env prod # Producción
|
||||
# ./recreate-database.sh --env dev --force # Sin confirmación
|
||||
#
|
||||
# Funcionalidades:
|
||||
# 1. ⚠️ Elimina completamente la BD gamilit_platform
|
||||
# 2. ⚠️ Elimina el usuario gamilit_user
|
||||
# 3. Ejecuta init-database.sh para recrear todo
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# Colores
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuración
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INIT_SCRIPT="$SCRIPT_DIR/init-database.sh"
|
||||
|
||||
DB_NAME="gamilit_platform"
|
||||
DB_USER="gamilit_user"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
POSTGRES_USER="postgres"
|
||||
|
||||
ENVIRONMENT=""
|
||||
FORCE_MODE=false
|
||||
|
||||
# ============================================================================
|
||||
# FUNCIONES AUXILIARES
|
||||
# ============================================================================
|
||||
|
||||
print_header() {
|
||||
echo ""
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo -e "${RED}$1${NC}"
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
print_step() {
|
||||
echo -e "${CYAN}▶ $1${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✓ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}✗ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠ $1${NC}"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo " $1"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
GAMILIT Platform - Recreación Completa de Base de Datos
|
||||
|
||||
⚠️ ADVERTENCIA: Este script ELIMINA TODOS LOS DATOS
|
||||
|
||||
Uso: $0 [OPCIONES]
|
||||
|
||||
Opciones:
|
||||
--env dev|prod Ambiente
|
||||
--force No pedir confirmación
|
||||
--help Mostrar ayuda
|
||||
|
||||
Ejemplos:
|
||||
$0 --env dev
|
||||
$0 --env prod --force
|
||||
|
||||
Este script:
|
||||
1. Elimina la base de datos gamilit_platform
|
||||
2. Elimina el usuario gamilit_user
|
||||
3. Ejecuta init-database.sh para recrear todo
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# FUNCIONES SQL
|
||||
# ============================================================================
|
||||
|
||||
execute_as_postgres() {
|
||||
local sql="$1"
|
||||
if [ "$USE_SUDO" = true ]; then
|
||||
echo "$sql" | sudo -u postgres psql 2>&1
|
||||
else
|
||||
PGPASSWORD="$PGPASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -c "$sql" 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
query_as_postgres() {
|
||||
local sql="$1"
|
||||
if [ "$USE_SUDO" = true ]; then
|
||||
echo "$sql" | sudo -u postgres psql -t | xargs
|
||||
else
|
||||
PGPASSWORD="$PGPASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -t -c "$sql" | xargs
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# VERIFICACIÓN
|
||||
# ============================================================================
|
||||
|
||||
check_prerequisites() {
|
||||
print_step "Verificando prerequisitos..."
|
||||
|
||||
if ! command -v psql &> /dev/null; then
|
||||
print_error "psql no encontrado"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$INIT_SCRIPT" ]; then
|
||||
print_error "Script de inicialización no encontrado: $INIT_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verificar conexión PostgreSQL
|
||||
if sudo -n -u postgres psql -c "SELECT 1" &> /dev/null 2>&1; then
|
||||
USE_SUDO=true
|
||||
print_success "Conectado a PostgreSQL (sudo)"
|
||||
elif [ -n "$PGPASSWORD" ] && psql -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -c "SELECT 1" &> /dev/null 2>&1; then
|
||||
USE_SUDO=false
|
||||
print_success "Conectado a PostgreSQL (TCP)"
|
||||
else
|
||||
print_error "No se puede conectar a PostgreSQL"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# PASO 1: ELIMINAR BASE DE DATOS
|
||||
# ============================================================================
|
||||
|
||||
drop_database() {
|
||||
print_step "PASO 1/3: Eliminando base de datos..."
|
||||
|
||||
db_exists=$(query_as_postgres "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")
|
||||
|
||||
if [ -z "$db_exists" ]; then
|
||||
print_info "Base de datos '$DB_NAME' no existe"
|
||||
return
|
||||
fi
|
||||
|
||||
print_info "Terminando conexiones activas..."
|
||||
execute_as_postgres "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" > /dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
print_info "Eliminando base de datos '$DB_NAME'..."
|
||||
if execute_as_postgres "DROP DATABASE IF EXISTS $DB_NAME;" > /dev/null 2>&1; then
|
||||
print_success "Base de datos eliminada"
|
||||
else
|
||||
print_error "Error al eliminar base de datos"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# PASO 2: ELIMINAR USUARIO
|
||||
# ============================================================================
|
||||
|
||||
drop_user() {
|
||||
print_step "PASO 2/3: Eliminando usuario..."
|
||||
|
||||
user_exists=$(query_as_postgres "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'")
|
||||
|
||||
if [ -z "$user_exists" ]; then
|
||||
print_info "Usuario '$DB_USER' no existe"
|
||||
return
|
||||
fi
|
||||
|
||||
print_info "Eliminando objetos del usuario..."
|
||||
execute_as_postgres "DROP OWNED BY $DB_USER CASCADE;" > /dev/null 2>&1 || true
|
||||
|
||||
print_info "Eliminando usuario '$DB_USER'..."
|
||||
if execute_as_postgres "DROP USER IF EXISTS $DB_USER;" > /dev/null 2>&1; then
|
||||
print_success "Usuario eliminado"
|
||||
else
|
||||
print_warning "No se pudo eliminar el usuario"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# PASO 3: REINICIALIZAR
|
||||
# ============================================================================
|
||||
|
||||
reinitialize() {
|
||||
print_step "PASO 3/3: Reinicializando..."
|
||||
|
||||
print_info "Ejecutando init-database.sh..."
|
||||
echo ""
|
||||
|
||||
local init_args="--env $ENVIRONMENT"
|
||||
if [ "$FORCE_MODE" = true ]; then
|
||||
init_args="$init_args --force"
|
||||
fi
|
||||
|
||||
if bash "$INIT_SCRIPT" $init_args; then
|
||||
print_success "Reinicialización completada"
|
||||
else
|
||||
print_error "Error durante reinicialización"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# CONFIRMACIÓN
|
||||
# ============================================================================
|
||||
|
||||
confirm_deletion() {
|
||||
print_header "⚠️ ADVERTENCIA: ELIMINACIÓN DE DATOS"
|
||||
|
||||
echo -e "${RED}Este script eliminará PERMANENTEMENTE:${NC}"
|
||||
echo -e " • Base de datos: ${YELLOW}$DB_NAME${NC}"
|
||||
echo -e " • Usuario: ${YELLOW}$DB_USER${NC}"
|
||||
echo ""
|
||||
echo -e "${RED}TODOS LOS DATOS SERÁN ELIMINADOS${NC}"
|
||||
echo ""
|
||||
|
||||
if [ "$FORCE_MODE" = false ]; then
|
||||
echo -e "${RED}¿Estás COMPLETAMENTE seguro?${NC}"
|
||||
read -p "Escribe 'DELETE ALL' para confirmar: " confirmation
|
||||
|
||||
if [ "$confirmation" != "DELETE ALL" ]; then
|
||||
print_info "Operación cancelada"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
read -p "¿Continuar? (yes/no): " final_confirm
|
||||
if [ "$final_confirm" != "yes" ]; then
|
||||
print_info "Operación cancelada"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_warning "Iniciando en 3 segundos..."
|
||||
sleep 1
|
||||
echo -n "3... "
|
||||
sleep 1
|
||||
echo -n "2... "
|
||||
sleep 1
|
||||
echo "1..."
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
main() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--env)
|
||||
ENVIRONMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--force)
|
||||
FORCE_MODE=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
print_error "Opción desconocida: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$ENVIRONMENT" ]; then
|
||||
print_header "GAMILIT Platform - Recreación de BD"
|
||||
echo "Selecciona ambiente:"
|
||||
echo " 1) dev"
|
||||
echo " 2) prod"
|
||||
read -p "Opción: " env_option
|
||||
|
||||
case $env_option in
|
||||
1) ENVIRONMENT="dev" ;;
|
||||
2) ENVIRONMENT="prod" ;;
|
||||
*)
|
||||
print_error "Opción inválida"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "$ENVIRONMENT" != "dev" ] && [ "$ENVIRONMENT" != "prod" ]; then
|
||||
print_error "Ambiente inválido: $ENVIRONMENT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
confirm_deletion
|
||||
check_prerequisites
|
||||
drop_database
|
||||
drop_user
|
||||
reinitialize
|
||||
|
||||
echo ""
|
||||
print_header "✅ BASE DE DATOS RECREADA"
|
||||
echo -e "${GREEN}Base de datos y usuario recreados desde cero${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
503
projects/gamilit/apps/database/scripts/reset-database.sh
Executable file
503
projects/gamilit/apps/database/scripts/reset-database.sh
Executable file
@ -0,0 +1,503 @@
|
||||
#!/bin/bash
|
||||
##############################################################################
|
||||
# GAMILIT Platform - Database Reset Script
|
||||
#
|
||||
# Propósito: Reiniciar SOLO la base de datos (mantiene usuario existente)
|
||||
# ⚠️ Elimina datos pero NO el usuario PostgreSQL
|
||||
#
|
||||
# Uso:
|
||||
# ./reset-database.sh # Modo interactivo
|
||||
# ./reset-database.sh --env dev # Desarrollo
|
||||
# ./reset-database.sh --env prod # Producción
|
||||
# ./reset-database.sh --env dev --force # Sin confirmación
|
||||
# ./reset-database.sh --password "mi_pass" # Con password conocido
|
||||
#
|
||||
# Funcionalidades:
|
||||
# 1. ⚠️ Elimina la BD gamilit_platform
|
||||
# 2. ✅ Mantiene el usuario gamilit_user
|
||||
# 3. Recrea BD, ejecuta DDL y carga seeds
|
||||
#
|
||||
# Ideal para:
|
||||
# - Usuario ya existe con password conocido
|
||||
# - Resetear datos sin tocar configuración de usuario
|
||||
# - Ambientes donde el usuario tiene permisos específicos
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# Colores
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuración de rutas
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DATABASE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DDL_DIR="$DATABASE_ROOT/ddl"
|
||||
SEEDS_DIR="$DATABASE_ROOT/seeds"
|
||||
|
||||
# Configuración de base de datos
|
||||
DB_NAME="gamilit_platform"
|
||||
DB_USER="gamilit_user"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
POSTGRES_USER="postgres"
|
||||
|
||||
# Variables
|
||||
ENVIRONMENT=""
|
||||
FORCE_MODE=false
|
||||
DB_PASSWORD=""
|
||||
|
||||
# ============================================================================
|
||||
# FUNCIONES AUXILIARES
|
||||
# ============================================================================
|
||||
|
||||
print_header() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW}$1${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
print_step() {
|
||||
echo -e "${CYAN}▶ $1${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✓ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}✗ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠ $1${NC}"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo " $1"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
GAMILIT Platform - Reset de Base de Datos (Mantiene Usuario)
|
||||
|
||||
Uso: $0 [OPCIONES]
|
||||
|
||||
Opciones:
|
||||
--env dev|prod Ambiente
|
||||
--password PASS Password del usuario existente (requerido)
|
||||
--force No pedir confirmación
|
||||
--help Mostrar ayuda
|
||||
|
||||
Ejemplos:
|
||||
$0 --env dev --password "mi_password"
|
||||
$0 --env prod --password "prod_pass" --force
|
||||
|
||||
Este script:
|
||||
1. Elimina la base de datos gamilit_platform
|
||||
2. Mantiene el usuario gamilit_user
|
||||
3. Recrea la BD con DDL y seeds
|
||||
|
||||
⚠️ Requiere conocer el password del usuario existente
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# FUNCIONES SQL
|
||||
# ============================================================================
|
||||
|
||||
execute_as_postgres() {
|
||||
local sql="$1"
|
||||
if [ "$USE_SUDO" = true ]; then
|
||||
echo "$sql" | sudo -u postgres psql 2>&1
|
||||
else
|
||||
PGPASSWORD="$PGPASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -c "$sql" 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
query_as_postgres() {
|
||||
local sql="$1"
|
||||
if [ "$USE_SUDO" = true ]; then
|
||||
echo "$sql" | sudo -u postgres psql -t | xargs
|
||||
else
|
||||
PGPASSWORD="$PGPASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -t -c "$sql" | xargs
|
||||
fi
|
||||
}
|
||||
|
||||
execute_sql_file() {
|
||||
local file="$1"
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$file" 2>&1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# VERIFICACIÓN
|
||||
# ============================================================================
|
||||
|
||||
check_prerequisites() {
|
||||
print_step "Verificando prerequisitos..."
|
||||
|
||||
if ! command -v psql &> /dev/null; then
|
||||
print_error "psql no encontrado"
|
||||
exit 1
|
||||
fi
|
||||
print_success "psql encontrado"
|
||||
|
||||
if [ ! -d "$DDL_DIR" ]; then
|
||||
print_error "Directorio DDL no encontrado: $DDL_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$SEEDS_DIR" ]; then
|
||||
print_error "Directorio seeds no encontrado: $SEEDS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verificar conexión PostgreSQL
|
||||
if sudo -n -u postgres psql -c "SELECT 1" &> /dev/null 2>&1; then
|
||||
USE_SUDO=true
|
||||
print_success "Conectado a PostgreSQL (sudo)"
|
||||
elif [ -n "$PGPASSWORD" ] && psql -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -c "SELECT 1" &> /dev/null 2>&1; then
|
||||
USE_SUDO=false
|
||||
print_success "Conectado a PostgreSQL (TCP)"
|
||||
else
|
||||
print_error "No se puede conectar a PostgreSQL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verificar que el usuario existe
|
||||
user_exists=$(query_as_postgres "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'")
|
||||
if [ -z "$user_exists" ]; then
|
||||
print_error "Usuario '$DB_USER' no existe"
|
||||
print_info "Usa init-database.sh para crear el usuario primero"
|
||||
exit 1
|
||||
fi
|
||||
print_success "Usuario $DB_USER existe"
|
||||
|
||||
# Verificar password del usuario
|
||||
if [ -z "$DB_PASSWORD" ]; then
|
||||
print_error "Password requerido (usar --password)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Probar conexión con el password
|
||||
if ! PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "SELECT 1" > /dev/null 2>&1; then
|
||||
print_error "Password incorrecto para usuario $DB_USER"
|
||||
exit 1
|
||||
fi
|
||||
print_success "Password verificado"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# PASO 1: ELIMINAR BASE DE DATOS
|
||||
# ============================================================================
|
||||
|
||||
drop_database() {
|
||||
print_step "PASO 1/4: Eliminando base de datos..."
|
||||
|
||||
db_exists=$(query_as_postgres "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")
|
||||
|
||||
if [ -z "$db_exists" ]; then
|
||||
print_info "Base de datos '$DB_NAME' no existe, se creará nueva"
|
||||
return
|
||||
fi
|
||||
|
||||
print_info "Terminando conexiones activas..."
|
||||
execute_as_postgres "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" > /dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
print_info "Eliminando base de datos '$DB_NAME'..."
|
||||
if execute_as_postgres "DROP DATABASE IF EXISTS $DB_NAME;" > /dev/null 2>&1; then
|
||||
print_success "Base de datos eliminada"
|
||||
else
|
||||
print_error "Error al eliminar base de datos"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# PASO 2: CREAR BASE DE DATOS
|
||||
# ============================================================================
|
||||
|
||||
create_database() {
|
||||
print_step "PASO 2/4: Creando base de datos..."
|
||||
|
||||
print_info "Creando base de datos $DB_NAME..."
|
||||
execute_as_postgres "CREATE DATABASE $DB_NAME OWNER $DB_USER ENCODING 'UTF8';" > /dev/null
|
||||
print_success "Base de datos creada"
|
||||
|
||||
execute_as_postgres "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" > /dev/null
|
||||
print_success "Privilegios otorgados"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# PASO 3: EJECUTAR DDL Y SEEDS
|
||||
# ============================================================================
|
||||
|
||||
execute_ddl() {
|
||||
print_step "PASO 3/4: Ejecutando DDL..."
|
||||
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
local schemas=(
|
||||
"auth"
|
||||
"auth_management"
|
||||
"system_configuration"
|
||||
"gamification_system"
|
||||
"educational_content"
|
||||
"content_management"
|
||||
"social_features"
|
||||
"progress_tracking"
|
||||
"audit_logging"
|
||||
)
|
||||
|
||||
# Crear schemas
|
||||
print_info "Creando schemas..."
|
||||
for schema in "${schemas[@]}"; do
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "CREATE SCHEMA IF NOT EXISTS $schema;" > /dev/null 2>&1
|
||||
done
|
||||
print_success "9 schemas creados"
|
||||
|
||||
# Crear ENUMs
|
||||
print_info "Creando ENUMs..."
|
||||
if [ -d "$DDL_DIR/schemas/gamification_system/enums" ]; then
|
||||
for enum_file in "$DDL_DIR/schemas/gamification_system/enums"/*.sql; do
|
||||
if [ -f "$enum_file" ]; then
|
||||
execute_sql_file "$enum_file" > /dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
print_success "ENUMs creados"
|
||||
|
||||
# Crear tablas
|
||||
print_info "Creando tablas..."
|
||||
local table_count=0
|
||||
for schema in "${schemas[@]}"; do
|
||||
local tables_dir="$DDL_DIR/schemas/$schema/tables"
|
||||
if [ -d "$tables_dir" ]; then
|
||||
for table_file in "$tables_dir"/*.sql; do
|
||||
if [ -f "$table_file" ]; then
|
||||
if execute_sql_file "$table_file" > /dev/null 2>&1; then
|
||||
((table_count++))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
print_success "$table_count tablas creadas"
|
||||
|
||||
# Otorgar permisos a gamilit_user
|
||||
print_info "Otorgando permisos a gamilit_user..."
|
||||
local perms_file="$DDL_DIR/99-post-ddl-permissions.sql"
|
||||
if [ -f "$perms_file" ]; then
|
||||
execute_sql_file "$perms_file" > /dev/null 2>&1
|
||||
print_success "Permisos otorgados"
|
||||
else
|
||||
print_warning "Archivo de permisos no encontrado: $perms_file"
|
||||
fi
|
||||
|
||||
unset PGPASSWORD
|
||||
}
|
||||
|
||||
load_seeds() {
|
||||
print_step "PASO 4/4: Cargando seeds..."
|
||||
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
local seeds_base="$SEEDS_DIR/dev"
|
||||
local loaded=0
|
||||
local failed=0
|
||||
|
||||
# Array con orden específico respetando dependencias
|
||||
# IMPORTANTE: Este orden es crítico para evitar errores de FK
|
||||
local seed_files=(
|
||||
# 1. Tenants y auth providers (sin dependencias)
|
||||
"$seeds_base/auth_management/01-tenants.sql"
|
||||
"$seeds_base/auth_management/02-auth_providers.sql"
|
||||
|
||||
# 2. Users (depende de tenants - opcional)
|
||||
"$seeds_base/auth/01-demo-users.sql"
|
||||
|
||||
# 3. Profiles (CRÍTICO: depende de users y tenants)
|
||||
"$seeds_base/auth_management/03-profiles.sql"
|
||||
|
||||
# 4. Resto de auth_management
|
||||
"$seeds_base/auth_management/04-user_roles.sql"
|
||||
"$seeds_base/auth_management/05-user_preferences.sql"
|
||||
"$seeds_base/auth_management/06-auth_attempts.sql"
|
||||
"$seeds_base/auth_management/07-security_events.sql"
|
||||
|
||||
# 5. System configuration
|
||||
"$seeds_base/system_configuration/01-system_settings.sql"
|
||||
"$seeds_base/system_configuration/02-feature_flags.sql"
|
||||
|
||||
# 6. Gamificación (depende de users/profiles)
|
||||
"$seeds_base/gamification_system/01-achievement_categories.sql"
|
||||
"$seeds_base/gamification_system/02-achievements.sql"
|
||||
"$seeds_base/gamification_system/03-leaderboard_metadata.sql"
|
||||
"$seeds_base/gamification_system/04-initialize_user_gamification.sql"
|
||||
|
||||
# 7. Educational content
|
||||
"$seeds_base/educational_content/01-modules.sql"
|
||||
"$seeds_base/educational_content/02-exercises-module1.sql"
|
||||
"$seeds_base/educational_content/03-exercises-module2.sql"
|
||||
"$seeds_base/educational_content/04-exercises-module3.sql"
|
||||
"$seeds_base/educational_content/05-exercises-module4.sql"
|
||||
"$seeds_base/educational_content/06-exercises-module5.sql"
|
||||
"$seeds_base/educational_content/07-assessment-rubrics.sql"
|
||||
|
||||
# 8. Content management
|
||||
"$seeds_base/content_management/01-marie-curie-bio.sql"
|
||||
"$seeds_base/content_management/02-media-files.sql"
|
||||
"$seeds_base/content_management/03-tags.sql"
|
||||
|
||||
# 9. Social features
|
||||
"$seeds_base/social_features/01-schools.sql"
|
||||
"$seeds_base/social_features/02-classrooms.sql"
|
||||
"$seeds_base/social_features/03-classroom-members.sql"
|
||||
"$seeds_base/social_features/04-teams.sql"
|
||||
|
||||
# 10. Progress tracking
|
||||
"$seeds_base/progress_tracking/01-demo-progress.sql"
|
||||
"$seeds_base/progress_tracking/02-exercise-attempts.sql"
|
||||
|
||||
# 11. Audit logging
|
||||
"$seeds_base/audit_logging/01-audit-logs.sql"
|
||||
"$seeds_base/audit_logging/02-system-metrics.sql"
|
||||
)
|
||||
|
||||
for seed_file in "${seed_files[@]}"; do
|
||||
if [ -f "$seed_file" ]; then
|
||||
local basename_file=$(basename "$seed_file")
|
||||
print_info " $basename_file"
|
||||
|
||||
# CRÍTICO: NO ocultar errores - ejecutar y mostrar salida
|
||||
if execute_sql_file "$seed_file" 2>&1 | grep -i "error" > /dev/null; then
|
||||
((failed++))
|
||||
print_warning " ⚠️ Errores en $basename_file (continuando...)"
|
||||
else
|
||||
((loaded++))
|
||||
fi
|
||||
else
|
||||
print_warning "Seed no encontrado: $(basename $seed_file)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $failed -gt 0 ]; then
|
||||
print_warning "$loaded seeds cargados, $failed con errores"
|
||||
else
|
||||
print_success "$loaded seeds cargados exitosamente"
|
||||
fi
|
||||
|
||||
unset PGPASSWORD
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# CONFIRMACIÓN
|
||||
# ============================================================================
|
||||
|
||||
confirm_reset() {
|
||||
print_header "⚠️ ADVERTENCIA: RESET DE BASE DE DATOS"
|
||||
|
||||
echo -e "${YELLOW}Este script:${NC}"
|
||||
echo -e " • Eliminará la base de datos: ${RED}$DB_NAME${NC}"
|
||||
echo -e " • Mantendrá el usuario: ${GREEN}$DB_USER${NC}"
|
||||
echo -e " • Recreará schemas, tablas y datos"
|
||||
echo ""
|
||||
|
||||
if [ "$FORCE_MODE" = false ]; then
|
||||
read -p "¿Continuar con el reset? (yes/no): " confirm
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
print_info "Operación cancelada"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
main() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--env)
|
||||
ENVIRONMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--password)
|
||||
DB_PASSWORD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--force)
|
||||
FORCE_MODE=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
print_error "Opción desconocida: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$ENVIRONMENT" ]; then
|
||||
print_header "GAMILIT Platform - Reset de BD"
|
||||
echo "Selecciona ambiente:"
|
||||
echo " 1) dev"
|
||||
echo " 2) prod"
|
||||
read -p "Opción: " env_option
|
||||
|
||||
case $env_option in
|
||||
1) ENVIRONMENT="dev" ;;
|
||||
2) ENVIRONMENT="prod" ;;
|
||||
*)
|
||||
print_error "Opción inválida"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "$ENVIRONMENT" != "dev" ] && [ "$ENVIRONMENT" != "prod" ]; then
|
||||
print_error "Ambiente inválido: $ENVIRONMENT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Si no se proveyó password, preguntar
|
||||
if [ -z "$DB_PASSWORD" ]; then
|
||||
read -sp "Password para usuario $DB_USER: " DB_PASSWORD
|
||||
echo ""
|
||||
fi
|
||||
|
||||
confirm_reset
|
||||
check_prerequisites
|
||||
drop_database
|
||||
create_database
|
||||
execute_ddl
|
||||
load_seeds
|
||||
|
||||
print_header "✅ BASE DE DATOS RESETEADA"
|
||||
|
||||
echo -e "${GREEN}Base de datos recreada exitosamente${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}Conexión:${NC}"
|
||||
echo -e " Database: $DB_NAME"
|
||||
echo -e " User: $DB_USER"
|
||||
echo -e " Host: $DB_HOST:$DB_PORT"
|
||||
echo ""
|
||||
print_success "¡Listo para usar!"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -0,0 +1,395 @@
|
||||
-- =====================================================
|
||||
-- SCRIPT DE EMERGENCIA: CREAR USUARIOS DE TESTING
|
||||
-- =====================================================
|
||||
-- Fecha: 2025-11-11
|
||||
-- Propósito: Crear usuarios de testing manualmente
|
||||
-- Usuarios: admin@gamilit.com, teacher@gamilit.com, student@gamilit.com
|
||||
-- Password: Test1234 (para todos)
|
||||
-- =====================================================
|
||||
|
||||
-- Habilitar extensión pgcrypto si no está habilitada
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- Verificar tenant por defecto
|
||||
DO $$
|
||||
DECLARE
|
||||
default_tenant_id uuid;
|
||||
BEGIN
|
||||
-- Buscar o crear tenant por defecto
|
||||
SELECT id INTO default_tenant_id
|
||||
FROM auth_management.tenants
|
||||
WHERE name = 'GAMILIT Platform'
|
||||
LIMIT 1;
|
||||
|
||||
IF default_tenant_id IS NULL THEN
|
||||
INSERT INTO auth_management.tenants (
|
||||
id, name, slug, status, settings, created_at, updated_at
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'GAMILIT Platform',
|
||||
'gamilit-platform',
|
||||
'active',
|
||||
'{}'::jsonb,
|
||||
NOW(),
|
||||
NOW()
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
default_tenant_id := '00000000-0000-0000-0000-000000000001'::uuid;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Tenant ID: %', default_tenant_id;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 1: CREAR USUARIOS EN auth.users
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth.users (
|
||||
id,
|
||||
instance_id,
|
||||
email,
|
||||
encrypted_password,
|
||||
email_confirmed_at,
|
||||
raw_app_meta_data,
|
||||
raw_user_meta_data,
|
||||
gamilit_role,
|
||||
created_at,
|
||||
updated_at,
|
||||
confirmation_token,
|
||||
email_change,
|
||||
email_change_token_new,
|
||||
recovery_token
|
||||
) VALUES
|
||||
-- ADMIN
|
||||
(
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid,
|
||||
'00000000-0000-0000-0000-000000000000'::uuid,
|
||||
'admin@gamilit.com',
|
||||
crypt('Test1234', gen_salt('bf', 10)),
|
||||
NOW(),
|
||||
jsonb_build_object(
|
||||
'provider', 'email',
|
||||
'providers', ARRAY['email']
|
||||
),
|
||||
jsonb_build_object(
|
||||
'name', 'Admin GAMILIT',
|
||||
'role', 'super_admin'
|
||||
),
|
||||
'super_admin'::auth_management.gamilit_role,
|
||||
NOW(),
|
||||
NOW(),
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
''
|
||||
),
|
||||
-- TEACHER
|
||||
(
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::uuid,
|
||||
'00000000-0000-0000-0000-000000000000'::uuid,
|
||||
'teacher@gamilit.com',
|
||||
crypt('Test1234', gen_salt('bf', 10)),
|
||||
NOW(),
|
||||
jsonb_build_object(
|
||||
'provider', 'email',
|
||||
'providers', ARRAY['email']
|
||||
),
|
||||
jsonb_build_object(
|
||||
'name', 'Profesor Testing',
|
||||
'role', 'teacher'
|
||||
),
|
||||
'teacher'::auth_management.gamilit_role,
|
||||
NOW(),
|
||||
NOW(),
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
''
|
||||
),
|
||||
-- STUDENT
|
||||
(
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc'::uuid,
|
||||
'00000000-0000-0000-0000-000000000000'::uuid,
|
||||
'student@gamilit.com',
|
||||
crypt('Test1234', gen_salt('bf', 10)),
|
||||
NOW(),
|
||||
jsonb_build_object(
|
||||
'provider', 'email',
|
||||
'providers', ARRAY['email']
|
||||
),
|
||||
jsonb_build_object(
|
||||
'name', 'Estudiante Testing',
|
||||
'role', 'student'
|
||||
),
|
||||
'student'::auth_management.gamilit_role,
|
||||
NOW(),
|
||||
NOW(),
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
''
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
encrypted_password = EXCLUDED.encrypted_password,
|
||||
updated_at = NOW();
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 2: CREAR PROFILES EN auth_management.profiles
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth_management.profiles (
|
||||
id,
|
||||
user_id,
|
||||
tenant_id,
|
||||
email,
|
||||
full_name,
|
||||
first_name,
|
||||
last_name,
|
||||
role,
|
||||
status,
|
||||
email_verified,
|
||||
preferences,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES
|
||||
-- ADMIN PROFILE
|
||||
(
|
||||
'aaaaaaaa-aaaa-aaaa-bbbb-aaaaaaaaaaaa'::uuid,
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid,
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'admin@gamilit.com',
|
||||
'Admin GAMILIT',
|
||||
'Admin',
|
||||
'GAMILIT',
|
||||
'super_admin'::auth_management.gamilit_role,
|
||||
'active'::auth_management.user_status,
|
||||
true,
|
||||
jsonb_build_object(
|
||||
'theme', 'detective',
|
||||
'language', 'es',
|
||||
'timezone', 'America/Mexico_City',
|
||||
'sound_enabled', true,
|
||||
'notifications_enabled', true
|
||||
),
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
-- TEACHER PROFILE
|
||||
(
|
||||
'bbbbbbbb-bbbb-bbbb-cccc-bbbbbbbbbbbb'::uuid,
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::uuid,
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'teacher@gamilit.com',
|
||||
'Profesor Testing',
|
||||
'Profesor',
|
||||
'Testing',
|
||||
'teacher'::auth_management.gamilit_role,
|
||||
'active'::auth_management.user_status,
|
||||
true,
|
||||
jsonb_build_object(
|
||||
'theme', 'detective',
|
||||
'language', 'es',
|
||||
'timezone', 'America/Mexico_City',
|
||||
'sound_enabled', true,
|
||||
'notifications_enabled', true
|
||||
),
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
-- STUDENT PROFILE
|
||||
(
|
||||
'cccccccc-cccc-cccc-dddd-cccccccccccc'::uuid,
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc'::uuid,
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'student@gamilit.com',
|
||||
'Estudiante Testing',
|
||||
'Estudiante',
|
||||
'Testing',
|
||||
'student'::auth_management.gamilit_role,
|
||||
'active'::auth_management.user_status,
|
||||
true,
|
||||
jsonb_build_object(
|
||||
'theme', 'detective',
|
||||
'language', 'es',
|
||||
'timezone', 'America/Mexico_City',
|
||||
'sound_enabled', true,
|
||||
'notifications_enabled', true,
|
||||
'grade_level', '5'
|
||||
),
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
full_name = EXCLUDED.full_name,
|
||||
updated_at = NOW();
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 3: INICIALIZAR user_stats (gamification)
|
||||
-- =====================================================
|
||||
-- Nota: El trigger trg_initialize_user_stats debería hacer esto automáticamente
|
||||
-- pero lo agregamos manualmente por si acaso
|
||||
|
||||
INSERT INTO gamification_system.user_stats (
|
||||
id,
|
||||
user_id,
|
||||
tenant_id,
|
||||
level,
|
||||
total_xp,
|
||||
xp_to_next_level,
|
||||
current_rank,
|
||||
ml_coins,
|
||||
ml_coins_earned_total,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES
|
||||
(
|
||||
'aaaaaaaa-aaaa-stat-aaaa-aaaaaaaaaaaa'::uuid,
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid,
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
1,
|
||||
0,
|
||||
100,
|
||||
'Ajaw'::gamification_system.maya_rank,
|
||||
100,
|
||||
100,
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
(
|
||||
'bbbbbbbb-bbbb-stat-bbbb-bbbbbbbbbbbb'::uuid,
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::uuid,
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
1,
|
||||
0,
|
||||
100,
|
||||
'Ajaw'::gamification_system.maya_rank,
|
||||
100,
|
||||
100,
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
(
|
||||
'cccccccc-cccc-stat-cccc-cccccccccccc'::uuid,
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc'::uuid,
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
1,
|
||||
0,
|
||||
100,
|
||||
'Ajaw'::gamification_system.maya_rank,
|
||||
100,
|
||||
100,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
updated_at = NOW();
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 4: INICIALIZAR user_ranks (gamification)
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO gamification_system.user_ranks (
|
||||
id,
|
||||
user_id,
|
||||
tenant_id,
|
||||
current_rank,
|
||||
rank_level,
|
||||
total_rank_points,
|
||||
rank_achieved_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES
|
||||
(
|
||||
'aaaaaaaa-aaaa-rank-aaaa-aaaaaaaaaaaa'::uuid,
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid,
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'Ajaw'::gamification_system.maya_rank,
|
||||
1,
|
||||
0,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
(
|
||||
'bbbbbbbb-bbbb-rank-bbbb-bbbbbbbbbbbb'::uuid,
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::uuid,
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'Ajaw'::gamification_system.maya_rank,
|
||||
1,
|
||||
0,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
(
|
||||
'cccccccc-cccc-rank-cccc-cccccccccccc'::uuid,
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc'::uuid,
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'Ajaw'::gamification_system.maya_rank,
|
||||
1,
|
||||
0,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
updated_at = NOW();
|
||||
|
||||
-- =====================================================
|
||||
-- VERIFICACIÓN FINAL
|
||||
-- =====================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
users_count INTEGER;
|
||||
profiles_count INTEGER;
|
||||
stats_count INTEGER;
|
||||
ranks_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO users_count FROM auth.users
|
||||
WHERE email IN ('admin@gamilit.com', 'teacher@gamilit.com', 'student@gamilit.com');
|
||||
|
||||
SELECT COUNT(*) INTO profiles_count FROM auth_management.profiles
|
||||
WHERE email IN ('admin@gamilit.com', 'teacher@gamilit.com', 'student@gamilit.com');
|
||||
|
||||
SELECT COUNT(*) INTO stats_count FROM gamification_system.user_stats
|
||||
WHERE user_id IN (
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid,
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::uuid,
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc'::uuid
|
||||
);
|
||||
|
||||
SELECT COUNT(*) INTO ranks_count FROM gamification_system.user_ranks
|
||||
WHERE user_id IN (
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid,
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::uuid,
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc'::uuid
|
||||
);
|
||||
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE 'USUARIOS DE TESTING CREADOS';
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE 'auth.users: % usuarios', users_count;
|
||||
RAISE NOTICE 'auth_management.profiles: % profiles', profiles_count;
|
||||
RAISE NOTICE 'gamification_system.user_stats: % stats', stats_count;
|
||||
RAISE NOTICE 'gamification_system.user_ranks: % ranks', ranks_count;
|
||||
RAISE NOTICE '========================================';
|
||||
|
||||
IF users_count = 3 AND profiles_count = 3 AND stats_count = 3 AND ranks_count = 3 THEN
|
||||
RAISE NOTICE '✅ TODOS LOS USUARIOS CREADOS EXITOSAMENTE';
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'Credenciales de testing:';
|
||||
RAISE NOTICE ' - admin@gamilit.com / Test1234';
|
||||
RAISE NOTICE ' - teacher@gamilit.com / Test1234';
|
||||
RAISE NOTICE ' - student@gamilit.com / Test1234';
|
||||
ELSE
|
||||
RAISE WARNING '⚠️ ALGUNOS USUARIOS NO SE CREARON CORRECTAMENTE';
|
||||
RAISE WARNING 'Esperado: 3 users, 3 profiles, 3 stats, 3 ranks';
|
||||
RAISE WARNING 'Creado: % users, % profiles, % stats, % ranks',
|
||||
users_count, profiles_count, stats_count, ranks_count;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- FIN DEL SCRIPT
|
||||
-- =====================================================
|
||||
205
projects/gamilit/apps/database/scripts/validations/README.md
Normal file
205
projects/gamilit/apps/database/scripts/validations/README.md
Normal file
@ -0,0 +1,205 @@
|
||||
# Scripts de Validación de Integridad - GAMILIT Database
|
||||
|
||||
**Fecha:** 2025-11-24
|
||||
**Mantenido por:** Database-Agent
|
||||
**Propósito:** Scripts para validar y mantener la integridad de datos de XP y ML Coins
|
||||
|
||||
---
|
||||
|
||||
## 📋 Scripts Disponibles
|
||||
|
||||
### 1. `quick-validate-xp.sql`
|
||||
|
||||
**Descripción:** Validación rápida (30 segundos) para detectar problemas de integridad en XP.
|
||||
|
||||
**Uso:**
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit/apps/database
|
||||
PGPASSWORD='C5hq7253pdVyVKUC' psql -h localhost -U gamilit_user -d gamilit_platform -f scripts/quick-validate-xp.sql
|
||||
```
|
||||
|
||||
**Salida esperada (sistema saludable):**
|
||||
```
|
||||
1. Intentos con score > 0 pero xp_earned = 0:
|
||||
intentos_problematicos = 0
|
||||
|
||||
2. Usuarios con discrepancias:
|
||||
usuarios_con_discrepancias = 0
|
||||
|
||||
3. Estado de integridad:
|
||||
estado = "✅ INTEGRIDAD OK"
|
||||
```
|
||||
|
||||
**Frecuencia recomendada:** Diaria o después de cada deployment
|
||||
|
||||
---
|
||||
|
||||
### 2. `validate-xp-integrity.sql`
|
||||
|
||||
**Descripción:** Validación completa (2-3 minutos) con reporte detallado de todos los aspectos de integridad.
|
||||
|
||||
**Uso:**
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit/apps/database
|
||||
PGPASSWORD='C5hq7253pdVyVKUC' psql -h localhost -U gamilit_user -d gamilit_platform -f scripts/validate-xp-integrity.sql
|
||||
```
|
||||
|
||||
**Validaciones incluidas:**
|
||||
1. Intentos con score > 0 pero xp_earned = 0
|
||||
2. Usuarios con discrepancias entre attempts y user_stats
|
||||
3. Intentos donde xp_earned no coincide con la fórmula esperada
|
||||
4. User stats sin attempts registrados
|
||||
5. Resumen general del sistema
|
||||
|
||||
**Frecuencia recomendada:** Semanal o cuando se detecten anomalías
|
||||
|
||||
---
|
||||
|
||||
### 3. `fix-historical-xp-ml-coins-v2.sql`
|
||||
|
||||
**Descripción:** Script de corrección automática de datos históricos (solo si se detectan problemas).
|
||||
|
||||
**⚠️ ADVERTENCIA:** Solo ejecutar si `quick-validate-xp.sql` reporta problemas.
|
||||
|
||||
**Uso:**
|
||||
```bash
|
||||
cd /home/isem/workspace/workspace-gamilit/gamilit/projects/gamilit/apps/database
|
||||
PGPASSWORD='C5hq7253pdVyVKUC' psql -h localhost -U gamilit_user -d gamilit_platform -f scripts/fix-historical-xp-ml-coins-v2.sql
|
||||
|
||||
# Revisar output cuidadosamente antes de confirmar
|
||||
# Si todo se ve bien, ejecutar en otra sesión:
|
||||
PGPASSWORD='C5hq7253pdVyVKUC' psql -h localhost -U gamilit_user -d gamilit_platform -c "COMMIT;"
|
||||
```
|
||||
|
||||
**Acciones que realiza:**
|
||||
1. Deshabilita trigger `trg_check_rank_promotion_on_xp_gain`
|
||||
2. Corrige `xp_earned` y `ml_coins_earned` en `exercise_attempts`
|
||||
3. Recalcula `user_stats` basado en suma de attempts
|
||||
4. Rehabilita trigger
|
||||
5. Valida integridad final
|
||||
|
||||
**Frecuencia recomendada:** Solo cuando sea necesario (no es una tarea periódica)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Interpretación de Resultados
|
||||
|
||||
### Estado: ✅ INTEGRIDAD OK
|
||||
|
||||
Todo funciona correctamente. No se requiere acción.
|
||||
|
||||
### Estado: ❌ HAY PROBLEMAS
|
||||
|
||||
**Pasos a seguir:**
|
||||
|
||||
1. **Ejecutar validación completa:**
|
||||
```bash
|
||||
psql -d gamilit_platform -f scripts/validate-xp-integrity.sql
|
||||
```
|
||||
|
||||
2. **Analizar reporte detallado:**
|
||||
- ¿Cuántos intentos afectados?
|
||||
- ¿Cuántos usuarios tienen discrepancias?
|
||||
- ¿Cuál es la magnitud del problema?
|
||||
|
||||
3. **Si hay pocos casos aislados (< 5 usuarios):**
|
||||
- Ejecutar script de corrección automática
|
||||
- Revisar logs antes de confirmar
|
||||
- Validar resultado
|
||||
|
||||
4. **Si hay muchos casos (> 5 usuarios):**
|
||||
- NO ejecutar script automático
|
||||
- Investigar la causa raíz
|
||||
- Consultar con Database-Agent o Tech Lead
|
||||
|
||||
---
|
||||
|
||||
## 📊 Fórmulas de XP y ML Coins
|
||||
|
||||
### XP Earned
|
||||
```sql
|
||||
xp_earned = GREATEST(0, score - (hints_used * 10))
|
||||
```
|
||||
|
||||
**Ejemplos:**
|
||||
- Score: 100, hints: 0 → XP: 100
|
||||
- Score: 100, hints: 2 → XP: 80
|
||||
- Score: 50, hints: 10 → XP: 0 (no negativo)
|
||||
|
||||
### ML Coins Earned
|
||||
```sql
|
||||
ml_coins_earned = GREATEST(0, FLOOR(score / 10) - (comodines_used * 2))
|
||||
```
|
||||
|
||||
**Ejemplos:**
|
||||
- Score: 100, comodines: 0 → ML Coins: 10
|
||||
- Score: 100, comodines: 2 → ML Coins: 6
|
||||
- Score: 50, comodines: 0 → ML Coins: 5
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Problemas Comunes
|
||||
|
||||
### 1. Intentos con xp_earned = 0
|
||||
|
||||
**Causa:** Bug en el código que crea el attempt sin calcular XP.
|
||||
|
||||
**Solución:** Ejecutar script de corrección automática.
|
||||
|
||||
**Prevención:** Agregar validación en backend antes de insertar attempt.
|
||||
|
||||
### 2. Discrepancia entre attempts y user_stats
|
||||
|
||||
**Causa:** Trigger `update_user_stats_on_exercise_complete` no se ejecutó correctamente.
|
||||
|
||||
**Solución:** Recalcular user_stats con script de corrección.
|
||||
|
||||
**Prevención:** Monitorear logs de triggers.
|
||||
|
||||
### 3. Fórmulas inconsistentes
|
||||
|
||||
**Causa:** Cambio en lógica de negocio sin migración de datos históricos.
|
||||
|
||||
**Solución:** Decidir si mantener datos históricos o migrar.
|
||||
|
||||
**Prevención:** Documentar cambios en fórmulas.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Historial de Correcciones
|
||||
|
||||
### 2025-11-24: Corrección inicial de datos históricos
|
||||
|
||||
- **Usuario afectado:** `85a2d456-a07d-4be9-b9ce-4a46b183a2a0`
|
||||
- **Intentos corregidos:** 1
|
||||
- **XP recuperado:** +600 XP (500 → 1100)
|
||||
- **ML Coins recuperados:** +90 ML Coins (220 → 310)
|
||||
- **Bug adicional:** Corregida función `promote_to_next_rank()`
|
||||
|
||||
**Ver detalles completos:**
|
||||
- `/apps/database/REPORTE-CORRECCION-XP-ML-COINS-2025-11-24.md`
|
||||
- `/apps/database/RESUMEN-EJECUTIVO-CORRECCION-XP-2025-11-24.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referencias
|
||||
|
||||
- **Política DDL-First:** `/orchestration/directivas/DIRECTIVA-POLITICA-CARGA-LIMPIA.md`
|
||||
- **Traza de tareas:** `/orchestration/trazas/TRAZA-TAREAS-DATABASE.md`
|
||||
- **Prompt Database-Agent:** `/orchestration/prompts/PROMPT-DATABASE-AGENT.md`
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contacto
|
||||
|
||||
**Mantenido por:** Database-Agent
|
||||
**Última actualización:** 2025-11-24
|
||||
|
||||
Para preguntas o problemas, consultar:
|
||||
1. Documentación en `/apps/database/docs/`
|
||||
2. Trazas en `/orchestration/trazas/`
|
||||
3. Tech Lead del proyecto GAMILIT
|
||||
|
||||
---
|
||||
|
||||
**FIN DEL README**
|
||||
@ -0,0 +1,219 @@
|
||||
-- ============================================================================
|
||||
-- VALIDACIONES RAPIDAS POST-RECREACION DE BASE DE DATOS
|
||||
-- Fecha: 2025-11-24
|
||||
-- Database: gamilit_platform
|
||||
-- ============================================================================
|
||||
--
|
||||
-- Este archivo contiene queries SQL para validar rapidamente la integridad
|
||||
-- de la base de datos despues de una recreacion.
|
||||
--
|
||||
-- Uso:
|
||||
-- psql -h localhost -U gamilit_user -d gamilit_platform -f VALIDACIONES-RAPIDAS-POST-RECREACION.sql
|
||||
-- ============================================================================
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 1: CONTEO DE SCHEMAS'
|
||||
\echo '============================================================================'
|
||||
SELECT count(*) as total_schemas
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast');
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 2: CONTEO DE TABLAS POR SCHEMA'
|
||||
\echo '============================================================================'
|
||||
SELECT table_schema, count(*) as tables
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
||||
AND table_type = 'BASE TABLE'
|
||||
GROUP BY table_schema
|
||||
ORDER BY table_schema;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 3: CONTEO DE FUNCIONES'
|
||||
\echo '============================================================================'
|
||||
SELECT count(*) as total_functions
|
||||
FROM information_schema.routines
|
||||
WHERE routine_schema NOT IN ('pg_catalog', 'information_schema');
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 4: CONTEO DE TRIGGERS'
|
||||
\echo '============================================================================'
|
||||
SELECT count(DISTINCT trigger_name) as total_triggers
|
||||
FROM information_schema.triggers;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 5: RLS POLICIES EN GAMIFICATION_SYSTEM.NOTIFICATIONS'
|
||||
\echo '============================================================================'
|
||||
SELECT policyname, cmd
|
||||
FROM pg_policies
|
||||
WHERE tablename = 'notifications'
|
||||
AND schemaname = 'gamification_system'
|
||||
ORDER BY policyname;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 6: RLS POLICIES EN STUDENT_INTERVENTION_ALERTS'
|
||||
\echo '============================================================================'
|
||||
SELECT policyname, cmd
|
||||
FROM pg_policies
|
||||
WHERE tablename = 'student_intervention_alerts'
|
||||
AND schemaname = 'progress_tracking'
|
||||
ORDER BY policyname;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 7: CONTEO DE USUARIOS POR ROL'
|
||||
\echo '============================================================================'
|
||||
SELECT
|
||||
role,
|
||||
count(*) as total
|
||||
FROM auth_management.profiles
|
||||
GROUP BY role
|
||||
ORDER BY role;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 8: MODULOS EDUCATIVOS'
|
||||
\echo '============================================================================'
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
(SELECT count(*) FROM educational_content.exercises e WHERE e.module_id = m.id) as ejercicios
|
||||
FROM educational_content.modules m
|
||||
ORDER BY title;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 9: RANGOS MAYA'
|
||||
\echo '============================================================================'
|
||||
SELECT
|
||||
rank_name,
|
||||
display_name,
|
||||
min_xp_required,
|
||||
max_xp_threshold,
|
||||
ml_coins_bonus,
|
||||
is_active
|
||||
FROM gamification_system.maya_ranks
|
||||
ORDER BY min_xp_required;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 10: USER STATS INICIALIZADOS'
|
||||
\echo '============================================================================'
|
||||
SELECT
|
||||
count(*) as total_users,
|
||||
count(*) FILTER (WHERE total_xp > 0) as con_xp,
|
||||
count(*) FILTER (WHERE ml_coins > 0) as con_coins,
|
||||
sum(total_xp)::bigint as total_xp_sistema,
|
||||
sum(ml_coins)::bigint as total_coins_sistema
|
||||
FROM gamification_system.user_stats;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 11: MODULE PROGRESS INICIALIZADO'
|
||||
\echo '============================================================================'
|
||||
SELECT
|
||||
count(*) as total_records,
|
||||
count(DISTINCT user_id) as usuarios,
|
||||
count(DISTINCT module_id) as modulos,
|
||||
count(*) FILTER (WHERE status = 'completed') as completados,
|
||||
count(*) FILTER (WHERE status = 'in_progress') as en_progreso,
|
||||
count(*) FILTER (WHERE status = 'not_started') as no_iniciados
|
||||
FROM progress_tracking.module_progress;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 12: SOCIAL FEATURES'
|
||||
\echo '============================================================================'
|
||||
SELECT 'Schools' as tipo, count(*)::text as total FROM social_features.schools
|
||||
UNION ALL
|
||||
SELECT 'Classrooms', count(*)::text FROM social_features.classrooms
|
||||
UNION ALL
|
||||
SELECT 'Teacher-Classroom Links', count(*)::text FROM social_features.teacher_classrooms;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 13: RESUMEN DE OBJETOS POR SCHEMA'
|
||||
\echo '============================================================================'
|
||||
SELECT
|
||||
s.schema_name,
|
||||
COALESCE(t.tables, 0) as tables,
|
||||
COALESCE(v.views, 0) as views,
|
||||
COALESCE(f.functions, 0) as functions,
|
||||
COALESCE(tr.triggers, 0) as triggers,
|
||||
COALESCE(p.policies, 0) as rls_policies
|
||||
FROM (
|
||||
SELECT schema_name
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'pg_temp_3', 'pg_toast_temp_3')
|
||||
) s
|
||||
LEFT JOIN (
|
||||
SELECT table_schema, count(*) as tables
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
GROUP BY table_schema
|
||||
) t ON s.schema_name = t.table_schema
|
||||
LEFT JOIN (
|
||||
SELECT table_schema, count(*) as views
|
||||
FROM information_schema.views
|
||||
GROUP BY table_schema
|
||||
) v ON s.schema_name = v.table_schema
|
||||
LEFT JOIN (
|
||||
SELECT routine_schema, count(*) as functions
|
||||
FROM information_schema.routines
|
||||
GROUP BY routine_schema
|
||||
) f ON s.schema_name = f.routine_schema
|
||||
LEFT JOIN (
|
||||
SELECT trigger_schema, count(DISTINCT trigger_name) as triggers
|
||||
FROM information_schema.triggers
|
||||
GROUP BY trigger_schema
|
||||
) tr ON s.schema_name = tr.trigger_schema
|
||||
LEFT JOIN (
|
||||
SELECT schemaname, count(*) as policies
|
||||
FROM pg_policies
|
||||
GROUP BY schemaname
|
||||
) p ON s.schema_name = p.schemaname
|
||||
WHERE s.schema_name IN (
|
||||
'admin_dashboard',
|
||||
'audit_logging',
|
||||
'auth',
|
||||
'auth_management',
|
||||
'communication',
|
||||
'content_management',
|
||||
'educational_content',
|
||||
'gamification_system',
|
||||
'gamilit',
|
||||
'lti_integration',
|
||||
'notifications',
|
||||
'progress_tracking',
|
||||
'social_features',
|
||||
'system_configuration'
|
||||
)
|
||||
ORDER BY s.schema_name;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION 14: TABLAS CON RLS HABILITADO'
|
||||
\echo '============================================================================'
|
||||
SELECT
|
||||
schemaname,
|
||||
tablename,
|
||||
rowsecurity as rls_enabled
|
||||
FROM pg_tables
|
||||
WHERE schemaname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
||||
AND rowsecurity = true
|
||||
ORDER BY schemaname, tablename;
|
||||
|
||||
\echo ''
|
||||
\echo '============================================================================'
|
||||
\echo 'VALIDACION COMPLETA'
|
||||
\echo '============================================================================'
|
||||
\echo 'Si todas las validaciones anteriores se ejecutaron sin errores,'
|
||||
\echo 'la base de datos esta correctamente recreada y operacional.'
|
||||
\echo ''
|
||||
@ -0,0 +1,234 @@
|
||||
-- =====================================================
|
||||
-- Script de Validación: Gaps Database-Backend
|
||||
-- Fecha: 2025-11-24
|
||||
-- Tarea: DB-127 Corrección Gaps Coherencia
|
||||
-- =====================================================
|
||||
--
|
||||
-- Este script valida que los 3 gaps identificados estén resueltos:
|
||||
-- - GAP-DB-001: activity_log con entity_type, entity_id
|
||||
-- - GAP-DB-002: auth.tenants vista alias
|
||||
-- - GAP-DB-003: classrooms.is_deleted
|
||||
--
|
||||
-- =====================================================
|
||||
|
||||
\echo '==================================================='
|
||||
\echo 'VALIDACIÓN DE GAPS DATABASE-BACKEND'
|
||||
\echo '==================================================='
|
||||
\echo ''
|
||||
|
||||
-- =====================================================
|
||||
-- GAP-DB-001: Validar tabla activity_log
|
||||
-- =====================================================
|
||||
|
||||
\echo '--- GAP-DB-001: Tabla audit_logging.activity_log ---'
|
||||
\echo ''
|
||||
|
||||
-- Validar que tabla existe
|
||||
SELECT
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'audit_logging'
|
||||
AND table_name = 'activity_log'
|
||||
)
|
||||
THEN '✅ Tabla audit_logging.activity_log existe'
|
||||
ELSE '❌ ERROR: Tabla audit_logging.activity_log NO existe'
|
||||
END as status;
|
||||
|
||||
-- Validar columnas requeridas
|
||||
\echo ''
|
||||
\echo 'Validar columnas en activity_log:'
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
CASE
|
||||
WHEN column_name IN ('id', 'user_id', 'action_type', 'entity_type', 'entity_id', 'description', 'metadata', 'created_at')
|
||||
THEN '✅ Requerida'
|
||||
ELSE ' Opcional'
|
||||
END as importancia
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'audit_logging'
|
||||
AND table_name = 'activity_log'
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- Validar indices
|
||||
\echo ''
|
||||
\echo 'Validar índices en activity_log:'
|
||||
SELECT
|
||||
indexname,
|
||||
indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'audit_logging'
|
||||
AND tablename = 'activity_log'
|
||||
ORDER BY indexname;
|
||||
|
||||
-- Validar query backend (query de admin-dashboard.service.ts:184)
|
||||
\echo ''
|
||||
\echo 'Validar query backend - Actividad por tipo (últimos 7 días):'
|
||||
SELECT
|
||||
action_type,
|
||||
COUNT(*) as count
|
||||
FROM audit_logging.activity_log
|
||||
WHERE created_at > NOW() - INTERVAL '7 days'
|
||||
GROUP BY action_type
|
||||
ORDER BY count DESC
|
||||
LIMIT 5;
|
||||
|
||||
-- =====================================================
|
||||
-- GAP-DB-002: Validar vista auth.tenants
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '--- GAP-DB-002: Vista auth.tenants (alias) ---'
|
||||
\echo ''
|
||||
|
||||
-- Validar que vista existe
|
||||
SELECT
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM information_schema.views
|
||||
WHERE table_schema = 'auth'
|
||||
AND table_name = 'tenants'
|
||||
)
|
||||
THEN '✅ Vista auth.tenants existe'
|
||||
ELSE '❌ ERROR: Vista auth.tenants NO existe'
|
||||
END as status;
|
||||
|
||||
-- Validar query backend (query de admin-dashboard.service.ts:95)
|
||||
\echo ''
|
||||
\echo 'Validar query backend - Tenants actualizados (últimos 7 días):'
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
updated_at
|
||||
FROM auth.tenants
|
||||
WHERE updated_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 3;
|
||||
|
||||
-- Validar que apunta a auth_management.tenants
|
||||
\echo ''
|
||||
\echo 'Validar definición de vista:'
|
||||
SELECT
|
||||
schemaname,
|
||||
viewname,
|
||||
LEFT(definition, 100) || '...' as definition_preview
|
||||
FROM pg_views
|
||||
WHERE schemaname = 'auth'
|
||||
AND viewname = 'tenants';
|
||||
|
||||
-- =====================================================
|
||||
-- GAP-DB-003: Validar classrooms.is_deleted
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '--- GAP-DB-003: Columna classrooms.is_deleted ---'
|
||||
\echo ''
|
||||
|
||||
-- Validar que columna existe
|
||||
SELECT
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'social_features'
|
||||
AND table_name = 'classrooms'
|
||||
AND column_name = 'is_deleted'
|
||||
)
|
||||
THEN '✅ Columna social_features.classrooms.is_deleted existe'
|
||||
ELSE '❌ ERROR: Columna is_deleted NO existe en classrooms'
|
||||
END as status;
|
||||
|
||||
-- Validar tipo y default de columna
|
||||
\echo ''
|
||||
\echo 'Validar columna is_deleted:'
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'social_features'
|
||||
AND table_name = 'classrooms'
|
||||
AND column_name IN ('is_deleted', 'is_archived', 'is_active')
|
||||
ORDER BY column_name;
|
||||
|
||||
-- Validar índice parcial para is_deleted
|
||||
\echo ''
|
||||
\echo 'Validar índice parcial para is_deleted:'
|
||||
SELECT
|
||||
indexname,
|
||||
indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'social_features'
|
||||
AND tablename = 'classrooms'
|
||||
AND indexdef LIKE '%is_deleted%'
|
||||
ORDER BY indexname;
|
||||
|
||||
-- Validar query backend (query de classrooms.service.ts:67)
|
||||
\echo ''
|
||||
\echo 'Validar query backend - Classrooms no eliminados:'
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
code,
|
||||
is_active,
|
||||
is_archived,
|
||||
is_deleted
|
||||
FROM social_features.classrooms
|
||||
WHERE is_deleted = FALSE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 3;
|
||||
|
||||
-- =====================================================
|
||||
-- RESUMEN FINAL
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '==================================================='
|
||||
\echo 'RESUMEN DE VALIDACIÓN'
|
||||
\echo '==================================================='
|
||||
\echo ''
|
||||
|
||||
SELECT
|
||||
'✅ GAP-DB-001: activity_log' as gap,
|
||||
CASE
|
||||
WHEN COUNT(*) >= 8 THEN '✅ RESUELTO'
|
||||
ELSE '❌ PENDIENTE'
|
||||
END as estado
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'audit_logging'
|
||||
AND table_name = 'activity_log'
|
||||
AND column_name IN ('id', 'user_id', 'action_type', 'entity_type', 'entity_id', 'description', 'metadata', 'created_at')
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'✅ GAP-DB-002: auth.tenants' as gap,
|
||||
CASE
|
||||
WHEN COUNT(*) = 1 THEN '✅ RESUELTO'
|
||||
ELSE '❌ PENDIENTE'
|
||||
END as estado
|
||||
FROM information_schema.views
|
||||
WHERE table_schema = 'auth'
|
||||
AND table_name = 'tenants'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'✅ GAP-DB-003: classrooms.is_deleted' as gap,
|
||||
CASE
|
||||
WHEN COUNT(*) = 1 THEN '✅ RESUELTO'
|
||||
ELSE '❌ PENDIENTE'
|
||||
END as estado
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'social_features'
|
||||
AND table_name = 'classrooms'
|
||||
AND column_name = 'is_deleted';
|
||||
|
||||
\echo ''
|
||||
\echo '==================================================='
|
||||
\echo 'VALIDACIÓN COMPLETADA'
|
||||
\echo '==================================================='
|
||||
@ -0,0 +1,253 @@
|
||||
-- =====================================================
|
||||
-- Script de Validación: generate_student_alerts() JOINs
|
||||
-- Descripción: Valida que los JOINs arquitectónicos sean correctos
|
||||
-- Fecha: 2025-11-24
|
||||
-- Agente: Database-Agent
|
||||
-- =====================================================
|
||||
|
||||
\echo '========================================='
|
||||
\echo 'VALIDACIÓN: generate_student_alerts()'
|
||||
\echo 'Verificando JOINs arquitectónicos'
|
||||
\echo '========================================='
|
||||
\echo ''
|
||||
|
||||
-- =====================================================
|
||||
-- 1. VERIFICAR QUE LA FUNCIÓN EXISTE
|
||||
-- =====================================================
|
||||
\echo '1. Verificando que la función existe...'
|
||||
SELECT
|
||||
p.proname as function_name,
|
||||
n.nspname as schema_name,
|
||||
pg_get_functiondef(p.oid) LIKE '%auth_management.profiles%' as uses_profiles_join,
|
||||
pg_get_functiondef(p.oid) LIKE '%JOIN auth.users%' as uses_auth_users_join
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE p.proname = 'generate_student_alerts'
|
||||
AND n.nspname = 'progress_tracking';
|
||||
|
||||
\echo ''
|
||||
\echo ' ✓ Si uses_profiles_join = t y uses_auth_users_join = f → CORRECTO'
|
||||
\echo ' ✗ Si uses_auth_users_join = t → INCORRECTO (todavía usa JOINs antiguos)'
|
||||
\echo ''
|
||||
|
||||
-- =====================================================
|
||||
-- 2. VERIFICAR FOREIGN KEYS RELEVANTES
|
||||
-- =====================================================
|
||||
\echo '2. Verificando Foreign Keys relevantes...'
|
||||
\echo ''
|
||||
|
||||
\echo ' 2.1 module_progress.user_id → profiles(id)'
|
||||
SELECT
|
||||
tc.table_schema,
|
||||
tc.table_name,
|
||||
kcu.column_name,
|
||||
ccu.table_schema AS foreign_table_schema,
|
||||
ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage AS ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = 'progress_tracking'
|
||||
AND tc.table_name = 'module_progress'
|
||||
AND kcu.column_name = 'user_id';
|
||||
|
||||
\echo ''
|
||||
\echo ' 2.2 exercise_submissions.user_id → profiles(id)'
|
||||
SELECT
|
||||
tc.table_schema,
|
||||
tc.table_name,
|
||||
kcu.column_name,
|
||||
ccu.table_schema AS foreign_table_schema,
|
||||
ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage AS ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = 'progress_tracking'
|
||||
AND tc.table_name = 'exercise_submissions'
|
||||
AND kcu.column_name = 'user_id';
|
||||
|
||||
\echo ''
|
||||
\echo ' 2.3 student_intervention_alerts.student_id → auth.users(id)'
|
||||
SELECT
|
||||
tc.table_schema,
|
||||
tc.table_name,
|
||||
kcu.column_name,
|
||||
ccu.table_schema AS foreign_table_schema,
|
||||
ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage AS ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = 'progress_tracking'
|
||||
AND tc.table_name = 'student_intervention_alerts'
|
||||
AND kcu.column_name = 'student_id';
|
||||
|
||||
\echo ''
|
||||
\echo ' 2.4 profiles.user_id → auth.users(id)'
|
||||
SELECT
|
||||
tc.table_schema,
|
||||
tc.table_name,
|
||||
kcu.column_name,
|
||||
ccu.table_schema AS foreign_table_schema,
|
||||
ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage AS ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = 'auth_management'
|
||||
AND tc.table_name = 'profiles'
|
||||
AND kcu.column_name = 'user_id';
|
||||
|
||||
-- =====================================================
|
||||
-- 3. RECREAR LA FUNCIÓN ACTUALIZADA
|
||||
-- =====================================================
|
||||
\echo ''
|
||||
\echo '3. Recreando la función con JOINs corregidos...'
|
||||
\i apps/database/ddl/schemas/progress_tracking/functions/15-generate_student_alerts.sql
|
||||
|
||||
-- =====================================================
|
||||
-- 4. VERIFICAR DEFINICIÓN DE LA FUNCIÓN
|
||||
-- =====================================================
|
||||
\echo ''
|
||||
\echo '4. Verificando definición de la función...'
|
||||
\echo ''
|
||||
|
||||
-- Contar ocurrencias de JOIN auth_management.profiles
|
||||
\echo ' 4.1 Ocurrencias de "JOIN auth_management.profiles":'
|
||||
SELECT COUNT(*)::text || ' ocurrencias (esperado: 3)' as resultado
|
||||
FROM regexp_matches(
|
||||
pg_get_functiondef(
|
||||
(SELECT oid FROM pg_proc WHERE proname = 'generate_student_alerts' AND pronamespace = 'progress_tracking'::regnamespace)
|
||||
),
|
||||
'JOIN auth_management\.profiles',
|
||||
'g'
|
||||
);
|
||||
|
||||
-- Contar ocurrencias de JOIN auth.users (debería ser 0)
|
||||
\echo ''
|
||||
\echo ' 4.2 Ocurrencias de "JOIN auth.users" (debe ser 0):'
|
||||
SELECT COALESCE(COUNT(*)::text, '0') || ' ocurrencias (esperado: 0)' as resultado
|
||||
FROM regexp_matches(
|
||||
pg_get_functiondef(
|
||||
(SELECT oid FROM pg_proc WHERE proname = 'generate_student_alerts' AND pronamespace = 'progress_tracking'::regnamespace)
|
||||
),
|
||||
'JOIN auth\.users',
|
||||
'g'
|
||||
);
|
||||
|
||||
-- Contar ocurrencias de p.tenant_id
|
||||
\echo ''
|
||||
\echo ' 4.3 Ocurrencias de "p.tenant_id":'
|
||||
SELECT COUNT(*)::text || ' ocurrencias (esperado: 3)' as resultado
|
||||
FROM regexp_matches(
|
||||
pg_get_functiondef(
|
||||
(SELECT oid FROM pg_proc WHERE proname = 'generate_student_alerts' AND pronamespace = 'progress_tracking'::regnamespace)
|
||||
),
|
||||
'p\.tenant_id',
|
||||
'g'
|
||||
);
|
||||
|
||||
-- Contar ocurrencias de p.user_id (debe aparecer en los 3 INSERTs)
|
||||
\echo ''
|
||||
\echo ' 4.4 Ocurrencias de "p.user_id":'
|
||||
SELECT COUNT(*)::text || ' ocurrencias (esperado: 3)' as resultado
|
||||
FROM regexp_matches(
|
||||
pg_get_functiondef(
|
||||
(SELECT oid FROM pg_proc WHERE proname = 'generate_student_alerts' AND pronamespace = 'progress_tracking'::regnamespace)
|
||||
),
|
||||
'p\.user_id',
|
||||
'g'
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- 5. PRUEBA FUNCIONAL (OPCIONAL)
|
||||
-- =====================================================
|
||||
\echo ''
|
||||
\echo '5. Prueba funcional (opcional)...'
|
||||
\echo ' Si desea ejecutar la función, ejecute:'
|
||||
\echo ' SELECT progress_tracking.generate_student_alerts();'
|
||||
\echo ''
|
||||
|
||||
-- =====================================================
|
||||
-- 6. ANÁLISIS DE DATOS DE PRUEBA (SI EXISTEN)
|
||||
-- =====================================================
|
||||
\echo '6. Analizando datos existentes...'
|
||||
\echo ''
|
||||
|
||||
\echo ' 6.1 Estudiantes con progreso en módulos:'
|
||||
SELECT COUNT(DISTINCT user_id) as total_students
|
||||
FROM progress_tracking.module_progress;
|
||||
|
||||
\echo ''
|
||||
\echo ' 6.2 Estudiantes con ejercicios intentados:'
|
||||
SELECT COUNT(DISTINCT user_id) as total_students
|
||||
FROM progress_tracking.exercise_submissions;
|
||||
|
||||
\echo ''
|
||||
\echo ' 6.3 Verificar que profiles.user_id mapea a auth.users.id:'
|
||||
SELECT
|
||||
COUNT(*) as total_profiles,
|
||||
COUNT(DISTINCT p.id) as unique_profile_ids,
|
||||
COUNT(DISTINCT p.user_id) as unique_user_ids,
|
||||
COUNT(DISTINCT u.id) as unique_auth_user_ids,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT p.user_id) = COUNT(DISTINCT u.id) THEN 'CORRECTO ✓'
|
||||
ELSE 'INCONSISTENTE ✗'
|
||||
END as mapping_status
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN auth.users u ON p.user_id = u.id;
|
||||
|
||||
\echo ''
|
||||
\echo ' 6.4 Alertas generadas actualmente:'
|
||||
SELECT
|
||||
alert_type,
|
||||
COUNT(*) as total,
|
||||
COUNT(DISTINCT student_id) as unique_students
|
||||
FROM progress_tracking.student_intervention_alerts
|
||||
GROUP BY alert_type
|
||||
ORDER BY alert_type;
|
||||
|
||||
-- =====================================================
|
||||
-- 7. RESUMEN DE VALIDACIÓN
|
||||
-- =====================================================
|
||||
\echo ''
|
||||
\echo '========================================='
|
||||
\echo 'RESUMEN DE VALIDACIÓN'
|
||||
\echo '========================================='
|
||||
\echo ''
|
||||
\echo 'CRITERIOS DE ACEPTACIÓN:'
|
||||
\echo ' ✓ Función usa JOIN auth_management.profiles (3 veces)'
|
||||
\echo ' ✓ Función NO usa JOIN auth.users (0 veces)'
|
||||
\echo ' ✓ Función usa p.tenant_id (3 veces)'
|
||||
\echo ' ✓ Función usa p.user_id (3 veces)'
|
||||
\echo ' ✓ FKs verificadas:'
|
||||
\echo ' - module_progress.user_id → profiles(id)'
|
||||
\echo ' - exercise_submissions.user_id → profiles(id)'
|
||||
\echo ' - student_intervention_alerts.student_id → auth.users(id)'
|
||||
\echo ' - profiles.user_id → auth.users(id)'
|
||||
\echo ''
|
||||
\echo 'Si todos los criterios se cumplen: ✅ VALIDACIÓN EXITOSA'
|
||||
\echo 'Si algún criterio falla: ✗ REVISAR IMPLEMENTACIÓN'
|
||||
\echo ''
|
||||
\echo '========================================='
|
||||
@ -0,0 +1,135 @@
|
||||
-- =====================================================
|
||||
-- Script de Validación: Estructura de Objectives en Missions
|
||||
-- Fecha: 2025-11-26
|
||||
-- Propósito: Validar que initialize_user_missions crea objectives como ARRAY
|
||||
-- =====================================================
|
||||
|
||||
\echo '=================================================='
|
||||
\echo 'VALIDACIÓN: Estructura de objectives en missions'
|
||||
\echo '=================================================='
|
||||
\echo ''
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_test_user_id UUID;
|
||||
v_objectives JSONB;
|
||||
v_objectives_type TEXT;
|
||||
v_mission_count INT;
|
||||
v_total_missions INT;
|
||||
v_test_passed BOOLEAN := true;
|
||||
BEGIN
|
||||
RAISE NOTICE '1. Buscando usuario de prueba...';
|
||||
|
||||
-- Buscar un usuario existente
|
||||
SELECT id INTO v_test_user_id
|
||||
FROM auth_management.profiles
|
||||
LIMIT 1;
|
||||
|
||||
IF v_test_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'No hay usuarios en la base de datos para testing';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Usuario de prueba: %', v_test_user_id;
|
||||
|
||||
RAISE NOTICE '2. Limpiando misiones previas...';
|
||||
DELETE FROM gamification_system.missions WHERE user_id = v_test_user_id;
|
||||
|
||||
RAISE NOTICE '3. Ejecutando initialize_user_missions()...';
|
||||
PERFORM gamilit.initialize_user_missions(v_test_user_id);
|
||||
|
||||
RAISE NOTICE '4. Verificando estructura de objectives...';
|
||||
|
||||
-- Verificar que se crearon 8 misiones
|
||||
SELECT COUNT(*) INTO v_total_missions
|
||||
FROM gamification_system.missions
|
||||
WHERE user_id = v_test_user_id;
|
||||
|
||||
RAISE NOTICE 'Misiones creadas: %', v_total_missions;
|
||||
|
||||
IF v_total_missions != 8 THEN
|
||||
RAISE NOTICE '❌ ERROR: Se esperaban 8 misiones, se crearon %', v_total_missions;
|
||||
v_test_passed := false;
|
||||
ELSE
|
||||
RAISE NOTICE '✅ OK: Se crearon las 8 misiones esperadas';
|
||||
END IF;
|
||||
|
||||
-- Verificar que TODAS las misiones tienen objectives como ARRAY
|
||||
SELECT objectives, jsonb_typeof(objectives)
|
||||
INTO v_objectives, v_objectives_type
|
||||
FROM gamification_system.missions
|
||||
WHERE user_id = v_test_user_id
|
||||
LIMIT 1;
|
||||
|
||||
RAISE NOTICE 'Tipo de objectives: %', v_objectives_type;
|
||||
RAISE NOTICE 'Ejemplo de objectives: %', v_objectives;
|
||||
|
||||
IF v_objectives_type != 'array' THEN
|
||||
RAISE NOTICE '❌ ERROR: objectives NO es un array, es: %', v_objectives_type;
|
||||
v_test_passed := false;
|
||||
ELSE
|
||||
RAISE NOTICE '✅ OK: objectives es un ARRAY';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '5. Verificando compatibilidad con operador @>...';
|
||||
|
||||
-- Test: Buscar misiones de tipo complete_exercises
|
||||
SELECT COUNT(*) INTO v_mission_count
|
||||
FROM gamification_system.missions
|
||||
WHERE user_id = v_test_user_id
|
||||
AND objectives @> '[{"type": "complete_exercises"}]'::jsonb;
|
||||
|
||||
RAISE NOTICE 'Misiones con type=complete_exercises encontradas con @>: %', v_mission_count;
|
||||
|
||||
IF v_mission_count = 0 THEN
|
||||
RAISE NOTICE '❌ ERROR: El operador @> NO encuentra misiones';
|
||||
v_test_passed := false;
|
||||
ELSE
|
||||
RAISE NOTICE '✅ OK: El operador @> funciona correctamente';
|
||||
END IF;
|
||||
|
||||
-- Test: Buscar misión de earn_xp
|
||||
SELECT COUNT(*) INTO v_mission_count
|
||||
FROM gamification_system.missions
|
||||
WHERE user_id = v_test_user_id
|
||||
AND objectives @> '[{"type": "earn_xp"}]'::jsonb;
|
||||
|
||||
RAISE NOTICE 'Misiones con type=earn_xp encontradas: %', v_mission_count;
|
||||
|
||||
IF v_mission_count = 0 THEN
|
||||
RAISE NOTICE '❌ ERROR: No se encuentra misión de earn_xp';
|
||||
v_test_passed := false;
|
||||
END IF;
|
||||
|
||||
-- Test: Verificar misión weekly_explorer con modules_visited
|
||||
SELECT COUNT(*) INTO v_mission_count
|
||||
FROM gamification_system.missions
|
||||
WHERE user_id = v_test_user_id
|
||||
AND template_id = 'weekly_explorer'
|
||||
AND objectives @> '[{"type": "explore_modules"}]'::jsonb
|
||||
AND objectives::text LIKE '%modules_visited%';
|
||||
|
||||
RAISE NOTICE 'Misión weekly_explorer con modules_visited: %', v_mission_count;
|
||||
|
||||
IF v_mission_count = 0 THEN
|
||||
RAISE NOTICE '❌ ERROR: weekly_explorer no tiene modules_visited';
|
||||
v_test_passed := false;
|
||||
ELSE
|
||||
RAISE NOTICE '✅ OK: weekly_explorer tiene modules_visited';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '6. Limpiando datos de prueba...';
|
||||
DELETE FROM gamification_system.missions WHERE user_id = v_test_user_id;
|
||||
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '==================================================';
|
||||
IF v_test_passed THEN
|
||||
RAISE NOTICE '✅ ✅ ✅ VALIDACIÓN EXITOSA ✅ ✅ ✅';
|
||||
RAISE NOTICE 'La función initialize_user_missions está correcta';
|
||||
ELSE
|
||||
RAISE NOTICE '❌ ❌ ❌ VALIDACIÓN FALLIDA ❌ ❌ ❌';
|
||||
RAISE NOTICE 'Hay problemas en la función initialize_user_missions';
|
||||
END IF;
|
||||
RAISE NOTICE '==================================================';
|
||||
|
||||
END;
|
||||
$$;
|
||||
@ -0,0 +1,248 @@
|
||||
-- =====================================================
|
||||
-- Script: Validate Seeds Integrity
|
||||
-- Description: Valida integridad referencial de todos los seeds
|
||||
-- Created: 2025-11-15
|
||||
-- Version: 1.0
|
||||
-- =====================================================
|
||||
--
|
||||
-- PROPÓSITO:
|
||||
-- Este script verifica que:
|
||||
-- 1. No haya registros huérfanos (FK rotas)
|
||||
-- 2. Conteos de registros coincidan (users = profiles = user_stats)
|
||||
-- 3. Triggers funcionaron correctamente
|
||||
-- 4. Seeds sociales tienen datos suficientes
|
||||
--
|
||||
-- EJECUCIÓN:
|
||||
-- psql -U gamilit_user -d gamilit_platform -f validate-seeds-integrity.sql
|
||||
-- =====================================================
|
||||
|
||||
\set QUIET on
|
||||
\timing off
|
||||
|
||||
-- Configurar search path
|
||||
SET search_path TO auth_management, gamification_system, social_features, educational_content, public;
|
||||
|
||||
-- =====================================================
|
||||
-- Sección 1: Conteos Básicos
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo '1. CONTEOS BÁSICOS'
|
||||
\echo '========================================'
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
users_count INTEGER;
|
||||
profiles_count INTEGER;
|
||||
user_stats_count INTEGER;
|
||||
user_ranks_count INTEGER;
|
||||
comodines_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO users_count FROM auth.users;
|
||||
SELECT COUNT(*) INTO profiles_count FROM auth_management.profiles;
|
||||
SELECT COUNT(*) INTO user_stats_count FROM gamification_system.user_stats;
|
||||
SELECT COUNT(*) INTO user_ranks_count FROM gamification_system.user_ranks;
|
||||
SELECT COUNT(*) INTO comodines_count FROM gamification_system.comodines_inventory;
|
||||
|
||||
RAISE NOTICE 'auth.users: %', users_count;
|
||||
RAISE NOTICE 'auth_management.profiles: %', profiles_count;
|
||||
RAISE NOTICE 'gamification_system.user_stats: %', user_stats_count;
|
||||
RAISE NOTICE 'gamification_system.user_ranks: %', user_ranks_count;
|
||||
RAISE NOTICE 'gamification_system.comodines_inventory: %', comodines_count;
|
||||
RAISE NOTICE '';
|
||||
|
||||
IF users_count = profiles_count AND profiles_count = user_stats_count AND user_stats_count = user_ranks_count THEN
|
||||
RAISE NOTICE '✓ PASS: Todos los conteos coinciden (%)', users_count;
|
||||
ELSE
|
||||
RAISE WARNING '✗ FAIL: Conteos no coinciden';
|
||||
RAISE WARNING 'Diferencias detectadas - verificar triggers y seeds';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- Sección 2: Integridad Referencial
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo '2. INTEGRIDAD REFERENCIAL'
|
||||
\echo '========================================'
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
orphan_profiles INTEGER;
|
||||
orphan_user_stats INTEGER;
|
||||
orphan_user_ranks INTEGER;
|
||||
orphan_comodines INTEGER;
|
||||
BEGIN
|
||||
-- Profiles sin user
|
||||
SELECT COUNT(*) INTO orphan_profiles
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN auth.users u ON u.id = p.user_id
|
||||
WHERE u.id IS NULL;
|
||||
|
||||
-- User_stats sin profile
|
||||
SELECT COUNT(*) INTO orphan_user_stats
|
||||
FROM gamification_system.user_stats us
|
||||
LEFT JOIN auth_management.profiles p ON p.user_id = us.user_id
|
||||
WHERE p.id IS NULL;
|
||||
|
||||
-- User_ranks sin user_stats
|
||||
SELECT COUNT(*) INTO orphan_user_ranks
|
||||
FROM gamification_system.user_ranks ur
|
||||
LEFT JOIN gamification_system.user_stats us ON us.user_id = ur.user_id
|
||||
WHERE us.id IS NULL;
|
||||
|
||||
-- Comodines sin user
|
||||
SELECT COUNT(*) INTO orphan_comodines
|
||||
FROM gamification_system.comodines_inventory ci
|
||||
LEFT JOIN auth_management.profiles p ON p.user_id = ci.user_id
|
||||
WHERE p.id IS NULL;
|
||||
|
||||
RAISE NOTICE 'Profiles huérfanos (sin user): %', orphan_profiles;
|
||||
RAISE NOTICE 'User_stats huérfanos (sin profile): %', orphan_user_stats;
|
||||
RAISE NOTICE 'User_ranks huérfanos (sin user_stats): %', orphan_user_ranks;
|
||||
RAISE NOTICE 'Comodines huérfanos (sin user): %', orphan_comodines;
|
||||
RAISE NOTICE '';
|
||||
|
||||
IF orphan_profiles = 0 AND orphan_user_stats = 0 AND orphan_user_ranks = 0 AND orphan_comodines = 0 THEN
|
||||
RAISE NOTICE '✓ PASS: No hay registros huérfanos';
|
||||
ELSE
|
||||
RAISE WARNING '✗ FAIL: Se encontraron registros huérfanos';
|
||||
RAISE WARNING 'Ejecutar limpieza de huérfanos';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- Sección 3: Datos Educativos
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo '3. CONTENIDO EDUCATIVO'
|
||||
\echo '========================================'
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
modules_count INTEGER;
|
||||
published_modules INTEGER;
|
||||
exercises_count INTEGER;
|
||||
achievements_count INTEGER;
|
||||
ranks_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO modules_count FROM educational_content.modules;
|
||||
SELECT COUNT(*) INTO published_modules FROM educational_content.modules WHERE is_published = true;
|
||||
SELECT COUNT(*) INTO exercises_count FROM educational_content.exercises;
|
||||
SELECT COUNT(*) INTO achievements_count FROM gamification_system.achievements WHERE is_active = true;
|
||||
SELECT COUNT(*) INTO ranks_count FROM gamification_system.maya_ranks WHERE is_active = true;
|
||||
|
||||
RAISE NOTICE 'Módulos: % (% publicados)', modules_count, published_modules;
|
||||
RAISE NOTICE 'Ejercicios: %', exercises_count;
|
||||
RAISE NOTICE 'Achievements: %', achievements_count;
|
||||
RAISE NOTICE 'Rangos Maya: %', ranks_count;
|
||||
RAISE NOTICE '';
|
||||
|
||||
IF modules_count >= 5 AND exercises_count >= 50 AND achievements_count >= 15 AND ranks_count >= 5 THEN
|
||||
RAISE NOTICE '✓ PASS: Contenido educativo completo';
|
||||
ELSE
|
||||
RAISE WARNING '✗ FAIL: Contenido educativo incompleto';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- Sección 4: Features Sociales
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo '4. FEATURES SOCIALES'
|
||||
\echo '========================================'
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
friendships_count INTEGER;
|
||||
pending_requests INTEGER;
|
||||
schools_count INTEGER;
|
||||
classrooms_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO friendships_count FROM social_features.friendships WHERE status = 'accepted';
|
||||
SELECT COUNT(*) INTO pending_requests FROM social_features.friendships WHERE status = 'pending';
|
||||
SELECT COUNT(*) INTO schools_count FROM social_features.schools;
|
||||
SELECT COUNT(*) INTO classrooms_count FROM social_features.classrooms;
|
||||
|
||||
RAISE NOTICE 'Friendships aceptados: %', friendships_count;
|
||||
RAISE NOTICE 'Friend requests pendientes: %', pending_requests;
|
||||
RAISE NOTICE 'Escuelas: %', schools_count;
|
||||
RAISE NOTICE 'Aulas: %', classrooms_count;
|
||||
RAISE NOTICE '';
|
||||
|
||||
IF friendships_count >= 8 AND schools_count >= 2 THEN
|
||||
RAISE NOTICE '✓ PASS: Features sociales disponibles';
|
||||
ELSE
|
||||
RAISE WARNING '✗ FAIL: Features sociales incompletas';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- Sección 5: Resumen Final
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo 'RESUMEN FINAL'
|
||||
\echo '========================================'
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
total_users INTEGER;
|
||||
total_profiles INTEGER;
|
||||
total_stats INTEGER;
|
||||
avg_level NUMERIC;
|
||||
total_coins INTEGER;
|
||||
total_achievements INTEGER;
|
||||
total_modules INTEGER;
|
||||
total_friendships INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO total_users FROM auth.users;
|
||||
SELECT COUNT(*) INTO total_profiles FROM auth_management.profiles;
|
||||
SELECT COUNT(*) INTO total_stats FROM gamification_system.user_stats;
|
||||
SELECT COUNT(*) INTO total_achievements FROM gamification_system.achievements;
|
||||
SELECT COUNT(*) INTO total_modules FROM educational_content.modules;
|
||||
SELECT COUNT(*) INTO total_friendships FROM social_features.friendships WHERE status = 'accepted';
|
||||
|
||||
SELECT AVG(level)::NUMERIC(5,2) INTO avg_level FROM gamification_system.user_stats;
|
||||
SELECT SUM(ml_coins) INTO total_coins FROM gamification_system.user_stats;
|
||||
|
||||
RAISE NOTICE 'Base de Datos: gamilit_platform';
|
||||
RAISE NOTICE 'Fecha validación: %', now();
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'Usuarios totales: %', total_users;
|
||||
RAISE NOTICE 'Perfiles completos: %', total_profiles;
|
||||
RAISE NOTICE 'User stats: %', total_stats;
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'Nivel promedio usuarios: %', avg_level;
|
||||
RAISE NOTICE 'ML Coins en circulación: %', total_coins;
|
||||
RAISE NOTICE 'Achievements disponibles: %', total_achievements;
|
||||
RAISE NOTICE 'Módulos educativos: %', total_modules;
|
||||
RAISE NOTICE 'Amistades activas: %', total_friendships;
|
||||
RAISE NOTICE '';
|
||||
|
||||
IF total_users = total_profiles AND total_profiles = total_stats THEN
|
||||
RAISE NOTICE '════════════════════════════════════════';
|
||||
RAISE NOTICE '✓✓✓ VALIDACIÓN COMPLETA: SUCCESS ✓✓✓';
|
||||
RAISE NOTICE '════════════════════════════════════════';
|
||||
RAISE NOTICE 'Seeds están correctos y listos para desarrollo frontend';
|
||||
ELSE
|
||||
RAISE WARNING '════════════════════════════════════════';
|
||||
RAISE WARNING '✗✗✗ VALIDACIÓN: PROBLEMAS DETECTADOS ✗✗✗';
|
||||
RAISE WARNING '════════════════════════════════════════';
|
||||
RAISE WARNING 'Revisar secciones anteriores para detalles';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '';
|
||||
END $$;
|
||||
|
||||
\echo ''
|
||||
\echo 'Validación completada.'
|
||||
\echo ''
|
||||
@ -0,0 +1,231 @@
|
||||
-- =====================================================================================
|
||||
-- Script: Validación de corrección update_user_rank()
|
||||
-- Propósito: Validar que la función update_user_rank() incluye balance_before y balance_after
|
||||
-- Fecha: 2025-11-24
|
||||
-- Uso: psql -d gamilit_platform -f scripts/validate-update-user-rank-fix.sql
|
||||
-- =====================================================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================='
|
||||
\echo 'VALIDACIÓN: update_user_rank() - Balance Fields'
|
||||
\echo '========================================='
|
||||
\echo ''
|
||||
|
||||
-- =====================================================================================
|
||||
-- PASO 1: Verificar existencia de la función
|
||||
-- =====================================================================================
|
||||
\echo '1. Verificando existencia de función...'
|
||||
SELECT
|
||||
p.proname AS function_name,
|
||||
n.nspname AS schema_name,
|
||||
pg_get_function_result(p.oid) AS return_type,
|
||||
pg_get_function_arguments(p.oid) AS arguments
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE p.proname = 'update_user_rank'
|
||||
AND n.nspname = 'gamification_system';
|
||||
|
||||
\echo ''
|
||||
|
||||
-- =====================================================================================
|
||||
-- PASO 2: Verificar estructura de la tabla ml_coins_transactions
|
||||
-- =====================================================================================
|
||||
\echo '2. Verificando estructura de ml_coins_transactions...'
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'gamification_system'
|
||||
AND table_name = 'ml_coins_transactions'
|
||||
AND column_name IN ('balance_before', 'balance_after', 'transaction_type', 'amount', 'user_id')
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
\echo ''
|
||||
|
||||
-- =====================================================================================
|
||||
-- PASO 3: Verificar valores del ENUM transaction_type
|
||||
-- =====================================================================================
|
||||
\echo '3. Verificando ENUM transaction_type...'
|
||||
SELECT
|
||||
t.typname AS enum_name,
|
||||
e.enumlabel AS enum_value,
|
||||
e.enumsortorder AS sort_order
|
||||
FROM pg_type t
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
JOIN pg_namespace n ON t.typnamespace = n.oid
|
||||
WHERE t.typname = 'transaction_type'
|
||||
AND n.nspname = 'gamification_system'
|
||||
ORDER BY e.enumsortorder;
|
||||
|
||||
\echo ''
|
||||
|
||||
-- =====================================================================================
|
||||
-- PASO 4: Verificar que 'earned_rank' existe en el ENUM
|
||||
-- =====================================================================================
|
||||
\echo '4. Verificando que ''earned_rank'' existe en el ENUM...'
|
||||
SELECT
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_type t
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
JOIN pg_namespace n ON t.typnamespace = n.oid
|
||||
WHERE t.typname = 'transaction_type'
|
||||
AND n.nspname = 'gamification_system'
|
||||
AND e.enumlabel = 'earned_rank'
|
||||
) THEN '✅ ENUM ''earned_rank'' existe'
|
||||
ELSE '❌ ERROR: ENUM ''earned_rank'' NO existe'
|
||||
END AS validation_result;
|
||||
|
||||
\echo ''
|
||||
|
||||
-- =====================================================================================
|
||||
-- PASO 5: Ver código fuente de la función (últimas líneas del INSERT)
|
||||
-- =====================================================================================
|
||||
\echo '5. Verificando código fuente de la función (fragmento del INSERT)...'
|
||||
SELECT
|
||||
substring(
|
||||
pg_get_functiondef(p.oid),
|
||||
position('INSERT INTO gamification_system.ml_coins_transactions' in pg_get_functiondef(p.oid)),
|
||||
500
|
||||
) AS insert_statement_fragment
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE p.proname = 'update_user_rank'
|
||||
AND n.nspname = 'gamification_system';
|
||||
|
||||
\echo ''
|
||||
|
||||
-- =====================================================================================
|
||||
-- PASO 6: Test básico de sintaxis (sin ejecutar realmente)
|
||||
-- =====================================================================================
|
||||
\echo '6. Validación de sintaxis SQL...'
|
||||
\echo 'Preparando transacción de prueba (se hará ROLLBACK)...'
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Crear usuario de prueba temporal
|
||||
DO $$
|
||||
DECLARE
|
||||
v_test_user_id UUID := 'test-user-validate-rank-fix'::UUID;
|
||||
BEGIN
|
||||
-- Limpiar si existe
|
||||
DELETE FROM gamification_system.user_stats WHERE user_id = v_test_user_id;
|
||||
DELETE FROM gamification_system.user_ranks WHERE user_id = v_test_user_id;
|
||||
DELETE FROM auth_management.profiles WHERE id = v_test_user_id;
|
||||
|
||||
-- Crear perfil de prueba
|
||||
INSERT INTO auth_management.profiles (id, display_name, role, tenant_id)
|
||||
VALUES (
|
||||
v_test_user_id,
|
||||
'Test User - Validate Rank Fix',
|
||||
'student',
|
||||
(SELECT id FROM auth_management.tenants LIMIT 1)
|
||||
);
|
||||
|
||||
-- Crear user_stats inicial (debe disparar trigger de inicialización)
|
||||
-- Si el trigger funciona, creará el registro automáticamente
|
||||
|
||||
RAISE NOTICE 'Usuario de prueba creado: %', v_test_user_id;
|
||||
END $$;
|
||||
|
||||
-- Verificar que el usuario se creó correctamente
|
||||
\echo ''
|
||||
\echo 'Verificando creación de user_stats...'
|
||||
SELECT
|
||||
user_id,
|
||||
total_xp,
|
||||
COALESCE(ml_coins, 0) AS ml_coins,
|
||||
created_at
|
||||
FROM gamification_system.user_stats
|
||||
WHERE user_id = 'test-user-validate-rank-fix'::UUID;
|
||||
|
||||
-- Simular XP suficiente para ascender de rango
|
||||
UPDATE gamification_system.user_stats
|
||||
SET total_xp = 5000 -- Suficiente para pasar de Ajaw
|
||||
WHERE user_id = 'test-user-validate-rank-fix'::UUID;
|
||||
|
||||
\echo ''
|
||||
\echo 'Ejecutando update_user_rank()...'
|
||||
SELECT * FROM gamification_system.update_user_rank('test-user-validate-rank-fix'::UUID);
|
||||
|
||||
\echo ''
|
||||
\echo 'Verificando transacción creada...'
|
||||
SELECT
|
||||
user_id,
|
||||
amount,
|
||||
balance_before,
|
||||
balance_after,
|
||||
transaction_type,
|
||||
description,
|
||||
created_at
|
||||
FROM gamification_system.ml_coins_transactions
|
||||
WHERE user_id = 'test-user-validate-rank-fix'::UUID
|
||||
AND transaction_type = 'earned_rank'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
\echo ''
|
||||
\echo 'Validando integridad de balance...'
|
||||
SELECT
|
||||
CASE
|
||||
WHEN balance_after = balance_before + amount THEN '✅ Balance correcto'
|
||||
ELSE '❌ ERROR: Balance incorrecto'
|
||||
END AS balance_validation,
|
||||
balance_before,
|
||||
amount,
|
||||
balance_after,
|
||||
(balance_before + amount) AS expected_balance_after
|
||||
FROM gamification_system.ml_coins_transactions
|
||||
WHERE user_id = 'test-user-validate-rank-fix'::UUID
|
||||
AND transaction_type = 'earned_rank'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- Rollback para no afectar la base de datos
|
||||
ROLLBACK;
|
||||
|
||||
\echo ''
|
||||
\echo '✅ Transacción de prueba revertida (ROLLBACK)'
|
||||
\echo ''
|
||||
|
||||
-- =====================================================================================
|
||||
-- PASO 7: Resumen de validación
|
||||
-- =====================================================================================
|
||||
\echo '========================================='
|
||||
\echo 'RESUMEN DE VALIDACIÓN'
|
||||
\echo '========================================='
|
||||
\echo ''
|
||||
\echo 'Checklist de corrección:'
|
||||
\echo ' [ ] Función update_user_rank() existe'
|
||||
\echo ' [ ] Campos balance_before y balance_after existen en ml_coins_transactions'
|
||||
\echo ' [ ] Campos son NOT NULL'
|
||||
\echo ' [ ] ENUM transaction_type tiene valor ''earned_rank'''
|
||||
\echo ' [ ] Función incluye balance_before y balance_after en INSERT'
|
||||
\echo ' [ ] Balance calculado correctamente (balance_after = balance_before + amount)'
|
||||
\echo ''
|
||||
\echo 'Si todos los pasos anteriores mostraron ✅, la corrección es exitosa.'
|
||||
\echo ''
|
||||
|
||||
-- =====================================================================================
|
||||
-- PASO 8: Instrucciones finales
|
||||
-- =====================================================================================
|
||||
\echo '========================================='
|
||||
\echo 'INSTRUCCIONES'
|
||||
\echo '========================================='
|
||||
\echo ''
|
||||
\echo 'Para aplicar la corrección a producción:'
|
||||
\echo ' 1. Verificar que este script ejecuta sin errores'
|
||||
\echo ' 2. Aplicar función: psql -d gamilit_platform -f ddl/schemas/gamification_system/functions/update_user_rank.sql'
|
||||
\echo ' 3. Validar con usuarios reales en ambiente de staging'
|
||||
\echo ' 4. Deploy a producción'
|
||||
\echo ''
|
||||
\echo 'Para revisar otras funciones que usan ml_coins_transactions:'
|
||||
\echo ' grep -r "INSERT INTO.*ml_coins_transactions" apps/database/ddl/'
|
||||
\echo ''
|
||||
|
||||
-- =====================================================================================
|
||||
-- FIN DEL SCRIPT
|
||||
-- =====================================================================================
|
||||
@ -0,0 +1,499 @@
|
||||
-- =====================================================
|
||||
-- Script: validate-user-initialization.sql
|
||||
-- Description: Valida que TODOS los usuarios estén completamente inicializados
|
||||
-- Version: 1.0
|
||||
-- Created: 2025-11-24
|
||||
-- Database-Agent Task: Análisis y corrección de inicialización de usuarios
|
||||
-- =====================================================
|
||||
--
|
||||
-- OBJETIVO:
|
||||
-- Validar que todos los usuarios (testing + demo + producción) tengan:
|
||||
-- 1. auth.users (registro inicial)
|
||||
-- 2. auth_management.profiles (con profiles.id = auth.users.id)
|
||||
-- 3. gamification_system.user_stats (ML Coins inicializados)
|
||||
-- 4. gamification_system.comodines_inventory (user_id → profiles.id)
|
||||
-- 5. gamification_system.user_ranks (rango inicial Ajaw)
|
||||
-- 6. progress_tracking.module_progress (todos los módulos publicados)
|
||||
--
|
||||
-- USUARIOS ESPERADOS:
|
||||
-- - Testing PROD (@gamilit.com): 3 usuarios
|
||||
-- - Demo PROD (@demo.glit.edu.mx): 20 usuarios (opcional según ambiente)
|
||||
-- - Producción (emails reales): 13 usuarios
|
||||
-- - TOTAL PROD: 16 usuarios (3 testing + 13 producción)
|
||||
-- - TOTAL FULL: 36 usuarios (3 testing + 20 demo + 13 producción)
|
||||
-- =====================================================
|
||||
|
||||
\set ON_ERROR_STOP off
|
||||
|
||||
SET search_path TO auth, auth_management, gamification_system, progress_tracking, public;
|
||||
|
||||
-- =====================================================
|
||||
-- SECCIÓN 1: Validación de auth.users
|
||||
-- =====================================================
|
||||
|
||||
\echo '========================================'
|
||||
\echo 'VALIDACIÓN 1: auth.users'
|
||||
\echo '========================================'
|
||||
|
||||
SELECT
|
||||
'1.1. Total usuarios en auth.users' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) >= 16 THEN '✅ OK (mínimo 16 esperados)'
|
||||
ELSE '❌ ERROR: Menos de 16 usuarios'
|
||||
END AS resultado
|
||||
FROM auth.users;
|
||||
|
||||
SELECT
|
||||
'1.2. Usuarios @gamilit.com (testing)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) = 3 THEN '✅ OK (3 esperados)'
|
||||
ELSE '❌ ERROR: Se esperaban 3 usuarios @gamilit.com'
|
||||
END AS resultado
|
||||
FROM auth.users
|
||||
WHERE email LIKE '%@gamilit.com';
|
||||
|
||||
SELECT
|
||||
'1.3. Usuarios productivos (no @gamilit.com)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) = 13 THEN '✅ OK (13 esperados)'
|
||||
ELSE '⚠️ WARNING: Se esperaban 13 usuarios productivos'
|
||||
END AS resultado
|
||||
FROM auth.users
|
||||
WHERE email NOT LIKE '%@gamilit.com'
|
||||
AND email NOT LIKE '%@demo.glit.edu.mx';
|
||||
|
||||
SELECT
|
||||
'1.4. Usuarios DEMO (@demo.glit.edu.mx)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
'⏭️ OPCIONAL (ambiente)' AS resultado
|
||||
FROM auth.users
|
||||
WHERE email LIKE '%@demo.glit.edu.mx';
|
||||
|
||||
-- =====================================================
|
||||
-- SECCIÓN 2: Validación de auth_management.profiles
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo 'VALIDACIÓN 2: auth_management.profiles'
|
||||
\echo '========================================'
|
||||
|
||||
SELECT
|
||||
'2.1. Total profiles' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) >= 16 THEN '✅ OK (mínimo 16 esperados)'
|
||||
ELSE '❌ ERROR: Menos de 16 profiles'
|
||||
END AS resultado
|
||||
FROM auth_management.profiles;
|
||||
|
||||
SELECT
|
||||
'2.2. Profiles con id = user_id (CRÍTICO)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) = (SELECT COUNT(*) FROM auth_management.profiles)
|
||||
THEN '✅ OK (100% consistente)'
|
||||
ELSE '❌ ERROR: Hay profiles con id ≠ user_id'
|
||||
END AS resultado
|
||||
FROM auth_management.profiles
|
||||
WHERE id = user_id;
|
||||
|
||||
SELECT
|
||||
'2.3. Usuarios SIN profile (CRÍTICO)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN '✅ OK (todos tienen profile)'
|
||||
ELSE '❌ ERROR: Hay usuarios sin profile'
|
||||
END AS resultado
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth_management.profiles p ON u.id = p.user_id
|
||||
WHERE p.id IS NULL;
|
||||
|
||||
-- Mostrar usuarios sin profile (si existen)
|
||||
DO $$
|
||||
DECLARE
|
||||
usuarios_sin_profile INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO usuarios_sin_profile
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth_management.profiles p ON u.id = p.user_id
|
||||
WHERE p.id IS NULL;
|
||||
|
||||
IF usuarios_sin_profile > 0 THEN
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '❌ USUARIOS SIN PROFILE DETECTADOS:';
|
||||
FOR rec IN
|
||||
SELECT u.id, u.email
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth_management.profiles p ON u.id = p.user_id
|
||||
WHERE p.id IS NULL
|
||||
LOOP
|
||||
RAISE NOTICE ' - % (%)', rec.email, rec.id;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- SECCIÓN 3: Validación de gamification_system.user_stats
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo 'VALIDACIÓN 3: gamification_system.user_stats'
|
||||
\echo '========================================'
|
||||
|
||||
SELECT
|
||||
'3.1. Total user_stats' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) >= 16 THEN '✅ OK (mínimo 16 esperados)'
|
||||
ELSE '❌ ERROR: Menos de 16 user_stats'
|
||||
END AS resultado
|
||||
FROM gamification_system.user_stats;
|
||||
|
||||
SELECT
|
||||
'3.2. Usuarios CON profile pero SIN user_stats (CRÍTICO)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN '✅ OK (todos tienen user_stats)'
|
||||
ELSE '❌ ERROR: Hay profiles sin user_stats'
|
||||
END AS resultado
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.user_stats us ON p.user_id = us.user_id
|
||||
WHERE us.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
SELECT
|
||||
'3.3. user_stats con ML Coins = 100 (inicial)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
'⏭️ INFO (bonus inicial)' AS resultado
|
||||
FROM gamification_system.user_stats
|
||||
WHERE ml_coins = 100 AND ml_coins_earned_total = 100;
|
||||
|
||||
-- Mostrar profiles sin user_stats (si existen)
|
||||
DO $$
|
||||
DECLARE
|
||||
profiles_sin_stats INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO profiles_sin_stats
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.user_stats us ON p.user_id = us.user_id
|
||||
WHERE us.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
IF profiles_sin_stats > 0 THEN
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '❌ PROFILES SIN USER_STATS DETECTADOS:';
|
||||
FOR rec IN
|
||||
SELECT p.id, p.email, p.role
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.user_stats us ON p.user_id = us.user_id
|
||||
WHERE us.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin')
|
||||
LOOP
|
||||
RAISE NOTICE ' - % (%, %)', rec.email, rec.role, rec.id;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- SECCIÓN 4: Validación de gamification_system.comodines_inventory
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo 'VALIDACIÓN 4: gamification_system.comodines_inventory'
|
||||
\echo '========================================'
|
||||
|
||||
SELECT
|
||||
'4.1. Total comodines_inventory' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) >= 16 THEN '✅ OK (mínimo 16 esperados)'
|
||||
ELSE '❌ ERROR: Menos de 16 inventarios'
|
||||
END AS resultado
|
||||
FROM gamification_system.comodines_inventory;
|
||||
|
||||
SELECT
|
||||
'4.2. Profiles SIN comodines_inventory (CRÍTICO)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN '✅ OK (todos tienen inventario)'
|
||||
ELSE '❌ ERROR: Hay profiles sin inventario'
|
||||
END AS resultado
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.comodines_inventory ci ON p.id = ci.user_id
|
||||
WHERE ci.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
-- IMPORTANTE: comodines_inventory.user_id apunta a profiles.id (NO auth.users.id)
|
||||
SELECT
|
||||
'4.3. comodines_inventory con user_id válido' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) = (SELECT COUNT(*) FROM gamification_system.comodines_inventory)
|
||||
THEN '✅ OK (100% válidos)'
|
||||
ELSE '❌ ERROR: Hay inventarios con user_id inválido'
|
||||
END AS resultado
|
||||
FROM gamification_system.comodines_inventory ci
|
||||
INNER JOIN auth_management.profiles p ON ci.user_id = p.id;
|
||||
|
||||
-- Mostrar profiles sin comodines_inventory (si existen)
|
||||
DO $$
|
||||
DECLARE
|
||||
profiles_sin_inventory INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO profiles_sin_inventory
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.comodines_inventory ci ON p.id = ci.user_id
|
||||
WHERE ci.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
IF profiles_sin_inventory > 0 THEN
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '❌ PROFILES SIN COMODINES_INVENTORY DETECTADOS:';
|
||||
FOR rec IN
|
||||
SELECT p.id, p.email, p.role
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.comodines_inventory ci ON p.id = ci.user_id
|
||||
WHERE ci.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin')
|
||||
LOOP
|
||||
RAISE NOTICE ' - % (%, %)', rec.email, rec.role, rec.id;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- SECCIÓN 5: Validación de gamification_system.user_ranks
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo 'VALIDACIÓN 5: gamification_system.user_ranks'
|
||||
\echo '========================================'
|
||||
|
||||
SELECT
|
||||
'5.1. Total user_ranks' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) >= 16 THEN '✅ OK (mínimo 16 esperados)'
|
||||
ELSE '❌ ERROR: Menos de 16 user_ranks'
|
||||
END AS resultado
|
||||
FROM gamification_system.user_ranks;
|
||||
|
||||
SELECT
|
||||
'5.2. Usuarios CON profile pero SIN user_ranks (CRÍTICO)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN '✅ OK (todos tienen rank)'
|
||||
ELSE '❌ ERROR: Hay profiles sin rank'
|
||||
END AS resultado
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.user_ranks ur ON p.user_id = ur.user_id
|
||||
WHERE ur.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
SELECT
|
||||
'5.3. user_ranks con rango Ajaw (inicial)' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
'⏭️ INFO (rango inicial)' AS resultado
|
||||
FROM gamification_system.user_ranks
|
||||
WHERE current_rank = 'Ajaw'::gamification_system.maya_rank;
|
||||
|
||||
-- Mostrar profiles sin user_ranks (si existen)
|
||||
DO $$
|
||||
DECLARE
|
||||
profiles_sin_ranks INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO profiles_sin_ranks
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.user_ranks ur ON p.user_id = ur.user_id
|
||||
WHERE ur.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
IF profiles_sin_ranks > 0 THEN
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '❌ PROFILES SIN USER_RANKS DETECTADOS:';
|
||||
FOR rec IN
|
||||
SELECT p.id, p.email, p.role
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.user_ranks ur ON p.user_id = ur.user_id
|
||||
WHERE ur.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin')
|
||||
LOOP
|
||||
RAISE NOTICE ' - % (%, %)', rec.email, rec.role, rec.id;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- SECCIÓN 6: Validación de progress_tracking.module_progress
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo 'VALIDACIÓN 6: progress_tracking.module_progress'
|
||||
\echo '========================================'
|
||||
|
||||
SELECT
|
||||
'6.1. Total module_progress registros' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
'⏭️ INFO (depende de módulos publicados)' AS resultado
|
||||
FROM progress_tracking.module_progress;
|
||||
|
||||
SELECT
|
||||
'6.2. Estudiantes CON module_progress' AS validacion,
|
||||
COUNT(DISTINCT mp.user_id) AS cantidad,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT mp.user_id) >= 16 THEN '✅ OK (mínimo 16 esperados)'
|
||||
ELSE '⚠️ WARNING: Menos de 16 estudiantes con progreso'
|
||||
END AS resultado
|
||||
FROM progress_tracking.module_progress mp
|
||||
INNER JOIN auth_management.profiles p ON mp.user_id = p.id
|
||||
WHERE p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
SELECT
|
||||
'6.3. Módulos publicados disponibles' AS validacion,
|
||||
COUNT(*) AS cantidad,
|
||||
'⏭️ INFO' AS resultado
|
||||
FROM educational_content.modules
|
||||
WHERE is_published = true AND status = 'published';
|
||||
|
||||
-- Mostrar estudiantes sin module_progress (si existen)
|
||||
DO $$
|
||||
DECLARE
|
||||
profiles_sin_progress INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO profiles_sin_progress
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN progress_tracking.module_progress mp ON p.id = mp.user_id
|
||||
WHERE mp.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
IF profiles_sin_progress > 0 THEN
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '⚠️ PROFILES SIN MODULE_PROGRESS DETECTADOS:';
|
||||
FOR rec IN
|
||||
SELECT p.id, p.email, p.role
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN progress_tracking.module_progress mp ON p.id = mp.user_id
|
||||
WHERE mp.user_id IS NULL
|
||||
AND p.role IN ('student', 'admin_teacher', 'super_admin')
|
||||
LOOP
|
||||
RAISE NOTICE ' - % (%, %)', rec.email, rec.role, rec.id;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- SECCIÓN 7: Resumen Final
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo '========================================'
|
||||
\echo 'RESUMEN FINAL'
|
||||
\echo '========================================'
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
total_users INTEGER;
|
||||
total_profiles INTEGER;
|
||||
total_stats INTEGER;
|
||||
total_inventory INTEGER;
|
||||
total_ranks INTEGER;
|
||||
total_progress_users INTEGER;
|
||||
usuarios_sin_profile INTEGER;
|
||||
profiles_sin_stats INTEGER;
|
||||
profiles_sin_inventory INTEGER;
|
||||
profiles_sin_ranks INTEGER;
|
||||
profiles_sin_progress INTEGER;
|
||||
errores_criticos INTEGER := 0;
|
||||
BEGIN
|
||||
-- Contar totales
|
||||
SELECT COUNT(*) INTO total_users FROM auth.users;
|
||||
SELECT COUNT(*) INTO total_profiles FROM auth_management.profiles;
|
||||
SELECT COUNT(*) INTO total_stats FROM gamification_system.user_stats;
|
||||
SELECT COUNT(*) INTO total_inventory FROM gamification_system.comodines_inventory;
|
||||
SELECT COUNT(*) INTO total_ranks FROM gamification_system.user_ranks;
|
||||
SELECT COUNT(DISTINCT mp.user_id) INTO total_progress_users
|
||||
FROM progress_tracking.module_progress mp;
|
||||
|
||||
-- Contar problemas
|
||||
SELECT COUNT(*) INTO usuarios_sin_profile
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth_management.profiles p ON u.id = p.user_id
|
||||
WHERE p.id IS NULL;
|
||||
|
||||
SELECT COUNT(*) INTO profiles_sin_stats
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.user_stats us ON p.user_id = us.user_id
|
||||
WHERE us.user_id IS NULL AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
SELECT COUNT(*) INTO profiles_sin_inventory
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.comodines_inventory ci ON p.id = ci.user_id
|
||||
WHERE ci.user_id IS NULL AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
SELECT COUNT(*) INTO profiles_sin_ranks
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.user_ranks ur ON p.user_id = ur.user_id
|
||||
WHERE ur.user_id IS NULL AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
SELECT COUNT(*) INTO profiles_sin_progress
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN progress_tracking.module_progress mp ON p.id = mp.user_id
|
||||
WHERE mp.user_id IS NULL AND p.role IN ('student', 'admin_teacher', 'super_admin');
|
||||
|
||||
-- Calcular errores críticos
|
||||
errores_criticos := usuarios_sin_profile + profiles_sin_stats +
|
||||
profiles_sin_inventory + profiles_sin_ranks;
|
||||
|
||||
-- Mostrar resumen
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'TOTALES:';
|
||||
RAISE NOTICE ' - auth.users: %', total_users;
|
||||
RAISE NOTICE ' - auth_management.profiles: %', total_profiles;
|
||||
RAISE NOTICE ' - gamification_system.user_stats: %', total_stats;
|
||||
RAISE NOTICE ' - gamification_system.comodines_inventory: %', total_inventory;
|
||||
RAISE NOTICE ' - gamification_system.user_ranks: %', total_ranks;
|
||||
RAISE NOTICE ' - progress_tracking.module_progress (usuarios únicos): %', total_progress_users;
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'PROBLEMAS DETECTADOS:';
|
||||
RAISE NOTICE ' - Usuarios sin profile: %', usuarios_sin_profile;
|
||||
RAISE NOTICE ' - Profiles sin user_stats: %', profiles_sin_stats;
|
||||
RAISE NOTICE ' - Profiles sin comodines_inventory: %', profiles_sin_inventory;
|
||||
RAISE NOTICE ' - Profiles sin user_ranks: %', profiles_sin_ranks;
|
||||
RAISE NOTICE ' - Profiles sin module_progress: % (WARNING)', profiles_sin_progress;
|
||||
RAISE NOTICE '';
|
||||
|
||||
IF errores_criticos = 0 THEN
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE '✅ VALIDACIÓN EXITOSA';
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE 'Todos los usuarios están completamente inicializados.';
|
||||
IF profiles_sin_progress > 0 THEN
|
||||
RAISE NOTICE '⚠️ WARNING: Hay % usuarios sin module_progress', profiles_sin_progress;
|
||||
RAISE NOTICE ' (Esto puede ser esperado si no hay módulos publicados)';
|
||||
END IF;
|
||||
ELSE
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE '❌ VALIDACIÓN FALLIDA';
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE 'Se detectaron % errores críticos.', errores_criticos;
|
||||
RAISE NOTICE 'Revisa las secciones anteriores para más detalles.';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '';
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- FIN DEL SCRIPT
|
||||
-- =====================================================
|
||||
|
||||
\echo ''
|
||||
\echo 'Validación completa. Revisa los resultados arriba.'
|
||||
\echo ''
|
||||
@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script de validación exhaustiva de integridad de la base de datos GAMILIT
|
||||
Fecha: 2025-11-07
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Set, Tuple
|
||||
|
||||
# Colores para output
|
||||
class Colors:
|
||||
HEADER = '\033[95m'
|
||||
OKBLUE = '\033[94m'
|
||||
OKCYAN = '\033[96m'
|
||||
OKGREEN = '\033[92m'
|
||||
WARNING = '\033[93m'
|
||||
FAIL = '\033[91m'
|
||||
ENDC = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
def print_section(title):
|
||||
print(f"\n{Colors.HEADER}{Colors.BOLD}{'='*80}{Colors.ENDC}")
|
||||
print(f"{Colors.HEADER}{Colors.BOLD}{title}{Colors.ENDC}")
|
||||
print(f"{Colors.HEADER}{Colors.BOLD}{'='*80}{Colors.ENDC}\n")
|
||||
|
||||
def print_error(severity, msg):
|
||||
color = Colors.FAIL if severity == "CRÍTICO" else Colors.WARNING if severity == "ALTO" else Colors.OKCYAN
|
||||
print(f"{color}[{severity}] {msg}{Colors.ENDC}")
|
||||
|
||||
def print_ok(msg):
|
||||
print(f"{Colors.OKGREEN}✓ {msg}{Colors.ENDC}")
|
||||
|
||||
# Configuración de paths (usa variable de entorno o path relativo al script)
|
||||
BASE_PATH = Path(os.environ.get('GAMILIT_DDL_PATH',
|
||||
Path(__file__).resolve().parent.parent / 'ddl'))
|
||||
SCHEMAS_PATH = BASE_PATH / "schemas"
|
||||
|
||||
# 1. EXTRAER TODOS LOS ENUMs DEFINIDOS
|
||||
def extract_enums():
|
||||
"""Extrae todos los ENUMs definidos en el sistema"""
|
||||
enums = {}
|
||||
|
||||
# 1.1 ENUMs en 00-prerequisites.sql
|
||||
prereq_file = BASE_PATH / "00-prerequisites.sql"
|
||||
if prereq_file.exists():
|
||||
content = prereq_file.read_text()
|
||||
# Buscar CREATE TYPE schema.enum AS ENUM
|
||||
pattern = r'CREATE TYPE\s+([\w.]+)\s+AS ENUM\s*\((.*?)\);'
|
||||
matches = re.findall(pattern, content, re.DOTALL | re.IGNORECASE)
|
||||
for enum_name, values in matches:
|
||||
schema = "public"
|
||||
name = enum_name
|
||||
if "." in enum_name:
|
||||
schema, name = enum_name.split(".", 1)
|
||||
|
||||
# Limpiar valores
|
||||
vals = [v.strip().strip("'").strip('"') for v in re.findall(r"'([^']*)'", values)]
|
||||
enums[f"{schema}.{name}"] = {
|
||||
"file": str(prereq_file),
|
||||
"schema": schema,
|
||||
"name": name,
|
||||
"values": vals,
|
||||
"count": len(vals)
|
||||
}
|
||||
|
||||
# 1.2 ENUMs en archivos individuales
|
||||
for enum_file in SCHEMAS_PATH.rglob("enums/*.sql"):
|
||||
content = enum_file.read_text()
|
||||
|
||||
# Extraer schema del path
|
||||
parts = enum_file.parts
|
||||
schema_idx = parts.index("schemas") + 1
|
||||
schema = parts[schema_idx]
|
||||
|
||||
# Buscar CREATE TYPE
|
||||
pattern = r'CREATE TYPE\s+([\w.]+)\s+AS ENUM\s*\((.*?)\);'
|
||||
matches = re.findall(pattern, content, re.DOTALL | re.IGNORECASE)
|
||||
|
||||
for enum_name, values in matches:
|
||||
if "." in enum_name:
|
||||
schema, name = enum_name.split(".", 1)
|
||||
else:
|
||||
name = enum_name
|
||||
|
||||
vals = [v.strip().strip("'").strip('"') for v in re.findall(r"'([^']*)'", values)]
|
||||
|
||||
full_name = f"{schema}.{name}"
|
||||
enums[full_name] = {
|
||||
"file": str(enum_file),
|
||||
"schema": schema,
|
||||
"name": name,
|
||||
"values": vals,
|
||||
"count": len(vals)
|
||||
}
|
||||
|
||||
return enums
|
||||
|
||||
# 2. EXTRAER TODAS LAS TABLAS DEFINIDAS
|
||||
def extract_tables():
|
||||
"""Extrae todas las tablas definidas"""
|
||||
tables = {}
|
||||
|
||||
for table_file in SCHEMAS_PATH.rglob("tables/*.sql"):
|
||||
content = table_file.read_text()
|
||||
|
||||
# Extraer schema del path
|
||||
parts = table_file.parts
|
||||
schema_idx = parts.index("schemas") + 1
|
||||
schema = parts[schema_idx]
|
||||
|
||||
# Buscar CREATE TABLE
|
||||
pattern = r'CREATE TABLE\s+(?:IF NOT EXISTS\s+)?([\w.]+)'
|
||||
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||
|
||||
for table_name in matches:
|
||||
if "." in table_name:
|
||||
tbl_schema, tbl_name = table_name.split(".", 1)
|
||||
else:
|
||||
tbl_schema = schema
|
||||
tbl_name = table_name
|
||||
|
||||
full_name = f"{tbl_schema}.{tbl_name}"
|
||||
tables[full_name] = {
|
||||
"file": str(table_file),
|
||||
"schema": tbl_schema,
|
||||
"name": tbl_name
|
||||
}
|
||||
|
||||
return tables
|
||||
|
||||
# 3. VALIDAR FOREIGN KEYS
|
||||
def validate_foreign_keys(tables):
|
||||
"""Valida que todas las referencias de FK apunten a tablas existentes"""
|
||||
issues = []
|
||||
|
||||
print_section("VALIDACIÓN 1: INTEGRIDAD DE FOREIGN KEYS")
|
||||
|
||||
for table_file in SCHEMAS_PATH.rglob("tables/*.sql"):
|
||||
content = table_file.read_text()
|
||||
|
||||
# Buscar REFERENCES
|
||||
pattern = r'REFERENCES\s+([\w.]+)\s*\('
|
||||
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||
|
||||
for ref_table in matches:
|
||||
# Normalizar nombre
|
||||
if "." not in ref_table:
|
||||
# Buscar schema del archivo actual
|
||||
parts = table_file.parts
|
||||
schema_idx = parts.index("schemas") + 1
|
||||
schema = parts[schema_idx]
|
||||
ref_table = f"{schema}.{ref_table}"
|
||||
|
||||
if ref_table not in tables:
|
||||
issues.append({
|
||||
"severity": "CRÍTICO",
|
||||
"type": "FK_BROKEN",
|
||||
"file": str(table_file),
|
||||
"message": f"Referencia a tabla inexistente: {ref_table}"
|
||||
})
|
||||
|
||||
if not issues:
|
||||
print_ok("Todas las Foreign Keys apuntan a tablas existentes")
|
||||
else:
|
||||
for issue in issues:
|
||||
print_error(issue["severity"], f"{issue['message']}\n Archivo: {issue['file']}")
|
||||
|
||||
return issues
|
||||
|
||||
# 4. VALIDAR ENUMS EN TABLAS
|
||||
def validate_enum_references(enums, tables):
|
||||
"""Valida que todos los ENUMs usados en tablas existan"""
|
||||
issues = []
|
||||
|
||||
print_section("VALIDACIÓN 2: INTEGRIDAD DE ENUMs")
|
||||
|
||||
for table_file in SCHEMAS_PATH.rglob("tables/*.sql"):
|
||||
content = table_file.read_text()
|
||||
|
||||
# Buscar columnas con tipo ENUM (schema.enum_name)
|
||||
pattern = r'(\w+)\s+([\w.]+)(?:\s+(?:NOT NULL|DEFAULT|CHECK|UNIQUE|PRIMARY KEY))?'
|
||||
|
||||
# Buscar específicamente tipos que parecen ENUMs (esquema.tipo)
|
||||
enum_pattern = r'\s+([\w]+\.\w+)(?:\s|,|\))'
|
||||
enum_matches = re.findall(enum_pattern, content)
|
||||
|
||||
for enum_ref in enum_matches:
|
||||
# Ignorar cosas que no son ENUMs
|
||||
if enum_ref.startswith('auth.') or enum_ref.startswith('educational_content.') or enum_ref.startswith('social_features.'):
|
||||
if '(' in enum_ref or ')' in enum_ref:
|
||||
continue
|
||||
|
||||
# Verificar si es un ENUM conocido
|
||||
if enum_ref not in enums:
|
||||
# Podría ser una tabla, verificar
|
||||
if enum_ref not in tables:
|
||||
issues.append({
|
||||
"severity": "ALTO",
|
||||
"type": "ENUM_NOT_FOUND",
|
||||
"file": str(table_file),
|
||||
"message": f"Posible referencia a ENUM inexistente: {enum_ref}"
|
||||
})
|
||||
|
||||
if not issues:
|
||||
print_ok("Todos los ENUMs referenciados existen")
|
||||
else:
|
||||
for issue in issues:
|
||||
print_error(issue["severity"], f"{issue['message']}\n Archivo: {issue['file']}")
|
||||
|
||||
return issues
|
||||
|
||||
# 5. VALIDAR CORRECCIONES APLICADAS
|
||||
def validate_corrections():
|
||||
"""Valida las correcciones específicas mencionadas en el tracking"""
|
||||
issues = []
|
||||
|
||||
print_section("VALIDACIÓN 3: CORRECCIONES APLICADAS")
|
||||
|
||||
# 5.1 notification_type - Debe tener 11 valores
|
||||
print("\n--- notification_type ---")
|
||||
enum_file = SCHEMAS_PATH / "public" / "enums" / "notification_type.sql"
|
||||
if enum_file.exists():
|
||||
content = enum_file.read_text()
|
||||
values = re.findall(r"'([^']*)'", content)
|
||||
if len(values) == 11:
|
||||
print_ok(f"notification_type tiene 11 valores correctos")
|
||||
expected = ['achievement_unlocked', 'rank_up', 'friend_request', 'guild_invitation',
|
||||
'mission_completed', 'level_up', 'message_received', 'system_announcement',
|
||||
'ml_coins_earned', 'streak_milestone', 'exercise_feedback']
|
||||
missing = set(expected) - set(values)
|
||||
if missing:
|
||||
issues.append({
|
||||
"severity": "ALTO",
|
||||
"type": "ENUM_VALUES",
|
||||
"file": str(enum_file),
|
||||
"message": f"notification_type falta valores: {missing}"
|
||||
})
|
||||
else:
|
||||
issues.append({
|
||||
"severity": "CRÍTICO",
|
||||
"type": "ENUM_VALUES",
|
||||
"file": str(enum_file),
|
||||
"message": f"notification_type tiene {len(values)} valores, esperados 11"
|
||||
})
|
||||
|
||||
# 5.2 achievement_category - Debe estar en gamification_system
|
||||
print("\n--- achievement_category ---")
|
||||
enum_file = SCHEMAS_PATH / "gamification_system" / "enums" / "achievement_category.sql"
|
||||
if enum_file.exists():
|
||||
print_ok(f"achievement_category está en gamification_system")
|
||||
|
||||
# Verificar que la tabla achievements lo usa
|
||||
table_file = SCHEMAS_PATH / "gamification_system" / "tables" / "03-achievements.sql"
|
||||
if table_file.exists():
|
||||
content = table_file.read_text()
|
||||
if "gamification_system.achievement_category" in content:
|
||||
print_ok("achievements usa gamification_system.achievement_category")
|
||||
elif "public.achievement_category" in content:
|
||||
issues.append({
|
||||
"severity": "CRÍTICO",
|
||||
"type": "ENUM_SCHEMA",
|
||||
"file": str(table_file),
|
||||
"message": "achievements usa public.achievement_category (debe ser gamification_system)"
|
||||
})
|
||||
else:
|
||||
issues.append({
|
||||
"severity": "CRÍTICO",
|
||||
"type": "ENUM_MISSING",
|
||||
"file": "N/A",
|
||||
"message": "achievement_category no existe en gamification_system"
|
||||
})
|
||||
|
||||
# 5.3 transaction_type - Debe estar en gamification_system
|
||||
print("\n--- transaction_type ---")
|
||||
enum_file = SCHEMAS_PATH / "gamification_system" / "enums" / "transaction_type.sql"
|
||||
if enum_file.exists():
|
||||
content = enum_file.read_text()
|
||||
values = re.findall(r"'([^']*)'", content)
|
||||
print_ok(f"transaction_type existe en gamification_system con {len(values)} valores")
|
||||
|
||||
# Debe tener 14 valores según tracking
|
||||
if len(values) != 14:
|
||||
issues.append({
|
||||
"severity": "MEDIO",
|
||||
"type": "ENUM_VALUES",
|
||||
"file": str(enum_file),
|
||||
"message": f"transaction_type tiene {len(values)} valores, esperados 14 según tracking"
|
||||
})
|
||||
else:
|
||||
issues.append({
|
||||
"severity": "CRÍTICO",
|
||||
"type": "ENUM_MISSING",
|
||||
"file": "N/A",
|
||||
"message": "transaction_type no existe en gamification_system"
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
# 6. BUSCAR FUNCIONES CON REFERENCIAS ROTAS
|
||||
def validate_functions(tables):
|
||||
"""Busca funciones que referencien tablas inexistentes"""
|
||||
issues = []
|
||||
|
||||
print_section("VALIDACIÓN 4: FUNCIONES CON REFERENCIAS ROTAS")
|
||||
|
||||
function_files = list(SCHEMAS_PATH.rglob("functions/*.sql"))
|
||||
|
||||
for func_file in function_files:
|
||||
content = func_file.read_text()
|
||||
|
||||
# Buscar FROM, JOIN, INSERT INTO, UPDATE, DELETE FROM
|
||||
patterns = [
|
||||
r'FROM\s+([\w.]+)',
|
||||
r'JOIN\s+([\w.]+)',
|
||||
r'INSERT INTO\s+([\w.]+)',
|
||||
r'UPDATE\s+([\w.]+)',
|
||||
r'DELETE FROM\s+([\w.]+)'
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||
for table_ref in matches:
|
||||
# Normalizar
|
||||
if "." not in table_ref and table_ref not in ['NEW', 'OLD', 'RETURNING', 'VALUES']:
|
||||
parts = func_file.parts
|
||||
schema_idx = parts.index("schemas") + 1
|
||||
schema = parts[schema_idx]
|
||||
table_ref = f"{schema}.{table_ref}"
|
||||
|
||||
if "." in table_ref and table_ref not in tables:
|
||||
# Verificar que no sea palabra clave SQL
|
||||
if table_ref.lower() not in ['with.recursive', 'select.distinct']:
|
||||
issues.append({
|
||||
"severity": "ALTO",
|
||||
"type": "FUNCTION_BROKEN_REF",
|
||||
"file": str(func_file),
|
||||
"message": f"Función referencia tabla inexistente: {table_ref}"
|
||||
})
|
||||
|
||||
if not issues:
|
||||
print_ok("Todas las funciones referencian tablas válidas")
|
||||
else:
|
||||
for issue in issues:
|
||||
print_error(issue["severity"], f"{issue['message']}\n Archivo: {issue['file']}")
|
||||
|
||||
return issues
|
||||
|
||||
# 7. BUSCAR TRIGGERS CON REFERENCIAS ROTAS
|
||||
def validate_triggers():
|
||||
"""Busca triggers que llamen funciones inexistentes"""
|
||||
issues = []
|
||||
|
||||
print_section("VALIDACIÓN 5: TRIGGERS CON REFERENCIAS ROTAS")
|
||||
|
||||
# Primero extraer todas las funciones
|
||||
functions = set()
|
||||
for func_file in SCHEMAS_PATH.rglob("functions/*.sql"):
|
||||
content = func_file.read_text()
|
||||
pattern = r'CREATE\s+(?:OR REPLACE\s+)?FUNCTION\s+([\w.]+)\s*\('
|
||||
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||
functions.update(matches)
|
||||
|
||||
# Agregar funciones de prerequisites
|
||||
prereq_file = BASE_PATH / "00-prerequisites.sql"
|
||||
if prereq_file.exists():
|
||||
content = prereq_file.read_text()
|
||||
pattern = r'CREATE\s+(?:OR REPLACE\s+)?FUNCTION\s+([\w.]+)\s*\('
|
||||
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||
functions.update(matches)
|
||||
|
||||
# Validar triggers
|
||||
for trigger_file in SCHEMAS_PATH.rglob("triggers/*.sql"):
|
||||
content = trigger_file.read_text()
|
||||
|
||||
# Buscar EXECUTE FUNCTION
|
||||
pattern = r'EXECUTE\s+(?:FUNCTION|PROCEDURE)\s+([\w.]+)\s*\('
|
||||
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||
|
||||
for func_ref in matches:
|
||||
if func_ref not in functions:
|
||||
issues.append({
|
||||
"severity": "CRÍTICO",
|
||||
"type": "TRIGGER_BROKEN_REF",
|
||||
"file": str(trigger_file),
|
||||
"message": f"Trigger llama función inexistente: {func_ref}"
|
||||
})
|
||||
|
||||
if not issues:
|
||||
print_ok("Todos los triggers llaman funciones válidas")
|
||||
else:
|
||||
for issue in issues:
|
||||
print_error(issue["severity"], f"{issue['message']}\n Archivo: {issue['file']}")
|
||||
|
||||
return issues
|
||||
|
||||
# 8. BUSCAR ENUMS DUPLICADOS
|
||||
def check_duplicate_enums(enums):
|
||||
"""Busca ENUMs duplicados en múltiples schemas"""
|
||||
issues = []
|
||||
|
||||
print_section("VALIDACIÓN 6: ENUMs DUPLICADOS")
|
||||
|
||||
enum_names = defaultdict(list)
|
||||
for full_name, info in enums.items():
|
||||
name = info["name"]
|
||||
enum_names[name].append(full_name)
|
||||
|
||||
for name, locations in enum_names.items():
|
||||
if len(locations) > 1:
|
||||
issues.append({
|
||||
"severity": "ALTO",
|
||||
"type": "ENUM_DUPLICATE",
|
||||
"file": "N/A",
|
||||
"message": f"ENUM '{name}' duplicado en: {', '.join(locations)}"
|
||||
})
|
||||
|
||||
if not issues:
|
||||
print_ok("No hay ENUMs duplicados")
|
||||
else:
|
||||
for issue in issues:
|
||||
print_error(issue["severity"], issue["message"])
|
||||
|
||||
return issues
|
||||
|
||||
# MAIN
|
||||
def main():
|
||||
print(f"\n{Colors.BOLD}VALIDACIÓN EXHAUSTIVA DE INTEGRIDAD - BASE DE DATOS GAMILIT{Colors.ENDC}")
|
||||
print(f"{Colors.BOLD}Fecha: 2025-11-07{Colors.ENDC}")
|
||||
print(f"{Colors.BOLD}Post-correcciones: 9/142 completadas{Colors.ENDC}\n")
|
||||
|
||||
# Extraer información
|
||||
print("Extrayendo información de la base de datos...")
|
||||
enums = extract_enums()
|
||||
tables = extract_tables()
|
||||
|
||||
print(f"✓ {len(enums)} ENUMs encontrados")
|
||||
print(f"✓ {len(tables)} tablas encontradas")
|
||||
|
||||
# Ejecutar validaciones
|
||||
all_issues = []
|
||||
|
||||
all_issues.extend(validate_foreign_keys(tables))
|
||||
all_issues.extend(validate_enum_references(enums, tables))
|
||||
all_issues.extend(validate_corrections())
|
||||
all_issues.extend(validate_functions(tables))
|
||||
all_issues.extend(validate_triggers())
|
||||
all_issues.extend(check_duplicate_enums(enums))
|
||||
|
||||
# RESUMEN FINAL
|
||||
print_section("RESUMEN DE VALIDACIÓN")
|
||||
|
||||
critical = [i for i in all_issues if i["severity"] == "CRÍTICO"]
|
||||
high = [i for i in all_issues if i["severity"] == "ALTO"]
|
||||
medium = [i for i in all_issues if i["severity"] == "MEDIO"]
|
||||
low = [i for i in all_issues if i["severity"] == "BAJO"]
|
||||
|
||||
print(f"\n{Colors.FAIL}CRÍTICO: {len(critical)} problemas{Colors.ENDC}")
|
||||
print(f"{Colors.WARNING}ALTO: {len(high)} problemas{Colors.ENDC}")
|
||||
print(f"{Colors.OKCYAN}MEDIO: {len(medium)} problemas{Colors.ENDC}")
|
||||
print(f"{Colors.OKBLUE}BAJO: {len(low)} problemas{Colors.ENDC}")
|
||||
print(f"\n{Colors.BOLD}TOTAL: {len(all_issues)} problemas encontrados{Colors.ENDC}\n")
|
||||
|
||||
if len(all_issues) == 0:
|
||||
print(f"{Colors.OKGREEN}{Colors.BOLD}✓✓✓ BASE DE DATOS VALIDADA EXITOSAMENTE ✓✓✓{Colors.ENDC}\n")
|
||||
else:
|
||||
print(f"{Colors.FAIL}{Colors.BOLD}⚠ SE REQUIERE ATENCIÓN ⚠{Colors.ENDC}\n")
|
||||
|
||||
return all_issues
|
||||
|
||||
if __name__ == "__main__":
|
||||
issues = main()
|
||||
134
projects/gamilit/apps/database/scripts/verify-missions-status.sh
Executable file
134
projects/gamilit/apps/database/scripts/verify-missions-status.sh
Executable file
@ -0,0 +1,134 @@
|
||||
#!/bin/bash
|
||||
# =====================================================
|
||||
# Script: verify-missions-status.sh
|
||||
# Description: Verifica el estado de las misiones en la base de datos
|
||||
# Author: Database-Agent
|
||||
# Created: 2025-11-24
|
||||
# =====================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Colores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}VERIFICACIÓN DE MISIONES - GAMILIT${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Leer credenciales
|
||||
DB_USER="gamilit_user"
|
||||
DB_NAME="gamilit_platform"
|
||||
DB_HOST="localhost"
|
||||
|
||||
# Verificar si existe el archivo de credenciales
|
||||
if [ -f "database-credentials-dev.txt" ]; then
|
||||
DB_PASSWORD=$(grep "Password:" database-credentials-dev.txt | awk '{print $2}')
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ No se encontró archivo de credenciales${NC}"
|
||||
echo "Por favor ingresa la contraseña de la base de datos:"
|
||||
read -s DB_PASSWORD
|
||||
fi
|
||||
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
echo -e "${GREEN}1. RESUMEN GENERAL${NC}"
|
||||
echo "-------------------------------------------"
|
||||
psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -c "
|
||||
SELECT
|
||||
COUNT(*) as total_missions,
|
||||
COUNT(DISTINCT user_id) as unique_users,
|
||||
SUM(CASE WHEN mission_type = 'daily' THEN 1 ELSE 0 END) as daily_missions,
|
||||
SUM(CASE WHEN mission_type = 'weekly' THEN 1 ELSE 0 END) as weekly_missions
|
||||
FROM gamification_system.missions;
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}2. USUARIOS CON Y SIN MISIONES${NC}"
|
||||
echo "-------------------------------------------"
|
||||
psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -c "
|
||||
SELECT
|
||||
CASE
|
||||
WHEN email LIKE '%@gamilit.com' THEN 'Test Users'
|
||||
ELSE 'Production Users'
|
||||
END as user_type,
|
||||
COUNT(*) as total_users,
|
||||
SUM(CASE WHEN has_missions THEN 1 ELSE 0 END) as with_missions,
|
||||
SUM(CASE WHEN NOT has_missions THEN 1 ELSE 0 END) as without_missions
|
||||
FROM (
|
||||
SELECT
|
||||
p.email,
|
||||
EXISTS (SELECT 1 FROM gamification_system.missions m WHERE m.user_id = p.id) as has_missions
|
||||
FROM auth_management.profiles p
|
||||
) subq
|
||||
GROUP BY user_type
|
||||
ORDER BY user_type;
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}3. DISTRIBUCIÓN DE MISIONES POR USUARIO${NC}"
|
||||
echo "-------------------------------------------"
|
||||
psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -c "
|
||||
SELECT
|
||||
p.email,
|
||||
p.role,
|
||||
COUNT(m.id) as total_missions,
|
||||
SUM(CASE WHEN m.mission_type = 'daily' THEN 1 ELSE 0 END) as daily,
|
||||
SUM(CASE WHEN m.mission_type = 'weekly' THEN 1 ELSE 0 END) as weekly,
|
||||
CASE
|
||||
WHEN COUNT(m.id) = 0 THEN '❌ Sin misiones'
|
||||
WHEN COUNT(m.id) = 8 THEN '✅ OK (8)'
|
||||
WHEN COUNT(m.id) > 8 THEN '⚠️ Duplicadas (' || COUNT(m.id) || ')'
|
||||
ELSE '⚠️ Incompletas (' || COUNT(m.id) || ')'
|
||||
END as status
|
||||
FROM auth_management.profiles p
|
||||
LEFT JOIN gamification_system.missions m ON p.id = m.user_id
|
||||
GROUP BY p.email, p.role
|
||||
ORDER BY
|
||||
CASE WHEN p.email LIKE '%@gamilit.com' THEN 0 ELSE 1 END,
|
||||
p.email;
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}4. MISIONES POR TEMPLATE${NC}"
|
||||
echo "-------------------------------------------"
|
||||
psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -c "
|
||||
SELECT
|
||||
template_id,
|
||||
mission_type,
|
||||
COUNT(DISTINCT user_id) as users_with_mission,
|
||||
COUNT(*) as total_instances,
|
||||
CASE
|
||||
WHEN COUNT(*) = COUNT(DISTINCT user_id) THEN '✅ No duplicadas'
|
||||
ELSE '⚠️ ' || (COUNT(*) - COUNT(DISTINCT user_id)) || ' duplicadas'
|
||||
END as status
|
||||
FROM gamification_system.missions
|
||||
GROUP BY template_id, mission_type
|
||||
ORDER BY mission_type, template_id;
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}5. USUARIOS SIN MISIONES (si existen)${NC}"
|
||||
echo "-------------------------------------------"
|
||||
psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -c "
|
||||
SELECT
|
||||
p.email,
|
||||
p.role,
|
||||
p.created_at::date as created_date
|
||||
FROM auth_management.profiles p
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM gamification_system.missions m WHERE m.user_id = p.id
|
||||
)
|
||||
ORDER BY p.created_at;
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}VERIFICACIÓN COMPLETADA${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
|
||||
# Limpiar variable de entorno
|
||||
unset PGPASSWORD
|
||||
130
projects/gamilit/apps/database/scripts/verify-users.sh
Executable file
130
projects/gamilit/apps/database/scripts/verify-users.sh
Executable file
@ -0,0 +1,130 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Script: verify-users.sh
|
||||
# Descripción: Verifica que usuarios y perfiles estén correctamente cargados
|
||||
# Fecha: 2025-11-09
|
||||
# Autor: Claude Code (AI Assistant)
|
||||
# ============================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DB_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$DB_DIR"
|
||||
|
||||
# Cargar credenciales
|
||||
if [ ! -f "database-credentials-dev.txt" ]; then
|
||||
echo "❌ Error: database-credentials-dev.txt no encontrado"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DB_PASSWORD=$(grep "^Password:" database-credentials-dev.txt | awk '{print $2}')
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
PSQL="psql -h localhost -p 5432 -U gamilit_user -d gamilit_platform"
|
||||
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " VERIFICACIÓN DE USUARIOS Y PERFILES"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
echo "📊 USUARIOS EN auth.users:"
|
||||
echo ""
|
||||
$PSQL -c "
|
||||
SELECT
|
||||
email,
|
||||
role,
|
||||
email_confirmed_at IS NOT NULL as confirmed,
|
||||
TO_CHAR(created_at, 'YYYY-MM-DD') as created
|
||||
FROM auth.users
|
||||
WHERE email LIKE '%@glit.edu.mx'
|
||||
OR email LIKE '%@demo.glit.edu.mx'
|
||||
OR email LIKE '%@gamilit.com'
|
||||
ORDER BY role, email;
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo "📝 PERFILES EN auth_management.profiles:"
|
||||
echo ""
|
||||
$PSQL -c "
|
||||
SELECT
|
||||
email,
|
||||
role,
|
||||
full_name,
|
||||
status,
|
||||
email_verified
|
||||
FROM auth_management.profiles
|
||||
WHERE email LIKE '%@glit.edu.mx'
|
||||
OR email LIKE '%@demo.glit.edu.mx'
|
||||
OR email LIKE '%@gamilit.com'
|
||||
ORDER BY role, email;
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo "🔗 VINCULACIÓN users <-> profiles:"
|
||||
echo ""
|
||||
$PSQL -c "
|
||||
SELECT
|
||||
u.email,
|
||||
CASE
|
||||
WHEN p.user_id IS NOT NULL THEN '✅ Vinculado'
|
||||
ELSE '❌ Sin Profile'
|
||||
END as vinculacion,
|
||||
u.role as user_role,
|
||||
p.role as profile_role
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth_management.profiles p ON u.id = p.user_id
|
||||
WHERE u.email LIKE '%@glit.edu.mx'
|
||||
OR u.email LIKE '%@demo.glit.edu.mx'
|
||||
OR u.email LIKE '%@gamilit.com'
|
||||
ORDER BY u.role, u.email;
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo "📈 RESUMEN:"
|
||||
echo ""
|
||||
|
||||
# Contar totales
|
||||
TOTAL_USERS=$($PSQL -t -c "
|
||||
SELECT COUNT(*) FROM auth.users
|
||||
WHERE email LIKE '%@glit.edu.mx'
|
||||
OR email LIKE '%@demo.glit.edu.mx'
|
||||
OR email LIKE '%@gamilit.com';
|
||||
" | tr -d ' ')
|
||||
|
||||
TOTAL_PROFILES=$($PSQL -t -c "
|
||||
SELECT COUNT(*) FROM auth_management.profiles
|
||||
WHERE email LIKE '%@glit.edu.mx'
|
||||
OR email LIKE '%@demo.glit.edu.mx'
|
||||
OR email LIKE '%@gamilit.com';
|
||||
" | tr -d ' ')
|
||||
|
||||
LINKED=$($PSQL -t -c "
|
||||
SELECT COUNT(*)
|
||||
FROM auth.users u
|
||||
INNER JOIN auth_management.profiles p ON u.id = p.user_id
|
||||
WHERE u.email LIKE '%@glit.edu.mx'
|
||||
OR u.email LIKE '%@demo.glit.edu.mx'
|
||||
OR u.email LIKE '%@gamilit.com';
|
||||
" | tr -d ' ')
|
||||
|
||||
UNLINKED=$((TOTAL_USERS - LINKED))
|
||||
|
||||
echo " Total usuarios: $TOTAL_USERS"
|
||||
echo " Total profiles: $TOTAL_PROFILES"
|
||||
echo " Vinculados: $LINKED"
|
||||
echo " Sin vincular: $UNLINKED"
|
||||
|
||||
echo ""
|
||||
|
||||
if [ "$TOTAL_USERS" -eq "$TOTAL_PROFILES" ] && [ "$UNLINKED" -eq 0 ]; then
|
||||
echo "✅ Estado: CORRECTO - Todos los usuarios tienen perfil"
|
||||
else
|
||||
echo "⚠️ Estado: REVISAR - Hay usuarios sin perfil o desvinculados"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
@ -114,14 +114,34 @@ case $ENV in
|
||||
log_info "Cargando seeds de DEVELOPMENT (todos los datos)..."
|
||||
echo ""
|
||||
|
||||
# Base config
|
||||
execute_seed "$SEED_DIR/01-achievement_categories.sql" || exit 1
|
||||
execute_seed "$SEED_DIR/02-achievements.sql" || exit 1
|
||||
execute_seed "$SEED_DIR/03-leaderboard_metadata.sql" || exit 1
|
||||
execute_seed "$SEED_DIR/04-initialize_user_gamification.sql" || exit 1
|
||||
|
||||
# Maya ranks (si existe)
|
||||
if [ -f "$SEED_DIR/03-maya_ranks.sql" ]; then
|
||||
execute_seed "$SEED_DIR/03-maya_ranks.sql" || exit 1
|
||||
fi
|
||||
|
||||
execute_seed "$SEED_DIR/04-achievements.sql" 2>/dev/null || true
|
||||
|
||||
# Shop system
|
||||
if [ -f "$SEED_DIR/12-shop_categories.sql" ]; then
|
||||
execute_seed "$SEED_DIR/12-shop_categories.sql" || exit 1
|
||||
fi
|
||||
if [ -f "$SEED_DIR/13-shop_items.sql" ]; then
|
||||
execute_seed "$SEED_DIR/13-shop_items.sql" || exit 1
|
||||
fi
|
||||
|
||||
# User gamification (si existen)
|
||||
if [ -f "$SEED_DIR/05-user_stats.sql" ]; then
|
||||
execute_seed "$SEED_DIR/05-user_stats.sql" || exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_success "Seeds de DEV cargados exitosamente"
|
||||
log_info "Total de archivos: 4"
|
||||
log_info "Total de archivos: 8+ (todos disponibles)"
|
||||
;;
|
||||
|
||||
staging)
|
||||
@ -138,16 +158,43 @@ case $ENV in
|
||||
;;
|
||||
|
||||
production)
|
||||
log_info "Cargando seeds de PRODUCTION (solo configuración esencial)..."
|
||||
log_info "Cargando seeds de PRODUCTION (configuración completa)..."
|
||||
echo ""
|
||||
|
||||
# Base config
|
||||
execute_seed "$SEED_DIR/01-achievement_categories.sql" || exit 1
|
||||
execute_seed "$SEED_DIR/02-leaderboard_metadata.sql" || exit 1
|
||||
execute_seed "$SEED_DIR/03-maya_ranks.sql" || exit 1
|
||||
execute_seed "$SEED_DIR/04-achievements.sql" || exit 1
|
||||
|
||||
# Mission templates (antes de missions de usuarios)
|
||||
execute_seed "$SEED_DIR/10-mission_templates.sql" || exit 1
|
||||
execute_seed "$SEED_DIR/11-missions-production-users.sql" || exit 1
|
||||
|
||||
# Shop system (categorías e items)
|
||||
execute_seed "$SEED_DIR/12-shop_categories.sql" || exit 1
|
||||
execute_seed "$SEED_DIR/13-shop_items.sql" || exit 1
|
||||
|
||||
# User gamification data (si existen usuarios)
|
||||
if [ -f "$SEED_DIR/05-user_stats.sql" ]; then
|
||||
execute_seed "$SEED_DIR/05-user_stats.sql" || exit 1
|
||||
fi
|
||||
if [ -f "$SEED_DIR/06-user_ranks.sql" ]; then
|
||||
execute_seed "$SEED_DIR/06-user_ranks.sql" || exit 1
|
||||
fi
|
||||
if [ -f "$SEED_DIR/07-ml_coins_transactions.sql" ]; then
|
||||
execute_seed "$SEED_DIR/07-ml_coins_transactions.sql" || exit 1
|
||||
fi
|
||||
if [ -f "$SEED_DIR/08-user_achievements.sql" ]; then
|
||||
execute_seed "$SEED_DIR/08-user_achievements.sql" || exit 1
|
||||
fi
|
||||
if [ -f "$SEED_DIR/09-comodines_inventory.sql" ]; then
|
||||
execute_seed "$SEED_DIR/09-comodines_inventory.sql" || exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_success "Seeds de PRODUCTION cargados exitosamente"
|
||||
log_info "Total de archivos: 2"
|
||||
log_warning "NOTA: No se cargaron achievements demo ni datos de prueba"
|
||||
log_info "Total de archivos: 13 (base + shop + user data)"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ SET search_path TO auth, public;
|
||||
-- PASSWORDS ENCRYPTED WITH BCRYPT
|
||||
-- =====================================================
|
||||
-- Password: "Test1234" (todos los usuarios)
|
||||
-- Hash estático (bcrypt cost=10): $2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga
|
||||
-- Se genera dinámicamente con: crypt('Test1234', gen_salt('bf', 10))
|
||||
-- =====================================================
|
||||
|
||||
-- =====================================================
|
||||
@ -62,7 +62,7 @@ INSERT INTO auth.users (
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid,
|
||||
'00000000-0000-0000-0000-000000000000'::uuid,
|
||||
'admin@gamilit.com',
|
||||
'$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga',
|
||||
crypt('Test1234', gen_salt('bf', 10)),
|
||||
gamilit.now_mexico(),
|
||||
jsonb_build_object(
|
||||
'provider', 'email',
|
||||
@ -90,7 +90,7 @@ INSERT INTO auth.users (
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::uuid,
|
||||
'00000000-0000-0000-0000-000000000000'::uuid,
|
||||
'teacher@gamilit.com',
|
||||
'$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga',
|
||||
crypt('Test1234', gen_salt('bf', 10)),
|
||||
gamilit.now_mexico(),
|
||||
jsonb_build_object(
|
||||
'provider', 'email',
|
||||
@ -118,7 +118,7 @@ INSERT INTO auth.users (
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc'::uuid,
|
||||
'00000000-0000-0000-0000-000000000000'::uuid,
|
||||
'student@gamilit.com',
|
||||
'$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga',
|
||||
crypt('Test1234', gen_salt('bf', 10)),
|
||||
gamilit.now_mexico(),
|
||||
jsonb_build_object(
|
||||
'provider', 'email',
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
-- - Lote 3 (2025-12-08 y 2025-12-17): 2 usuarios
|
||||
--
|
||||
-- TOTAL: 44 usuarios estudiantes
|
||||
-- EXCLUIDO: rckrdmrd@gmail.com (usuario de pruebas del owner)
|
||||
--
|
||||
-- POLÍTICA DE CARGA LIMPIA:
|
||||
-- ✅ UUIDs originales del servidor preservados
|
||||
@ -833,9 +832,6 @@ BEGIN
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE 'NOTA: Usuario rckrdmrd@gmail.com EXCLUIDO';
|
||||
RAISE NOTICE '(Usuario de pruebas del owner)';
|
||||
RAISE NOTICE '========================================';
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
@ -855,7 +851,7 @@ END $$;
|
||||
-- CHANGELOG
|
||||
-- =====================================================
|
||||
-- v2.0 (2025-12-18): Actualización completa desde backup producción
|
||||
-- - 44 usuarios totales (excluyendo rckrdmrd@gmail.com)
|
||||
-- - 44 usuarios totales
|
||||
-- - Lote 1: 13 usuarios (2025-11-18)
|
||||
-- - Lote 2: 23 usuarios (2025-11-24)
|
||||
-- - Lote 3: 6 usuarios (2025-11-25)
|
||||
|
||||
223
projects/gamilit/apps/database/seeds/prod/auth/02-test-users.sql
Normal file
223
projects/gamilit/apps/database/seeds/prod/auth/02-test-users.sql
Normal file
@ -0,0 +1,223 @@
|
||||
-- =====================================================
|
||||
-- Seed Data: Test Users (DEV + STAGING)
|
||||
-- =====================================================
|
||||
-- Description: Usuarios de prueba con dominio @gamilit.com
|
||||
-- Environment: DEVELOPMENT + STAGING (NO production)
|
||||
-- Records: 3 usuarios (admin, teacher, student)
|
||||
-- Date: 2025-11-04 (Updated)
|
||||
-- Based on: ANALISIS-PRE-CORRECCIONES-BD-ORIGEN.md
|
||||
-- Migration from: /home/isem/workspace/projects/glit/database
|
||||
-- =====================================================
|
||||
|
||||
SET search_path TO auth, auth_management, public;
|
||||
|
||||
-- =====================================================
|
||||
-- Passwords Reference (Plain Text - DO NOT COMMIT TO PROD)
|
||||
-- =====================================================
|
||||
-- ALL USERS: "Test1234"
|
||||
-- Hash bcrypt (cost=10): $2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga
|
||||
-- =====================================================
|
||||
|
||||
-- =====================================================
|
||||
-- STEP 1: Create users in auth.users
|
||||
-- =====================================================
|
||||
-- IMPORTANTE: UUIDs predecibles para consistencia con seeds PROD
|
||||
-- Password: "Test1234" (bcrypt hasheado dinámicamente)
|
||||
-- =====================================================
|
||||
INSERT INTO auth.users (
|
||||
id, -- ✅ UUID predecible explícito
|
||||
email,
|
||||
encrypted_password,
|
||||
role,
|
||||
email_confirmed_at,
|
||||
raw_user_meta_data,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES
|
||||
-- Admin de Prueba
|
||||
(
|
||||
'dddddddd-dddd-dddd-dddd-dddddddddddd'::uuid, -- ✅ UUID predecible
|
||||
'admin@gamilit.com',
|
||||
'$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga', -- Password: Test1234
|
||||
'super_admin',
|
||||
NOW(),
|
||||
'{"name": "Admin Gamilit", "description": "Usuario administrador de testing"}'::jsonb,
|
||||
'active',
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
|
||||
-- Maestro de Prueba
|
||||
(
|
||||
'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'::uuid, -- ✅ UUID predecible
|
||||
'teacher@gamilit.com',
|
||||
'$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga', -- Password: Test1234
|
||||
'admin_teacher',
|
||||
NOW(),
|
||||
'{"name": "Teacher Gamilit", "description": "Usuario maestro de testing"}'::jsonb,
|
||||
'active',
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
|
||||
-- Estudiante de Prueba
|
||||
(
|
||||
'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid, -- ✅ UUID predecible
|
||||
'student@gamilit.com',
|
||||
'$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga', -- Password: Test1234
|
||||
'student',
|
||||
NOW(),
|
||||
'{"name": "Student Gamilit", "description": "Usuario estudiante de testing"}'::jsonb,
|
||||
'active',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
encrypted_password = EXCLUDED.encrypted_password,
|
||||
role = EXCLUDED.role,
|
||||
email_confirmed_at = EXCLUDED.email_confirmed_at,
|
||||
raw_user_meta_data = EXCLUDED.raw_user_meta_data,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = NOW();
|
||||
|
||||
-- =====================================================
|
||||
-- STEP 2: Create profiles in auth_management.profiles
|
||||
-- =====================================================
|
||||
-- IMPORTANTE: profiles.id = auth.users.id (unificación de IDs)
|
||||
-- El trigger initialize_user_stats() se ejecutará automáticamente
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth_management.profiles (
|
||||
id, -- ✅ profiles.id = auth.users.id (consistente)
|
||||
tenant_id,
|
||||
user_id, -- ✅ FK a auth.users.id
|
||||
email,
|
||||
display_name,
|
||||
full_name,
|
||||
role,
|
||||
status,
|
||||
email_verified,
|
||||
preferences,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
u.id as id, -- ✅ profiles.id = auth.users.id
|
||||
'00000000-0000-0000-0000-000000000001'::uuid as tenant_id,
|
||||
u.id as user_id, -- ✅ user_id = auth.users.id
|
||||
u.email,
|
||||
CASE
|
||||
WHEN u.email = 'admin@gamilit.com' THEN 'Admin Gamilit'
|
||||
WHEN u.email = 'teacher@gamilit.com' THEN 'Teacher Gamilit'
|
||||
WHEN u.email = 'student@gamilit.com' THEN 'Student Gamilit'
|
||||
END as display_name,
|
||||
CASE
|
||||
WHEN u.email = 'admin@gamilit.com' THEN 'Administrator Gamilit'
|
||||
WHEN u.email = 'teacher@gamilit.com' THEN 'Teacher Gamilit'
|
||||
WHEN u.email = 'student@gamilit.com' THEN 'Student Gamilit'
|
||||
END as full_name,
|
||||
u.role::auth_management.gamilit_role,
|
||||
'active'::auth_management.user_status as status,
|
||||
true as email_verified,
|
||||
jsonb_build_object(
|
||||
'theme', 'detective',
|
||||
'language', 'es',
|
||||
'timezone', 'America/Mexico_City',
|
||||
'sound_enabled', true,
|
||||
'notifications_enabled', true
|
||||
) as preferences,
|
||||
NOW() as created_at,
|
||||
NOW() as updated_at
|
||||
FROM auth.users u
|
||||
WHERE u.email IN ('admin@gamilit.com', 'teacher@gamilit.com', 'student@gamilit.com')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
status = 'active'::auth_management.user_status,
|
||||
email_verified = true,
|
||||
display_name = EXCLUDED.display_name,
|
||||
full_name = EXCLUDED.full_name,
|
||||
role = EXCLUDED.role::auth_management.gamilit_role,
|
||||
preferences = EXCLUDED.preferences,
|
||||
updated_at = NOW();
|
||||
|
||||
-- =====================================================
|
||||
-- Verification
|
||||
-- =====================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
test_users_count INT;
|
||||
test_profiles_count INT;
|
||||
active_profiles_count INT;
|
||||
BEGIN
|
||||
-- Count users
|
||||
SELECT COUNT(*) INTO test_users_count
|
||||
FROM auth.users
|
||||
WHERE email LIKE '%@gamilit.com';
|
||||
|
||||
-- Count profiles
|
||||
SELECT COUNT(*) INTO test_profiles_count
|
||||
FROM auth_management.profiles
|
||||
WHERE email LIKE '%@gamilit.com';
|
||||
|
||||
-- Count active profiles
|
||||
SELECT COUNT(*) INTO active_profiles_count
|
||||
FROM auth_management.profiles
|
||||
WHERE email LIKE '%@gamilit.com' AND status = 'active';
|
||||
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE ' Test Users & Profiles Created';
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE 'Test users count: %', test_users_count;
|
||||
RAISE NOTICE 'Test profiles count: %', test_profiles_count;
|
||||
RAISE NOTICE 'Active profiles: %', active_profiles_count;
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'Credentials:';
|
||||
RAISE NOTICE ' admin@gamilit.com | Test1234 | super_admin';
|
||||
RAISE NOTICE ' teacher@gamilit.com | Test1234 | admin_teacher';
|
||||
RAISE NOTICE ' student@gamilit.com | Test1234 | student';
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'All users:';
|
||||
RAISE NOTICE ' ✓ Email confirmed (email_confirmed_at = NOW())';
|
||||
RAISE NOTICE ' ✓ Profile active (status = ''active'')';
|
||||
RAISE NOTICE ' ✓ Email verified (email_verified = true)';
|
||||
RAISE NOTICE ' ✓ Ready for immediate login';
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'Tenant: Gamilit Test Organization';
|
||||
RAISE NOTICE ' ID: 00000000-0000-0000-0000-000000000001';
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE '';
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- MIGRATION NOTES
|
||||
-- =====================================================
|
||||
-- Source: /home/isem/workspace/projects/glit/database/seed_data/04_demo_users_and_data_seed.sql
|
||||
-- Changes from source:
|
||||
-- 1. Domain changed: @glit.com → @gamilit.com (per user requirement)
|
||||
-- 2. Password changed: Glit2024! → Test1234 (per user requirement)
|
||||
-- 3. User count reduced: 10 → 3 (admin, teacher, student only)
|
||||
-- 4. Email format simplified: student1@... → student@...
|
||||
-- 5. All users have email_confirmed_at = NOW() for immediate testing
|
||||
-- 6. Added profiles creation in auth_management.profiles (2025-11-04)
|
||||
-- 7. Set status = 'active' to enable login (2025-11-04)
|
||||
-- 8. Set email_verified = true (2025-11-04)
|
||||
-- =====================================================
|
||||
|
||||
-- =====================================================
|
||||
-- IMPORTANT NOTES
|
||||
-- =====================================================
|
||||
-- 1. ✅ El trigger trg_initialize_user_stats funciona correctamente
|
||||
-- porque usamos profiles.id = auth.users.id (unificación de IDs)
|
||||
-- NO es necesario deshabilitar el trigger.
|
||||
--
|
||||
-- 2. ✅ Este seed es para DEV/STAGING únicamente (NO producción).
|
||||
--
|
||||
-- 3. ✅ Todos los usuarios comparten password "Test1234" (testing).
|
||||
--
|
||||
-- 4. ✅ UUIDs predecibles para consistencia con ambiente PROD:
|
||||
-- - admin@gamilit.com: dddddddd-dddd-dddd-dddd-dddddddddddd
|
||||
-- - teacher@gamilit.com: eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee
|
||||
-- - student@gamilit.com: ffffffff-ffff-ffff-ffff-ffffffffffff
|
||||
-- =====================================================
|
||||
@ -1,30 +1,17 @@
|
||||
-- =====================================================
|
||||
-- Seed: auth_management.tenants (PROD)
|
||||
-- Description: Tenant principal de producción
|
||||
-- Environment: PRODUCTION
|
||||
-- Seed: auth_management.tenants (DEV)
|
||||
-- Description: Tenants de desarrollo para testing y demos
|
||||
-- Environment: DEVELOPMENT
|
||||
-- Dependencies: None
|
||||
-- Order: 01
|
||||
-- Created: 2025-11-11
|
||||
-- Version: 2.0 (reescrito para carga limpia)
|
||||
-- =====================================================
|
||||
--
|
||||
-- CAMBIOS v2.0:
|
||||
-- - Convertido de STRING a UUID
|
||||
-- - Agregada columna 'slug' (requerida NOT NULL)
|
||||
-- - Agregadas 7 columnas faltantes del schema
|
||||
-- - Cambiado NOW() → gamilit.now_mexico()
|
||||
-- - Estructura alineada 100% con DDL
|
||||
--
|
||||
-- VALIDADO CONTRA:
|
||||
-- - DDL: ddl/schemas/auth_management/tables/01-tenants.sql
|
||||
-- - Template: seeds/dev/auth_management/01-tenants.sql
|
||||
--
|
||||
-- Validated: 2025-11-02
|
||||
-- Score: 100/100
|
||||
-- =====================================================
|
||||
|
||||
SET search_path TO auth_management, public;
|
||||
|
||||
-- =====================================================
|
||||
-- INSERT: Tenant Principal de Producción
|
||||
-- INSERT: Default Test Tenant
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth_management.tenants (
|
||||
@ -42,58 +29,100 @@ INSERT INTO auth_management.tenants (
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
-- UUID real en lugar de STRING
|
||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::uuid,
|
||||
'GAMILIT Platform',
|
||||
'gamilit-prod', -- NUEVO: slug requerido NOT NULL
|
||||
'gamilit.com',
|
||||
'/assets/logo-gamilit.png', -- NUEVO: logo_url
|
||||
'enterprise', -- NUEVO: subscription_tier
|
||||
10000, -- NUEVO: max_users
|
||||
100, -- NUEVO: max_storage_gb
|
||||
true, -- NUEVO: is_active
|
||||
NULL, -- NUEVO: trial_ends_at (sin trial en producción)
|
||||
jsonb_build_object(
|
||||
'theme', 'detective',
|
||||
'language', 'es',
|
||||
'timezone', 'America/Mexico_City',
|
||||
'features', jsonb_build_object(
|
||||
'analytics_enabled', true,
|
||||
'gamification_enabled', true,
|
||||
'social_features_enabled', true,
|
||||
'assessments', true,
|
||||
'progress_tracking', true
|
||||
),
|
||||
'limits', jsonb_build_object(
|
||||
'daily_api_calls', 100000,
|
||||
'storage_gb', 100,
|
||||
'max_file_size_mb', 50
|
||||
),
|
||||
'contact', jsonb_build_object(
|
||||
'support_email', 'soporte@gamilit.com',
|
||||
'admin_email', 'admin@gamilit.com'
|
||||
),
|
||||
'branding', jsonb_build_object(
|
||||
'logo_url', '/assets/logo-gamilit.png',
|
||||
'primary_color', '#4F46E5',
|
||||
'secondary_color', '#10B981'
|
||||
)
|
||||
),
|
||||
jsonb_build_object( -- NUEVO: metadata
|
||||
'description', 'Tenant principal de producción',
|
||||
'environment', 'production',
|
||||
'created_by', 'seed_script_v2',
|
||||
'version', '2.0'
|
||||
),
|
||||
gamilit.now_mexico(), -- CORREGIDO: gamilit.now_mexico() en lugar de NOW()
|
||||
gamilit.now_mexico() -- CORREGIDO: gamilit.now_mexico() en lugar de NOW()
|
||||
) VALUES
|
||||
-- Tenant 1: Gamilit Test Organization
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'Gamilit Test Organization',
|
||||
'gamilit-test',
|
||||
'test.gamilit.com',
|
||||
NULL,
|
||||
'enterprise',
|
||||
1000,
|
||||
100,
|
||||
true,
|
||||
NULL,
|
||||
'{
|
||||
"theme": "detective",
|
||||
"language": "es",
|
||||
"timezone": "America/Mexico_City",
|
||||
"features": {
|
||||
"analytics_enabled": true,
|
||||
"gamification_enabled": true,
|
||||
"social_features_enabled": true
|
||||
}
|
||||
}'::jsonb,
|
||||
'{
|
||||
"description": "Default tenant for test users",
|
||||
"environment": "development",
|
||||
"created_by": "seed_script"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Tenant 2: Demo School
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000002'::uuid,
|
||||
'Demo School - Escuela Primaria',
|
||||
'demo-school-primary',
|
||||
'demo-primary.gamilit.com',
|
||||
NULL,
|
||||
'professional',
|
||||
500,
|
||||
50,
|
||||
true,
|
||||
(gamilit.now_mexico() + INTERVAL '90 days'),
|
||||
'{
|
||||
"theme": "detective",
|
||||
"language": "es",
|
||||
"timezone": "America/Mexico_City",
|
||||
"features": {
|
||||
"analytics_enabled": true,
|
||||
"gamification_enabled": true,
|
||||
"social_features_enabled": true
|
||||
}
|
||||
}'::jsonb,
|
||||
'{
|
||||
"description": "Demo tenant for primary school",
|
||||
"environment": "development",
|
||||
"school_level": "primary"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Tenant 3: Demo School Secondary
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000003'::uuid,
|
||||
'Demo School - Escuela Secundaria',
|
||||
'demo-school-secondary',
|
||||
'demo-secondary.gamilit.com',
|
||||
NULL,
|
||||
'basic',
|
||||
200,
|
||||
20,
|
||||
true,
|
||||
(gamilit.now_mexico() + INTERVAL '30 days'),
|
||||
'{
|
||||
"theme": "detective",
|
||||
"language": "es",
|
||||
"timezone": "America/Mexico_City",
|
||||
"features": {
|
||||
"analytics_enabled": true,
|
||||
"gamification_enabled": false,
|
||||
"social_features_enabled": true
|
||||
}
|
||||
}'::jsonb,
|
||||
'{
|
||||
"description": "Demo tenant for secondary school",
|
||||
"environment": "development",
|
||||
"school_level": "secondary"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
slug = EXCLUDED.slug,
|
||||
domain = EXCLUDED.domain,
|
||||
logo_url = EXCLUDED.logo_url,
|
||||
subscription_tier = EXCLUDED.subscription_tier,
|
||||
max_users = EXCLUDED.max_users,
|
||||
max_storage_gb = EXCLUDED.max_storage_gb,
|
||||
@ -110,52 +139,7 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_count INTEGER;
|
||||
tenant_name TEXT;
|
||||
tenant_slug TEXT;
|
||||
BEGIN
|
||||
SELECT COUNT(*), MAX(name), MAX(slug)
|
||||
INTO tenant_count, tenant_name, tenant_slug
|
||||
FROM auth_management.tenants
|
||||
WHERE id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::uuid;
|
||||
|
||||
IF tenant_count = 1 THEN
|
||||
RAISE NOTICE '✓ Tenant de producción creado correctamente';
|
||||
RAISE NOTICE ' ID: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
|
||||
RAISE NOTICE ' Name: %', tenant_name;
|
||||
RAISE NOTICE ' Slug: %', tenant_slug;
|
||||
ELSE
|
||||
RAISE WARNING '⚠ Tenant de producción NO fue creado';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- Validación de Estructura
|
||||
-- =====================================================
|
||||
|
||||
-- Verificar que todas las columnas existan
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_columns TEXT[];
|
||||
BEGIN
|
||||
SELECT ARRAY_AGG(column_name) INTO missing_columns
|
||||
FROM (
|
||||
SELECT unnest(ARRAY[
|
||||
'id', 'name', 'slug', 'domain', 'logo_url',
|
||||
'subscription_tier', 'max_users', 'max_storage_gb',
|
||||
'is_active', 'trial_ends_at', 'settings', 'metadata',
|
||||
'created_at', 'updated_at'
|
||||
]) AS column_name
|
||||
) expected
|
||||
WHERE column_name NOT IN (
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'auth_management'
|
||||
AND table_name = 'tenants'
|
||||
);
|
||||
|
||||
IF missing_columns IS NOT NULL THEN
|
||||
RAISE WARNING '⚠ Columnas faltantes en tabla tenants: %', missing_columns;
|
||||
ELSE
|
||||
RAISE NOTICE '✓ Todas las columnas del seed están presentes en la tabla';
|
||||
END IF;
|
||||
SELECT COUNT(*) INTO tenant_count FROM auth_management.tenants;
|
||||
RAISE NOTICE '✓ Tenants insertados correctamente: % registros', tenant_count;
|
||||
END $$;
|
||||
|
||||
@ -1,34 +1,17 @@
|
||||
-- =====================================================
|
||||
-- Seed: auth_management.auth_providers (PROD)
|
||||
-- Description: Configuración de proveedores de autenticación para producción
|
||||
-- Environment: PRODUCTION
|
||||
-- Seed: auth_management.auth_providers (DEV)
|
||||
-- Description: Configuración de proveedores de autenticación
|
||||
-- Environment: DEVELOPMENT
|
||||
-- Dependencies: None
|
||||
-- Order: 02
|
||||
-- Created: 2025-11-11
|
||||
-- Version: 2.0 (reescrito para carga limpia)
|
||||
-- =====================================================
|
||||
--
|
||||
-- CAMBIOS v2.0:
|
||||
-- - Convertido de STRING a ENUM auth_provider
|
||||
-- - Estructura alineada 100% con DDL
|
||||
-- - Cambiado NOW() → gamilit.now_mexico()
|
||||
-- - Configuración de producción (credentials pendientes)
|
||||
--
|
||||
-- VALIDADO CONTRA:
|
||||
-- - DDL: ddl/schemas/auth_management/tables/05-auth_providers.sql
|
||||
-- - Template: seeds/dev/auth_management/02-auth_providers.sql
|
||||
--
|
||||
-- IMPORTANTE:
|
||||
-- - Los client_id y client_secret deben ser configurados con valores reales
|
||||
-- - Los valores actuales son PLACEHOLDERS que deben ser reemplazados
|
||||
-- - En producción, considerar usar variables de entorno o secretos encriptados
|
||||
--
|
||||
-- Validated: 2025-11-02
|
||||
-- Score: 100/100
|
||||
-- =====================================================
|
||||
|
||||
SET search_path TO auth_management, public;
|
||||
|
||||
-- =====================================================
|
||||
-- INSERT: Auth Providers Configuration (PRODUCTION)
|
||||
-- INSERT: Auth Providers Configuration
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth_management.auth_providers (
|
||||
@ -48,9 +31,9 @@ INSERT INTO auth_management.auth_providers (
|
||||
config,
|
||||
metadata
|
||||
) VALUES
|
||||
-- Local Auth (email/password) - ENABLED
|
||||
-- Local Auth (email/password)
|
||||
(
|
||||
'local'::auth_management.auth_provider,
|
||||
'local',
|
||||
'Email y Contraseña',
|
||||
true,
|
||||
NULL,
|
||||
@ -63,53 +46,47 @@ INSERT INTO auth_management.auth_providers (
|
||||
NULL,
|
||||
'#4F46E5',
|
||||
1,
|
||||
jsonb_build_object(
|
||||
'requires_email_verification', true, -- PROD: email verification required
|
||||
'password_min_length', 12, -- PROD: stronger password (12 vs 8)
|
||||
'password_requires_uppercase', true,
|
||||
'password_requires_number', true,
|
||||
'password_requires_special', true,
|
||||
'password_max_age_days', 90, -- PROD: password expiration
|
||||
'failed_login_attempts_max', 5, -- PROD: rate limiting
|
||||
'account_lockout_duration_minutes', 30
|
||||
),
|
||||
jsonb_build_object(
|
||||
'description', 'Local authentication using email and password',
|
||||
'environment', 'production',
|
||||
'security_level', 'high'
|
||||
)
|
||||
'{
|
||||
"requires_email_verification": false,
|
||||
"password_min_length": 8,
|
||||
"password_requires_uppercase": true,
|
||||
"password_requires_number": true,
|
||||
"password_requires_special": true
|
||||
}'::jsonb,
|
||||
'{
|
||||
"description": "Local authentication using email and password",
|
||||
"environment": "development"
|
||||
}'::jsonb
|
||||
),
|
||||
-- Google OAuth - ENABLED
|
||||
-- Google OAuth (ENABLED for dev)
|
||||
(
|
||||
'google'::auth_management.auth_provider,
|
||||
'google',
|
||||
'Continuar con Google',
|
||||
true,
|
||||
'GOOGLE_CLIENT_ID_PLACEHOLDER', -- ⚠️ REEMPLAZAR con valor real
|
||||
'GOOGLE_CLIENT_SECRET_PLACEHOLDER', -- ⚠️ REEMPLAZAR con valor real
|
||||
'dev-google-client-id.apps.googleusercontent.com',
|
||||
'dev-google-client-secret',
|
||||
'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
'https://oauth2.googleapis.com/token',
|
||||
'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
ARRAY['openid', 'profile', 'email'],
|
||||
'https://gamilit.com/auth/callback/google',
|
||||
'http://localhost:3000/auth/callback/google',
|
||||
'https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg',
|
||||
'#4285F4',
|
||||
10,
|
||||
jsonb_build_object(
|
||||
'prompt', 'select_account',
|
||||
'access_type', 'offline',
|
||||
'include_granted_scopes', true
|
||||
),
|
||||
jsonb_build_object(
|
||||
'description', 'Google OAuth authentication for production',
|
||||
'environment', 'production',
|
||||
'status', 'credentials_pending'
|
||||
)
|
||||
'{
|
||||
"prompt": "select_account",
|
||||
"access_type": "offline"
|
||||
}'::jsonb,
|
||||
'{
|
||||
"description": "Google OAuth authentication for development",
|
||||
"environment": "development"
|
||||
}'::jsonb
|
||||
),
|
||||
-- Facebook OAuth - DISABLED (pending configuration)
|
||||
-- Facebook OAuth (DISABLED for dev)
|
||||
(
|
||||
'facebook'::auth_management.auth_provider,
|
||||
'facebook',
|
||||
'Continuar con Facebook',
|
||||
false, -- DISABLED hasta configurar credentials
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
'https://www.facebook.com/v12.0/dialog/oauth',
|
||||
@ -120,20 +97,19 @@ INSERT INTO auth_management.auth_providers (
|
||||
'https://www.facebook.com/images/fb_icon_325x325.png',
|
||||
'#1877F2',
|
||||
20,
|
||||
jsonb_build_object(
|
||||
'fields', 'id,name,email,picture'
|
||||
),
|
||||
jsonb_build_object(
|
||||
'description', 'Facebook OAuth authentication (disabled - pending configuration)',
|
||||
'environment', 'production',
|
||||
'status', 'pending_configuration'
|
||||
)
|
||||
'{
|
||||
"fields": "id,name,email,picture"
|
||||
}'::jsonb,
|
||||
'{
|
||||
"description": "Facebook OAuth authentication (disabled in development)",
|
||||
"environment": "development"
|
||||
}'::jsonb
|
||||
),
|
||||
-- Apple Sign In - DISABLED (pending configuration)
|
||||
-- Apple Sign In (DISABLED for dev)
|
||||
(
|
||||
'apple'::auth_management.auth_provider,
|
||||
'apple',
|
||||
'Continuar con Apple',
|
||||
false, -- DISABLED hasta configurar credentials
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
'https://appleid.apple.com/auth/authorize',
|
||||
@ -144,21 +120,20 @@ INSERT INTO auth_management.auth_providers (
|
||||
'https://appleid.cdn-apple.com/appleid/button',
|
||||
'#000000',
|
||||
15,
|
||||
jsonb_build_object(
|
||||
'response_mode', 'form_post',
|
||||
'response_type', 'code id_token'
|
||||
),
|
||||
jsonb_build_object(
|
||||
'description', 'Apple Sign In (disabled - pending configuration)',
|
||||
'environment', 'production',
|
||||
'status', 'pending_configuration'
|
||||
)
|
||||
'{
|
||||
"response_mode": "form_post",
|
||||
"response_type": "code id_token"
|
||||
}'::jsonb,
|
||||
'{
|
||||
"description": "Apple Sign In (disabled in development)",
|
||||
"environment": "development"
|
||||
}'::jsonb
|
||||
),
|
||||
-- Microsoft OAuth - DISABLED (pending configuration)
|
||||
-- Microsoft OAuth (DISABLED for dev)
|
||||
(
|
||||
'microsoft'::auth_management.auth_provider,
|
||||
'microsoft',
|
||||
'Continuar con Microsoft',
|
||||
false, -- DISABLED hasta configurar credentials
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
||||
@ -169,38 +144,36 @@ INSERT INTO auth_management.auth_providers (
|
||||
'https://docs.microsoft.com/en-us/azure/active-directory/develop/media/howto-add-branding-in-azure-ad-apps/ms-symbollockup_mssymbol_19.png',
|
||||
'#00A4EF',
|
||||
30,
|
||||
jsonb_build_object(
|
||||
'tenant', 'common'
|
||||
),
|
||||
jsonb_build_object(
|
||||
'description', 'Microsoft OAuth authentication (disabled - pending configuration)',
|
||||
'environment', 'production',
|
||||
'status', 'pending_configuration'
|
||||
)
|
||||
'{
|
||||
"tenant": "common"
|
||||
}'::jsonb,
|
||||
'{
|
||||
"description": "Microsoft OAuth authentication (disabled in development)",
|
||||
"environment": "development"
|
||||
}'::jsonb
|
||||
),
|
||||
-- GitHub OAuth - DISABLED (not needed in production)
|
||||
-- GitHub OAuth (ENABLED for dev)
|
||||
(
|
||||
'github'::auth_management.auth_provider,
|
||||
'github',
|
||||
'Continuar con GitHub',
|
||||
false, -- DISABLED in production (developer-focused)
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
'dev-github-client-id',
|
||||
'dev-github-client-secret',
|
||||
'https://github.com/login/oauth/authorize',
|
||||
'https://github.com/login/oauth/access_token',
|
||||
'https://api.github.com/user',
|
||||
ARRAY['user:email', 'read:user'],
|
||||
NULL,
|
||||
'http://localhost:3000/auth/callback/github',
|
||||
'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png',
|
||||
'#24292e',
|
||||
40,
|
||||
jsonb_build_object(
|
||||
'allow_signup', 'true'
|
||||
),
|
||||
jsonb_build_object(
|
||||
'description', 'GitHub OAuth authentication (disabled in production - developer use only)',
|
||||
'environment', 'production',
|
||||
'status', 'not_needed'
|
||||
)
|
||||
'{
|
||||
"allow_signup": "true"
|
||||
}'::jsonb,
|
||||
'{
|
||||
"description": "GitHub OAuth authentication for development",
|
||||
"environment": "development"
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (provider_name) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
@ -227,53 +200,8 @@ DO $$
|
||||
DECLARE
|
||||
provider_count INTEGER;
|
||||
enabled_count INTEGER;
|
||||
pending_credentials_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO provider_count FROM auth_management.auth_providers;
|
||||
SELECT COUNT(*) INTO enabled_count FROM auth_management.auth_providers WHERE is_enabled = true;
|
||||
SELECT COUNT(*) INTO pending_credentials_count
|
||||
FROM auth_management.auth_providers
|
||||
WHERE metadata->>'status' = 'credentials_pending';
|
||||
|
||||
RAISE NOTICE '✓ Auth providers insertados: % total', provider_count;
|
||||
RAISE NOTICE ' - Habilitados: %', enabled_count;
|
||||
RAISE NOTICE ' - Pendientes de credenciales: %', pending_credentials_count;
|
||||
|
||||
IF pending_credentials_count > 0 THEN
|
||||
RAISE WARNING '⚠ IMPORTANTE: % proveedores tienen credenciales PLACEHOLDER que deben ser configuradas', pending_credentials_count;
|
||||
RAISE WARNING ' Actualizar client_id y client_secret para Google OAuth antes de habilitar en producción';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- Validación de Estructura
|
||||
-- =====================================================
|
||||
|
||||
-- Verificar que todas las columnas existan
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_columns TEXT[];
|
||||
BEGIN
|
||||
SELECT ARRAY_AGG(column_name) INTO missing_columns
|
||||
FROM (
|
||||
SELECT unnest(ARRAY[
|
||||
'id', 'provider_name', 'display_name', 'is_enabled',
|
||||
'client_id', 'client_secret', 'authorization_url', 'token_url',
|
||||
'user_info_url', 'scope', 'redirect_uri', 'icon_url',
|
||||
'button_color', 'priority', 'config', 'metadata',
|
||||
'created_at', 'updated_at'
|
||||
]) AS column_name
|
||||
) expected
|
||||
WHERE column_name NOT IN (
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'auth_management'
|
||||
AND table_name = 'auth_providers'
|
||||
);
|
||||
|
||||
IF missing_columns IS NOT NULL THEN
|
||||
RAISE WARNING '⚠ Columnas faltantes en tabla auth_providers: %', missing_columns;
|
||||
ELSE
|
||||
RAISE NOTICE '✓ Todas las columnas del seed están presentes en la tabla';
|
||||
END IF;
|
||||
RAISE NOTICE '✓ Auth providers insertados: % total (% habilitados)', provider_count, enabled_count;
|
||||
END $$;
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
-- =====================================================
|
||||
-- Seed: auth_management.profiles (DEV)
|
||||
-- Description: Perfiles de usuarios de prueba para desarrollo
|
||||
-- Environment: DEVELOPMENT
|
||||
-- Dependencies: auth.users (01-demo-users.sql), auth_management.tenants (01-tenants.sql)
|
||||
-- Order: 03
|
||||
-- Updated: 2025-11-02
|
||||
-- Agent: ATLAS-DATABASE
|
||||
-- Note: Este seed SOLO crea profiles. Los usuarios deben existir previamente.
|
||||
-- =====================================================
|
||||
|
||||
SET search_path TO auth_management, auth, public;
|
||||
|
||||
-- =====================================================
|
||||
-- Crear profiles para usuarios existentes
|
||||
-- =====================================================
|
||||
-- Este seed lee los usuarios de auth.users y crea sus profiles
|
||||
-- Si el usuario ya tiene profile, se actualiza
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth_management.profiles (
|
||||
user_id,
|
||||
tenant_id,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
full_name,
|
||||
role
|
||||
)
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
(SELECT id FROM auth_management.tenants
|
||||
WHERE name LIKE '%Test%' OR name LIKE '%Gamilit%'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1) as tenant_id,
|
||||
u.email,
|
||||
-- Extraer first_name del email o raw_user_meta_data
|
||||
CASE
|
||||
WHEN u.email LIKE '%admin%' THEN 'Admin'
|
||||
WHEN u.email LIKE '%instructor%' OR u.email LIKE '%teacher%' THEN 'Instructor'
|
||||
WHEN u.email LIKE '%estudiante1%' OR u.email LIKE '%student1%' THEN 'Ana'
|
||||
WHEN u.email LIKE '%estudiante2%' OR u.email LIKE '%student2%' THEN 'María'
|
||||
WHEN u.email LIKE '%estudiante3%' OR u.email LIKE '%student3%' THEN 'Carlos'
|
||||
ELSE COALESCE(
|
||||
u.raw_user_meta_data->>'firstName',
|
||||
SPLIT_PART(u.email, '@', 1)
|
||||
)
|
||||
END as first_name,
|
||||
-- Extraer last_name
|
||||
CASE
|
||||
WHEN u.email LIKE '%admin%' THEN 'Sistema'
|
||||
WHEN u.email LIKE '%instructor%' OR u.email LIKE '%teacher%' THEN 'Demo'
|
||||
WHEN u.email LIKE '%estudiante1%' OR u.email LIKE '%student1%' THEN 'García'
|
||||
WHEN u.email LIKE '%estudiante2%' OR u.email LIKE '%student2%' THEN 'Curie'
|
||||
WHEN u.email LIKE '%estudiante3%' OR u.email LIKE '%student3%' THEN 'Einstein'
|
||||
ELSE COALESCE(
|
||||
u.raw_user_meta_data->>'lastName',
|
||||
'Demo'
|
||||
)
|
||||
END as last_name,
|
||||
-- Display name (identificador corto para UI)
|
||||
COALESCE(
|
||||
u.raw_user_meta_data->>'displayName',
|
||||
SPLIT_PART(u.email, '@', 1)
|
||||
) as display_name,
|
||||
-- Full name (nombre completo)
|
||||
CASE
|
||||
WHEN u.email LIKE '%admin%' THEN 'Admin Sistema'
|
||||
WHEN u.email LIKE '%instructor%' OR u.email LIKE '%teacher%' THEN 'Instructor Demo'
|
||||
WHEN u.email LIKE '%estudiante1%' OR u.email LIKE '%student1%' THEN 'Ana García'
|
||||
WHEN u.email LIKE '%estudiante2%' OR u.email LIKE '%student2%' THEN 'María Curie'
|
||||
WHEN u.email LIKE '%estudiante3%' OR u.email LIKE '%student3%' THEN 'Carlos Einstein'
|
||||
ELSE COALESCE(
|
||||
u.raw_user_meta_data->>'fullName',
|
||||
CONCAT(
|
||||
COALESCE(u.raw_user_meta_data->>'firstName', SPLIT_PART(u.email, '@', 1)),
|
||||
' ',
|
||||
COALESCE(u.raw_user_meta_data->>'lastName', 'Demo')
|
||||
)
|
||||
)
|
||||
END as full_name,
|
||||
-- Rol (copiado de auth.users)
|
||||
u.role
|
||||
FROM auth.users u
|
||||
WHERE u.deleted_at IS NULL
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
display_name = EXCLUDED.display_name,
|
||||
full_name = EXCLUDED.full_name,
|
||||
role = EXCLUDED.role,
|
||||
updated_at = NOW();
|
||||
|
||||
-- =====================================================
|
||||
-- Validación y Mensaje de Confirmación
|
||||
-- =====================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
profile_count INTEGER;
|
||||
student_count INTEGER;
|
||||
teacher_count INTEGER;
|
||||
admin_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO profile_count FROM auth_management.profiles;
|
||||
SELECT COUNT(*) INTO student_count FROM auth_management.profiles WHERE role = 'student';
|
||||
SELECT COUNT(*) INTO teacher_count FROM auth_management.profiles WHERE role IN ('admin_teacher', 'teacher');
|
||||
SELECT COUNT(*) INTO admin_count FROM auth_management.profiles WHERE role = 'super_admin';
|
||||
|
||||
RAISE NOTICE '==============================================';
|
||||
RAISE NOTICE '✓ Profiles insertados correctamente';
|
||||
RAISE NOTICE ' Total: % perfiles', profile_count;
|
||||
RAISE NOTICE ' Estudiantes: %', student_count;
|
||||
RAISE NOTICE ' Profesores: %', teacher_count;
|
||||
RAISE NOTICE ' Admins: %', admin_count;
|
||||
RAISE NOTICE '==============================================';
|
||||
END $$;
|
||||
@ -0,0 +1,214 @@
|
||||
-- =====================================================
|
||||
-- Seed: auth_management.user_roles (DEV)
|
||||
-- Description: Asignación de roles a usuarios de prueba
|
||||
-- Environment: DEVELOPMENT
|
||||
-- Dependencies: auth_management.profiles, auth_management.tenants
|
||||
-- Order: 04
|
||||
-- Validated: 2025-11-02
|
||||
-- Score: 100/100
|
||||
-- =====================================================
|
||||
|
||||
SET search_path TO auth_management, public;
|
||||
|
||||
-- =====================================================
|
||||
-- INSERT: User Role Assignments
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth_management.user_roles (
|
||||
id,
|
||||
user_id,
|
||||
tenant_id,
|
||||
role,
|
||||
permissions,
|
||||
assigned_by,
|
||||
assigned_at,
|
||||
expires_at,
|
||||
revoked_by,
|
||||
revoked_at,
|
||||
is_active,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES
|
||||
-- Student 1 Role
|
||||
(
|
||||
gen_random_uuid(),
|
||||
(SELECT id FROM auth.users WHERE email = 'estudiante1@demo.glit.edu.mx'),
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'student'::public.gamilit_role,
|
||||
'{
|
||||
"read": true,
|
||||
"write": false,
|
||||
"admin": false,
|
||||
"analytics": false,
|
||||
"can_view_own_progress": true,
|
||||
"can_submit_assignments": true,
|
||||
"can_participate_challenges": true
|
||||
}'::jsonb,
|
||||
(SELECT id FROM auth.users WHERE email = 'admin@glit.edu.mx'),
|
||||
gamilit.now_mexico(),
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
'{
|
||||
"test_role": true,
|
||||
"environment": "development",
|
||||
"assigned_by_name": "Admin Gamilit"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Student 2 Role
|
||||
(
|
||||
gen_random_uuid(),
|
||||
(SELECT id FROM auth.users WHERE email = 'estudiante2@demo.glit.edu.mx'),
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'student'::public.gamilit_role,
|
||||
'{
|
||||
"read": true,
|
||||
"write": false,
|
||||
"admin": false,
|
||||
"analytics": false,
|
||||
"can_view_own_progress": true,
|
||||
"can_submit_assignments": true,
|
||||
"can_participate_challenges": true
|
||||
}'::jsonb,
|
||||
(SELECT id FROM auth.users WHERE email = 'admin@glit.edu.mx'),
|
||||
gamilit.now_mexico(),
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
'{
|
||||
"test_role": true,
|
||||
"environment": "development"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Student 3 Role
|
||||
(
|
||||
gen_random_uuid(),
|
||||
(SELECT id FROM auth.users WHERE email = 'estudiante3@demo.glit.edu.mx'),
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'student'::public.gamilit_role,
|
||||
'{
|
||||
"read": true,
|
||||
"write": false,
|
||||
"admin": false,
|
||||
"analytics": false,
|
||||
"can_view_own_progress": true,
|
||||
"can_submit_assignments": true,
|
||||
"can_participate_challenges": true
|
||||
}'::jsonb,
|
||||
(SELECT id FROM auth.users WHERE email = 'admin@glit.edu.mx'),
|
||||
gamilit.now_mexico(),
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
'{
|
||||
"test_role": true,
|
||||
"environment": "development"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Teacher Role
|
||||
(
|
||||
gen_random_uuid(),
|
||||
(SELECT id FROM auth.users WHERE email = 'instructor@demo.glit.edu.mx'),
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'admin_teacher'::public.gamilit_role,
|
||||
'{
|
||||
"read": true,
|
||||
"write": true,
|
||||
"admin": false,
|
||||
"analytics": true,
|
||||
"can_manage_students": true,
|
||||
"can_create_assignments": true,
|
||||
"can_grade_submissions": true,
|
||||
"can_view_class_analytics": true,
|
||||
"can_manage_content": true
|
||||
}'::jsonb,
|
||||
(SELECT id FROM auth.users WHERE email = 'admin@glit.edu.mx'),
|
||||
gamilit.now_mexico(),
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
'{
|
||||
"test_role": true,
|
||||
"environment": "development",
|
||||
"assigned_by_name": "Admin Gamilit"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Admin Role
|
||||
(
|
||||
gen_random_uuid(),
|
||||
(SELECT id FROM auth.users WHERE email = 'admin@glit.edu.mx'),
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'super_admin'::public.gamilit_role,
|
||||
'{
|
||||
"read": true,
|
||||
"write": true,
|
||||
"admin": true,
|
||||
"analytics": true,
|
||||
"can_manage_all": true,
|
||||
"can_manage_users": true,
|
||||
"can_manage_tenants": true,
|
||||
"can_manage_system_settings": true,
|
||||
"can_view_all_analytics": true,
|
||||
"can_manage_roles": true
|
||||
}'::jsonb,
|
||||
NULL,
|
||||
gamilit.now_mexico(),
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
'{
|
||||
"test_role": true,
|
||||
"environment": "development",
|
||||
"note": "Self-assigned admin role"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
)
|
||||
ON CONFLICT (user_id, tenant_id, role) DO UPDATE SET
|
||||
permissions = EXCLUDED.permissions,
|
||||
assigned_by = EXCLUDED.assigned_by,
|
||||
is_active = EXCLUDED.is_active,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = gamilit.now_mexico();
|
||||
|
||||
-- =====================================================
|
||||
-- Verification Query
|
||||
-- =====================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
role_count INTEGER;
|
||||
active_count INTEGER;
|
||||
student_roles INTEGER;
|
||||
teacher_roles INTEGER;
|
||||
admin_roles INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO role_count FROM auth_management.user_roles;
|
||||
SELECT COUNT(*) INTO active_count FROM auth_management.user_roles WHERE is_active = true;
|
||||
SELECT COUNT(*) INTO student_roles FROM auth_management.user_roles WHERE role = 'student';
|
||||
SELECT COUNT(*) INTO teacher_roles FROM auth_management.user_roles WHERE role = 'admin_teacher';
|
||||
SELECT COUNT(*) INTO admin_roles FROM auth_management.user_roles WHERE role = 'super_admin';
|
||||
|
||||
RAISE NOTICE '==============================================';
|
||||
RAISE NOTICE '✓ User roles asignados correctamente';
|
||||
RAISE NOTICE ' Total: % roles', role_count;
|
||||
RAISE NOTICE ' Activos: %', active_count;
|
||||
RAISE NOTICE ' Estudiantes: %', student_roles;
|
||||
RAISE NOTICE ' Profesores: %', teacher_roles;
|
||||
RAISE NOTICE ' Admins: %', admin_roles;
|
||||
RAISE NOTICE '==============================================';
|
||||
END $$;
|
||||
@ -0,0 +1,208 @@
|
||||
-- =====================================================
|
||||
-- Seed: auth_management.user_preferences (DEV)
|
||||
-- Description: Preferencias de usuarios de prueba
|
||||
-- Environment: DEVELOPMENT
|
||||
-- Dependencies: auth_management.profiles
|
||||
-- Order: 05
|
||||
-- Validated: 2025-11-02
|
||||
-- Score: 100/100
|
||||
-- =====================================================
|
||||
|
||||
SET search_path TO auth_management, public;
|
||||
|
||||
-- =====================================================
|
||||
-- INSERT: User Preferences
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth_management.user_preferences (
|
||||
user_id,
|
||||
theme,
|
||||
language,
|
||||
notifications_enabled,
|
||||
email_notifications,
|
||||
sound_enabled,
|
||||
tutorial_completed,
|
||||
preferences,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES
|
||||
-- Student 1 Preferences
|
||||
(
|
||||
(SELECT id FROM auth.users WHERE email = 'estudiante1@demo.glit.edu.mx'),
|
||||
'light',
|
||||
'es',
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
'{
|
||||
"detective_theme_variant": "classic",
|
||||
"accessibility": {
|
||||
"high_contrast": false,
|
||||
"large_text": false,
|
||||
"reduce_motion": false
|
||||
},
|
||||
"game_settings": {
|
||||
"difficulty": "medium",
|
||||
"show_hints": true,
|
||||
"timer_visible": true
|
||||
},
|
||||
"privacy": {
|
||||
"show_profile": true,
|
||||
"show_achievements": true,
|
||||
"allow_friend_requests": true
|
||||
}
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Student 2 Preferences
|
||||
(
|
||||
(SELECT id FROM auth.users WHERE email = 'estudiante2@demo.glit.edu.mx'),
|
||||
'dark',
|
||||
'es',
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
'{
|
||||
"detective_theme_variant": "noir",
|
||||
"accessibility": {
|
||||
"high_contrast": true,
|
||||
"large_text": false,
|
||||
"reduce_motion": false
|
||||
},
|
||||
"game_settings": {
|
||||
"difficulty": "hard",
|
||||
"show_hints": false,
|
||||
"timer_visible": true
|
||||
},
|
||||
"privacy": {
|
||||
"show_profile": true,
|
||||
"show_achievements": true,
|
||||
"allow_friend_requests": true
|
||||
}
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Student 3 Preferences
|
||||
(
|
||||
(SELECT id FROM auth.users WHERE email = 'estudiante3@demo.glit.edu.mx'),
|
||||
'auto',
|
||||
'es',
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
'{
|
||||
"detective_theme_variant": "modern",
|
||||
"accessibility": {
|
||||
"high_contrast": false,
|
||||
"large_text": true,
|
||||
"reduce_motion": false
|
||||
},
|
||||
"game_settings": {
|
||||
"difficulty": "easy",
|
||||
"show_hints": true,
|
||||
"timer_visible": false
|
||||
},
|
||||
"privacy": {
|
||||
"show_profile": false,
|
||||
"show_achievements": false,
|
||||
"allow_friend_requests": false
|
||||
}
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Teacher Preferences
|
||||
(
|
||||
(SELECT id FROM auth.users WHERE email = 'instructor@demo.glit.edu.mx'),
|
||||
'light',
|
||||
'es',
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
'{
|
||||
"detective_theme_variant": "classic",
|
||||
"accessibility": {
|
||||
"high_contrast": false,
|
||||
"large_text": false,
|
||||
"reduce_motion": false
|
||||
},
|
||||
"dashboard_settings": {
|
||||
"default_view": "overview",
|
||||
"show_quick_stats": true,
|
||||
"charts_enabled": true
|
||||
},
|
||||
"teacher_tools": {
|
||||
"auto_save_grades": true,
|
||||
"show_student_progress": true,
|
||||
"enable_bulk_actions": true
|
||||
}
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
),
|
||||
-- Admin Preferences
|
||||
(
|
||||
(SELECT id FROM auth.users WHERE email = 'admin@glit.edu.mx'),
|
||||
'dark',
|
||||
'es',
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
'{
|
||||
"detective_theme_variant": "admin",
|
||||
"accessibility": {
|
||||
"high_contrast": false,
|
||||
"large_text": false,
|
||||
"reduce_motion": false
|
||||
},
|
||||
"admin_settings": {
|
||||
"show_system_stats": true,
|
||||
"enable_debug_mode": true,
|
||||
"show_all_logs": true
|
||||
},
|
||||
"dashboard_layout": {
|
||||
"widgets": ["users", "activity", "performance", "security"],
|
||||
"refresh_interval": 30
|
||||
}
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico(),
|
||||
gamilit.now_mexico()
|
||||
)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
theme = EXCLUDED.theme,
|
||||
language = EXCLUDED.language,
|
||||
notifications_enabled = EXCLUDED.notifications_enabled,
|
||||
email_notifications = EXCLUDED.email_notifications,
|
||||
sound_enabled = EXCLUDED.sound_enabled,
|
||||
tutorial_completed = EXCLUDED.tutorial_completed,
|
||||
preferences = EXCLUDED.preferences,
|
||||
updated_at = gamilit.now_mexico();
|
||||
|
||||
-- =====================================================
|
||||
-- Verification Query
|
||||
-- =====================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
pref_count INTEGER;
|
||||
tutorial_completed INTEGER;
|
||||
dark_theme INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO pref_count FROM auth_management.user_preferences;
|
||||
SELECT COUNT(*) INTO tutorial_completed FROM auth_management.user_preferences WHERE tutorial_completed = true;
|
||||
SELECT COUNT(*) INTO dark_theme FROM auth_management.user_preferences WHERE theme = 'dark';
|
||||
|
||||
RAISE NOTICE '==============================================';
|
||||
RAISE NOTICE '✓ User preferences insertadas correctamente';
|
||||
RAISE NOTICE ' Total: % preferencias', pref_count;
|
||||
RAISE NOTICE ' Tutorial completado: %', tutorial_completed;
|
||||
RAISE NOTICE ' Tema oscuro: %', dark_theme;
|
||||
RAISE NOTICE '==============================================';
|
||||
END $$;
|
||||
@ -0,0 +1,122 @@
|
||||
-- =====================================================
|
||||
-- Seed: auth_management.auth_attempts (DEV)
|
||||
-- Description: Ejemplos de intentos de autenticación para pruebas de auditoría
|
||||
-- Environment: DEVELOPMENT
|
||||
-- Dependencies: None (tabla de auditoría independiente)
|
||||
-- Order: 06
|
||||
-- Validated: 2025-11-02
|
||||
-- Score: 100/100
|
||||
-- Note: Seed opcional - datos de ejemplo para testing de auditoría
|
||||
-- =====================================================
|
||||
|
||||
SET search_path TO auth_management, public;
|
||||
|
||||
-- =====================================================
|
||||
-- INSERT: Sample Auth Attempts
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth_management.auth_attempts (
|
||||
id,
|
||||
email,
|
||||
ip_address,
|
||||
user_agent,
|
||||
success,
|
||||
failure_reason,
|
||||
tenant_slug,
|
||||
attempted_at,
|
||||
metadata
|
||||
) VALUES
|
||||
-- Successful login - Student
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'student@test.gamilit.com',
|
||||
'127.0.0.1'::inet,
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
true,
|
||||
NULL,
|
||||
'gamilit-test',
|
||||
gamilit.now_mexico() - INTERVAL '2 hours',
|
||||
'{"device": "desktop", "os": "Windows 10", "browser": "Chrome"}'::jsonb
|
||||
),
|
||||
-- Successful login - Teacher
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'teacher@test.gamilit.com',
|
||||
'127.0.0.1'::inet,
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
true,
|
||||
NULL,
|
||||
'gamilit-test',
|
||||
gamilit.now_mexico() - INTERVAL '1 hour',
|
||||
'{"device": "desktop", "os": "macOS", "browser": "Chrome"}'::jsonb
|
||||
),
|
||||
-- Failed login - Wrong password
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'student@test.gamilit.com',
|
||||
'192.168.1.100'::inet,
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X)',
|
||||
false,
|
||||
'invalid_password',
|
||||
'gamilit-test',
|
||||
gamilit.now_mexico() - INTERVAL '3 hours',
|
||||
'{"device": "mobile", "os": "iOS", "browser": "Safari", "attempts": 1}'::jsonb
|
||||
),
|
||||
-- Failed login - User not found
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'nonexistent@test.gamilit.com',
|
||||
'192.168.1.200'::inet,
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||
false,
|
||||
'user_not_found',
|
||||
'gamilit-test',
|
||||
gamilit.now_mexico() - INTERVAL '5 hours',
|
||||
'{"device": "desktop", "os": "Linux", "browser": "Firefox"}'::jsonb
|
||||
),
|
||||
-- Successful login - Admin
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'admin@test.gamilit.com',
|
||||
'127.0.0.1'::inet,
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
true,
|
||||
NULL,
|
||||
'gamilit-test',
|
||||
gamilit.now_mexico() - INTERVAL '30 minutes',
|
||||
'{"device": "desktop", "os": "Windows 10", "browser": "Edge"}'::jsonb
|
||||
),
|
||||
-- Failed login - Multiple attempts (suspicious)
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'admin@test.gamilit.com',
|
||||
'203.0.113.45'::inet,
|
||||
'curl/7.68.0',
|
||||
false,
|
||||
'invalid_password',
|
||||
'gamilit-test',
|
||||
gamilit.now_mexico() - INTERVAL '6 hours',
|
||||
'{"device": "bot", "suspicious": true, "attempts": 5, "blocked": true}'::jsonb
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- Verification Query
|
||||
-- =====================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
attempt_count INTEGER;
|
||||
success_count INTEGER;
|
||||
failed_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO attempt_count FROM auth_management.auth_attempts;
|
||||
SELECT COUNT(*) INTO success_count FROM auth_management.auth_attempts WHERE success = true;
|
||||
SELECT COUNT(*) INTO failed_count FROM auth_management.auth_attempts WHERE success = false;
|
||||
|
||||
RAISE NOTICE '==============================================';
|
||||
RAISE NOTICE '✓ Auth attempts insertados (datos de ejemplo)';
|
||||
RAISE NOTICE ' Total: %', attempt_count;
|
||||
RAISE NOTICE ' Exitosos: %', success_count;
|
||||
RAISE NOTICE ' Fallidos: %', failed_count;
|
||||
RAISE NOTICE '==============================================';
|
||||
END $$;
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,186 @@
|
||||
-- =====================================================
|
||||
-- Seed: auth_management.security_events (DEV)
|
||||
-- Description: Ejemplos de eventos de seguridad para testing
|
||||
-- Environment: DEVELOPMENT
|
||||
-- Dependencies: auth.users (opcional)
|
||||
-- Order: 07
|
||||
-- Validated: 2025-11-02
|
||||
-- Score: 100/100
|
||||
-- Note: Seed opcional - datos de ejemplo para testing de auditoría de seguridad
|
||||
-- =====================================================
|
||||
|
||||
SET search_path TO auth_management, public;
|
||||
|
||||
-- =====================================================
|
||||
-- INSERT: Sample Security Events
|
||||
-- =====================================================
|
||||
|
||||
INSERT INTO auth_management.security_events (
|
||||
id,
|
||||
user_id,
|
||||
event_type,
|
||||
severity,
|
||||
description,
|
||||
ip_address,
|
||||
user_agent,
|
||||
metadata,
|
||||
created_at
|
||||
) VALUES
|
||||
-- Low severity - Successful login
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||
'login_success',
|
||||
'low',
|
||||
'Usuario inició sesión exitosamente',
|
||||
'127.0.0.1'::inet,
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'{
|
||||
"method": "email_password",
|
||||
"device": "desktop",
|
||||
"location": "Mexico City, MX"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico() - INTERVAL '2 hours'
|
||||
),
|
||||
-- Medium severity - Password change
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'20000000-0000-0000-0000-000000000001'::uuid,
|
||||
'password_change',
|
||||
'medium',
|
||||
'Usuario cambió su contraseña',
|
||||
'127.0.0.1'::inet,
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
'{
|
||||
"initiated_by": "user",
|
||||
"verified": true,
|
||||
"old_password_hash_prefix": "$2a$10$jcD1M4"
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico() - INTERVAL '1 day'
|
||||
),
|
||||
-- High severity - Multiple failed login attempts
|
||||
(
|
||||
gen_random_uuid(),
|
||||
NULL,
|
||||
'multiple_failed_logins',
|
||||
'high',
|
||||
'Múltiples intentos fallidos de inicio de sesión desde la misma IP',
|
||||
'203.0.113.45'::inet,
|
||||
'curl/7.68.0',
|
||||
'{
|
||||
"attempts": 5,
|
||||
"timespan_minutes": 10,
|
||||
"targeted_email": "admin@test.gamilit.com",
|
||||
"blocked": true
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico() - INTERVAL '3 hours'
|
||||
),
|
||||
-- Medium severity - Email verification sent
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'10000000-0000-0000-0000-000000000002'::uuid,
|
||||
'email_verification_sent',
|
||||
'low',
|
||||
'Token de verificación de email enviado',
|
||||
'127.0.0.1'::inet,
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X)',
|
||||
'{
|
||||
"email": "student2@test.gamilit.com",
|
||||
"token_expires_in_hours": 24
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico() - INTERVAL '5 hours'
|
||||
),
|
||||
-- Low severity - Logout
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'30000000-0000-0000-0000-000000000001'::uuid,
|
||||
'logout',
|
||||
'low',
|
||||
'Usuario cerró sesión',
|
||||
'127.0.0.1'::inet,
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'{
|
||||
"session_duration_minutes": 45,
|
||||
"manual_logout": true
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico() - INTERVAL '30 minutes'
|
||||
),
|
||||
-- Critical severity - Unauthorized access attempt
|
||||
(
|
||||
gen_random_uuid(),
|
||||
NULL,
|
||||
'unauthorized_access_attempt',
|
||||
'critical',
|
||||
'Intento de acceso a recursos sin autorización',
|
||||
'198.51.100.23'::inet,
|
||||
'Python/3.9 requests/2.26.0',
|
||||
'{
|
||||
"endpoint": "/api/admin/users",
|
||||
"method": "GET",
|
||||
"attempted_user": "unknown",
|
||||
"blocked": true,
|
||||
"firewall_triggered": true
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico() - INTERVAL '6 hours'
|
||||
),
|
||||
-- Medium severity - Permission elevation
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'20000000-0000-0000-0000-000000000001'::uuid,
|
||||
'permission_elevation',
|
||||
'medium',
|
||||
'Permisos de usuario elevados temporalmente',
|
||||
'127.0.0.1'::inet,
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
'{
|
||||
"elevated_by": "30000000-0000-0000-0000-000000000001",
|
||||
"from_role": "admin_teacher",
|
||||
"to_role": "admin_teacher",
|
||||
"additional_permissions": ["can_manage_system_settings"],
|
||||
"duration_hours": 2
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico() - INTERVAL '4 hours'
|
||||
),
|
||||
-- Low severity - Profile update
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'10000000-0000-0000-0000-000000000003'::uuid,
|
||||
'profile_update',
|
||||
'low',
|
||||
'Usuario actualizó su perfil',
|
||||
'127.0.0.1'::inet,
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||
'{
|
||||
"fields_updated": ["display_name", "avatar_url", "bio"],
|
||||
"verified": true
|
||||
}'::jsonb,
|
||||
gamilit.now_mexico() - INTERVAL '12 hours'
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- Verification Query
|
||||
-- =====================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
event_count INTEGER;
|
||||
critical_count INTEGER;
|
||||
high_count INTEGER;
|
||||
medium_count INTEGER;
|
||||
low_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO event_count FROM auth_management.security_events;
|
||||
SELECT COUNT(*) INTO critical_count FROM auth_management.security_events WHERE severity = 'critical';
|
||||
SELECT COUNT(*) INTO high_count FROM auth_management.security_events WHERE severity = 'high';
|
||||
SELECT COUNT(*) INTO medium_count FROM auth_management.security_events WHERE severity = 'medium';
|
||||
SELECT COUNT(*) INTO low_count FROM auth_management.security_events WHERE severity = 'low';
|
||||
|
||||
RAISE NOTICE '==============================================';
|
||||
RAISE NOTICE '✓ Security events insertados (datos de ejemplo)';
|
||||
RAISE NOTICE ' Total: %', event_count;
|
||||
RAISE NOTICE ' Críticos: %', critical_count;
|
||||
RAISE NOTICE ' Altos: %', high_count;
|
||||
RAISE NOTICE ' Medios: %', medium_count;
|
||||
RAISE NOTICE ' Bajos: %', low_count;
|
||||
RAISE NOTICE '==============================================';
|
||||
END $$;
|
||||
53
projects/gamilit/apps/frontend/.env.production.example
Normal file
53
projects/gamilit/apps/frontend/.env.production.example
Normal file
@ -0,0 +1,53 @@
|
||||
# ============================================================================
|
||||
# GAMILIT Frontend - Production Environment EXAMPLE
|
||||
# ============================================================================
|
||||
# COPIAR A .env.production
|
||||
# Fecha: 2025-12-18
|
||||
# Server: 74.208.126.102
|
||||
# ============================================================================
|
||||
|
||||
# ==================== APPLICATION ====================
|
||||
VITE_APP_NAME=GAMILIT Platform
|
||||
VITE_APP_VERSION=1.0.0
|
||||
VITE_APP_ENV=production
|
||||
VITE_ENV=production
|
||||
|
||||
# ==================== API CONFIGURATION ====================
|
||||
# IMPORTANTE: Usar HTTPS/WSS si el servidor tiene SSL configurado
|
||||
# ============================================================================
|
||||
# SSL CONFIGURADO - Usar HTTPS/WSS
|
||||
# ============================================================================
|
||||
VITE_API_HOST=74.208.126.102:3006
|
||||
VITE_API_PROTOCOL=https
|
||||
VITE_API_VERSION=v1
|
||||
VITE_API_TIMEOUT=30000
|
||||
|
||||
# WebSocket configuration (WSS para conexiones seguras)
|
||||
VITE_WS_HOST=74.208.126.102:3006
|
||||
VITE_WS_PROTOCOL=wss
|
||||
|
||||
# ==================== AUTHENTICATION ====================
|
||||
VITE_JWT_EXPIRATION=7d
|
||||
|
||||
# ==================== FEATURE FLAGS ====================
|
||||
VITE_ENABLE_GAMIFICATION=true
|
||||
VITE_ENABLE_SOCIAL_FEATURES=true
|
||||
VITE_ENABLE_ANALYTICS=true
|
||||
VITE_ENABLE_DEBUG=false
|
||||
VITE_ENABLE_STORYBOOK=false
|
||||
VITE_MOCK_API=false
|
||||
|
||||
# ==================== EXTERNAL SERVICES ====================
|
||||
# Configurar en produccion si se usan
|
||||
VITE_GOOGLE_ANALYTICS_ID=
|
||||
VITE_SENTRY_DSN=
|
||||
VITE_AI_SERVICE_URL=
|
||||
|
||||
# ==================== PRODUCTION ====================
|
||||
VITE_ENABLE_DEBUG=false
|
||||
VITE_LOG_LEVEL=error
|
||||
|
||||
# ==================== TESTING ====================
|
||||
# NO usar credenciales en produccion
|
||||
VITE_TEST_USER_EMAIL=
|
||||
VITE_TEST_USER_PASSWORD=
|
||||
@ -0,0 +1,733 @@
|
||||
/**
|
||||
* RubricEvaluator Component
|
||||
*
|
||||
* P2-02: Created 2025-12-18
|
||||
* Standardized rubric-based evaluation component for manual grading mechanics.
|
||||
*
|
||||
* Features:
|
||||
* - Configurable rubric criteria with weight support
|
||||
* - Visual scoring interface with level descriptors
|
||||
* - Automatic weighted score calculation
|
||||
* - Feedback templates per criterion
|
||||
* - Support for 10 manual grading mechanics
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Star,
|
||||
Info,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
MessageSquare,
|
||||
Save,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface RubricLevel {
|
||||
score: number;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RubricCriterion {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
weight: number; // Percentage weight (0-100)
|
||||
levels: RubricLevel[];
|
||||
feedbackTemplates?: string[];
|
||||
}
|
||||
|
||||
export interface RubricConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mechanicType: string;
|
||||
maxScore: number;
|
||||
criteria: RubricCriterion[];
|
||||
}
|
||||
|
||||
export interface RubricScore {
|
||||
criterionId: string;
|
||||
selectedLevel: number;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
export interface RubricEvaluatorProps {
|
||||
rubric: RubricConfig;
|
||||
initialScores?: RubricScore[];
|
||||
onScoreChange?: (scores: RubricScore[], totalScore: number, percentage: number) => void;
|
||||
onSubmit?: (scores: RubricScore[], totalScore: number, feedback: string) => Promise<void>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface RubricEvaluatorResult {
|
||||
scores: RubricScore[];
|
||||
totalScore: number;
|
||||
percentage: number;
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DEFAULT RUBRICS BY MECHANIC TYPE
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_RUBRICS: Record<string, RubricConfig> = {
|
||||
prediccion_narrativa: {
|
||||
id: 'rubric-prediccion',
|
||||
name: 'Evaluaci\u00f3n de Predicci\u00f3n Narrativa',
|
||||
description: 'R\u00fabrica para evaluar predicciones narrativas basadas en el texto',
|
||||
mechanicType: 'prediccion_narrativa',
|
||||
maxScore: 100,
|
||||
criteria: [
|
||||
{
|
||||
id: 'coherencia',
|
||||
name: 'Coherencia con el texto',
|
||||
description: '\u00bfLa predicci\u00f3n es coherente con la informaci\u00f3n presentada en el texto?',
|
||||
weight: 30,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Sin relaci\u00f3n con el texto' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Relaci\u00f3n m\u00ednima' },
|
||||
{ score: 3, label: 'Competente', description: 'Relaci\u00f3n adecuada' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Relaci\u00f3n s\u00f3lida con evidencia' },
|
||||
{ score: 5, label: 'Excelente', description: 'Fundamentaci\u00f3n excepcional' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'creatividad',
|
||||
name: 'Creatividad',
|
||||
description: '\u00bfLa predicci\u00f3n muestra pensamiento original?',
|
||||
weight: 25,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Respuesta literal/copiada' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Poca originalidad' },
|
||||
{ score: 3, label: 'Competente', description: 'Algo de creatividad' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Ideas originales' },
|
||||
{ score: 5, label: 'Excelente', description: 'Muy creativo e innovador' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'justificacion',
|
||||
name: 'Justificaci\u00f3n',
|
||||
description: '\u00bfEl estudiante justifica su predicci\u00f3n adecuadamente?',
|
||||
weight: 30,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Sin justificaci\u00f3n' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Justificaci\u00f3n vaga' },
|
||||
{ score: 3, label: 'Competente', description: 'Justificaci\u00f3n aceptable' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Bien justificado con ejemplos' },
|
||||
{ score: 5, label: 'Excelente', description: 'Argumentaci\u00f3n excepcional' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'expresion',
|
||||
name: 'Expresi\u00f3n escrita',
|
||||
description: 'Claridad, gram\u00e1tica y ortograf\u00eda',
|
||||
weight: 15,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Muchos errores' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Errores frecuentes' },
|
||||
{ score: 3, label: 'Competente', description: 'Algunos errores' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Pocos errores' },
|
||||
{ score: 5, label: 'Excelente', description: 'Escritura impecable' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
tribunal_opiniones: {
|
||||
id: 'rubric-tribunal',
|
||||
name: 'Evaluaci\u00f3n de Tribunal de Opiniones',
|
||||
description: 'R\u00fabrica para evaluar argumentos en debates',
|
||||
mechanicType: 'tribunal_opiniones',
|
||||
maxScore: 100,
|
||||
criteria: [
|
||||
{
|
||||
id: 'argumentacion',
|
||||
name: 'Calidad argumentativa',
|
||||
description: 'Solidez y l\u00f3gica de los argumentos',
|
||||
weight: 35,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Sin argumentos claros' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Argumentos d\u00e9biles' },
|
||||
{ score: 3, label: 'Competente', description: 'Argumentos aceptables' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Argumentos s\u00f3lidos' },
|
||||
{ score: 5, label: 'Excelente', description: 'Argumentaci\u00f3n excepcional' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidencia',
|
||||
name: 'Uso de evidencia',
|
||||
description: 'Respaldo con datos o ejemplos del texto',
|
||||
weight: 30,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Sin evidencia' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Evidencia irrelevante' },
|
||||
{ score: 3, label: 'Competente', description: 'Alguna evidencia' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Buena evidencia' },
|
||||
{ score: 5, label: 'Excelente', description: 'Evidencia excepcional' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contraargumentos',
|
||||
name: 'Manejo de contraargumentos',
|
||||
description: 'Capacidad de anticipar y responder objeciones',
|
||||
weight: 20,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'No considera otras perspectivas' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Reconocimiento superficial' },
|
||||
{ score: 3, label: 'Competente', description: 'Considera algunas objeciones' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Buen manejo de objeciones' },
|
||||
{ score: 5, label: 'Excelente', description: 'Anticipaci\u00f3n magistral' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'respeto',
|
||||
name: 'Respeto y \u00e9tica',
|
||||
description: 'Tono respetuoso y \u00e9tico en el debate',
|
||||
weight: 15,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Irrespetuoso' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Poco respetuoso' },
|
||||
{ score: 3, label: 'Competente', description: 'Generalmente respetuoso' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Respetuoso' },
|
||||
{ score: 5, label: 'Excelente', description: 'Ejemplar' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
comic_digital: {
|
||||
id: 'rubric-comic',
|
||||
name: 'Evaluaci\u00f3n de C\u00f3mic Digital',
|
||||
description: 'R\u00fabrica para evaluar creaciones de c\u00f3mic digital',
|
||||
mechanicType: 'comic_digital',
|
||||
maxScore: 100,
|
||||
criteria: [
|
||||
{
|
||||
id: 'narrativa',
|
||||
name: 'Narrativa visual',
|
||||
description: 'Secuencia l\u00f3gica y fluidez de la historia',
|
||||
weight: 30,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Sin secuencia clara' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Secuencia confusa' },
|
||||
{ score: 3, label: 'Competente', description: 'Secuencia aceptable' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Buena secuencia' },
|
||||
{ score: 5, label: 'Excelente', description: 'Narrativa excepcional' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'creatividad',
|
||||
name: 'Creatividad visual',
|
||||
description: 'Originalidad en dise\u00f1o y presentaci\u00f3n',
|
||||
weight: 25,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Sin esfuerzo creativo' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Poca creatividad' },
|
||||
{ score: 3, label: 'Competente', description: 'Algo de creatividad' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Creativo' },
|
||||
{ score: 5, label: 'Excelente', description: 'Muy creativo' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'comprension',
|
||||
name: 'Comprensi\u00f3n del tema',
|
||||
description: 'Demuestra entendimiento del contenido',
|
||||
weight: 30,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'No demuestra comprensi\u00f3n' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Comprensi\u00f3n limitada' },
|
||||
{ score: 3, label: 'Competente', description: 'Comprensi\u00f3n adecuada' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Buena comprensi\u00f3n' },
|
||||
{ score: 5, label: 'Excelente', description: 'Comprensi\u00f3n profunda' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'presentacion',
|
||||
name: 'Presentaci\u00f3n t\u00e9cnica',
|
||||
description: 'Calidad t\u00e9cnica del c\u00f3mic',
|
||||
weight: 15,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Ilegible/incompleto' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Dif\u00edcil de leer' },
|
||||
{ score: 3, label: 'Competente', description: 'Legible' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Bien presentado' },
|
||||
{ score: 5, label: 'Excelente', description: 'Presentaci\u00f3n profesional' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// Generic rubric for other manual mechanics
|
||||
generic_creative: {
|
||||
id: 'rubric-generic',
|
||||
name: 'Evaluaci\u00f3n de Trabajo Creativo',
|
||||
description: 'R\u00fabrica general para trabajos creativos',
|
||||
mechanicType: 'generic',
|
||||
maxScore: 100,
|
||||
criteria: [
|
||||
{
|
||||
id: 'contenido',
|
||||
name: 'Contenido',
|
||||
description: 'Calidad y relevancia del contenido',
|
||||
weight: 35,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Contenido inadecuado' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Contenido m\u00ednimo' },
|
||||
{ score: 3, label: 'Competente', description: 'Contenido adecuado' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Buen contenido' },
|
||||
{ score: 5, label: 'Excelente', description: 'Contenido excepcional' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'creatividad',
|
||||
name: 'Creatividad',
|
||||
description: 'Originalidad y esfuerzo creativo',
|
||||
weight: 25,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Sin creatividad' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Poca creatividad' },
|
||||
{ score: 3, label: 'Competente', description: 'Algo de creatividad' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Creativo' },
|
||||
{ score: 5, label: 'Excelente', description: 'Muy creativo' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'esfuerzo',
|
||||
name: 'Esfuerzo',
|
||||
description: 'Dedicaci\u00f3n y completitud del trabajo',
|
||||
weight: 25,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Trabajo incompleto' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Esfuerzo m\u00ednimo' },
|
||||
{ score: 3, label: 'Competente', description: 'Esfuerzo aceptable' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Buen esfuerzo' },
|
||||
{ score: 5, label: 'Excelente', description: 'Esfuerzo sobresaliente' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'presentacion',
|
||||
name: 'Presentaci\u00f3n',
|
||||
description: 'Claridad y organizaci\u00f3n',
|
||||
weight: 15,
|
||||
levels: [
|
||||
{ score: 1, label: 'Insuficiente', description: 'Desorganizado' },
|
||||
{ score: 2, label: 'B\u00e1sico', description: 'Poco organizado' },
|
||||
{ score: 3, label: 'Competente', description: 'Organizado' },
|
||||
{ score: 4, label: 'Avanzado', description: 'Bien organizado' },
|
||||
{ score: 5, label: 'Excelente', description: 'Excelente presentaci\u00f3n' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get rubric for a specific mechanic type
|
||||
*/
|
||||
export const getRubricForMechanic = (mechanicType: string): RubricConfig => {
|
||||
return DEFAULT_RUBRICS[mechanicType] || DEFAULT_RUBRICS.generic_creative;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SUB-COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
interface CriterionCardProps {
|
||||
criterion: RubricCriterion;
|
||||
selectedLevel: number | undefined;
|
||||
feedback: string;
|
||||
onLevelSelect: (level: number) => void;
|
||||
onFeedbackChange: (feedback: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const CriterionCard: React.FC<CriterionCardProps> = ({
|
||||
criterion,
|
||||
selectedLevel,
|
||||
feedback,
|
||||
onLevelSelect,
|
||||
onFeedbackChange,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold text-gray-800">{criterion.name}</h4>
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||
{criterion.weight}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">{criterion.description}</p>
|
||||
</div>
|
||||
{selectedLevel !== undefined && (
|
||||
<div className="ml-4 flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`h-4 w-4 ${
|
||||
star <= selectedLevel
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Level Selection */}
|
||||
<div className="mb-3 grid grid-cols-5 gap-2">
|
||||
{criterion.levels.map((level) => (
|
||||
<button
|
||||
key={level.score}
|
||||
onClick={() => !readOnly && onLevelSelect(level.score)}
|
||||
disabled={readOnly}
|
||||
className={`group relative rounded-lg border-2 p-2 transition-all ${
|
||||
selectedLevel === level.score
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
} ${readOnly ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<span
|
||||
className={`block text-lg font-bold ${
|
||||
selectedLevel === level.score ? 'text-blue-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{level.score}
|
||||
</span>
|
||||
<span className="block text-xs text-gray-600">{level.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden w-48 -translate-x-1/2 rounded-lg bg-gray-800 p-2 text-xs text-white group-hover:block">
|
||||
{level.description}
|
||||
<div className="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-800" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feedback Toggle */}
|
||||
{!readOnly && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowFeedback(!showFeedback)}
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{showFeedback ? 'Ocultar comentario' : 'Agregar comentario'}
|
||||
</button>
|
||||
|
||||
{showFeedback && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="mt-2"
|
||||
>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => onFeedbackChange(e.target.value)}
|
||||
placeholder="Comentario sobre este criterio..."
|
||||
className="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
rows={2}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show feedback in readOnly mode */}
|
||||
{readOnly && feedback && (
|
||||
<div className="mt-2 rounded-lg bg-gray-50 p-2 text-sm text-gray-700">
|
||||
<span className="font-medium">Comentario:</span> {feedback}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export const RubricEvaluator: React.FC<RubricEvaluatorProps> = ({
|
||||
rubric,
|
||||
initialScores = [],
|
||||
onScoreChange,
|
||||
onSubmit,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
// Initialize scores from props or empty
|
||||
const [scores, setScores] = useState<Record<string, RubricScore>>(() => {
|
||||
const initial: Record<string, RubricScore> = {};
|
||||
initialScores.forEach((score) => {
|
||||
initial[score.criterionId] = score;
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
const [generalFeedback, setGeneralFeedback] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
|
||||
// Calculate total score
|
||||
const { totalScore, percentage, isComplete } = useMemo(() => {
|
||||
let weightedSum = 0;
|
||||
let totalWeight = 0;
|
||||
let complete = true;
|
||||
|
||||
rubric.criteria.forEach((criterion) => {
|
||||
const score = scores[criterion.id];
|
||||
if (score?.selectedLevel !== undefined) {
|
||||
// Normalize to percentage: (score/5) * weight
|
||||
weightedSum += (score.selectedLevel / 5) * criterion.weight;
|
||||
totalWeight += criterion.weight;
|
||||
} else {
|
||||
complete = false;
|
||||
}
|
||||
});
|
||||
|
||||
const pct = totalWeight > 0 ? (weightedSum / totalWeight) * 100 : 0;
|
||||
const total = Math.round((pct / 100) * rubric.maxScore);
|
||||
|
||||
return {
|
||||
totalScore: total,
|
||||
percentage: Math.round(pct),
|
||||
isComplete: complete,
|
||||
};
|
||||
}, [scores, rubric]);
|
||||
|
||||
// Notify parent of changes
|
||||
const notifyChange = useCallback(
|
||||
(newScores: Record<string, RubricScore>) => {
|
||||
if (onScoreChange) {
|
||||
const scoresArray = Object.values(newScores);
|
||||
let weightedSum = 0;
|
||||
|
||||
rubric.criteria.forEach((criterion) => {
|
||||
const score = newScores[criterion.id];
|
||||
if (score?.selectedLevel !== undefined) {
|
||||
weightedSum += (score.selectedLevel / 5) * criterion.weight;
|
||||
}
|
||||
});
|
||||
|
||||
const pct = Math.round(weightedSum);
|
||||
const total = Math.round((weightedSum / 100) * rubric.maxScore);
|
||||
onScoreChange(scoresArray, total, pct);
|
||||
}
|
||||
},
|
||||
[onScoreChange, rubric],
|
||||
);
|
||||
|
||||
// Handle level selection
|
||||
const handleLevelSelect = useCallback(
|
||||
(criterionId: string, level: number) => {
|
||||
const newScores = {
|
||||
...scores,
|
||||
[criterionId]: {
|
||||
...scores[criterionId],
|
||||
criterionId,
|
||||
selectedLevel: level,
|
||||
},
|
||||
};
|
||||
setScores(newScores);
|
||||
notifyChange(newScores);
|
||||
},
|
||||
[scores, notifyChange],
|
||||
);
|
||||
|
||||
// Handle criterion feedback
|
||||
const handleFeedbackChange = useCallback(
|
||||
(criterionId: string, feedback: string) => {
|
||||
const newScores = {
|
||||
...scores,
|
||||
[criterionId]: {
|
||||
...scores[criterionId],
|
||||
criterionId,
|
||||
feedback,
|
||||
},
|
||||
};
|
||||
setScores(newScores);
|
||||
notifyChange(newScores);
|
||||
},
|
||||
[scores, notifyChange],
|
||||
);
|
||||
|
||||
// Reset all scores
|
||||
const handleReset = () => {
|
||||
setScores({});
|
||||
setGeneralFeedback('');
|
||||
setSubmitStatus('idle');
|
||||
if (onScoreChange) {
|
||||
onScoreChange([], 0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Submit evaluation
|
||||
const handleSubmit = async () => {
|
||||
if (!onSubmit || !isComplete) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitStatus('idle');
|
||||
|
||||
try {
|
||||
await onSubmit(Object.values(scores), totalScore, generalFeedback);
|
||||
setSubmitStatus('success');
|
||||
} catch (error) {
|
||||
console.error('[RubricEvaluator] Submit error:', error);
|
||||
setSubmitStatus('error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get grade color
|
||||
const getGradeColor = (pct: number): string => {
|
||||
if (pct >= 90) return 'text-green-600 bg-green-100';
|
||||
if (pct >= 80) return 'text-blue-600 bg-blue-100';
|
||||
if (pct >= 70) return 'text-yellow-600 bg-yellow-100';
|
||||
if (pct >= 60) return 'text-orange-600 bg-orange-100';
|
||||
return 'text-red-600 bg-red-100';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-xl bg-gradient-to-r from-purple-50 to-blue-50 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">{rubric.name}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">{rubric.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">
|
||||
{rubric.criteria.length} criterios
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score Summary */}
|
||||
<div className="flex items-center justify-between rounded-xl border-2 border-gray-200 p-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Puntaje calculado</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-gray-800">{totalScore}</span>
|
||||
<span className="text-lg text-gray-500">/ {rubric.maxScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`rounded-xl px-6 py-3 ${getGradeColor(percentage)}`}>
|
||||
<p className="text-3xl font-bold">{percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criteria */}
|
||||
<div className="space-y-4">
|
||||
{rubric.criteria.map((criterion) => (
|
||||
<CriterionCard
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
selectedLevel={scores[criterion.id]?.selectedLevel}
|
||||
feedback={scores[criterion.id]?.feedback || ''}
|
||||
onLevelSelect={(level) => handleLevelSelect(criterion.id, level)}
|
||||
onFeedbackChange={(feedback) => handleFeedbackChange(criterion.id, feedback)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* General Feedback */}
|
||||
{!readOnly && (
|
||||
<div>
|
||||
<label className="mb-2 block font-semibold text-gray-700">
|
||||
Retroalimentaci\u00f3n general
|
||||
</label>
|
||||
<textarea
|
||||
value={generalFeedback}
|
||||
onChange={(e) => setGeneralFeedback(e.target.value)}
|
||||
placeholder="Escribe comentarios generales para el estudiante..."
|
||||
className="w-full rounded-lg border border-gray-300 p-3 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Messages */}
|
||||
{submitStatus === 'success' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4"
|
||||
>
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<p className="font-medium text-green-800">Evaluaci\u00f3n guardada exitosamente</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{submitStatus === 'error' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-4"
|
||||
>
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<p className="font-medium text-red-800">Error al guardar la evaluaci\u00f3n</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{!readOnly && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 pt-4">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reiniciar
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{!isComplete && (
|
||||
<p className="text-sm text-orange-600">
|
||||
Faltan {rubric.criteria.length - Object.keys(scores).length} criterios
|
||||
</p>
|
||||
)}
|
||||
{onSubmit && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isComplete || isSubmitting}
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-to-r from-purple-500 to-indigo-500 px-6 py-2 font-semibold text-white transition-all hover:shadow-lg disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Guardar evaluaci\u00f3n
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RubricEvaluator;
|
||||
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Grading Components - Central export point
|
||||
*
|
||||
* P2-02: Created 2025-12-18
|
||||
*/
|
||||
|
||||
export {
|
||||
RubricEvaluator,
|
||||
DEFAULT_RUBRICS,
|
||||
getRubricForMechanic,
|
||||
} from './RubricEvaluator';
|
||||
|
||||
export type {
|
||||
RubricLevel,
|
||||
RubricCriterion,
|
||||
RubricConfig,
|
||||
RubricScore,
|
||||
RubricEvaluatorProps,
|
||||
RubricEvaluatorResult,
|
||||
} from './RubricEvaluator';
|
||||
@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
X,
|
||||
User,
|
||||
@ -28,6 +29,15 @@ import {
|
||||
TrendingUp,
|
||||
BookOpen,
|
||||
ClipboardCheck,
|
||||
Play,
|
||||
Pause,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize2,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAttemptDetail } from '@apps/teacher/hooks/useExerciseResponses';
|
||||
@ -66,26 +76,510 @@ const formatDate = (dateString: string): string => {
|
||||
|
||||
/**
|
||||
* Determines if an exercise requires manual grading
|
||||
* Modules 3, 4, 5 (creative exercises) require manual review
|
||||
* P0-03: Updated 2025-12-18 - Complete list of manual mechanics
|
||||
*
|
||||
* 10 manual mechanics identified in analysis:
|
||||
* - Predicción Narrativa, Tribunal de Opiniones, Podcast Argumentativo
|
||||
* - Debate Digital, Cómic Digital, Video Carta, Diario Multimedia
|
||||
* - Collage Prensa, Call to Action, Texto en Movimiento
|
||||
*/
|
||||
const requiresManualGrading = (exerciseType: string): boolean => {
|
||||
const manualGradingTypes = [
|
||||
// Módulo 3
|
||||
// Módulo 2 - Manual
|
||||
'prediccion_narrativa',
|
||||
// Módulo 3 - Críticos/Argumentativos
|
||||
'tribunal_opiniones',
|
||||
'podcast_argumentativo',
|
||||
// Módulo 4
|
||||
'verificador_fake_news',
|
||||
'quiz_tiktok',
|
||||
'analisis_memes',
|
||||
'infografia_interactiva',
|
||||
'navegacion_hipertextual',
|
||||
// Módulo 5
|
||||
'diario_multimedia',
|
||||
'debate_digital',
|
||||
// Módulo 4 - Alfabetización Mediática (creativos)
|
||||
'analisis_memes', // Semi-auto but needs review
|
||||
// Módulo 5 - Creación de Contenido
|
||||
'comic_digital',
|
||||
'video_carta',
|
||||
'diario_multimedia',
|
||||
// Auxiliares
|
||||
'collage_prensa',
|
||||
'call_to_action',
|
||||
'texto_en_movimiento',
|
||||
];
|
||||
return manualGradingTypes.includes(exerciseType);
|
||||
};
|
||||
|
||||
/**
|
||||
* P2-03: Multimedia content type detection
|
||||
* Identifies multimedia content types for creative exercises
|
||||
*/
|
||||
type MediaType = 'video' | 'audio' | 'image' | 'text' | 'unknown';
|
||||
|
||||
interface MediaContent {
|
||||
type: MediaType;
|
||||
url?: string;
|
||||
urls?: string[];
|
||||
text?: string;
|
||||
mimeType?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
const detectMediaType = (url: string): MediaType => {
|
||||
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||
const videoExts = ['mp4', 'webm', 'ogg', 'mov', 'avi'];
|
||||
const audioExts = ['mp3', 'wav', 'ogg', 'aac', 'm4a'];
|
||||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
|
||||
|
||||
if (videoExts.includes(extension)) return 'video';
|
||||
if (audioExts.includes(extension)) return 'audio';
|
||||
if (imageExts.includes(extension)) return 'image';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts multimedia content from answer data
|
||||
*/
|
||||
const extractMediaContent = (answerData: Record<string, unknown>): MediaContent[] => {
|
||||
const media: MediaContent[] = [];
|
||||
|
||||
// Check for direct media URLs
|
||||
const mediaFields = ['videoUrl', 'audioUrl', 'imageUrl', 'mediaUrl', 'url', 'file'];
|
||||
for (const field of mediaFields) {
|
||||
if (typeof answerData[field] === 'string' && answerData[field]) {
|
||||
const url = answerData[field] as string;
|
||||
media.push({
|
||||
type: detectMediaType(url),
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for arrays of media
|
||||
const arrayFields = ['images', 'videos', 'audios', 'files', 'media'];
|
||||
for (const field of arrayFields) {
|
||||
if (Array.isArray(answerData[field])) {
|
||||
for (const item of answerData[field] as (string | { url: string })[]) {
|
||||
const url = typeof item === 'string' ? item : item?.url;
|
||||
if (url) {
|
||||
media.push({
|
||||
type: detectMediaType(url),
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for podcast/video specific fields
|
||||
if (answerData.podcast_url && typeof answerData.podcast_url === 'string') {
|
||||
media.push({ type: 'audio', url: answerData.podcast_url });
|
||||
}
|
||||
if (answerData.video_url && typeof answerData.video_url === 'string') {
|
||||
media.push({ type: 'video', url: answerData.video_url });
|
||||
}
|
||||
|
||||
// Check for comic panels (images)
|
||||
if (Array.isArray(answerData.panels)) {
|
||||
for (const panel of answerData.panels as { imageUrl?: string }[]) {
|
||||
if (panel.imageUrl) {
|
||||
media.push({ type: 'image', url: panel.imageUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for collage images
|
||||
if (Array.isArray(answerData.collage_items)) {
|
||||
for (const item of answerData.collage_items as { url?: string }[]) {
|
||||
if (item.url) {
|
||||
media.push({ type: 'image', url: item.url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return media;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if exercise type has multimedia content
|
||||
*/
|
||||
const hasMultimediaContent = (exerciseType: string): boolean => {
|
||||
const multimediaTypes = [
|
||||
'video_carta',
|
||||
'podcast_argumentativo',
|
||||
'comic_digital',
|
||||
'diario_multimedia',
|
||||
'collage_prensa',
|
||||
'infografia_interactiva',
|
||||
'creacion_storyboard',
|
||||
];
|
||||
return multimediaTypes.includes(exerciseType);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MULTIMEDIA PLAYER COMPONENTS (P2-03)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Video Player Component
|
||||
*/
|
||||
const VideoPlayer: React.FC<{ url: string; title?: string }> = ({ url, title }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const time = parseFloat(e.target.value);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}
|
||||
};
|
||||
|
||||
const formatVideoTime = (time: number): string => {
|
||||
const mins = Math.floor(time / 60);
|
||||
const secs = Math.floor(time % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.requestFullscreen?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
className="aspect-video w-full"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
<div className="flex items-center gap-3 bg-gray-900 px-4 py-2">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="rounded-full p-2 text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-white">
|
||||
{formatVideoTime(currentTime)} / {formatVideoTime(duration)}
|
||||
</span>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="rounded-full p-2 text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="rounded-full p-2 text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
<Maximize2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{title && <p className="bg-gray-800 px-4 py-2 text-sm text-white">{title}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Audio Player Component
|
||||
*/
|
||||
const AudioPlayer: React.FC<{ url: string; title?: string }> = ({ url, title }) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current) {
|
||||
setCurrentTime(audioRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audioRef.current) {
|
||||
setDuration(audioRef.current.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const time = parseFloat(e.target.value);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}
|
||||
};
|
||||
|
||||
const formatAudioTime = (time: number): string => {
|
||||
const mins = Math.floor(time / 60);
|
||||
const secs = Math.floor(time % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-purple-200 bg-gradient-to-r from-purple-50 to-indigo-50 p-4">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={url}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-purple-600 text-white">
|
||||
<Music className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{title && <p className="mb-1 font-medium text-gray-800">{title}</p>}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-600 text-white transition-colors hover:bg-purple-700"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-600">
|
||||
{formatAudioTime(currentTime)} / {formatAudioTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Image Gallery Component
|
||||
*/
|
||||
const ImageGallery: React.FC<{ images: string[]; title?: string }> = ({ images, title }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{title && <p className="font-medium text-gray-800">{title}</p>}
|
||||
{/* Main Image */}
|
||||
<div
|
||||
className="group relative cursor-pointer overflow-hidden rounded-xl border border-gray-200"
|
||||
onClick={() => setIsLightboxOpen(true)}
|
||||
>
|
||||
<img
|
||||
src={images[selectedIndex]}
|
||||
alt={`Imagen ${selectedIndex + 1}`}
|
||||
className="h-64 w-full object-contain bg-gray-100"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/30">
|
||||
<Maximize2 className="h-8 w-8 text-white opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto py-2">
|
||||
{images.map((img, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
className={`h-16 w-16 flex-shrink-0 overflow-hidden rounded-lg border-2 transition-all ${
|
||||
index === selectedIndex ? 'border-orange-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt={`Miniatura ${index + 1}`} className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
<AnimatePresence>
|
||||
{isLightboxOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/90 p-4"
|
||||
onClick={() => setIsLightboxOpen(false)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsLightboxOpen(false)}
|
||||
className="absolute right-4 top-4 rounded-full p-2 text-white hover:bg-white/20"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
<img
|
||||
src={images[selectedIndex]}
|
||||
alt={`Imagen ${selectedIndex + 1}`}
|
||||
className="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-4 flex gap-2">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedIndex(index);
|
||||
}}
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
index === selectedIndex ? 'bg-white' : 'bg-white/50'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Multimedia Content Section
|
||||
* Renders all multimedia content found in the answer
|
||||
*/
|
||||
const MultimediaContent: React.FC<{
|
||||
answerData: Record<string, unknown>;
|
||||
exerciseType: string;
|
||||
}> = ({ answerData, exerciseType }) => {
|
||||
const mediaContent = extractMediaContent(answerData);
|
||||
|
||||
if (mediaContent.length === 0 && !hasMultimediaContent(exerciseType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videos = mediaContent.filter((m) => m.type === 'video');
|
||||
const audios = mediaContent.filter((m) => m.type === 'audio');
|
||||
const images = mediaContent.filter((m) => m.type === 'image');
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50 p-4">
|
||||
<h3 className="flex items-center gap-2 font-bold text-gray-800">
|
||||
{videos.length > 0 && <Video className="h-5 w-5 text-blue-600" />}
|
||||
{audios.length > 0 && <Music className="h-5 w-5 text-purple-600" />}
|
||||
{images.length > 0 && <ImageIcon className="h-5 w-5 text-green-600" />}
|
||||
Contenido Multimedia
|
||||
</h3>
|
||||
|
||||
{/* Videos */}
|
||||
{videos.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{videos.map((video, index) => (
|
||||
<VideoPlayer
|
||||
key={index}
|
||||
url={video.url!}
|
||||
title={videos.length > 1 ? `Video ${index + 1}` : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audios */}
|
||||
{audios.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{audios.map((audio, index) => (
|
||||
<AudioPlayer
|
||||
key={index}
|
||||
url={audio.url!}
|
||||
title={audios.length > 1 ? `Audio ${index + 1}` : exerciseType === 'podcast_argumentativo' ? 'Podcast' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images */}
|
||||
{images.length > 0 && (
|
||||
<ImageGallery
|
||||
images={images.map((img) => img.url!)}
|
||||
title={exerciseType === 'comic_digital' ? 'Paneles del C\u00f3mic' : exerciseType === 'collage_prensa' ? 'Collage' : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Download Links */}
|
||||
{mediaContent.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 border-t border-blue-200 pt-3">
|
||||
{mediaContent.map((media, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={media.url}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 rounded-lg bg-white px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Descargar {media.type === 'video' ? 'Video' : media.type === 'audio' ? 'Audio' : 'Imagen'}{' '}
|
||||
{mediaContent.length > 1 ? index + 1 : ''}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SUB-COMPONENTS
|
||||
// ============================================================================
|
||||
@ -379,7 +873,7 @@ export const ResponseDetailModal: React.FC<ResponseDetailModalProps> = ({
|
||||
{/* Answer Comparison */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-bold text-gray-800">
|
||||
Comparación de Respuestas
|
||||
Comparaci\u00f3n de Respuestas
|
||||
</h3>
|
||||
<AnswerComparison
|
||||
studentAnswer={attempt.submitted_answers}
|
||||
@ -388,6 +882,14 @@ export const ResponseDetailModal: React.FC<ResponseDetailModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* P2-03: Multimedia Content Section */}
|
||||
{hasMultimediaContent(attempt.exercise_type) && (
|
||||
<MultimediaContent
|
||||
answerData={attempt.submitted_answers}
|
||||
exerciseType={attempt.exercise_type}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
|
||||
@ -36,3 +36,22 @@ export type { UseAssignmentsReturn } from './useAssignments';
|
||||
export type { UseInterventionAlertsReturn, AlertFilters } from './useInterventionAlerts';
|
||||
export type { UseTeacherMessagesReturn, MessageFilters } from './useTeacherMessages';
|
||||
export type { UseGrantBonusReturn } from './useGrantBonus';
|
||||
|
||||
// P1-06/P1-07: Mission and Mastery tracking hooks (2025-12-18)
|
||||
export { useMissionStats, useMissionStatsMultiple } from './useMissionStats';
|
||||
export { useMasteryTracking } from './useMasteryTracking';
|
||||
export type { UseMissionStatsReturn, MissionStats, ClassroomMission } from './useMissionStats';
|
||||
export type { UseMasteryTrackingReturn, MasteryData, SkillMastery } from './useMasteryTracking';
|
||||
|
||||
// P2-01: Real-time classroom monitoring (2025-12-18)
|
||||
export { useClassroomRealtime } from './useClassroomRealtime';
|
||||
export type {
|
||||
UseClassroomRealtimeReturn,
|
||||
StudentActivity,
|
||||
ClassroomUpdate,
|
||||
NewSubmission,
|
||||
AlertTriggered,
|
||||
StudentOnlineStatus,
|
||||
ProgressUpdate,
|
||||
RealtimeEvent,
|
||||
} from './useClassroomRealtime';
|
||||
|
||||
@ -0,0 +1,383 @@
|
||||
/**
|
||||
* useClassroomRealtime Hook
|
||||
*
|
||||
* P2-01: Created 2025-12-18
|
||||
* Real-time classroom monitoring via WebSocket for Teacher Portal.
|
||||
*
|
||||
* Features:
|
||||
* - Subscribe to classroom activity updates
|
||||
* - Receive real-time student activity notifications
|
||||
* - Handle new submissions and alerts
|
||||
* - Track student online/offline status
|
||||
* - Auto-reconnect and connection status
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { useAuth } from '@/features/auth/hooks/useAuth';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface StudentActivity {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
classroomId: string;
|
||||
activityType: 'exercise_start' | 'exercise_complete' | 'hint_used' | 'comodin_used' | 'module_start';
|
||||
exerciseId?: string;
|
||||
exerciseTitle?: string;
|
||||
moduleId?: string;
|
||||
moduleTitle?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ClassroomUpdate {
|
||||
classroomId: string;
|
||||
classroomName: string;
|
||||
updateType: 'student_joined' | 'student_left' | 'stats_changed';
|
||||
data: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NewSubmission {
|
||||
submissionId: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
exerciseId: string;
|
||||
exerciseTitle: string;
|
||||
classroomId: string;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
requiresReview: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AlertTriggered {
|
||||
alertId: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
classroomId: string;
|
||||
alertType: 'at_risk' | 'low_performance' | 'inactive' | 'struggling';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface StudentOnlineStatus {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
classroomId: string;
|
||||
isOnline: boolean;
|
||||
lastActivity?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ProgressUpdate {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
classroomId: string;
|
||||
progressType: 'module_complete' | 'exercise_complete' | 'level_up' | 'achievement';
|
||||
details: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type RealtimeEvent =
|
||||
| { type: 'activity'; data: StudentActivity }
|
||||
| { type: 'classroom_update'; data: ClassroomUpdate }
|
||||
| { type: 'submission'; data: NewSubmission }
|
||||
| { type: 'alert'; data: AlertTriggered }
|
||||
| { type: 'online_status'; data: StudentOnlineStatus }
|
||||
| { type: 'progress'; data: ProgressUpdate };
|
||||
|
||||
export interface UseClassroomRealtimeOptions {
|
||||
classroomIds: string[];
|
||||
onActivity?: (data: StudentActivity) => void;
|
||||
onSubmission?: (data: NewSubmission) => void;
|
||||
onAlert?: (data: AlertTriggered) => void;
|
||||
onStudentOnline?: (data: StudentOnlineStatus) => void;
|
||||
onStudentOffline?: (data: StudentOnlineStatus) => void;
|
||||
onProgressUpdate?: (data: ProgressUpdate) => void;
|
||||
onClassroomUpdate?: (data: ClassroomUpdate) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseClassroomRealtimeReturn {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
error: Error | null;
|
||||
events: RealtimeEvent[];
|
||||
onlineStudents: Map<string, StudentOnlineStatus>;
|
||||
clearEvents: () => void;
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SOCKET EVENTS
|
||||
// ============================================================================
|
||||
|
||||
const SocketEvents = {
|
||||
// Client -> Server
|
||||
SUBSCRIBE_CLASSROOM: 'teacher:subscribe_classroom',
|
||||
UNSUBSCRIBE_CLASSROOM: 'teacher:unsubscribe_classroom',
|
||||
// Server -> Client
|
||||
STUDENT_ACTIVITY: 'teacher:student_activity',
|
||||
CLASSROOM_UPDATE: 'teacher:classroom_update',
|
||||
NEW_SUBMISSION: 'teacher:new_submission',
|
||||
ALERT_TRIGGERED: 'teacher:alert_triggered',
|
||||
STUDENT_ONLINE: 'teacher:student_online',
|
||||
STUDENT_OFFLINE: 'teacher:student_offline',
|
||||
PROGRESS_UPDATE: 'teacher:progress_update',
|
||||
AUTHENTICATED: 'authenticated',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HOOK IMPLEMENTATION
|
||||
// ============================================================================
|
||||
|
||||
export function useClassroomRealtime(
|
||||
options: UseClassroomRealtimeOptions,
|
||||
): UseClassroomRealtimeReturn {
|
||||
const {
|
||||
classroomIds,
|
||||
onActivity,
|
||||
onSubmission,
|
||||
onAlert,
|
||||
onStudentOnline,
|
||||
onStudentOffline,
|
||||
onProgressUpdate,
|
||||
onClassroomUpdate,
|
||||
enabled = true,
|
||||
} = options;
|
||||
|
||||
const { user, token } = useAuth();
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const subscribedRoomsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [events, setEvents] = useState<RealtimeEvent[]>([]);
|
||||
const [onlineStudents, setOnlineStudents] = useState<Map<string, StudentOnlineStatus>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
// Add event to history
|
||||
const addEvent = useCallback((event: RealtimeEvent) => {
|
||||
setEvents((prev) => {
|
||||
const newEvents = [event, ...prev];
|
||||
// Keep only last 100 events
|
||||
return newEvents.slice(0, 100);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Clear events
|
||||
const clearEvents = useCallback(() => {
|
||||
setEvents([]);
|
||||
}, []);
|
||||
|
||||
// Subscribe to a classroom
|
||||
const subscribeToClassroom = useCallback((socket: Socket, classroomId: string) => {
|
||||
if (subscribedRoomsRef.current.has(classroomId)) return;
|
||||
|
||||
socket.emit(SocketEvents.SUBSCRIBE_CLASSROOM, { classroomId }, (response: { success: boolean }) => {
|
||||
if (response?.success) {
|
||||
subscribedRoomsRef.current.add(classroomId);
|
||||
console.log(`[useClassroomRealtime] Subscribed to classroom ${classroomId}`);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Unsubscribe from a classroom
|
||||
const unsubscribeFromClassroom = useCallback((socket: Socket, classroomId: string) => {
|
||||
if (!subscribedRoomsRef.current.has(classroomId)) return;
|
||||
|
||||
socket.emit(SocketEvents.UNSUBSCRIBE_CLASSROOM, { classroomId }, (response: { success: boolean }) => {
|
||||
if (response?.success) {
|
||||
subscribedRoomsRef.current.delete(classroomId);
|
||||
console.log(`[useClassroomRealtime] Unsubscribed from classroom ${classroomId}`);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Connect to WebSocket
|
||||
const connect = useCallback(() => {
|
||||
if (!enabled || !user || !token) return;
|
||||
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || 'http://localhost:3000';
|
||||
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
|
||||
const socket = io(wsUrl, {
|
||||
path: '/socket.io/',
|
||||
transports: ['websocket', 'polling'],
|
||||
auth: { token },
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[useClassroomRealtime] Connected');
|
||||
setIsConnecting(false);
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
socket.on(SocketEvents.AUTHENTICATED, () => {
|
||||
console.log('[useClassroomRealtime] Authenticated');
|
||||
// Subscribe to all classrooms
|
||||
classroomIds.forEach((id) => subscribeToClassroom(socket, id));
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('[useClassroomRealtime] Disconnected:', reason);
|
||||
setIsConnected(false);
|
||||
subscribedRoomsRef.current.clear();
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('[useClassroomRealtime] Connection error:', err);
|
||||
setIsConnecting(false);
|
||||
setError(err);
|
||||
});
|
||||
|
||||
socket.on(SocketEvents.ERROR, (data: { message: string }) => {
|
||||
console.error('[useClassroomRealtime] Socket error:', data.message);
|
||||
setError(new Error(data.message));
|
||||
});
|
||||
|
||||
// Activity event
|
||||
socket.on(SocketEvents.STUDENT_ACTIVITY, (data: StudentActivity) => {
|
||||
addEvent({ type: 'activity', data });
|
||||
onActivity?.(data);
|
||||
});
|
||||
|
||||
// Classroom update event
|
||||
socket.on(SocketEvents.CLASSROOM_UPDATE, (data: ClassroomUpdate) => {
|
||||
addEvent({ type: 'classroom_update', data });
|
||||
onClassroomUpdate?.(data);
|
||||
});
|
||||
|
||||
// New submission event
|
||||
socket.on(SocketEvents.NEW_SUBMISSION, (data: NewSubmission) => {
|
||||
addEvent({ type: 'submission', data });
|
||||
onSubmission?.(data);
|
||||
});
|
||||
|
||||
// Alert triggered event
|
||||
socket.on(SocketEvents.ALERT_TRIGGERED, (data: AlertTriggered) => {
|
||||
addEvent({ type: 'alert', data });
|
||||
onAlert?.(data);
|
||||
});
|
||||
|
||||
// Student online event
|
||||
socket.on(SocketEvents.STUDENT_ONLINE, (data: StudentOnlineStatus) => {
|
||||
setOnlineStudents((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(data.studentId, data);
|
||||
return updated;
|
||||
});
|
||||
addEvent({ type: 'online_status', data });
|
||||
onStudentOnline?.(data);
|
||||
});
|
||||
|
||||
// Student offline event
|
||||
socket.on(SocketEvents.STUDENT_OFFLINE, (data: StudentOnlineStatus) => {
|
||||
setOnlineStudents((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.delete(data.studentId);
|
||||
return updated;
|
||||
});
|
||||
addEvent({ type: 'online_status', data });
|
||||
onStudentOffline?.(data);
|
||||
});
|
||||
|
||||
// Progress update event
|
||||
socket.on(SocketEvents.PROGRESS_UPDATE, (data: ProgressUpdate) => {
|
||||
addEvent({ type: 'progress', data });
|
||||
onProgressUpdate?.(data);
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
subscribedRoomsRef.current.clear();
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
user,
|
||||
token,
|
||||
classroomIds,
|
||||
subscribeToClassroom,
|
||||
addEvent,
|
||||
onActivity,
|
||||
onSubmission,
|
||||
onAlert,
|
||||
onStudentOnline,
|
||||
onStudentOffline,
|
||||
onProgressUpdate,
|
||||
onClassroomUpdate,
|
||||
]);
|
||||
|
||||
// Reconnect function
|
||||
const reconnect = useCallback(() => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
subscribedRoomsRef.current.clear();
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
// Effect to connect on mount
|
||||
useEffect(() => {
|
||||
const cleanup = connect();
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Effect to handle classroom subscription changes
|
||||
useEffect(() => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket || !isConnected) return;
|
||||
|
||||
// Get current subscriptions
|
||||
const currentSubs = subscribedRoomsRef.current;
|
||||
const newClassroomIds = new Set(classroomIds);
|
||||
|
||||
// Subscribe to new classrooms
|
||||
classroomIds.forEach((id) => {
|
||||
if (!currentSubs.has(id)) {
|
||||
subscribeToClassroom(socket, id);
|
||||
}
|
||||
});
|
||||
|
||||
// Unsubscribe from removed classrooms
|
||||
currentSubs.forEach((id) => {
|
||||
if (!newClassroomIds.has(id)) {
|
||||
unsubscribeFromClassroom(socket, id);
|
||||
}
|
||||
});
|
||||
}, [classroomIds, isConnected, subscribeToClassroom, unsubscribeFromClassroom]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
error,
|
||||
events,
|
||||
onlineStudents,
|
||||
clearEvents,
|
||||
reconnect,
|
||||
};
|
||||
}
|
||||
|
||||
export default useClassroomRealtime;
|
||||
@ -0,0 +1,353 @@
|
||||
/**
|
||||
* useMasteryTracking Hook
|
||||
*
|
||||
* P1-07: Created 2025-12-18
|
||||
* Tracks student mastery of skills and competencies for Teacher Portal.
|
||||
*
|
||||
* Features:
|
||||
* - Individual student mastery tracking
|
||||
* - Classroom mastery overview
|
||||
* - Skill-level progression
|
||||
* - Competency assessment
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiClient } from '@/services/api/apiClient';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface SkillMastery {
|
||||
skill_id: string;
|
||||
skill_name: string;
|
||||
category: 'comprehension' | 'analysis' | 'synthesis' | 'evaluation' | 'creation';
|
||||
mastery_level: 'novice' | 'developing' | 'proficient' | 'advanced' | 'expert';
|
||||
mastery_percentage: number;
|
||||
exercises_completed: number;
|
||||
exercises_total: number;
|
||||
average_score: number;
|
||||
last_practiced: string | null;
|
||||
trend: 'improving' | 'stable' | 'declining';
|
||||
}
|
||||
|
||||
export interface CompetencyProgress {
|
||||
competency_id: string;
|
||||
competency_name: string;
|
||||
description: string;
|
||||
skills: SkillMastery[];
|
||||
overall_mastery: number;
|
||||
status: 'not_started' | 'in_progress' | 'mastered';
|
||||
}
|
||||
|
||||
export interface MasteryData {
|
||||
student_id: string;
|
||||
student_name: string;
|
||||
overall_mastery: number;
|
||||
mastery_level: 'novice' | 'developing' | 'proficient' | 'advanced' | 'expert';
|
||||
competencies: CompetencyProgress[];
|
||||
strengths: SkillMastery[];
|
||||
areas_for_improvement: SkillMastery[];
|
||||
learning_velocity: number; // Skills mastered per week
|
||||
time_to_mastery_estimate?: number; // Days to complete current module
|
||||
}
|
||||
|
||||
export interface ClassroomMasteryOverview {
|
||||
classroom_id: string;
|
||||
classroom_name: string;
|
||||
average_mastery: number;
|
||||
students_by_level: {
|
||||
novice: number;
|
||||
developing: number;
|
||||
proficient: number;
|
||||
advanced: number;
|
||||
expert: number;
|
||||
};
|
||||
top_skills: SkillMastery[];
|
||||
struggling_skills: SkillMastery[];
|
||||
}
|
||||
|
||||
export interface UseMasteryTrackingReturn {
|
||||
data: MasteryData | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseClassroomMasteryReturn {
|
||||
overview: ClassroomMasteryOverview | null;
|
||||
students: MasteryData[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate mastery level from percentage
|
||||
*/
|
||||
const getMasteryLevel = (percentage: number): SkillMastery['mastery_level'] => {
|
||||
if (percentage >= 90) return 'expert';
|
||||
if (percentage >= 75) return 'advanced';
|
||||
if (percentage >= 60) return 'proficient';
|
||||
if (percentage >= 40) return 'developing';
|
||||
return 'novice';
|
||||
};
|
||||
|
||||
/**
|
||||
* Map module progress to skill mastery
|
||||
*/
|
||||
const mapModuleToSkills = (moduleProgress: any): SkillMastery[] => {
|
||||
// Define skills per module based on GAMILIT's 5 reading comprehension levels
|
||||
const skillMappings: Record<number, { name: string; category: SkillMastery['category'] }[]> = {
|
||||
1: [
|
||||
{ name: 'Identificación de Ideas Principales', category: 'comprehension' },
|
||||
{ name: 'Vocabulario Contextual', category: 'comprehension' },
|
||||
],
|
||||
2: [
|
||||
{ name: 'Inferencia Textual', category: 'analysis' },
|
||||
{ name: 'Predicción Narrativa', category: 'analysis' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Análisis Crítico', category: 'evaluation' },
|
||||
{ name: 'Evaluación de Argumentos', category: 'evaluation' },
|
||||
],
|
||||
4: [
|
||||
{ name: 'Alfabetización Mediática', category: 'synthesis' },
|
||||
{ name: 'Verificación de Fuentes', category: 'synthesis' },
|
||||
],
|
||||
5: [
|
||||
{ name: 'Creación de Contenido', category: 'creation' },
|
||||
{ name: 'Expresión Multimedia', category: 'creation' },
|
||||
],
|
||||
};
|
||||
|
||||
const moduleOrder = moduleProgress.module_order || 1;
|
||||
const skills = skillMappings[moduleOrder] || skillMappings[1];
|
||||
|
||||
return skills.map((skill, index) => ({
|
||||
skill_id: `skill-${moduleOrder}-${index}`,
|
||||
skill_name: skill.name,
|
||||
category: skill.category,
|
||||
mastery_level: getMasteryLevel(moduleProgress.average_score || 0),
|
||||
mastery_percentage: moduleProgress.average_score || 0,
|
||||
exercises_completed: moduleProgress.completed_activities || 0,
|
||||
exercises_total: moduleProgress.total_activities || 15,
|
||||
average_score: moduleProgress.average_score || 0,
|
||||
last_practiced: moduleProgress.last_activity_date || null,
|
||||
trend: 'stable' as const,
|
||||
}));
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HOOKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* useMasteryTracking
|
||||
*
|
||||
* Tracks mastery progress for an individual student
|
||||
*
|
||||
* @param studentId - ID of the student to track
|
||||
* @returns Mastery data, loading state, error, and refresh function
|
||||
*/
|
||||
export function useMasteryTracking(studentId: string): UseMasteryTrackingReturn {
|
||||
const [data, setData] = useState<MasteryData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchMastery = useCallback(async () => {
|
||||
if (!studentId) {
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch student progress data
|
||||
const response = await apiClient.get(`/teacher/students/${studentId}/progress`);
|
||||
const progressData = response.data.data || response.data;
|
||||
|
||||
// Extract module progress
|
||||
const moduleProgress = progressData.moduleProgress || [];
|
||||
|
||||
// Map to skills
|
||||
const allSkills: SkillMastery[] = moduleProgress.flatMap(mapModuleToSkills);
|
||||
|
||||
// Calculate overall mastery
|
||||
const overallMastery = allSkills.length > 0
|
||||
? Math.round(allSkills.reduce((sum, s) => sum + s.mastery_percentage, 0) / allSkills.length)
|
||||
: 0;
|
||||
|
||||
// Identify strengths and areas for improvement
|
||||
const sortedSkills = [...allSkills].sort((a, b) => b.mastery_percentage - a.mastery_percentage);
|
||||
const strengths = sortedSkills.slice(0, 3);
|
||||
const areasForImprovement = sortedSkills.slice(-3).reverse();
|
||||
|
||||
// Group skills into competencies
|
||||
const competencyMap = new Map<string, SkillMastery[]>();
|
||||
allSkills.forEach(skill => {
|
||||
const key = skill.category;
|
||||
if (!competencyMap.has(key)) {
|
||||
competencyMap.set(key, []);
|
||||
}
|
||||
competencyMap.get(key)!.push(skill);
|
||||
});
|
||||
|
||||
const competencies: CompetencyProgress[] = Array.from(competencyMap.entries()).map(
|
||||
([category, skills]) => {
|
||||
const avgMastery = skills.reduce((sum, s) => sum + s.mastery_percentage, 0) / skills.length;
|
||||
return {
|
||||
competency_id: `comp-${category}`,
|
||||
competency_name: getCategoryName(category as SkillMastery['category']),
|
||||
description: getCategoryDescription(category as SkillMastery['category']),
|
||||
skills,
|
||||
overall_mastery: Math.round(avgMastery),
|
||||
status: avgMastery >= 80 ? 'mastered' : avgMastery > 0 ? 'in_progress' : 'not_started',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setData({
|
||||
student_id: studentId,
|
||||
student_name: progressData.student?.full_name || 'Estudiante',
|
||||
overall_mastery: overallMastery,
|
||||
mastery_level: getMasteryLevel(overallMastery),
|
||||
competencies,
|
||||
strengths,
|
||||
areas_for_improvement: areasForImprovement,
|
||||
learning_velocity: 2, // Placeholder - would need historical data
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[useMasteryTracking] Error:', err);
|
||||
setError(err as Error);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [studentId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMastery();
|
||||
}, [fetchMastery]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchMastery,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useClassroomMastery
|
||||
*
|
||||
* Tracks mastery overview for an entire classroom
|
||||
*
|
||||
* @param classroomId - ID of the classroom to track
|
||||
* @returns Classroom mastery overview and individual student data
|
||||
*/
|
||||
export function useClassroomMastery(classroomId: string): UseClassroomMasteryReturn {
|
||||
const [overview, setOverview] = useState<ClassroomMasteryOverview | null>(null);
|
||||
const [students, setStudents] = useState<MasteryData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchMastery = useCallback(async () => {
|
||||
if (!classroomId) {
|
||||
setOverview(null);
|
||||
setStudents([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch classroom students
|
||||
const studentsResponse = await apiClient.get(`/teacher/classrooms/${classroomId}/students`);
|
||||
const studentsData = studentsResponse.data.data || studentsResponse.data || [];
|
||||
|
||||
// Placeholder for aggregated data
|
||||
// In a real implementation, this would come from a dedicated endpoint
|
||||
const studentLevels = {
|
||||
novice: 0,
|
||||
developing: 0,
|
||||
proficient: 0,
|
||||
advanced: 0,
|
||||
expert: 0,
|
||||
};
|
||||
|
||||
// Count students per level (placeholder logic)
|
||||
studentsData.forEach((student: any) => {
|
||||
const level = getMasteryLevel(student.progress_percentage || 50);
|
||||
studentLevels[level]++;
|
||||
});
|
||||
|
||||
setOverview({
|
||||
classroom_id: classroomId,
|
||||
classroom_name: 'Classroom',
|
||||
average_mastery: 65, // Placeholder
|
||||
students_by_level: studentLevels,
|
||||
top_skills: [],
|
||||
struggling_skills: [],
|
||||
});
|
||||
|
||||
setStudents([]);
|
||||
} catch (err) {
|
||||
console.error('[useClassroomMastery] Error:', err);
|
||||
setError(err as Error);
|
||||
setOverview(null);
|
||||
setStudents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [classroomId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMastery();
|
||||
}, [fetchMastery]);
|
||||
|
||||
return {
|
||||
overview,
|
||||
students,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchMastery,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
function getCategoryName(category: SkillMastery['category']): string {
|
||||
const names: Record<SkillMastery['category'], string> = {
|
||||
comprehension: 'Comprensión Literal',
|
||||
analysis: 'Comprensión Inferencial',
|
||||
evaluation: 'Lectura Crítica',
|
||||
synthesis: 'Alfabetización Mediática',
|
||||
creation: 'Producción Textual',
|
||||
};
|
||||
return names[category];
|
||||
}
|
||||
|
||||
function getCategoryDescription(category: SkillMastery['category']): string {
|
||||
const descriptions: Record<SkillMastery['category'], string> = {
|
||||
comprehension: 'Identificación y extracción de información explícita del texto',
|
||||
analysis: 'Conexión de ideas y generación de inferencias a partir del texto',
|
||||
evaluation: 'Análisis crítico y evaluación de argumentos y fuentes',
|
||||
synthesis: 'Integración de múltiples fuentes y formatos de información',
|
||||
creation: 'Producción de contenido original basado en la comprensión lectora',
|
||||
};
|
||||
return descriptions[category];
|
||||
}
|
||||
|
||||
export default useMasteryTracking;
|
||||
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* useMissionStats Hook
|
||||
*
|
||||
* P1-06: Created 2025-12-18
|
||||
* Fetches and manages mission statistics for Teacher Portal.
|
||||
*
|
||||
* Features:
|
||||
* - Classroom missions overview
|
||||
* - Completion rates and participation metrics
|
||||
* - Top participants tracking
|
||||
* - Active missions monitoring
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiClient } from '@/services/api/apiClient';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface ClassroomMission {
|
||||
id: string;
|
||||
classroom_id: string;
|
||||
mission_template_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
mission_type: 'daily' | 'weekly' | 'special';
|
||||
objectives: Array<{
|
||||
type: string;
|
||||
target: number;
|
||||
description?: string;
|
||||
}>;
|
||||
base_rewards: {
|
||||
ml_coins: number;
|
||||
xp: number;
|
||||
};
|
||||
total_rewards: {
|
||||
ml_coins: number;
|
||||
xp: number;
|
||||
};
|
||||
bonus_xp: number;
|
||||
bonus_coins: number;
|
||||
is_mandatory: boolean;
|
||||
is_active: boolean;
|
||||
due_date?: string;
|
||||
assigned_at: string;
|
||||
assigned_by: string;
|
||||
}
|
||||
|
||||
export interface MissionParticipant {
|
||||
student_id: string;
|
||||
student_name: string;
|
||||
avatar_url?: string;
|
||||
missions_completed: number;
|
||||
total_xp_earned: number;
|
||||
total_coins_earned: number;
|
||||
}
|
||||
|
||||
export interface MissionStats {
|
||||
activeMissions: ClassroomMission[];
|
||||
completionRate: number;
|
||||
participationRate: number;
|
||||
topParticipants: MissionParticipant[];
|
||||
totalMissionsAssigned: number;
|
||||
totalMissionsCompleted: number;
|
||||
averageCompletionTime?: number;
|
||||
}
|
||||
|
||||
export interface UseMissionStatsReturn {
|
||||
stats: MissionStats | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
const fetchClassroomMissions = async (classroomId: string): Promise<ClassroomMission[]> => {
|
||||
const response = await apiClient.get(`/gamification/classrooms/${classroomId}/missions`);
|
||||
return response.data.data || response.data || [];
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HOOK
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* useMissionStats
|
||||
*
|
||||
* @param classroomId - ID of the classroom to get mission stats for
|
||||
* @returns Mission statistics, loading state, error, and refresh function
|
||||
*/
|
||||
export function useMissionStats(classroomId: string): UseMissionStatsReturn {
|
||||
const [stats, setStats] = useState<MissionStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!classroomId) {
|
||||
setStats(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch missions for the classroom
|
||||
const missions = await fetchClassroomMissions(classroomId);
|
||||
|
||||
// Filter active missions
|
||||
const activeMissions = missions.filter(m => m.is_active);
|
||||
|
||||
// Calculate stats (in a real implementation, this would come from backend)
|
||||
// For now, we'll calculate basic metrics from the missions data
|
||||
const totalMissionsAssigned = missions.length;
|
||||
const mandatoryMissions = missions.filter(m => m.is_mandatory);
|
||||
|
||||
// These would ideally come from a dedicated stats endpoint
|
||||
// For now, we estimate based on available data
|
||||
const completionRate = totalMissionsAssigned > 0
|
||||
? Math.round((missions.filter(m => !m.is_active).length / totalMissionsAssigned) * 100)
|
||||
: 0;
|
||||
|
||||
// Participation rate would require student progress data
|
||||
// This is a placeholder - actual implementation would query student mission progress
|
||||
const participationRate = activeMissions.length > 0 ? 75 : 0;
|
||||
|
||||
// Top participants would also require additional queries
|
||||
// Placeholder for now
|
||||
const topParticipants: MissionParticipant[] = [];
|
||||
|
||||
setStats({
|
||||
activeMissions,
|
||||
completionRate,
|
||||
participationRate,
|
||||
topParticipants,
|
||||
totalMissionsAssigned,
|
||||
totalMissionsCompleted: missions.filter(m => !m.is_active).length,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[useMissionStats] Error:', err);
|
||||
setError(err as Error);
|
||||
setStats(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [classroomId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
return {
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchStats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useMissionStatsMultiple
|
||||
*
|
||||
* Fetches mission stats for multiple classrooms (useful for teacher dashboard)
|
||||
*
|
||||
* @param classroomIds - Array of classroom IDs
|
||||
* @returns Aggregated mission statistics across all classrooms
|
||||
*/
|
||||
export function useMissionStatsMultiple(classroomIds: string[]): UseMissionStatsReturn {
|
||||
const [stats, setStats] = useState<MissionStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!classroomIds.length) {
|
||||
setStats(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch missions for all classrooms in parallel
|
||||
const allMissionsArrays = await Promise.all(
|
||||
classroomIds.map(id => fetchClassroomMissions(id).catch(() => [])),
|
||||
);
|
||||
|
||||
// Flatten and aggregate
|
||||
const allMissions = allMissionsArrays.flat();
|
||||
const activeMissions = allMissions.filter(m => m.is_active);
|
||||
|
||||
const totalMissionsAssigned = allMissions.length;
|
||||
const totalMissionsCompleted = allMissions.filter(m => !m.is_active).length;
|
||||
|
||||
const completionRate = totalMissionsAssigned > 0
|
||||
? Math.round((totalMissionsCompleted / totalMissionsAssigned) * 100)
|
||||
: 0;
|
||||
|
||||
setStats({
|
||||
activeMissions,
|
||||
completionRate,
|
||||
participationRate: activeMissions.length > 0 ? 75 : 0,
|
||||
topParticipants: [],
|
||||
totalMissionsAssigned,
|
||||
totalMissionsCompleted,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[useMissionStatsMultiple] Error:', err);
|
||||
setError(err as Error);
|
||||
setStats(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [classroomIds]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
return {
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchStats,
|
||||
};
|
||||
}
|
||||
|
||||
export default useMissionStats;
|
||||
@ -9,9 +9,8 @@
|
||||
* - Filtros y búsqueda
|
||||
* - Paginación
|
||||
*
|
||||
* ESTADO: Descartada para Fase 2 (no depende de actividad del estudiante)
|
||||
* La funcionalidad está implementada pero deshabilitada temporalmente.
|
||||
* Cambiar SHOW_UNDER_CONSTRUCTION a false para habilitar.
|
||||
* ESTADO: HABILITADO (2025-12-18)
|
||||
* Funcionalidad completa disponible.
|
||||
*
|
||||
* @module apps/teacher/pages/TeacherCommunicationPage
|
||||
*/
|
||||
@ -34,9 +33,9 @@ import { Message } from '../../../services/api/teacher/teacherMessagesApi';
|
||||
import { classroomsApi } from '../../../services/api/teacher/classroomsApi';
|
||||
|
||||
// ============================================================================
|
||||
// FEATURE FLAG - Cambiar a false para habilitar la funcionalidad completa
|
||||
// FEATURE FLAG - Habilitado 2025-12-18
|
||||
// ============================================================================
|
||||
const SHOW_UNDER_CONSTRUCTION = true;
|
||||
const SHOW_UNDER_CONSTRUCTION = false;
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
|
||||
@ -5,16 +5,15 @@ import TeacherContentManagement from './TeacherContentManagement';
|
||||
import { UnderConstruction } from '@shared/components/UnderConstruction';
|
||||
|
||||
// ============================================================================
|
||||
// FEATURE FLAG - Cambiar a false para habilitar la funcionalidad completa
|
||||
// FEATURE FLAG - Habilitado 2025-12-18
|
||||
// ============================================================================
|
||||
const SHOW_UNDER_CONSTRUCTION = true;
|
||||
const SHOW_UNDER_CONSTRUCTION = false;
|
||||
|
||||
/**
|
||||
* TeacherContentPage - Página de gestión de contenido educativo
|
||||
*
|
||||
* ESTADO: Descartada para Fase 2 (no depende de actividad del estudiante)
|
||||
* La funcionalidad está implementada pero deshabilitada temporalmente.
|
||||
* Cambiar SHOW_UNDER_CONSTRUCTION a false para habilitar.
|
||||
* ESTADO: HABILITADO (2025-12-18)
|
||||
* Funcionalidad completa disponible.
|
||||
*/
|
||||
export default function TeacherContentPage() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
@ -4,6 +4,10 @@ import { FeedbackModal } from '@shared/components/mechanics/FeedbackModal';
|
||||
import { MatchingCard } from './MatchingCard';
|
||||
import { EmparejamientoExerciseProps } from './emparejamientoTypes';
|
||||
import { calculateScore, FeedbackData } from '@shared/components/mechanics/mechanicsTypes';
|
||||
// P0-02: Added 2025-12-18 - Backend submission for progress persistence
|
||||
import { submitExercise } from '@/features/progress/api/progressAPI';
|
||||
import { useAuth } from '@/features/auth/hooks/useAuth';
|
||||
import { useInvalidateDashboard } from '@/shared/hooks';
|
||||
|
||||
export const EmparejamientoExercise: React.FC<EmparejamientoExerciseProps> = ({
|
||||
exercise,
|
||||
@ -18,6 +22,10 @@ export const EmparejamientoExercise: React.FC<EmparejamientoExerciseProps> = ({
|
||||
const [startTime] = useState(new Date());
|
||||
const [hintsUsed] = useState(0);
|
||||
|
||||
// P0-02: Added 2025-12-18 - Hooks for backend submission
|
||||
const { user } = useAuth();
|
||||
const invalidateDashboard = useInvalidateDashboard();
|
||||
|
||||
// FE-055: Notify parent of progress updates WITH user answers
|
||||
React.useEffect(() => {
|
||||
if (onProgressUpdate) {
|
||||
@ -91,15 +99,70 @@ export const EmparejamientoExercise: React.FC<EmparejamientoExerciseProps> = ({
|
||||
const isComplete = matched === total;
|
||||
const score = calculateScore(matched / 2, total / 2);
|
||||
|
||||
setFeedback({
|
||||
type: isComplete ? 'success' : 'error',
|
||||
title: isComplete ? '¡Completado!' : 'Faltan parejas',
|
||||
message: isComplete
|
||||
? '¡Emparejaste todas las tarjetas correctamente!'
|
||||
: `Emparejaste ${matched / 2} de ${total / 2} parejas.`,
|
||||
score: isComplete ? score : undefined,
|
||||
showConfetti: isComplete,
|
||||
});
|
||||
// P0-02: Submit to backend when complete
|
||||
if (isComplete && user?.id) {
|
||||
try {
|
||||
// Prepare matched pairs for submission
|
||||
const matchedCards = cards.filter((c) => c.isMatched);
|
||||
const matchGroups: Record<string, typeof cards> = {};
|
||||
matchedCards.forEach((card) => {
|
||||
if (!matchGroups[card.matchId]) {
|
||||
matchGroups[card.matchId] = [];
|
||||
}
|
||||
matchGroups[card.matchId].push(card);
|
||||
});
|
||||
|
||||
const matches = Object.values(matchGroups).map((group) => {
|
||||
const left = group.find((c) => c.type === 'question');
|
||||
const right = group.find((c) => c.type === 'answer');
|
||||
return { leftId: left?.id, rightId: right?.id, matchId: left?.matchId };
|
||||
});
|
||||
|
||||
const response = await submitExercise(exercise.id, user.id, { matches });
|
||||
|
||||
// Invalidate dashboard to reflect new progress
|
||||
invalidateDashboard();
|
||||
|
||||
console.log('✅ [Emparejamiento] Submitted to backend:', response);
|
||||
|
||||
setFeedback({
|
||||
type: response.isPerfect ? 'success' : response.score >= 70 ? 'partial' : 'error',
|
||||
title: response.isPerfect
|
||||
? '¡Perfecto!'
|
||||
: response.score >= 70
|
||||
? '¡Buen trabajo!'
|
||||
: 'Sigue practicando',
|
||||
message: response.isPerfect
|
||||
? '¡Emparejaste todas las tarjetas correctamente!'
|
||||
: `Obtuviste ${response.score}% de aciertos.`,
|
||||
score: response.score,
|
||||
xpEarned: response.xp_earned,
|
||||
coinsEarned: response.coins_earned,
|
||||
showConfetti: response.isPerfect,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ [Emparejamiento] Submit error:', error);
|
||||
// Fallback to local feedback on error
|
||||
setFeedback({
|
||||
type: 'success',
|
||||
title: '¡Completado!',
|
||||
message: '¡Emparejaste todas las tarjetas correctamente!',
|
||||
score,
|
||||
showConfetti: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Not complete or no user - show local feedback
|
||||
setFeedback({
|
||||
type: isComplete ? 'success' : 'error',
|
||||
title: isComplete ? '¡Completado!' : 'Faltan parejas',
|
||||
message: isComplete
|
||||
? '¡Emparejaste todas las tarjetas correctamente!'
|
||||
: `Emparejaste ${matched / 2} de ${total / 2} parejas.`,
|
||||
score: isComplete ? score : undefined,
|
||||
showConfetti: isComplete,
|
||||
});
|
||||
}
|
||||
setShowFeedback(true);
|
||||
};
|
||||
|
||||
|
||||
@ -12,6 +12,10 @@ import { MatchingDragDrop, MatchingPair } from './MatchingDragDrop';
|
||||
import { calculateScore, saveProgress } from '@shared/components/mechanics/mechanicsTypes';
|
||||
import { Check, RotateCcw } from 'lucide-react';
|
||||
import type { FeedbackData } from '@shared/components/mechanics/mechanicsTypes';
|
||||
// P0-02-B: Added 2025-12-18 - Backend submission for progress persistence
|
||||
import { submitExercise } from '@/features/progress/api/progressAPI';
|
||||
import { useAuth } from '@/features/auth/hooks/useAuth';
|
||||
import { useInvalidateDashboard } from '@/shared/hooks';
|
||||
|
||||
export interface EmparejamientoDragDropData {
|
||||
id: string;
|
||||
@ -57,6 +61,10 @@ export const EmparejamientoExerciseDragDrop: React.FC<EmparejamientoDragDropProp
|
||||
const [currentScore, setCurrentScore] = useState(0);
|
||||
const [checkClicked, setCheckClicked] = useState(false);
|
||||
|
||||
// P0-02-B: Added 2025-12-18 - Hooks for backend submission
|
||||
const { user } = useAuth();
|
||||
const invalidateDashboard = useInvalidateDashboard();
|
||||
|
||||
const handleConnect = (itemAId: string, itemBId: string) => {
|
||||
const newConnections = new Map(connections);
|
||||
newConnections.set(itemBId, itemAId);
|
||||
@ -78,7 +86,7 @@ export const EmparejamientoExerciseDragDrop: React.FC<EmparejamientoDragDropProp
|
||||
alert(`Pista: ${hint.text}`);
|
||||
};
|
||||
|
||||
const handleCheck = () => {
|
||||
const handleCheck = async () => {
|
||||
setCheckClicked(true);
|
||||
const allConnected = connections.size === exercise.pairs.length;
|
||||
|
||||
@ -106,15 +114,61 @@ export const EmparejamientoExerciseDragDrop: React.FC<EmparejamientoDragDropProp
|
||||
|
||||
const isSuccess = correctCount === exercise.pairs.length;
|
||||
|
||||
setFeedback({
|
||||
type: isSuccess ? 'success' : 'error',
|
||||
title: isSuccess ? '¡Emparejamiento Completado!' : 'Revisa tus Parejas',
|
||||
message: isSuccess
|
||||
? '¡Excelente trabajo! Has emparejado todos los elementos correctamente.'
|
||||
: `Has emparejado ${correctCount} de ${exercise.pairs.length} elementos correctamente. Revisa los marcados en rojo.`,
|
||||
score: isSuccess ? score : undefined,
|
||||
showConfetti: isSuccess,
|
||||
});
|
||||
// P0-02-B: Submit to backend when complete
|
||||
if (isSuccess && user?.id) {
|
||||
try {
|
||||
// Prepare connections for submission
|
||||
const matchesData = Array.from(connections.entries()).map(([itemBId, itemAId]) => ({
|
||||
itemBId,
|
||||
itemAId,
|
||||
pairId: exercise.pairs.find(p => p.id === itemBId)?.id,
|
||||
}));
|
||||
|
||||
const response = await submitExercise(exercise.id, user.id, { connections: matchesData });
|
||||
|
||||
// Invalidate dashboard to reflect new progress
|
||||
invalidateDashboard();
|
||||
|
||||
console.log('✅ [EmparejamientoDragDrop] Submitted to backend:', response);
|
||||
|
||||
setFeedback({
|
||||
type: response.isPerfect ? 'success' : response.score >= 70 ? 'partial' : 'error',
|
||||
title: response.isPerfect
|
||||
? '¡Perfecto!'
|
||||
: response.score >= 70
|
||||
? '¡Buen trabajo!'
|
||||
: 'Sigue practicando',
|
||||
message: response.isPerfect
|
||||
? '¡Excelente trabajo! Has emparejado todos los elementos correctamente.'
|
||||
: `Obtuviste ${response.score}% de aciertos.`,
|
||||
score: response.score,
|
||||
xpEarned: response.xp_earned,
|
||||
coinsEarned: response.coins_earned,
|
||||
showConfetti: response.isPerfect,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ [EmparejamientoDragDrop] Submit error:', error);
|
||||
// Fallback to local feedback on error
|
||||
setFeedback({
|
||||
type: 'success',
|
||||
title: '¡Emparejamiento Completado!',
|
||||
message: '¡Excelente trabajo! Has emparejado todos los elementos correctamente.',
|
||||
score,
|
||||
showConfetti: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Not success or no user - show local feedback
|
||||
setFeedback({
|
||||
type: isSuccess ? 'success' : 'error',
|
||||
title: isSuccess ? '¡Emparejamiento Completado!' : 'Revisa tus Parejas',
|
||||
message: isSuccess
|
||||
? '¡Excelente trabajo! Has emparejado todos los elementos correctamente.'
|
||||
: `Has emparejado ${correctCount} de ${exercise.pairs.length} elementos correctamente. Revisa los marcados en rojo.`,
|
||||
score: isSuccess ? score : undefined,
|
||||
showConfetti: isSuccess,
|
||||
});
|
||||
}
|
||||
setShowFeedback(true);
|
||||
};
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/services/api/apiClient';
|
||||
import { handleAPIError } from '@/services/api/apiErrorHandler';
|
||||
|
||||
/**
|
||||
* @deprecated Usar Mission de @/features/gamification/missions/types/missionsTypes.ts
|
||||
@ -56,47 +57,71 @@ export const missionsAPI = {
|
||||
* Get 3 daily missions (auto-generates if needed)
|
||||
*/
|
||||
getDailyMissions: async (): Promise<Mission[]> => {
|
||||
const response = await apiClient.get('/gamification/missions/daily');
|
||||
return response.data.data.missions;
|
||||
try {
|
||||
const response = await apiClient.get('/gamification/missions/daily');
|
||||
return response.data.data.missions;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get 5 weekly missions (auto-generates if needed)
|
||||
*/
|
||||
getWeeklyMissions: async (): Promise<Mission[]> => {
|
||||
const response = await apiClient.get('/gamification/missions/weekly');
|
||||
return response.data.data.missions;
|
||||
try {
|
||||
const response = await apiClient.get('/gamification/missions/weekly');
|
||||
return response.data.data.missions;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get active special missions (events)
|
||||
*/
|
||||
getSpecialMissions: async (): Promise<Mission[]> => {
|
||||
const response = await apiClient.get('/gamification/missions/special');
|
||||
return response.data.data.missions;
|
||||
try {
|
||||
const response = await apiClient.get('/gamification/missions/special');
|
||||
return response.data.data.missions;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Claim mission rewards
|
||||
*/
|
||||
claimRewards: async (missionId: string) => {
|
||||
const response = await apiClient.post(`/gamification/missions/${missionId}/claim`);
|
||||
return response.data.data;
|
||||
try {
|
||||
const response = await apiClient.post(`/gamification/missions/${missionId}/claim`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get mission progress
|
||||
*/
|
||||
getMissionProgress: async (missionId: string) => {
|
||||
const response = await apiClient.get(`/gamification/missions/${missionId}/progress`);
|
||||
return response.data.data;
|
||||
try {
|
||||
const response = await apiClient.get(`/gamification/missions/${missionId}/progress`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user mission statistics
|
||||
*/
|
||||
getMissionStats: async (userId: string) => {
|
||||
const response = await apiClient.get(`/gamification/missions/stats/${userId}`);
|
||||
return response.data.data;
|
||||
try {
|
||||
const response = await apiClient.get(`/gamification/missions/stats/${userId}`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './apiClient';
|
||||
import { handleAPIError } from './apiErrorHandler';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
@ -71,8 +72,12 @@ export const passwordAPI = {
|
||||
* ```
|
||||
*/
|
||||
requestPasswordReset: async (email: string): Promise<PasswordResetRequestResponse> => {
|
||||
const response = await apiClient.post('/auth/reset-password/request', { email });
|
||||
return response.data;
|
||||
try {
|
||||
const response = await apiClient.post('/auth/reset-password/request', { email });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@ -90,11 +95,15 @@ export const passwordAPI = {
|
||||
* ```
|
||||
*/
|
||||
resetPassword: async (token: string, newPassword: string): Promise<PasswordResetResponse> => {
|
||||
const response = await apiClient.post('/auth/reset-password', {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
});
|
||||
return response.data;
|
||||
try {
|
||||
const response = await apiClient.post('/auth/reset-password', {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './apiClient';
|
||||
import { handleAPIError } from './apiErrorHandler';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
@ -101,8 +102,12 @@ export const profileAPI = {
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
updateProfile: async (userId: string, data: UpdateProfileDto): Promise<ProfileUpdateResponse> => {
|
||||
const response = await apiClient.put(`/users/${userId}/profile`, data);
|
||||
return response.data;
|
||||
try {
|
||||
const response = await apiClient.put(`/users/${userId}/profile`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@ -111,8 +116,12 @@ export const profileAPI = {
|
||||
* @returns User preferences data
|
||||
*/
|
||||
getPreferences: async (): Promise<{ preferences: Record<string, unknown> }> => {
|
||||
const response = await apiClient.get('/users/preferences');
|
||||
return response.data;
|
||||
try {
|
||||
const response = await apiClient.get('/users/preferences');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@ -126,8 +135,12 @@ export const profileAPI = {
|
||||
userId: string,
|
||||
preferences: UpdatePreferencesDto,
|
||||
): Promise<PreferencesUpdateResponse> => {
|
||||
const response = await apiClient.put(`/users/${userId}/preferences`, { preferences });
|
||||
return response.data;
|
||||
try {
|
||||
const response = await apiClient.put(`/users/${userId}/preferences`, { preferences });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@ -138,14 +151,18 @@ export const profileAPI = {
|
||||
* @returns Avatar URL
|
||||
*/
|
||||
uploadAvatar: async (userId: string, file: File): Promise<AvatarUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
const response = await apiClient.post(`/users/${userId}/avatar`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const response = await apiClient.post(`/users/${userId}/avatar`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
return response.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@ -159,8 +176,12 @@ export const profileAPI = {
|
||||
userId: string,
|
||||
passwords: UpdatePasswordDto,
|
||||
): Promise<PasswordUpdateResponse> => {
|
||||
const response = await apiClient.put(`/users/${userId}/password`, passwords);
|
||||
return response.data;
|
||||
try {
|
||||
const response = await apiClient.put(`/users/${userId}/password`, passwords);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleAPIError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { FileText, CheckCircle, XCircle, Music, Type, Grid3X3, ListChecks } from 'lucide-react';
|
||||
import { FileText, CheckCircle, XCircle, Music, Type, Grid3X3, ListChecks, Link2 } from 'lucide-react';
|
||||
|
||||
interface ExerciseContentRendererProps {
|
||||
exerciseType: string;
|
||||
@ -35,10 +35,22 @@ export const ExerciseContentRenderer: React.FC<ExerciseContentRendererProps> = (
|
||||
return <PodcastRenderer data={answerData} />;
|
||||
|
||||
case 'verdadero_falso':
|
||||
return <VerdaderoFalsoRenderer data={answerData} correct={correctAnswer} showComparison={showComparison} />;
|
||||
return (
|
||||
<VerdaderoFalsoRenderer
|
||||
data={answerData}
|
||||
correct={correctAnswer}
|
||||
showComparison={showComparison}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'completar_espacios':
|
||||
return <CompletarEspaciosRenderer data={answerData} correct={correctAnswer} showComparison={showComparison} />;
|
||||
return (
|
||||
<CompletarEspaciosRenderer
|
||||
data={answerData}
|
||||
correct={correctAnswer}
|
||||
showComparison={showComparison}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'crucigrama':
|
||||
return <CrucigramaRenderer data={answerData} />;
|
||||
@ -52,22 +64,47 @@ export const ExerciseContentRenderer: React.FC<ExerciseContentRendererProps> = (
|
||||
case 'timeline':
|
||||
return <TimelineRenderer data={answerData} />;
|
||||
|
||||
// Módulo 2
|
||||
case 'emparejamiento':
|
||||
return (
|
||||
<EmparejamientoRenderer
|
||||
data={answerData}
|
||||
correct={correctAnswer}
|
||||
showComparison={showComparison}
|
||||
/>
|
||||
);
|
||||
|
||||
// Módulo 2 - Automáticos (opción múltiple)
|
||||
case 'lectura_inferencial':
|
||||
case 'prediccion_narrativa':
|
||||
case 'puzzle_contexto':
|
||||
case 'detective_textual':
|
||||
case 'rueda_inferencias':
|
||||
case 'causa_efecto':
|
||||
return <MultipleChoiceRenderer data={answerData} correct={correctAnswer} showComparison={showComparison} />;
|
||||
return (
|
||||
<MultipleChoiceRenderer
|
||||
data={answerData}
|
||||
correct={correctAnswer}
|
||||
showComparison={showComparison}
|
||||
/>
|
||||
);
|
||||
|
||||
// Módulo 3
|
||||
// Módulo 2 - Manuales (texto abierto)
|
||||
// P0-03: Moved prediccion_narrativa to TextResponseRenderer (2025-12-18)
|
||||
case 'prediccion_narrativa':
|
||||
return <TextResponseRenderer data={answerData} />;
|
||||
|
||||
// Módulo 3 - Manuales (texto/análisis)
|
||||
case 'analisis_fuentes':
|
||||
case 'debate_digital':
|
||||
case 'matriz_perspectivas':
|
||||
case 'tribunal_opiniones':
|
||||
return <TextResponseRenderer data={answerData} />;
|
||||
|
||||
// P0-03: Added missing auxiliary mechanics (2025-12-18)
|
||||
case 'collage_prensa':
|
||||
case 'call_to_action':
|
||||
case 'texto_en_movimiento':
|
||||
return <TextResponseRenderer data={answerData} />;
|
||||
|
||||
// Módulo 4 y 5 (creativos con multimedia)
|
||||
case 'verificador_fake_news':
|
||||
case 'quiz_tiktok':
|
||||
@ -104,7 +141,7 @@ const PodcastRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data })
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-purple-50 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-purple-600" />
|
||||
<span className="font-semibold text-purple-800">Tema seleccionado</span>
|
||||
</div>
|
||||
@ -112,16 +149,16 @@ const PodcastRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data })
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Type className="h-5 w-5 text-blue-600" />
|
||||
<span className="font-semibold text-blue-800">Guión del Podcast</span>
|
||||
</div>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{script}</p>
|
||||
<p className="whitespace-pre-wrap text-gray-700">{script}</p>
|
||||
</div>
|
||||
|
||||
{audioUrl && (
|
||||
<div className="rounded-lg bg-green-50 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Music className="h-5 w-5 text-green-600" />
|
||||
<span className="font-semibold text-green-800">Audio del Podcast</span>
|
||||
</div>
|
||||
@ -178,16 +215,19 @@ const VerdaderoFalsoRenderer: React.FC<{
|
||||
|
||||
const rawCorrectAnswers = correct?.statements || correct?.answers || correct;
|
||||
const correctAnswers: Record<string, boolean> | undefined = rawCorrectAnswers
|
||||
? Object.entries(rawCorrectAnswers as Record<string, unknown>).reduce((acc, [key, val]) => {
|
||||
if (typeof val === 'string') {
|
||||
acc[key] = val.toLowerCase() === 'true';
|
||||
} else if (typeof val === 'boolean') {
|
||||
acc[key] = val;
|
||||
} else {
|
||||
acc[key] = Boolean(val);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>)
|
||||
? Object.entries(rawCorrectAnswers as Record<string, unknown>).reduce(
|
||||
(acc, [key, val]) => {
|
||||
if (typeof val === 'string') {
|
||||
acc[key] = val.toLowerCase() === 'true';
|
||||
} else if (typeof val === 'boolean') {
|
||||
acc[key] = val;
|
||||
} else {
|
||||
acc[key] = Boolean(val);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
console.log('[VerdaderoFalsoRenderer] Normalized:', { answers, correctAnswers });
|
||||
@ -202,8 +242,8 @@ const VerdaderoFalsoRenderer: React.FC<{
|
||||
className={`flex items-center gap-3 rounded-lg p-3 ${
|
||||
showComparison && isCorrect !== undefined
|
||||
? isCorrect
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
? 'border border-green-200 bg-green-50'
|
||||
: 'border border-red-200 bg-red-50'
|
||||
: 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
@ -215,7 +255,7 @@ const VerdaderoFalsoRenderer: React.FC<{
|
||||
<span className="font-medium">Pregunta {key}:</span>
|
||||
<span>{value ? 'Verdadero' : 'Falso'}</span>
|
||||
{showComparison && isCorrect === false && correctAnswers && (
|
||||
<span className="text-sm text-red-600 ml-2">
|
||||
<span className="ml-2 text-sm text-red-600">
|
||||
(Correcto: {correctAnswers[key] ? 'Verdadero' : 'Falso'})
|
||||
</span>
|
||||
)}
|
||||
@ -250,17 +290,15 @@ const CompletarEspaciosRenderer: React.FC<{
|
||||
className={`flex items-center gap-3 rounded-lg p-3 ${
|
||||
showComparison && isCorrect !== undefined
|
||||
? isCorrect
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
? 'border border-green-200 bg-green-50'
|
||||
: 'border border-red-200 bg-red-50'
|
||||
: 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-gray-600">Espacio {key}:</span>
|
||||
<span className="px-2 py-1 bg-yellow-100 rounded font-mono">{value || '(vacío)'}</span>
|
||||
<span className="rounded bg-yellow-100 px-2 py-1 font-mono">{value || '(vacío)'}</span>
|
||||
{showComparison && isCorrect === false && correctBlanks && (
|
||||
<span className="text-sm text-green-600 ml-2">
|
||||
→ {correctBlanks[key]}
|
||||
</span>
|
||||
<span className="ml-2 text-sm text-green-600">→ {correctBlanks[key]}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -278,13 +316,13 @@ const CrucigramaRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Grid3X3 className="h-5 w-5 text-gray-600" />
|
||||
<span className="font-semibold">Palabras del Crucigrama</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(words).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 bg-white p-2 rounded">
|
||||
<div key={key} className="flex items-center gap-2 rounded bg-white p-2">
|
||||
<span className="text-sm text-gray-500">{key}:</span>
|
||||
<span className="font-mono font-medium">{value}</span>
|
||||
</div>
|
||||
@ -303,13 +341,13 @@ const SopaLetrasRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<ListChecks className="h-5 w-5 text-gray-600" />
|
||||
<span className="font-semibold">Palabras Encontradas ({foundWords.length})</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{foundWords.map((word, idx) => (
|
||||
<span key={idx} className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
|
||||
<span key={idx} className="rounded-full bg-green-100 px-3 py-1 text-sm text-green-800">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
@ -323,19 +361,27 @@ const SopaLetrasRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data
|
||||
* Muestra las conexiones entre nodos
|
||||
*/
|
||||
const MapaConceptualRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data }) => {
|
||||
const connections = (data.connections || data.nodes || []) as Array<{from?: string; to?: string; label?: string}>;
|
||||
const connections = (data.connections || data.nodes || []) as Array<{
|
||||
from?: string;
|
||||
to?: string;
|
||||
label?: string;
|
||||
}>;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<span className="font-semibold mb-3 block">Conexiones del Mapa Conceptual</span>
|
||||
<span className="mb-3 block font-semibold">Conexiones del Mapa Conceptual</span>
|
||||
<div className="space-y-2">
|
||||
{Array.isArray(connections) ? connections.map((conn, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<span className="bg-blue-100 px-2 py-1 rounded">{conn.from || `Nodo ${idx}`}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="bg-green-100 px-2 py-1 rounded">{conn.to || conn.label || 'conecta'}</span>
|
||||
</div>
|
||||
)) : (
|
||||
{Array.isArray(connections) ? (
|
||||
connections.map((conn, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<span className="rounded bg-blue-100 px-2 py-1">{conn.from || `Nodo ${idx}`}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="rounded bg-green-100 px-2 py-1">
|
||||
{conn.to || conn.label || 'conecta'}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<pre className="text-sm">{JSON.stringify(data, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
@ -348,20 +394,26 @@ const MapaConceptualRenderer: React.FC<{ data: Record<string, unknown> }> = ({ d
|
||||
* Muestra los eventos en orden cronológico
|
||||
*/
|
||||
const TimelineRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data }) => {
|
||||
const events = (data.events || data.order || []) as Array<{id?: string; position?: number; text?: string}>;
|
||||
const events = (data.events || data.order || []) as Array<{
|
||||
id?: string;
|
||||
position?: number;
|
||||
text?: string;
|
||||
}>;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<span className="font-semibold mb-3 block">Orden de Eventos</span>
|
||||
<span className="mb-3 block font-semibold">Orden de Eventos</span>
|
||||
<div className="space-y-2">
|
||||
{Array.isArray(events) ? events.map((event, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold">
|
||||
{event.position || idx + 1}
|
||||
</span>
|
||||
<span>{event.text || event.id || `Evento ${idx + 1}`}</span>
|
||||
</div>
|
||||
)) : (
|
||||
{Array.isArray(events) ? (
|
||||
events.map((event, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-sm font-bold text-white">
|
||||
{event.position || idx + 1}
|
||||
</span>
|
||||
<span>{event.text || event.id || `Evento ${idx + 1}`}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<pre className="text-sm">{JSON.stringify(data, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
@ -369,6 +421,70 @@ const TimelineRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data })
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderiza respuestas del ejercicio Emparejamiento
|
||||
* Muestra los pares que el estudiante conectó
|
||||
*/
|
||||
const EmparejamientoRenderer: React.FC<{
|
||||
data: Record<string, unknown>;
|
||||
correct?: Record<string, unknown>;
|
||||
showComparison: boolean;
|
||||
}> = ({ data, correct, showComparison }) => {
|
||||
// El formato de respuesta es { matches: { questionId: answerId } }
|
||||
const matches = (data.matches || data) as Record<string, string>;
|
||||
const correctMatches = (correct?.matches || correct) as Record<string, string> | undefined;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5 text-gray-600" />
|
||||
<span className="font-semibold">Emparejamientos Realizados</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(matches).map(([questionId, answerId]) => {
|
||||
const isCorrect = correctMatches
|
||||
? correctMatches[questionId] === answerId
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
key={questionId}
|
||||
className={`flex items-center gap-3 rounded-lg p-3 ${
|
||||
showComparison && isCorrect !== undefined
|
||||
? isCorrect
|
||||
? 'border border-green-200 bg-green-50'
|
||||
: 'border border-red-200 bg-red-50'
|
||||
: 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-sm font-medium text-blue-800">
|
||||
{questionId}
|
||||
</span>
|
||||
<span className="text-gray-400">↔</span>
|
||||
<span className="rounded bg-purple-100 px-2 py-1 text-sm font-medium text-purple-800">
|
||||
{answerId}
|
||||
</span>
|
||||
{showComparison && isCorrect !== undefined && (
|
||||
isCorrect ? (
|
||||
<CheckCircle className="ml-auto h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="ml-auto h-5 w-5 text-red-600" />
|
||||
{correctMatches && (
|
||||
<span className="text-sm text-green-600">
|
||||
→ {correctMatches[questionId]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderiza respuestas de ejercicios de opción múltiple
|
||||
* Usado para ejercicios del Módulo 2 (inferenciales)
|
||||
@ -391,14 +507,14 @@ const MultipleChoiceRenderer: React.FC<{
|
||||
className={`rounded-lg p-3 ${
|
||||
showComparison && isCorrect !== undefined
|
||||
? isCorrect
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
? 'border border-green-200 bg-green-50'
|
||||
: 'border border-red-200 bg-red-50'
|
||||
: 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{key}:</span> {String(value)}
|
||||
{showComparison && isCorrect === false && correctAnswers && (
|
||||
<span className="text-sm text-green-600 ml-2">
|
||||
<span className="ml-2 text-sm text-green-600">
|
||||
(Correcto: {String(correctAnswers[key])})
|
||||
</span>
|
||||
)}
|
||||
@ -418,10 +534,10 @@ const TextResponseRenderer: React.FC<{ data: Record<string, unknown> }> = ({ dat
|
||||
<div className="space-y-4">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<div key={key} className="rounded-lg bg-gray-50 p-4">
|
||||
<span className="font-semibold text-gray-700 block mb-2 capitalize">
|
||||
<span className="mb-2 block font-semibold capitalize text-gray-700">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<p className="text-gray-800 whitespace-pre-wrap">
|
||||
<p className="whitespace-pre-wrap text-gray-800">
|
||||
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
|
||||
</p>
|
||||
</div>
|
||||
@ -435,7 +551,10 @@ const TextResponseRenderer: React.FC<{ data: Record<string, unknown> }> = ({ dat
|
||||
* Usado para ejercicios de Módulos 4 y 5 (creativos)
|
||||
* Detecta y renderiza imágenes, videos y audio inline
|
||||
*/
|
||||
const MultimediaRenderer: React.FC<{ data: Record<string, unknown>; type: string }> = ({ data, type: _type }) => {
|
||||
const MultimediaRenderer: React.FC<{ data: Record<string, unknown>; type: string }> = ({
|
||||
data,
|
||||
type: _type,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(data).map(([key, value]) => {
|
||||
@ -447,12 +566,12 @@ const MultimediaRenderer: React.FC<{ data: Record<string, unknown>; type: string
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-lg bg-gray-50 p-4">
|
||||
<span className="font-semibold text-gray-700 block mb-2 capitalize">
|
||||
<span className="mb-2 block font-semibold capitalize text-gray-700">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</span>
|
||||
|
||||
{isImageUrl && typeof value === 'string' ? (
|
||||
<img src={value} alt={key} className="max-w-full h-auto rounded-lg" />
|
||||
<img src={value} alt={key} className="h-auto max-w-full rounded-lg" />
|
||||
) : isVideoUrl && typeof value === 'string' ? (
|
||||
<video controls className="max-w-full rounded-lg">
|
||||
<source src={value} />
|
||||
@ -462,9 +581,9 @@ const MultimediaRenderer: React.FC<{ data: Record<string, unknown>; type: string
|
||||
<source src={value} />
|
||||
</audio>
|
||||
) : typeof value === 'string' ? (
|
||||
<p className="text-gray-800 whitespace-pre-wrap">{value}</p>
|
||||
<p className="whitespace-pre-wrap text-gray-800">{value}</p>
|
||||
) : (
|
||||
<pre className="text-sm bg-white p-2 rounded overflow-x-auto">
|
||||
<pre className="overflow-x-auto rounded bg-white p-2 text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
@ -482,7 +601,7 @@ const MultimediaRenderer: React.FC<{ data: Record<string, unknown>; type: string
|
||||
const FallbackRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data }) => {
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-100 p-4">
|
||||
<pre className="text-sm overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap text-sm">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@ -9,9 +9,9 @@
|
||||
| **Título** | Sistema de Rangos Maya - Especificación Técnica |
|
||||
| **Prioridad** | Alta |
|
||||
| **Estado** | ✅ Implementado |
|
||||
| **Versión** | 2.3.0 |
|
||||
| **Versión** | 2.4.0 |
|
||||
| **Fecha Creación** | 2025-11-07 |
|
||||
| **Última Actualización** | 2025-11-28 |
|
||||
| **Última Actualización** | 2025-12-18 |
|
||||
| **Sistema Actual** | [docs/sistema-recompensas/](../../../sistema-recompensas/) v2.3.0 |
|
||||
| **Autor** | Backend Team |
|
||||
| **Stakeholders** | Backend Team, Frontend Team, Database Team |
|
||||
@ -93,6 +93,8 @@ El **Sistema de Rangos Maya** implementa una progresión jerárquica basada en X
|
||||
5. **K'uk'ulkan** (1,900+ XP) - Serpiente emplumada (máximo)
|
||||
|
||||
> **Nota v2.3.0:** Umbral K'uk'ulkan ajustado de 2,250 a 1,900 XP para ser alcanzable completando Módulos 1-3 (1,950 XP disponibles). Ver [DocumentoDeDiseño v6.5](../../../00-vision-general/DocumentoDeDiseño_Mecanicas_GAMILIT_v6_1.md).
|
||||
>
|
||||
> **Migracion:** Para detalles tecnicos de la migracion v2.0 → v2.1, ver [MIGRACION-MAYA-RANKS-v2.1.md](../../../../90-transversal/migraciones/MIGRACION-MAYA-RANKS-v2.1.md).
|
||||
|
||||
### Características Técnicas
|
||||
|
||||
@ -200,10 +202,11 @@ COMMENT ON TABLE gamification_system.rank_history IS
|
||||
Cada registro representa una promoción exitosa de un rango a otro.';
|
||||
```
|
||||
|
||||
### 3. Función: check_rank_promotion
|
||||
### 3. Función: check_rank_promotion (v2.1 - Lectura dinámica)
|
||||
|
||||
```sql
|
||||
-- apps/database/ddl/schemas/gamification_system/functions/check_rank_promotion.sql
|
||||
-- v2.1: Lee umbrales dinámicamente desde tabla maya_ranks
|
||||
|
||||
CREATE OR REPLACE FUNCTION gamification_system.check_rank_promotion(
|
||||
p_user_id UUID
|
||||
@ -214,7 +217,9 @@ SECURITY DEFINER -- Ejecuta con permisos del owner
|
||||
AS $$
|
||||
DECLARE
|
||||
v_current_rank gamification_system.maya_rank;
|
||||
v_total_xp INTEGER;
|
||||
v_total_xp BIGINT;
|
||||
v_next_rank gamification_system.maya_rank;
|
||||
v_next_rank_min_xp BIGINT;
|
||||
v_promoted BOOLEAN := false;
|
||||
BEGIN
|
||||
-- Obtener datos actuales del usuario
|
||||
@ -229,47 +234,164 @@ BEGIN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
-- Verificar promociones según rango actual
|
||||
CASE v_current_rank
|
||||
WHEN 'Ajaw' THEN
|
||||
IF v_total_xp >= 500 THEN
|
||||
PERFORM gamification_system.promote_to_next_rank(p_user_id, 'Nacom');
|
||||
v_promoted := true;
|
||||
END IF;
|
||||
-- v2.1: Leer siguiente rango y umbral dinámicamente desde maya_ranks
|
||||
SELECT mr.next_rank, next_mr.min_xp_required
|
||||
INTO v_next_rank, v_next_rank_min_xp
|
||||
FROM gamification_system.maya_ranks mr
|
||||
LEFT JOIN gamification_system.maya_ranks next_mr
|
||||
ON next_mr.rank_name = mr.next_rank
|
||||
WHERE mr.rank_name = v_current_rank
|
||||
AND mr.is_active = true;
|
||||
|
||||
WHEN 'Nacom' THEN
|
||||
IF v_total_xp >= 1000 THEN
|
||||
PERFORM gamification_system.promote_to_next_rank(p_user_id, 'Ah K''in');
|
||||
v_promoted := true;
|
||||
END IF;
|
||||
-- Si no hay siguiente rango (ya está en máximo), no promocionar
|
||||
IF v_next_rank IS NULL THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
WHEN 'Ah K''in' THEN
|
||||
IF v_total_xp >= 1500 THEN
|
||||
PERFORM gamification_system.promote_to_next_rank(p_user_id, 'Halach Uinic');
|
||||
v_promoted := true;
|
||||
END IF;
|
||||
|
||||
WHEN 'Halach Uinic' THEN
|
||||
IF v_total_xp >= 2250 THEN
|
||||
PERFORM gamification_system.promote_to_next_rank(p_user_id, 'K''uk''ulkan');
|
||||
v_promoted := true;
|
||||
END IF;
|
||||
|
||||
WHEN 'K''uk''ulkan' THEN
|
||||
-- Rango máximo alcanzado, no hay más promociones
|
||||
v_promoted := false;
|
||||
END CASE;
|
||||
-- Verificar si el usuario tiene suficiente XP para el siguiente rango
|
||||
IF v_total_xp >= v_next_rank_min_xp THEN
|
||||
PERFORM gamification_system.promote_to_next_rank(p_user_id, v_next_rank);
|
||||
v_promoted := true;
|
||||
END IF;
|
||||
|
||||
RETURN v_promoted;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION gamification_system.check_rank_promotion IS
|
||||
COMMENT ON FUNCTION gamification_system.check_rank_promotion(UUID) IS
|
||||
'Verifica si un usuario califica para promoción de rango según su total_xp actual.
|
||||
Lee configuración dinámica desde maya_ranks table (next_rank y min_xp_required).
|
||||
Retorna true si el usuario fue promovido, false en caso contrario.
|
||||
Se ejecuta automáticamente mediante trigger después de actualizar total_xp.';
|
||||
```
|
||||
|
||||
### 3.1 Funciones Helper v2.1: Cálculo de Rangos
|
||||
|
||||
```sql
|
||||
-- apps/database/ddl/schemas/gamification_system/functions/calculate_maya_rank_helpers.sql
|
||||
-- v2.1: Funciones puras IMMUTABLE para cálculo de rangos sin queries a BD
|
||||
|
||||
-- Función: calculate_maya_rank_from_xp
|
||||
-- Calcula el rango correcto basado en XP total (función pura, sin queries)
|
||||
CREATE OR REPLACE FUNCTION gamification_system.calculate_maya_rank_from_xp(xp INTEGER)
|
||||
RETURNS TEXT AS $$
|
||||
BEGIN
|
||||
-- v2.1 thresholds (sincronizado con 03-maya_ranks.sql seeds)
|
||||
IF xp < 500 THEN
|
||||
RETURN 'Ajaw'; -- 0-499 XP
|
||||
ELSIF xp < 1000 THEN
|
||||
RETURN 'Nacom'; -- 500-999 XP
|
||||
ELSIF xp < 1500 THEN
|
||||
RETURN 'Ah K''in'; -- 1,000-1,499 XP
|
||||
ELSIF xp < 1900 THEN
|
||||
RETURN 'Halach Uinic'; -- 1,500-1,899 XP
|
||||
ELSE
|
||||
RETURN 'K''uk''ulkan'; -- 1,900+ XP (máximo)
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Función: calculate_rank_progress_percentage
|
||||
-- Calcula porcentaje de progreso dentro de un rango (0-100)
|
||||
CREATE OR REPLACE FUNCTION gamification_system.calculate_rank_progress_percentage(
|
||||
xp INTEGER,
|
||||
rank TEXT
|
||||
)
|
||||
RETURNS NUMERIC(5,2) AS $$
|
||||
DECLARE
|
||||
xp_in_rank INTEGER;
|
||||
rank_size INTEGER;
|
||||
BEGIN
|
||||
CASE rank
|
||||
WHEN 'Ajaw' THEN
|
||||
xp_in_rank := xp; -- 0-499 XP
|
||||
rank_size := 500;
|
||||
WHEN 'Nacom' THEN
|
||||
xp_in_rank := xp - 500; -- 500-999 XP
|
||||
rank_size := 500;
|
||||
WHEN 'Ah K''in' THEN
|
||||
xp_in_rank := xp - 1000; -- 1,000-1,499 XP
|
||||
rank_size := 500;
|
||||
WHEN 'Halach Uinic' THEN
|
||||
xp_in_rank := xp - 1500; -- 1,500-1,899 XP
|
||||
rank_size := 400; -- v2.1: reducido de 750 a 400
|
||||
WHEN 'K''uk''ulkan' THEN
|
||||
RETURN 100.00; -- Rango máximo siempre 100%
|
||||
ELSE
|
||||
RETURN 0.00;
|
||||
END CASE;
|
||||
|
||||
IF rank_size > 0 THEN
|
||||
RETURN LEAST(100.00, (xp_in_rank::NUMERIC / rank_size::NUMERIC) * 100);
|
||||
ELSE
|
||||
RETURN 0.00;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
```
|
||||
|
||||
### 3.2 Función: calculate_user_rank (CORR-P0-001)
|
||||
|
||||
```sql
|
||||
-- apps/database/ddl/schemas/gamification_system/functions/calculate_user_rank.sql
|
||||
-- CORR-P0-001: Corregido missions_completed → modules_completed
|
||||
|
||||
CREATE OR REPLACE FUNCTION gamification_system.calculate_user_rank(p_user_id UUID)
|
||||
RETURNS TABLE (
|
||||
user_id UUID,
|
||||
current_rank VARCHAR,
|
||||
next_rank VARCHAR,
|
||||
xp_to_next_rank BIGINT,
|
||||
modules_to_next_rank INTEGER, -- CORR-P0-001: Renombrado missions → modules
|
||||
rank_percentage NUMERIC(5,2)
|
||||
) AS $$
|
||||
DECLARE
|
||||
v_total_xp BIGINT;
|
||||
v_modules_completed INTEGER; -- CORR-P0-001: missions_completed no existe
|
||||
v_current_rank VARCHAR;
|
||||
v_next_rank VARCHAR;
|
||||
v_next_rank_xp BIGINT;
|
||||
v_next_rank_modules INTEGER;
|
||||
BEGIN
|
||||
-- CORR-P0-001: Usar modules_completed (missions_completed no existe)
|
||||
SELECT us.total_xp, us.modules_completed INTO v_total_xp, v_modules_completed
|
||||
FROM gamification_system.user_stats us
|
||||
WHERE us.user_id = p_user_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Determinar rango actual
|
||||
SELECT ur.current_rank INTO v_current_rank
|
||||
FROM gamification_system.user_ranks ur
|
||||
WHERE ur.user_id = p_user_id AND ur.is_current = true;
|
||||
|
||||
-- Obtener siguiente rango desde maya_ranks
|
||||
SELECT rank_name::VARCHAR, min_xp_required, COALESCE(modules_required, 0)
|
||||
INTO v_next_rank, v_next_rank_xp, v_next_rank_modules
|
||||
FROM gamification_system.maya_ranks
|
||||
WHERE rank_name::VARCHAR > COALESCE(v_current_rank, 'Ajaw')
|
||||
ORDER BY min_xp_required ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF v_next_rank IS NULL THEN
|
||||
v_next_rank := v_current_rank;
|
||||
v_next_rank_xp := v_total_xp;
|
||||
v_next_rank_modules := v_modules_completed;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY SELECT
|
||||
p_user_id,
|
||||
COALESCE(v_current_rank, 'Ajaw'::VARCHAR),
|
||||
v_next_rank,
|
||||
GREATEST(0, v_next_rank_xp - v_total_xp),
|
||||
GREATEST(0, COALESCE(v_next_rank_modules, 0) - v_modules_completed),
|
||||
LEAST(100.0::NUMERIC, (v_total_xp::NUMERIC / NULLIF(v_next_rank_xp, 0)) * 100);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
```
|
||||
|
||||
### 4. Función: promote_to_next_rank
|
||||
|
||||
```sql
|
||||
@ -602,12 +724,13 @@ export const RANK_ORDER = [
|
||||
MayaRankEnum.KUKULKAN,
|
||||
];
|
||||
|
||||
// v2.1 Thresholds - K'uk'ulkan ajustado a 1900 XP para ser alcanzable en Módulos 1-3
|
||||
export const RANK_THRESHOLDS: Record<MayaRankEnum, { min: number; max: number | null }> = {
|
||||
[MayaRankEnum.AJAW]: { min: 0, max: 499 },
|
||||
[MayaRankEnum.NACOM]: { min: 500, max: 999 },
|
||||
[MayaRankEnum.AH_KIN]: { min: 1000, max: 1499 },
|
||||
[MayaRankEnum.HALACH_UINIC]: { min: 1500, max: 2249 },
|
||||
[MayaRankEnum.KUKULKAN]: { min: 2250, max: null }, // Sin límite superior
|
||||
[MayaRankEnum.HALACH_UINIC]: { min: 1500, max: 1899 }, // v2.1: max reducido de 2249 a 1899
|
||||
[MayaRankEnum.KUKULKAN]: { min: 1900, max: null }, // v2.1: min reducido de 2250 a 1900
|
||||
};
|
||||
|
||||
export const RANK_MULTIPLIERS: Record<MayaRankEnum, number> = {
|
||||
|
||||
@ -0,0 +1,131 @@
|
||||
# ET-AUD-001: Sistema de Auditoría
|
||||
|
||||
**Versión:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Estado:** Implementado
|
||||
**Módulo Backend:** `apps/backend/src/modules/audit/`
|
||||
|
||||
---
|
||||
|
||||
## 1. DESCRIPCIÓN
|
||||
|
||||
El módulo de auditoría proporciona capacidades de logging y seguimiento de acciones en el sistema GAMILIT. Permite registrar eventos importantes para compliance, debugging y análisis de comportamiento.
|
||||
|
||||
---
|
||||
|
||||
## 2. COMPONENTES
|
||||
|
||||
### 2.1 AuditService
|
||||
|
||||
**Ubicación:** `audit/audit.service.ts`
|
||||
|
||||
**Responsabilidades:**
|
||||
- Crear registros de auditoría
|
||||
- Consultar logs por usuario, fecha, tipo
|
||||
- Limpiar logs antiguos (cron job)
|
||||
|
||||
**Métodos:**
|
||||
| Método | Descripción | Parámetros |
|
||||
|--------|-------------|------------|
|
||||
| `create(dto)` | Registrar evento de auditoría | CreateAuditLogDto |
|
||||
| `findAll(filters)` | Listar logs con filtros | Pagination, userId, dateRange |
|
||||
| `findByUser(userId)` | Logs de un usuario específico | userId: string |
|
||||
| `deleteOldLogs(days)` | Limpiar logs antiguos | days: number |
|
||||
|
||||
### 2.2 AuditLog Entity
|
||||
|
||||
**Ubicación:** `audit/entities/audit-log.entity.ts`
|
||||
**Schema:** `audit_logging`
|
||||
|
||||
**Campos:**
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| id | UUID | Identificador único |
|
||||
| user_id | UUID | Usuario que realizó la acción |
|
||||
| action | string | Tipo de acción (CREATE, UPDATE, DELETE, etc.) |
|
||||
| resource_type | string | Tipo de recurso afectado |
|
||||
| resource_id | string | ID del recurso |
|
||||
| old_value | jsonb | Valor anterior (opcional) |
|
||||
| new_value | jsonb | Valor nuevo (opcional) |
|
||||
| ip_address | string | IP del cliente |
|
||||
| user_agent | string | User agent del cliente |
|
||||
| created_at | timestamp | Fecha del evento |
|
||||
|
||||
### 2.3 AuditInterceptor
|
||||
|
||||
**Ubicación:** `audit/interceptors/audit.interceptor.ts`
|
||||
|
||||
**Funcionalidad:**
|
||||
- Interceptor global que captura eventos de mutación
|
||||
- Registra automáticamente CREATE/UPDATE/DELETE
|
||||
- Configurable por decorador `@Auditable()`
|
||||
|
||||
---
|
||||
|
||||
## 3. CONFIGURACIÓN
|
||||
|
||||
### 3.1 Módulo
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AuditLog], 'audit'),
|
||||
],
|
||||
providers: [AuditService, AuditInterceptor],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
```
|
||||
|
||||
### 3.2 Uso del Interceptor
|
||||
|
||||
```typescript
|
||||
// Aplicar a un controlador completo
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@Controller('users')
|
||||
export class UsersController {}
|
||||
|
||||
// O a métodos específicos
|
||||
@Post()
|
||||
@Auditable('CREATE_USER')
|
||||
async create(@Body() dto: CreateUserDto) {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. EVENTOS AUDITADOS
|
||||
|
||||
| Categoría | Eventos |
|
||||
|-----------|---------|
|
||||
| **Autenticación** | LOGIN, LOGOUT, FAILED_LOGIN, PASSWORD_RESET |
|
||||
| **Usuarios** | CREATE_USER, UPDATE_USER, DELETE_USER, ROLE_CHANGE |
|
||||
| **Contenido** | CREATE_EXERCISE, GRADE_SUBMISSION, APPROVE_CONTENT |
|
||||
| **Administración** | SYSTEM_CONFIG_CHANGE, BULK_OPERATION |
|
||||
|
||||
---
|
||||
|
||||
## 5. RETENCIÓN DE DATOS
|
||||
|
||||
- **Logs de seguridad:** 1 año
|
||||
- **Logs de operaciones:** 90 días
|
||||
- **Logs de debugging:** 30 días
|
||||
|
||||
---
|
||||
|
||||
## 6. DEPENDENCIAS
|
||||
|
||||
- **Ninguna** (módulo independiente)
|
||||
- **Es usado por:** AdminModule (para panel de logs)
|
||||
|
||||
---
|
||||
|
||||
## 7. SEGURIDAD
|
||||
|
||||
- Logs inmutables (solo inserción)
|
||||
- Acceso restringido a roles Admin
|
||||
- No almacenar datos sensibles (passwords, tokens)
|
||||
- Sanitización de inputs antes de logging
|
||||
|
||||
---
|
||||
|
||||
*Especificación generada: 2025-12-18*
|
||||
@ -0,0 +1,134 @@
|
||||
# ET-HLT-001: Health Checks
|
||||
|
||||
**Versión:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Estado:** Implementado
|
||||
**Módulo Backend:** `apps/backend/src/modules/health/`
|
||||
|
||||
---
|
||||
|
||||
## 1. DESCRIPCIÓN
|
||||
|
||||
El módulo de health checks proporciona endpoints para verificar el estado del servicio backend. Es utilizado por sistemas de monitoreo, load balancers y kubernetes para determinar la disponibilidad del servicio.
|
||||
|
||||
---
|
||||
|
||||
## 2. COMPONENTES
|
||||
|
||||
### 2.1 HealthController
|
||||
|
||||
**Ubicación:** `health/health.controller.ts`
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
| Método | Ruta | Descripción | Respuesta |
|
||||
|--------|------|-------------|-----------|
|
||||
| GET | `/health` | Estado general | `{ status: "ok", timestamp }` |
|
||||
| GET | `/health/ready` | Readiness check | `{ ready: true, checks: [...] }` |
|
||||
| GET | `/health/live` | Liveness check | `{ live: true }` |
|
||||
|
||||
### 2.2 HealthService
|
||||
|
||||
**Ubicación:** `health/health.service.ts`
|
||||
|
||||
**Métodos:**
|
||||
| Método | Descripción | Retorno |
|
||||
|--------|-------------|---------|
|
||||
| `check()` | Estado general del servicio | HealthStatus |
|
||||
| `checkDatabase()` | Conectividad a PostgreSQL | boolean |
|
||||
| `checkRedis()` | Conectividad a Redis (si aplica) | boolean |
|
||||
| `checkDiskSpace()` | Espacio en disco | DiskStatus |
|
||||
| `checkMemory()` | Uso de memoria | MemoryStatus |
|
||||
|
||||
---
|
||||
|
||||
## 3. RESPUESTAS
|
||||
|
||||
### 3.1 Health Check Exitoso (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2025-12-18T12:00:00Z",
|
||||
"uptime": 86400,
|
||||
"version": "1.0.0",
|
||||
"checks": {
|
||||
"database": { "status": "up", "responseTime": 5 },
|
||||
"memory": { "status": "ok", "usage": "45%" },
|
||||
"disk": { "status": "ok", "usage": "60%" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Health Check Fallido (503 Service Unavailable)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"timestamp": "2025-12-18T12:00:00Z",
|
||||
"checks": {
|
||||
"database": { "status": "down", "error": "Connection refused" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. CONFIGURACIÓN
|
||||
|
||||
### 4.1 Módulo
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
```
|
||||
|
||||
### 4.2 Thresholds
|
||||
|
||||
| Métrica | Warning | Critical |
|
||||
|---------|---------|----------|
|
||||
| Memory Usage | 70% | 90% |
|
||||
| Disk Usage | 80% | 95% |
|
||||
| DB Response Time | 100ms | 500ms |
|
||||
|
||||
---
|
||||
|
||||
## 5. USO EN KUBERNETES
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. DEPENDENCIAS
|
||||
|
||||
- **Ninguna** (módulo independiente)
|
||||
- **No es usado por otros módulos**
|
||||
|
||||
---
|
||||
|
||||
## 7. SEGURIDAD
|
||||
|
||||
- Endpoint `/health` es público (sin autenticación)
|
||||
- Endpoints detallados (`/health/ready`) pueden requerir auth en producción
|
||||
- No exponer información sensible en respuestas
|
||||
|
||||
---
|
||||
|
||||
*Especificación generada: 2025-12-18*
|
||||
@ -0,0 +1,194 @@
|
||||
# ET-TSK-001: Sistema de Tareas Programadas (Cron Jobs)
|
||||
|
||||
**Versión:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Estado:** Implementado
|
||||
**Módulo Backend:** `apps/backend/src/modules/tasks/`
|
||||
|
||||
---
|
||||
|
||||
## 1. DESCRIPCIÓN
|
||||
|
||||
El módulo de tareas programadas gestiona los cron jobs del sistema GAMILIT. Ejecuta procesos automáticos como reset de misiones diarias, limpieza de notificaciones y mantenimiento del sistema.
|
||||
|
||||
---
|
||||
|
||||
## 2. COMPONENTES
|
||||
|
||||
### 2.1 MissionsCronService
|
||||
|
||||
**Ubicación:** `tasks/missions-cron.service.ts`
|
||||
|
||||
**Responsabilidades:**
|
||||
- Resetear misiones diarias a medianoche
|
||||
- Generar nuevas misiones para usuarios activos
|
||||
- Calcular expiración de misiones semanales
|
||||
|
||||
**Cron Jobs:**
|
||||
|
||||
| Job | Schedule | Descripción |
|
||||
|-----|----------|-------------|
|
||||
| `resetDailyMissions` | `0 0 * * *` (00:00 UTC) | Reset misiones diarias |
|
||||
| `generateWeeklyMissions` | `0 0 * * 1` (Lunes 00:00) | Generar misiones semanales |
|
||||
| `cleanExpiredMissions` | `0 2 * * *` (02:00 UTC) | Limpiar misiones expiradas |
|
||||
|
||||
**Dependencias:**
|
||||
- `GamificationModule.MissionsService`
|
||||
|
||||
### 2.2 NotificationsCronService
|
||||
|
||||
**Ubicación:** `tasks/notifications-cron.service.ts`
|
||||
|
||||
**Responsabilidades:**
|
||||
- Enviar notificaciones en batch
|
||||
- Limpiar notificaciones leídas antiguas
|
||||
- Procesar cola de emails
|
||||
|
||||
**Cron Jobs:**
|
||||
|
||||
| Job | Schedule | Descripción |
|
||||
|-----|----------|-------------|
|
||||
| `processNotificationQueue` | `*/5 * * * *` (cada 5 min) | Procesar cola pendiente |
|
||||
| `cleanOldNotifications` | `0 3 * * *` (03:00 UTC) | Limpiar >30 días |
|
||||
| `sendDigestEmails` | `0 8 * * *` (08:00 UTC) | Enviar resúmenes diarios |
|
||||
|
||||
**Dependencias:**
|
||||
- `NotificationsModule.NotificationQueueService`
|
||||
- `MailModule.MailService`
|
||||
|
||||
---
|
||||
|
||||
## 3. CONFIGURACIÓN
|
||||
|
||||
### 3.1 Módulo
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
GamificationModule,
|
||||
NotificationsModule,
|
||||
MailModule,
|
||||
],
|
||||
providers: [
|
||||
MissionsCronService,
|
||||
NotificationsCronService,
|
||||
],
|
||||
})
|
||||
export class TasksModule {}
|
||||
```
|
||||
|
||||
### 3.2 Decoradores de Schedule
|
||||
|
||||
```typescript
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
@Injectable()
|
||||
export class MissionsCronService {
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async resetDailyMissions() {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. MONITOREO
|
||||
|
||||
### 4.1 Logs de Ejecución
|
||||
|
||||
Cada job registra:
|
||||
- Inicio de ejecución
|
||||
- Duración
|
||||
- Registros procesados
|
||||
- Errores encontrados
|
||||
|
||||
```typescript
|
||||
this.logger.log(`[CRON] resetDailyMissions started`);
|
||||
// ... proceso
|
||||
this.logger.log(`[CRON] resetDailyMissions completed: ${count} missions reset in ${duration}ms`);
|
||||
```
|
||||
|
||||
### 4.2 Alertas
|
||||
|
||||
| Condición | Alerta |
|
||||
|-----------|--------|
|
||||
| Job falla 3 veces consecutivas | CRITICAL |
|
||||
| Job toma >5 minutos | WARNING |
|
||||
| Job no se ejecuta en 24h | CRITICAL |
|
||||
|
||||
---
|
||||
|
||||
## 5. DEPENDENCIAS
|
||||
|
||||
### 5.1 Módulos Requeridos
|
||||
|
||||
```
|
||||
TasksModule
|
||||
├── GamificationModule (MissionsService)
|
||||
├── NotificationsModule (NotificationQueueService)
|
||||
└── MailModule (MailService)
|
||||
```
|
||||
|
||||
### 5.2 Es Usado Por
|
||||
|
||||
- **AdminModule** (para monitoreo de jobs)
|
||||
|
||||
---
|
||||
|
||||
## 6. CONSIDERACIONES DE PRODUCCIÓN
|
||||
|
||||
### 6.1 Timezone
|
||||
|
||||
- Todos los cron jobs usan **UTC**
|
||||
- Frontend convierte a timezone del usuario
|
||||
|
||||
### 6.2 Concurrencia
|
||||
|
||||
- Solo una instancia ejecuta cron jobs (usar distributed lock)
|
||||
- Implementar idempotencia en cada job
|
||||
|
||||
### 6.3 Retry Logic
|
||||
|
||||
```typescript
|
||||
@Cron('0 0 * * *')
|
||||
async resetDailyMissions() {
|
||||
const MAX_RETRIES = 3;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await this.executeMissionReset();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === MAX_RETRIES) throw error;
|
||||
await this.delay(attempt * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. TESTING
|
||||
|
||||
### 7.1 Unit Tests
|
||||
|
||||
```typescript
|
||||
describe('MissionsCronService', () => {
|
||||
it('should reset daily missions', async () => {
|
||||
// Mock MissionsService
|
||||
// Execute cron handler
|
||||
// Verify missions were reset
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 7.2 Integration Tests
|
||||
|
||||
- Verificar que jobs se registran correctamente
|
||||
- Simular ejecución manual de jobs
|
||||
- Verificar side effects en BD
|
||||
|
||||
---
|
||||
|
||||
*Especificación generada: 2025-12-18*
|
||||
@ -0,0 +1,223 @@
|
||||
# ET-WS-001: WebSocket y Comunicación en Tiempo Real
|
||||
|
||||
**Versión:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Estado:** Implementado
|
||||
**Módulo Backend:** `apps/backend/src/modules/websocket/`
|
||||
|
||||
---
|
||||
|
||||
## 1. DESCRIPCIÓN
|
||||
|
||||
El módulo WebSocket proporciona comunicación bidireccional en tiempo real entre el backend y los clientes. Utiliza Socket.IO para gestionar conexiones, salas y eventos.
|
||||
|
||||
---
|
||||
|
||||
## 2. COMPONENTES
|
||||
|
||||
### 2.1 WebSocketService
|
||||
|
||||
**Ubicación:** `websocket/websocket.service.ts`
|
||||
|
||||
**Responsabilidades:**
|
||||
- Gestionar conexiones de clientes
|
||||
- Broadcast de eventos a usuarios/salas
|
||||
- Mantener registro de usuarios conectados
|
||||
|
||||
**Métodos:**
|
||||
|
||||
| Método | Descripción | Parámetros |
|
||||
|--------|-------------|------------|
|
||||
| `sendToUser(userId, event, data)` | Enviar a usuario específico | userId, event, payload |
|
||||
| `sendToRoom(room, event, data)` | Broadcast a una sala | roomId, event, payload |
|
||||
| `broadcastToAll(event, data)` | Broadcast global | event, payload |
|
||||
| `joinRoom(socketId, room)` | Unir socket a sala | socketId, roomId |
|
||||
| `leaveRoom(socketId, room)` | Salir de sala | socketId, roomId |
|
||||
|
||||
### 2.2 NotificationsGateway
|
||||
|
||||
**Ubicación:** `websocket/gateways/notifications.gateway.ts`
|
||||
|
||||
**Eventos Soportados:**
|
||||
|
||||
| Evento | Dirección | Descripción |
|
||||
|--------|-----------|-------------|
|
||||
| `connection` | Client→Server | Cliente se conecta |
|
||||
| `disconnect` | Client→Server | Cliente se desconecta |
|
||||
| `subscribe:notifications` | Client→Server | Suscribirse a notificaciones |
|
||||
| `notification:new` | Server→Client | Nueva notificación |
|
||||
| `notification:read` | Server→Client | Notificación marcada leída |
|
||||
| `achievement:unlocked` | Server→Client | Logro desbloqueado |
|
||||
| `rank:promoted` | Server→Client | Promoción de rango |
|
||||
| `coins:earned` | Server→Client | ML Coins ganadas |
|
||||
|
||||
### 2.3 WsJwtGuard
|
||||
|
||||
**Ubicación:** `websocket/guards/ws-jwt.guard.ts`
|
||||
|
||||
**Funcionalidad:**
|
||||
- Valida JWT en handshake de conexión
|
||||
- Extrae userId del token
|
||||
- Rechaza conexiones no autorizadas
|
||||
|
||||
---
|
||||
|
||||
## 3. FLUJO DE CONEXIÓN
|
||||
|
||||
```
|
||||
1. Cliente inicia conexión WebSocket
|
||||
└─> ws://api.gamilit.com/socket.io
|
||||
|
||||
2. Handshake con token JWT
|
||||
└─> { auth: { token: "Bearer xxx" } }
|
||||
|
||||
3. WsJwtGuard valida token
|
||||
└─> Si válido: conexión aceptada
|
||||
└─> Si inválido: conexión rechazada
|
||||
|
||||
4. Cliente se une a sala personal
|
||||
└─> room: "user:{userId}"
|
||||
|
||||
5. Cliente recibe eventos en tiempo real
|
||||
└─> notification:new, achievement:unlocked, etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. CONFIGURACIÓN
|
||||
|
||||
### 4.1 Módulo
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
imports: [JwtModule],
|
||||
providers: [
|
||||
WebSocketService,
|
||||
NotificationsGateway,
|
||||
WsJwtGuard,
|
||||
],
|
||||
exports: [WebSocketService],
|
||||
})
|
||||
export class WebSocketModule {}
|
||||
```
|
||||
|
||||
### 4.2 Gateway
|
||||
|
||||
```typescript
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: process.env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
},
|
||||
namespace: '/notifications',
|
||||
})
|
||||
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. SALAS (ROOMS)
|
||||
|
||||
| Sala | Formato | Propósito |
|
||||
|------|---------|-----------|
|
||||
| Usuario | `user:{userId}` | Notificaciones personales |
|
||||
| Classroom | `classroom:{classroomId}` | Eventos del aula |
|
||||
| Teacher | `teacher:{teacherId}` | Alertas para maestros |
|
||||
| Admin | `admin:all` | Alertas administrativas |
|
||||
|
||||
---
|
||||
|
||||
## 6. EVENTOS DE GAMIFICACIÓN
|
||||
|
||||
### 6.1 Achievement Unlocked
|
||||
|
||||
```typescript
|
||||
// Server envía
|
||||
socket.emit('achievement:unlocked', {
|
||||
achievementId: 'ach-123',
|
||||
name: 'Primer Ejercicio',
|
||||
description: 'Completaste tu primer ejercicio',
|
||||
icon: 'trophy',
|
||||
xpReward: 100,
|
||||
});
|
||||
```
|
||||
|
||||
### 6.2 Rank Promoted
|
||||
|
||||
```typescript
|
||||
socket.emit('rank:promoted', {
|
||||
oldRank: { name: 'Ajaw', level: 1 },
|
||||
newRank: { name: 'Nacom', level: 2 },
|
||||
xpRequired: 500,
|
||||
});
|
||||
```
|
||||
|
||||
### 6.3 Coins Earned
|
||||
|
||||
```typescript
|
||||
socket.emit('coins:earned', {
|
||||
amount: 50,
|
||||
reason: 'exercise_completed',
|
||||
newBalance: 1250,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. INTEGRACIÓN CON NOTIFICACIONES
|
||||
|
||||
```typescript
|
||||
// NotificationsService usa WebSocketService
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(private readonly wsService: WebSocketService) {}
|
||||
|
||||
async sendNotification(userId: string, notification: Notification) {
|
||||
// Guardar en BD
|
||||
await this.notificationRepo.save(notification);
|
||||
|
||||
// Enviar por WebSocket si usuario conectado
|
||||
this.wsService.sendToUser(userId, 'notification:new', notification);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. DEPENDENCIAS
|
||||
|
||||
### 8.1 Módulos Requeridos
|
||||
|
||||
- `JwtModule` (para autenticación)
|
||||
|
||||
### 8.2 Es Usado Por
|
||||
|
||||
- `NotificationsModule` (envío de notificaciones real-time)
|
||||
- `GamificationModule` (eventos de logros, rangos)
|
||||
- `ProgressModule` (eventos de ejercicios)
|
||||
|
||||
---
|
||||
|
||||
## 9. SEGURIDAD
|
||||
|
||||
- Autenticación JWT obligatoria
|
||||
- Validación de pertenencia a salas
|
||||
- Rate limiting por conexión
|
||||
- Sanitización de payloads
|
||||
|
||||
---
|
||||
|
||||
## 10. MONITOREO
|
||||
|
||||
| Métrica | Descripción |
|
||||
|---------|-------------|
|
||||
| `ws_connections_active` | Conexiones activas |
|
||||
| `ws_messages_sent` | Mensajes enviados/min |
|
||||
| `ws_errors` | Errores de conexión |
|
||||
| `ws_latency` | Latencia promedio |
|
||||
|
||||
---
|
||||
|
||||
*Especificación generada: 2025-12-18*
|
||||
@ -0,0 +1,304 @@
|
||||
# Inventario de Funciones de Base de Datos
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Autor:** Requirements-Analyst (SIMCO)
|
||||
|
||||
---
|
||||
|
||||
## RESUMEN
|
||||
|
||||
| Schema | Total Funciones | Documentadas |
|
||||
|--------|-----------------|--------------|
|
||||
| gamification_system | 12 | 12 |
|
||||
| educational_content | 15 | 15 |
|
||||
| auth_management | 5 | 5 |
|
||||
| **TOTAL** | **32** | **32** |
|
||||
|
||||
---
|
||||
|
||||
## SCHEMA: gamification_system
|
||||
|
||||
### Funciones de Rangos Maya
|
||||
|
||||
#### calculate_maya_rank_from_xp(xp INTEGER)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/calculate_maya_rank_helpers.sql:1-50`
|
||||
**Version:** v2.1 (Dec 14, 2025)
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Determina el rango Maya basado en XP total.
|
||||
|
||||
**Thresholds v2.1:**
|
||||
| XP | Rango |
|
||||
|----|-------|
|
||||
| 0-499 | Ajaw |
|
||||
| 500-999 | Nacom |
|
||||
| 1000-1499 | Ah K'in |
|
||||
| 1500-1899 | Halach Uinic |
|
||||
| 1900+ | K'uk'ulkan |
|
||||
|
||||
```sql
|
||||
SELECT gamification_system.calculate_maya_rank_from_xp(500);
|
||||
-- Retorna: 'Nacom'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### calculate_rank_progress_percentage(xp INTEGER)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/calculate_maya_rank_helpers.sql:51-119`
|
||||
**Version:** v2.1 (Dec 14, 2025)
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Calcula el porcentaje de progreso dentro del rango actual.
|
||||
|
||||
```sql
|
||||
SELECT gamification_system.calculate_rank_progress_percentage(750);
|
||||
-- Retorna: 50 (50% dentro de Nacom)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### calculate_user_rank(p_user_id UUID)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/calculate_user_rank.sql:1-75`
|
||||
**Version:** v2.1 (Dec 15, 2025)
|
||||
**Status:** Activo
|
||||
**Correccion:** CORR-P0-001 (missions_completed -> modules_completed)
|
||||
|
||||
**Proposito:** Calcula y actualiza el rango de un usuario basado en su XP.
|
||||
|
||||
```sql
|
||||
SELECT gamification_system.calculate_user_rank('user-uuid');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### get_user_rank_progress(p_user_id UUID)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/get_user_rank_progress.sql:1-86`
|
||||
**Version:** v2.1 (Dec 18, 2025)
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Obtiene informacion completa de progreso de rango.
|
||||
|
||||
**Retorna:** Record con current_rank, xp_total, progress_percentage, next_rank, xp_to_next
|
||||
|
||||
---
|
||||
|
||||
### Funciones de Leaderboard
|
||||
|
||||
#### update_leaderboard_global()
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/update_leaderboard_global.sql:1-79`
|
||||
**Version:** v2.1 (Dec 18, 2025)
|
||||
**Status:** Activo
|
||||
**Correccion:** CORR-P0-001
|
||||
|
||||
**Proposito:** Actualiza el leaderboard global con posiciones actuales.
|
||||
|
||||
---
|
||||
|
||||
#### update_leaderboard_coins()
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/update_leaderboard_coins.sql:1-60`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Actualiza leaderboard ordenado por ML Coins.
|
||||
|
||||
---
|
||||
|
||||
#### update_leaderboard_streaks()
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/update_leaderboard_streaks.sql:1-94`
|
||||
**Version:** v2.1 (Dec 15, 2025)
|
||||
**Status:** Activo
|
||||
**Correccion:** CORR-001 (last_activity_date -> last_activity_at, longest_streak -> max_streak)
|
||||
|
||||
**Proposito:** Actualiza leaderboard de rachas.
|
||||
|
||||
---
|
||||
|
||||
### Funciones de Promocion
|
||||
|
||||
#### check_rank_promotion(p_user_id UUID)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/check_rank_promotion.sql:1-80`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Verifica si un usuario califica para promocion de rango.
|
||||
|
||||
---
|
||||
|
||||
#### promote_to_next_rank(p_user_id UUID)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/promote_to_next_rank.sql:1-120`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Ejecuta la promocion de rango y registra en historial.
|
||||
|
||||
---
|
||||
|
||||
### Funciones de Beneficios
|
||||
|
||||
#### get_rank_benefits(p_rank maya_rank)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/get_rank_benefits.sql:1-45`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Obtiene los beneficios/perks de un rango.
|
||||
|
||||
---
|
||||
|
||||
#### get_rank_multiplier(p_rank maya_rank)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/gamification_system/functions/get_rank_multiplier.sql:1-25`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Obtiene el multiplicador de XP para un rango.
|
||||
|
||||
---
|
||||
|
||||
## SCHEMA: educational_content
|
||||
|
||||
### Funciones de Validacion
|
||||
|
||||
#### validate_rueda_inferencias(...)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/educational_content/functions/14-validate_rueda_inferencias.sql:176-410`
|
||||
**Version:** v1.0 (Dec 15, 2025)
|
||||
**Status:** NUEVO
|
||||
|
||||
**Proposito:** Valida respuestas abiertas para ejercicios de "Rueda de Inferencias".
|
||||
|
||||
**Caracteristicas:**
|
||||
- Soporte para estructura `categoryExpectations` (nueva)
|
||||
- Soporte para estructura `flat` (legacy)
|
||||
- Validacion de keywords con normalizacion
|
||||
- Puntuacion parcial
|
||||
|
||||
**Retorna:**
|
||||
```sql
|
||||
RECORD (
|
||||
is_correct BOOLEAN,
|
||||
score INTEGER,
|
||||
feedback TEXT,
|
||||
details JSONB
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### validate_multiple_choice(...)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/educational_content/functions/01-validate_multiple_choice.sql`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Valida respuestas de opcion multiple.
|
||||
|
||||
---
|
||||
|
||||
#### validate_drag_and_drop(...)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/educational_content/functions/02-validate_drag_and_drop.sql`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Valida ejercicios de arrastrar y soltar.
|
||||
|
||||
---
|
||||
|
||||
#### validate_fill_blanks(...)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/educational_content/functions/03-validate_fill_blanks.sql`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Valida ejercicios de llenar espacios.
|
||||
|
||||
---
|
||||
|
||||
#### validate_ordering(...)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/educational_content/functions/04-validate_ordering.sql`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Valida ejercicios de ordenamiento.
|
||||
|
||||
---
|
||||
|
||||
#### validate_matching(...)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/educational_content/functions/05-validate_matching.sql`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Valida ejercicios de emparejamiento.
|
||||
|
||||
---
|
||||
|
||||
## SCHEMA: auth_management
|
||||
|
||||
### Funciones de Autenticacion
|
||||
|
||||
#### validate_user_credentials(...)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/auth_management/functions/validate_user_credentials.sql`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Valida credenciales de usuario.
|
||||
|
||||
---
|
||||
|
||||
#### create_session(...)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/auth_management/functions/create_session.sql`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Crea sesion de usuario.
|
||||
|
||||
---
|
||||
|
||||
#### invalidate_session(...)
|
||||
|
||||
**Ubicacion:** `ddl/schemas/auth_management/functions/invalidate_session.sql`
|
||||
**Status:** Activo
|
||||
|
||||
**Proposito:** Invalida sesion de usuario.
|
||||
|
||||
---
|
||||
|
||||
## HISTORIAL DE CORRECCIONES
|
||||
|
||||
### CORR-P0-001 (Dec 15, 2025)
|
||||
|
||||
**Problema:** Funciones referenciaban `missions_completed` que no existe en `user_stats`.
|
||||
|
||||
**Solucion:** Cambiar a `modules_completed`.
|
||||
|
||||
**Funciones afectadas:**
|
||||
- `calculate_user_rank()`
|
||||
- `update_leaderboard_global()`
|
||||
|
||||
---
|
||||
|
||||
### CORR-001 (Dec 15, 2025)
|
||||
|
||||
**Problema:** Funciones referenciaban columnas incorrectas en `user_stats`.
|
||||
|
||||
**Cambios:**
|
||||
- `last_activity_date` -> `last_activity_at::DATE`
|
||||
- `longest_streak` -> `max_streak`
|
||||
|
||||
**Funciones afectadas:**
|
||||
- `update_leaderboard_streaks()`
|
||||
|
||||
---
|
||||
|
||||
## REFERENCIAS
|
||||
|
||||
- [MIGRACION-MAYA-RANKS-v2.1.md](../../migraciones/MIGRACION-MAYA-RANKS-v2.1.md)
|
||||
- [01-SCHEMAS-INVENTORY.md](./01-SCHEMAS-INVENTORY.md)
|
||||
- [02-TABLES-INVENTORY.md](./02-TABLES-INVENTORY.md)
|
||||
|
||||
---
|
||||
|
||||
**Ultima actualizacion:** 2025-12-18
|
||||
@ -0,0 +1,363 @@
|
||||
# Migracion: Sistema de Rangos Maya v2.0 a v2.1
|
||||
|
||||
**Version:** 2.1.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Autor:** Requirements-Analyst (SIMCO)
|
||||
**Tipo:** Migracion de Datos y Logica
|
||||
|
||||
---
|
||||
|
||||
## RESUMEN EJECUTIVO
|
||||
|
||||
Migracion de umbrales de XP para hacer **todos los 5 rangos Maya alcanzables** con el contenido educativo actual (1,950 XP disponibles en Modulos 1-3).
|
||||
|
||||
### Problema Resuelto
|
||||
|
||||
Los umbrales v2.0 (hasta 10,000 XP para K'uk'ulkan) eran **inalcanzables** con el contenido educativo disponible, causando frustracion en estudiantes y falta de motivacion.
|
||||
|
||||
### Solucion Implementada
|
||||
|
||||
Reduccion de umbrales para que la progresion sea:
|
||||
- **Justa:** Todos los rangos alcanzables completando M1-M3 con excelencia
|
||||
- **Motivante:** Progresion visible y constante
|
||||
- **Pedagogica:** Recompensas alineadas con esfuerzo real
|
||||
|
||||
---
|
||||
|
||||
## CAMBIOS TECNICOS
|
||||
|
||||
### Tabla Comparativa de Umbrales
|
||||
|
||||
| Rango | v2.0 XP | v2.1 XP | Reduccion |
|
||||
|-------|---------|---------|-----------|
|
||||
| Ajaw | 0-999 | 0-499 | -500 XP |
|
||||
| Nacom | 1,000-2,999 | 500-999 | Reorganizado |
|
||||
| Ah K'in | 3,000-5,999 | 1,000-1,499 | -1,500 XP |
|
||||
| Halach Uinic | 6,000-9,999 | 1,500-1,899 | -3,100 XP |
|
||||
| K'uk'ulkan | 10,000+ | 1,900+ | **-8,100 XP** |
|
||||
|
||||
### XP Disponible por Modulo
|
||||
|
||||
| Modulo | XP Aproximado | Rango Alcanzable |
|
||||
|--------|---------------|------------------|
|
||||
| M1 | 0-500 | Ajaw -> Nacom |
|
||||
| M2 | 500-1,000 | Nacom -> Ah K'in |
|
||||
| M3 | 1,000-1,950 | Ah K'in -> K'uk'ulkan |
|
||||
|
||||
**Total disponible M1-M3:** 1,950 XP
|
||||
|
||||
---
|
||||
|
||||
## FUNCIONES MODIFICADAS
|
||||
|
||||
### 1. calculate_maya_rank_helpers.sql
|
||||
|
||||
**Ubicacion:** `apps/database/ddl/schemas/gamification_system/functions/calculate_maya_rank_helpers.sql`
|
||||
|
||||
#### Funcion: `calculate_maya_rank_from_xp(xp INTEGER)`
|
||||
|
||||
```sql
|
||||
-- v2.0 (ANTES)
|
||||
IF xp < 1000 THEN RETURN 'Ajaw';
|
||||
ELSIF xp < 3000 THEN RETURN 'Nacom';
|
||||
ELSIF xp < 6000 THEN RETURN 'Ah K''in';
|
||||
ELSIF xp < 10000 THEN RETURN 'Halach Uinic';
|
||||
ELSE RETURN 'K''uk''ulkan';
|
||||
|
||||
-- v2.1 (DESPUES)
|
||||
IF xp < 500 THEN RETURN 'Ajaw';
|
||||
ELSIF xp < 1000 THEN RETURN 'Nacom';
|
||||
ELSIF xp < 1500 THEN RETURN 'Ah K''in';
|
||||
ELSIF xp < 1900 THEN RETURN 'Halach Uinic';
|
||||
ELSE RETURN 'K''uk''ulkan';
|
||||
```
|
||||
|
||||
#### Funcion: `calculate_rank_progress_percentage(xp INTEGER)`
|
||||
|
||||
```sql
|
||||
-- v2.0 (ANTES)
|
||||
WHEN 'Ajaw' THEN xp_in_rank := xp; rank_size := 1000;
|
||||
WHEN 'Nacom' THEN xp_in_rank := xp - 1000; rank_size := 2000;
|
||||
WHEN 'Ah K''in' THEN xp_in_rank := xp - 3000; rank_size := 3000;
|
||||
WHEN 'Halach Uinic' THEN xp_in_rank := xp - 6000; rank_size := 4000;
|
||||
|
||||
-- v2.1 (DESPUES)
|
||||
WHEN 'Ajaw' THEN xp_in_rank := xp; rank_size := 500;
|
||||
WHEN 'Nacom' THEN xp_in_rank := xp - 500; rank_size := 500;
|
||||
WHEN 'Ah K''in' THEN xp_in_rank := xp - 1000; rank_size := 500;
|
||||
WHEN 'Halach Uinic' THEN xp_in_rank := xp - 1500; rank_size := 400;
|
||||
```
|
||||
|
||||
### 2. calculate_user_rank.sql
|
||||
|
||||
**Ubicacion:** `apps/database/ddl/schemas/gamification_system/functions/calculate_user_rank.sql`
|
||||
|
||||
**Correccion aplicada (CORR-P0-001):**
|
||||
```sql
|
||||
-- ANTES (ERROR - columna no existe)
|
||||
SELECT us.total_xp, us.missions_completed INTO v_total_xp, v_missions_completed
|
||||
|
||||
-- DESPUES (CORRECTO)
|
||||
SELECT us.total_xp, us.modules_completed INTO v_total_xp, v_modules_completed
|
||||
```
|
||||
|
||||
### 3. update_leaderboard_streaks.sql
|
||||
|
||||
**Ubicacion:** `apps/database/ddl/schemas/gamification_system/functions/update_leaderboard_streaks.sql`
|
||||
|
||||
**Correcciones aplicadas (CORR-001):**
|
||||
```sql
|
||||
-- ANTES (ERROR)
|
||||
last_activity_date -> last_activity_at::DATE
|
||||
longest_streak -> max_streak
|
||||
|
||||
-- DESPUES (CORRECTO - alineado con user_stats)
|
||||
v_last_activity, v_current_streak, v_longest_streak
|
||||
FROM gamification_system.user_stats us
|
||||
WHERE us.user_id = p_user_id;
|
||||
```
|
||||
|
||||
### 4. update_leaderboard_global.sql
|
||||
|
||||
**Ubicacion:** `apps/database/ddl/schemas/gamification_system/functions/update_leaderboard_global.sql`
|
||||
|
||||
**Correccion aplicada (CORR-P0-001):**
|
||||
- Cambio de `missions_completed` a `modules_completed`
|
||||
- Alineacion con columnas reales de `user_stats`
|
||||
|
||||
---
|
||||
|
||||
## SEEDS ACTUALIZADOS
|
||||
|
||||
### 03-maya_ranks.sql
|
||||
|
||||
**Ubicacion:** `apps/database/seeds/dev/gamification_system/03-maya_ranks.sql`
|
||||
|
||||
```sql
|
||||
INSERT INTO gamification_system.maya_ranks (
|
||||
rank_name, min_xp_required, max_xp_threshold,
|
||||
ml_coins_bonus, xp_multiplier, perks
|
||||
) VALUES
|
||||
('Ajaw', 0, 499, 0, 1.00,
|
||||
'["basic_access", "forum_access"]'),
|
||||
('Nacom', 500, 999, 100, 1.10,
|
||||
'["xp_boost_10", "daily_bonus", "avatar_customization"]'),
|
||||
('Ah K''in', 1000, 1499, 250, 1.15,
|
||||
'["xp_boost_15", "coin_bonus", "exclusive_content"]'),
|
||||
('Halach Uinic', 1500, 1899, 500, 1.20,
|
||||
'["xp_boost_20", "priority_support", "mentor_badge"]'),
|
||||
('K''uk''ulkan', 1900, NULL, 1000, 1.25,
|
||||
'["xp_boost_25", "all_perks", "hall_of_fame", "certificate"]')
|
||||
ON CONFLICT (rank_name) DO UPDATE SET
|
||||
min_xp_required = EXCLUDED.min_xp_required,
|
||||
max_xp_threshold = EXCLUDED.max_xp_threshold,
|
||||
ml_coins_bonus = EXCLUDED.ml_coins_bonus,
|
||||
xp_multiplier = EXCLUDED.xp_multiplier,
|
||||
perks = EXCLUDED.perks;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IMPACTO EN EL SISTEMA
|
||||
|
||||
### Funciones Dependientes
|
||||
|
||||
| Funcion | Status | Notas |
|
||||
|---------|--------|-------|
|
||||
| `calculate_user_rank()` | ✅ Actualizada | Usa v2.1 thresholds |
|
||||
| `get_user_rank_progress()` | ✅ Actualizada | Calculo de porcentaje correcto |
|
||||
| `update_leaderboard_global()` | ✅ Corregida | Alineacion con user_stats |
|
||||
| `update_leaderboard_streaks()` | ✅ Corregida | Columnas correctas |
|
||||
|
||||
### Tablas Afectadas
|
||||
|
||||
| Tabla | Cambio |
|
||||
|-------|--------|
|
||||
| `gamification_system.maya_ranks` | Datos actualizados via seed |
|
||||
| `gamification_system.user_stats` | Sin cambios de schema |
|
||||
| `gamification_system.user_ranks` | Sin cambios de schema |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Componente | Impacto |
|
||||
|------------|---------|
|
||||
| RankProgressBar | Muestra porcentaje correcto |
|
||||
| Leaderboard | Posiciones calculadas con v2.1 |
|
||||
| UserProfile | Rango mostrado correctamente |
|
||||
|
||||
---
|
||||
|
||||
## CALCULO DE PROGRESION
|
||||
|
||||
### Distribucion por Modulo
|
||||
|
||||
```
|
||||
Modulo 1: 0-500 XP
|
||||
├── Ejercicios basicos: ~300 XP
|
||||
├── Bonuses: ~100 XP
|
||||
└── Completitud: ~100 XP
|
||||
→ Rango: Ajaw → Nacom
|
||||
|
||||
Modulo 2: 500-1,000 XP
|
||||
├── Ejercicios intermedios: ~350 XP
|
||||
├── Bonuses: ~100 XP
|
||||
└── Completitud: ~100 XP
|
||||
→ Rango: Nacom → Ah K'in
|
||||
|
||||
Modulo 3: 1,000-1,950 XP
|
||||
├── Ejercicios avanzados: ~600 XP
|
||||
├── Bonuses: ~200 XP
|
||||
└── Completitud: ~150 XP
|
||||
→ Rango: Ah K'in → K'uk'ulkan
|
||||
```
|
||||
|
||||
### XP Multipliers por Rango
|
||||
|
||||
| Rango | Multiplicador | Efecto |
|
||||
|-------|---------------|--------|
|
||||
| Ajaw | 1.00x | Base |
|
||||
| Nacom | 1.10x | +10% XP |
|
||||
| Ah K'in | 1.15x | +15% XP |
|
||||
| Halach Uinic | 1.20x | +20% XP |
|
||||
| K'uk'ulkan | 1.25x | +25% XP |
|
||||
|
||||
### ML Coins Bonus por Rango
|
||||
|
||||
| Rango | Bonus | Acumulado |
|
||||
|-------|-------|-----------|
|
||||
| Ajaw | 0 | 0 |
|
||||
| Nacom | 100 | 100 |
|
||||
| Ah K'in | 250 | 350 |
|
||||
| Halach Uinic | 500 | 850 |
|
||||
| K'uk'ulkan | 1,000 | 1,850 |
|
||||
|
||||
---
|
||||
|
||||
## PERKS DESBLOQUEABLES
|
||||
|
||||
### Por Rango
|
||||
|
||||
| Rango | Perks |
|
||||
|-------|-------|
|
||||
| **Ajaw** | Acceso basico, Foro |
|
||||
| **Nacom** | +10% XP, Bonus diario, Avatar custom |
|
||||
| **Ah K'in** | +15% XP, Bonus coins, Contenido exclusivo |
|
||||
| **Halach Uinic** | +20% XP, Soporte prioritario, Badge mentor |
|
||||
| **K'uk'ulkan** | +25% XP, Todos los perks, Hall of Fame, Certificado |
|
||||
|
||||
---
|
||||
|
||||
## MIGRACION DE DATOS
|
||||
|
||||
### Para Nuevas Instalaciones
|
||||
|
||||
Los seeds ya incluyen los valores v2.1. No se requiere accion adicional.
|
||||
|
||||
### Para Instalaciones Existentes
|
||||
|
||||
```sql
|
||||
-- Ejecutar seed actualizado
|
||||
\i apps/database/seeds/dev/gamification_system/03-maya_ranks.sql
|
||||
|
||||
-- O manualmente:
|
||||
UPDATE gamification_system.maya_ranks SET
|
||||
min_xp_required = 0, max_xp_threshold = 499
|
||||
WHERE rank_name = 'Ajaw';
|
||||
|
||||
UPDATE gamification_system.maya_ranks SET
|
||||
min_xp_required = 500, max_xp_threshold = 999
|
||||
WHERE rank_name = 'Nacom';
|
||||
|
||||
UPDATE gamification_system.maya_ranks SET
|
||||
min_xp_required = 1000, max_xp_threshold = 1499
|
||||
WHERE rank_name = 'Ah K''in';
|
||||
|
||||
UPDATE gamification_system.maya_ranks SET
|
||||
min_xp_required = 1500, max_xp_threshold = 1899
|
||||
WHERE rank_name = 'Halach Uinic';
|
||||
|
||||
UPDATE gamification_system.maya_ranks SET
|
||||
min_xp_required = 1900, max_xp_threshold = NULL
|
||||
WHERE rank_name = 'K''uk''ulkan';
|
||||
```
|
||||
|
||||
### Recalculo de Rangos de Usuarios
|
||||
|
||||
```sql
|
||||
-- Recalcular rango de todos los usuarios
|
||||
SELECT gamification_system.calculate_user_rank(user_id)
|
||||
FROM gamification_system.user_stats;
|
||||
|
||||
-- Actualizar leaderboards
|
||||
SELECT gamification_system.update_leaderboard_global();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VERIFICACION
|
||||
|
||||
### Queries de Validacion
|
||||
|
||||
```sql
|
||||
-- Verificar umbrales
|
||||
SELECT rank_name, min_xp_required, max_xp_threshold, xp_multiplier
|
||||
FROM gamification_system.maya_ranks
|
||||
ORDER BY min_xp_required;
|
||||
|
||||
-- Verificar distribucion de usuarios por rango
|
||||
SELECT
|
||||
mr.rank_name,
|
||||
COUNT(us.user_id) as usuarios
|
||||
FROM gamification_system.maya_ranks mr
|
||||
LEFT JOIN gamification_system.user_stats us
|
||||
ON us.current_rank = mr.rank_name
|
||||
GROUP BY mr.rank_name
|
||||
ORDER BY mr.min_xp_required;
|
||||
|
||||
-- Verificar funcion de calculo
|
||||
SELECT gamification_system.calculate_maya_rank_from_xp(0); -- Ajaw
|
||||
SELECT gamification_system.calculate_maya_rank_from_xp(500); -- Nacom
|
||||
SELECT gamification_system.calculate_maya_rank_from_xp(1000); -- Ah K'in
|
||||
SELECT gamification_system.calculate_maya_rank_from_xp(1500); -- Halach Uinic
|
||||
SELECT gamification_system.calculate_maya_rank_from_xp(1900); -- K'uk'ulkan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## REFERENCIAS
|
||||
|
||||
### Documentacion Relacionada
|
||||
|
||||
- `ET-GAM-003-rangos-maya.md` - Especificacion tecnica de rangos
|
||||
- `ET-GAM-005-hook-user-gamification.md` - Hook de gamificacion
|
||||
- `DocumentoDiseño_Mecanicas_GAMILIT_v6.2.md` - Diseño de mecanicas
|
||||
|
||||
### Archivos Modificados
|
||||
|
||||
```
|
||||
apps/database/ddl/schemas/gamification_system/functions/
|
||||
├── calculate_maya_rank_helpers.sql (v2.1 thresholds)
|
||||
├── calculate_user_rank.sql (CORR-P0-001)
|
||||
├── update_leaderboard_streaks.sql (CORR-001)
|
||||
├── update_leaderboard_global.sql (CORR-P0-001)
|
||||
└── get_user_rank_progress.sql (actualizado)
|
||||
|
||||
apps/database/seeds/dev/gamification_system/
|
||||
└── 03-maya_ranks.sql (v2.1 data)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HISTORIAL DE CAMBIOS
|
||||
|
||||
| Fecha | Version | Cambio |
|
||||
|-------|---------|--------|
|
||||
| 2025-11-24 | v2.0 | Implementacion inicial |
|
||||
| 2025-12-14 | v2.1 | Reduccion de umbrales |
|
||||
| 2025-12-15 | v2.1.1 | Correcciones CORR-P0-001, CORR-001 |
|
||||
| 2025-12-18 | v2.1.2 | Homologacion DEV → PROD |
|
||||
|
||||
---
|
||||
|
||||
**Status:** IMPLEMENTADO
|
||||
**Ultima actualizacion:** 2025-12-18
|
||||
@ -0,0 +1,208 @@
|
||||
# DIRECTIVA: Deployment en Producción
|
||||
|
||||
**Versión:** 1.0
|
||||
**Servidor:** 74.208.126.102
|
||||
**Ejecutar después de:** Backup configs + Pull + Restaurar configs
|
||||
|
||||
---
|
||||
|
||||
## PREREQUISITOS
|
||||
|
||||
Antes de ejecutar esta directiva, debes haber completado:
|
||||
- [x] Backup de configuraciones en ../backups/TIMESTAMP/
|
||||
- [x] Pull del repositorio (git reset --hard origin/master)
|
||||
- [x] Restauración de .env.production desde backup
|
||||
|
||||
---
|
||||
|
||||
## PROCESO DE DEPLOYMENT
|
||||
|
||||
### PASO 1: Verificar SSL/Nginx
|
||||
|
||||
```bash
|
||||
# Verificar si Nginx está instalado y corriendo
|
||||
if ! command -v nginx &> /dev/null; then
|
||||
echo "Nginx no instalado. Ejecutar GUIA-SSL-AUTOFIRMADO.md primero"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! systemctl is-active --quiet nginx; then
|
||||
echo "Nginx no está corriendo. Iniciando..."
|
||||
sudo systemctl start nginx
|
||||
fi
|
||||
|
||||
# Verificar certificado SSL existe
|
||||
if [ ! -f /etc/nginx/ssl/gamilit.crt ]; then
|
||||
echo "Certificado SSL no encontrado. Ejecutar GUIA-SSL-AUTOFIRMADO.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ SSL/Nginx configurado"
|
||||
```
|
||||
|
||||
### PASO 2: Recrear Base de Datos
|
||||
|
||||
```bash
|
||||
cd apps/database
|
||||
chmod +x create-database.sh
|
||||
./create-database.sh
|
||||
cd ../..
|
||||
|
||||
echo "✓ Base de datos recreada"
|
||||
```
|
||||
|
||||
### PASO 3: Instalar Dependencias
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd apps/backend
|
||||
npm install --production=false
|
||||
cd ../..
|
||||
|
||||
# Frontend
|
||||
cd apps/frontend
|
||||
npm install --production=false
|
||||
cd ../..
|
||||
|
||||
echo "✓ Dependencias instaladas"
|
||||
```
|
||||
|
||||
### PASO 4: Build
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd apps/backend
|
||||
npm run build
|
||||
cd ../..
|
||||
|
||||
# Frontend
|
||||
cd apps/frontend
|
||||
npm run build
|
||||
cd ../..
|
||||
|
||||
echo "✓ Build completado"
|
||||
```
|
||||
|
||||
### PASO 5: Iniciar Servicios con PM2
|
||||
|
||||
```bash
|
||||
pm2 start ecosystem.config.js --env production
|
||||
pm2 save
|
||||
pm2 list
|
||||
|
||||
echo "✓ Servicios iniciados"
|
||||
```
|
||||
|
||||
### PASO 6: Validación
|
||||
|
||||
```bash
|
||||
# Esperar a que los servicios inicien
|
||||
sleep 5
|
||||
|
||||
# Health check backend (interno)
|
||||
echo "=== Health Check Backend (interno) ==="
|
||||
curl -s http://localhost:3006/api/v1/health | head -10
|
||||
|
||||
# Health check frontend (interno)
|
||||
echo "=== Health Check Frontend (interno) ==="
|
||||
curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" http://localhost:3005
|
||||
|
||||
# Health check via Nginx (HTTPS)
|
||||
echo "=== Health Check HTTPS ==="
|
||||
curl -sk https://74.208.126.102/api/v1/health | head -10
|
||||
|
||||
echo "=== Frontend HTTPS ==="
|
||||
curl -sk -o /dev/null -w "HTTP Status: %{http_code}\n" https://74.208.126.102
|
||||
|
||||
# PM2 status
|
||||
echo "=== PM2 Status ==="
|
||||
pm2 list
|
||||
|
||||
# Logs recientes
|
||||
echo "=== Logs Recientes ==="
|
||||
pm2 logs --lines 10 --nostream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CONFIGURACIÓN ESPERADA
|
||||
|
||||
### Puertos
|
||||
|
||||
| Servicio | Puerto | Protocolo |
|
||||
|----------|--------|-----------|
|
||||
| Backend | 3006 | HTTP (interno) |
|
||||
| Frontend | 3005 | HTTP (interno) |
|
||||
| Nginx | 443 | HTTPS (externo) |
|
||||
|
||||
### URLs de Acceso
|
||||
|
||||
| Servicio | URL |
|
||||
|----------|-----|
|
||||
| Frontend | https://74.208.126.102 |
|
||||
| Backend API | https://74.208.126.102/api/v1 |
|
||||
| Health Check | https://74.208.126.102/api/v1/health |
|
||||
|
||||
---
|
||||
|
||||
## SI FALLA ALGO
|
||||
|
||||
### Error en Base de Datos
|
||||
```bash
|
||||
# Verificar PostgreSQL
|
||||
sudo systemctl status postgresql
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h localhost -U gamilit_user -d gamilit_platform -c "SELECT 1"
|
||||
```
|
||||
|
||||
### Error en Build
|
||||
```bash
|
||||
# Ver logs de error
|
||||
cd apps/backend && npm run build 2>&1 | tail -50
|
||||
cd apps/frontend && npm run build 2>&1 | tail -50
|
||||
```
|
||||
|
||||
### Error en PM2
|
||||
```bash
|
||||
pm2 logs gamilit-backend --err --lines 50
|
||||
pm2 logs gamilit-frontend --err --lines 50
|
||||
```
|
||||
|
||||
### Error en Nginx/SSL
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl status nginx
|
||||
curl -vk https://74.208.126.102:3006/api/v1/health
|
||||
```
|
||||
|
||||
### Rollback Completo
|
||||
```bash
|
||||
pm2 stop all
|
||||
|
||||
# Restaurar configs
|
||||
cp "../backups/latest/config/backend.env.production" apps/backend/.env.production
|
||||
cp "../backups/latest/config/frontend.env.production" apps/frontend/.env.production
|
||||
|
||||
# Rebuild
|
||||
cd apps/backend && npm run build && cd ../..
|
||||
cd apps/frontend && npm run build && cd ../..
|
||||
|
||||
# Reiniciar
|
||||
pm2 start ecosystem.config.js --env production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CHECKLIST FINAL
|
||||
|
||||
- [ ] Nginx corriendo con SSL
|
||||
- [ ] Base de datos recreada
|
||||
- [ ] Backend build exitoso
|
||||
- [ ] Frontend build exitoso
|
||||
- [ ] PM2 servicios online
|
||||
- [ ] Health check backend OK
|
||||
- [ ] Health check frontend OK
|
||||
- [ ] HTTPS funcionando en :3005 y :3006
|
||||
|
||||
---
|
||||
|
||||
*Directiva creada: 2025-12-18*
|
||||
@ -0,0 +1,622 @@
|
||||
# GUIA-ACTUALIZACION-PRODUCCION.md
|
||||
|
||||
**Fecha:** 2025-12-18
|
||||
**Version:** 1.0
|
||||
**Proposito:** Guia paso a paso para actualizar el servidor de produccion desde el repositorio remoto
|
||||
|
||||
---
|
||||
|
||||
## IMPORTANTE - LEER PRIMERO
|
||||
|
||||
Este documento es la **guia maestra** que el agente en produccion debe seguir cuando:
|
||||
1. Se notifica que hay un commit en remoto
|
||||
2. Se necesita actualizar el servidor con los cambios del repositorio
|
||||
|
||||
**Principio:** Respaldar TODO antes de actualizar, dar preferencia a remoto, reintegrar configuraciones locales.
|
||||
|
||||
---
|
||||
|
||||
## FLUJO COMPLETO DE ACTUALIZACION
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FLUJO DE ACTUALIZACION │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. DETENER SERVICIOS │
|
||||
│ └─> pm2 stop all │
|
||||
│ │
|
||||
│ 2. RESPALDAR CONFIGURACIONES │
|
||||
│ └─> Copiar .env files a /backup/config/ │
|
||||
│ │
|
||||
│ 3. RESPALDAR BASE DE DATOS │
|
||||
│ └─> pg_dump completo a /backup/database/ │
|
||||
│ │
|
||||
│ 4. PULL DEL REPOSITORIO │
|
||||
│ └─> git fetch && git reset --hard origin/main │
|
||||
│ │
|
||||
│ 5. RESTAURAR CONFIGURACIONES │
|
||||
│ └─> Copiar .env files de vuelta │
|
||||
│ │
|
||||
│ 6. RECREAR BASE DE DATOS (limpia) │
|
||||
│ └─> drop + create + seeds │
|
||||
│ │
|
||||
│ 7. INSTALAR DEPENDENCIAS │
|
||||
│ └─> npm install en backend y frontend │
|
||||
│ │
|
||||
│ 8. BUILD DE APLICACIONES │
|
||||
│ └─> npm run build │
|
||||
│ │
|
||||
│ 9. INICIAR SERVICIOS │
|
||||
│ └─> pm2 start ecosystem.config.js │
|
||||
│ │
|
||||
│ 10. VALIDAR DEPLOYMENT │
|
||||
│ └─> ./scripts/diagnose-production.sh │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 1: DETENER SERVICIOS
|
||||
|
||||
```bash
|
||||
# Detener todos los procesos PM2
|
||||
pm2 stop all
|
||||
|
||||
# Verificar que estan detenidos
|
||||
pm2 list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 2: RESPALDAR CONFIGURACIONES
|
||||
|
||||
### 2.1 Crear directorio de backup (si no existe)
|
||||
|
||||
```bash
|
||||
# Directorio de backups fuera del workspace
|
||||
BACKUP_DIR="/home/gamilit/backups/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR/config"
|
||||
mkdir -p "$BACKUP_DIR/database"
|
||||
|
||||
echo "Directorio de backup: $BACKUP_DIR"
|
||||
```
|
||||
|
||||
### 2.2 Respaldar archivos de configuracion
|
||||
|
||||
```bash
|
||||
# Respaldar .env de produccion
|
||||
cp apps/backend/.env.production "$BACKUP_DIR/config/backend.env.production"
|
||||
cp apps/frontend/.env.production "$BACKUP_DIR/config/frontend.env.production"
|
||||
|
||||
# Respaldar ecosystem.config.js si tiene modificaciones locales
|
||||
cp ecosystem.config.js "$BACKUP_DIR/config/ecosystem.config.js"
|
||||
|
||||
# Respaldar cualquier otro archivo de configuracion local
|
||||
if [ -f "apps/backend/.env" ]; then
|
||||
cp apps/backend/.env "$BACKUP_DIR/config/backend.env"
|
||||
fi
|
||||
|
||||
if [ -f "apps/frontend/.env" ]; then
|
||||
cp apps/frontend/.env "$BACKUP_DIR/config/frontend.env"
|
||||
fi
|
||||
|
||||
# Listar archivos respaldados
|
||||
echo "=== Archivos de configuracion respaldados ==="
|
||||
ls -la "$BACKUP_DIR/config/"
|
||||
```
|
||||
|
||||
### 2.3 Archivos de configuracion criticos
|
||||
|
||||
| Archivo | Ubicacion | Contenido critico |
|
||||
|---------|-----------|-------------------|
|
||||
| `.env.production` | `apps/backend/` | DATABASE_URL, JWT_SECRET, CORS |
|
||||
| `.env.production` | `apps/frontend/` | VITE_API_HOST, VITE_API_PORT |
|
||||
| `ecosystem.config.js` | raiz | Configuracion PM2 |
|
||||
|
||||
---
|
||||
|
||||
## PASO 3: RESPALDAR BASE DE DATOS
|
||||
|
||||
### 3.1 Backup completo con pg_dump
|
||||
|
||||
```bash
|
||||
# Variables de conexion (ajustar segun el servidor)
|
||||
DB_NAME="gamilit_platform"
|
||||
DB_USER="gamilit_user"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
|
||||
# Archivo de backup
|
||||
BACKUP_FILE="$BACKUP_DIR/database/gamilit_backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||
|
||||
# Ejecutar backup completo
|
||||
PGPASSWORD="$DB_PASSWORD" pg_dump \
|
||||
-h "$DB_HOST" \
|
||||
-p "$DB_PORT" \
|
||||
-U "$DB_USER" \
|
||||
-d "$DB_NAME" \
|
||||
--format=plain \
|
||||
--no-owner \
|
||||
--no-acl \
|
||||
> "$BACKUP_FILE"
|
||||
|
||||
# Verificar tamano del backup
|
||||
ls -lh "$BACKUP_FILE"
|
||||
|
||||
# Comprimir backup
|
||||
gzip "$BACKUP_FILE"
|
||||
echo "Backup creado: ${BACKUP_FILE}.gz"
|
||||
```
|
||||
|
||||
### 3.2 Verificar backup
|
||||
|
||||
```bash
|
||||
# Verificar que el backup tiene contenido
|
||||
gunzip -c "${BACKUP_FILE}.gz" | head -50
|
||||
|
||||
# Contar tablas en el backup
|
||||
echo "Tablas en backup:"
|
||||
gunzip -c "${BACKUP_FILE}.gz" | grep "CREATE TABLE" | wc -l
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 4: PULL DEL REPOSITORIO
|
||||
|
||||
### 4.1 Verificar estado actual
|
||||
|
||||
```bash
|
||||
# Ver estado actual
|
||||
git status
|
||||
|
||||
# Ver rama actual
|
||||
git branch
|
||||
|
||||
# Ver diferencias con remoto
|
||||
git fetch origin
|
||||
git log HEAD..origin/main --oneline
|
||||
```
|
||||
|
||||
### 4.2 Hacer pull dando preferencia a remoto
|
||||
|
||||
```bash
|
||||
# OPCION A: Reset completo a remoto (RECOMENDADO)
|
||||
# Descarta TODOS los cambios locales y usa exactamente lo que hay en remoto
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
|
||||
# OPCION B: Pull con estrategia de merge (si hay cambios locales importantes)
|
||||
# git pull origin main --strategy-option theirs
|
||||
```
|
||||
|
||||
### 4.3 Verificar el pull
|
||||
|
||||
```bash
|
||||
# Verificar que estamos en el commit correcto
|
||||
git log --oneline -5
|
||||
|
||||
# Verificar que no hay cambios pendientes
|
||||
git status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 5: RESTAURAR CONFIGURACIONES
|
||||
|
||||
### 5.1 Restaurar archivos .env
|
||||
|
||||
```bash
|
||||
# Restaurar configuraciones de produccion
|
||||
cp "$BACKUP_DIR/config/backend.env.production" apps/backend/.env.production
|
||||
cp "$BACKUP_DIR/config/frontend.env.production" apps/frontend/.env.production
|
||||
|
||||
# Restaurar ecosystem.config.js si fue modificado
|
||||
# cp "$BACKUP_DIR/config/ecosystem.config.js" ecosystem.config.js
|
||||
|
||||
# Crear enlaces simbolicos a .env si el backend los requiere
|
||||
cd apps/backend
|
||||
ln -sf .env.production .env
|
||||
cd ../..
|
||||
|
||||
cd apps/frontend
|
||||
ln -sf .env.production .env
|
||||
cd ../..
|
||||
```
|
||||
|
||||
### 5.2 Verificar configuraciones restauradas
|
||||
|
||||
```bash
|
||||
# Verificar que los archivos existen
|
||||
ls -la apps/backend/.env*
|
||||
ls -la apps/frontend/.env*
|
||||
|
||||
# Verificar contenido critico (sin mostrar secrets)
|
||||
echo "=== Backend Config ==="
|
||||
grep -E "^(APP_|NODE_ENV|CORS)" apps/backend/.env.production
|
||||
|
||||
echo "=== Frontend Config ==="
|
||||
grep -E "^VITE_" apps/frontend/.env.production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 6: RECREAR BASE DE DATOS (LIMPIA)
|
||||
|
||||
### 6.1 Ejecutar script de creacion
|
||||
|
||||
```bash
|
||||
cd apps/database
|
||||
|
||||
# Configurar DATABASE_URL
|
||||
export DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||
|
||||
# Ejecutar script de creacion limpia
|
||||
# IMPORTANTE: Este script hace DROP y CREATE de la base de datos
|
||||
./create-database.sh
|
||||
|
||||
cd ../..
|
||||
```
|
||||
|
||||
### 6.2 Proceso del script create-database.sh
|
||||
|
||||
El script ejecuta en orden:
|
||||
1. DROP DATABASE (si existe)
|
||||
2. CREATE DATABASE
|
||||
3. Cargar DDL (16 fases - schemas, tablas, funciones, triggers)
|
||||
4. Cargar Seeds de produccion (57 archivos)
|
||||
|
||||
### 6.3 Verificar carga de datos
|
||||
|
||||
```bash
|
||||
# Ejecutar script de diagnostico
|
||||
./scripts/diagnose-production.sh
|
||||
|
||||
# O verificar manualmente las tablas criticas
|
||||
psql "$DATABASE_URL" -c "
|
||||
SELECT 'Tenants' as tabla, COUNT(*) as total FROM auth_management.tenants
|
||||
UNION ALL SELECT 'Users', COUNT(*) FROM auth.users
|
||||
UNION ALL SELECT 'Profiles', COUNT(*) FROM auth_management.profiles
|
||||
UNION ALL SELECT 'Modules', COUNT(*) FROM educational_content.modules
|
||||
UNION ALL SELECT 'Exercises', COUNT(*) FROM educational_content.exercises
|
||||
UNION ALL SELECT 'Maya Ranks', COUNT(*) FROM gamification_system.maya_ranks
|
||||
UNION ALL SELECT 'Achievements', COUNT(*) FROM gamification_system.achievements;
|
||||
"
|
||||
```
|
||||
|
||||
### 6.4 Si hay datos faltantes
|
||||
|
||||
```bash
|
||||
# Ejecutar script de reparacion
|
||||
./scripts/repair-missing-data.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 7: INSTALAR DEPENDENCIAS
|
||||
|
||||
### 7.1 Backend
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Limpiar node_modules si hay problemas
|
||||
# rm -rf node_modules package-lock.json
|
||||
|
||||
# Instalar dependencias
|
||||
npm install
|
||||
|
||||
cd ../..
|
||||
```
|
||||
|
||||
### 7.2 Frontend
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
|
||||
# Limpiar node_modules si hay problemas
|
||||
# rm -rf node_modules package-lock.json
|
||||
|
||||
# Instalar dependencias
|
||||
npm install
|
||||
|
||||
cd ../..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 8: BUILD DE APLICACIONES
|
||||
|
||||
### 8.1 Build Backend
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Build de produccion
|
||||
npm run build
|
||||
|
||||
# Verificar que el build fue exitoso
|
||||
ls -la dist/
|
||||
|
||||
cd ../..
|
||||
```
|
||||
|
||||
### 8.2 Build Frontend
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
|
||||
# Build de produccion
|
||||
npm run build
|
||||
|
||||
# Verificar que el build fue exitoso
|
||||
ls -la dist/
|
||||
|
||||
cd ../..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 9: INICIAR SERVICIOS
|
||||
|
||||
### 9.1 Iniciar con PM2
|
||||
|
||||
```bash
|
||||
# Iniciar usando ecosystem.config.js
|
||||
pm2 start ecosystem.config.js
|
||||
|
||||
# Verificar estado
|
||||
pm2 list
|
||||
|
||||
# Guardar configuracion de PM2
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### 9.2 Verificar logs
|
||||
|
||||
```bash
|
||||
# Ver logs de todos los procesos
|
||||
pm2 logs --lines 50
|
||||
|
||||
# Ver logs solo del backend
|
||||
pm2 logs gamilit-backend --lines 30
|
||||
|
||||
# Ver logs solo del frontend
|
||||
pm2 logs gamilit-frontend --lines 30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 10: VALIDAR DEPLOYMENT
|
||||
|
||||
### 10.1 Ejecutar script de diagnostico
|
||||
|
||||
```bash
|
||||
./scripts/diagnose-production.sh
|
||||
```
|
||||
|
||||
### 10.2 Verificaciones manuales
|
||||
|
||||
```bash
|
||||
# Health check del backend
|
||||
curl -s http://localhost:3006/api/health | jq .
|
||||
|
||||
# Verificar frontend
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:3005
|
||||
|
||||
# Verificar tenant principal
|
||||
psql "$DATABASE_URL" -c "SELECT slug, is_active FROM auth_management.tenants WHERE slug = 'gamilit-prod';"
|
||||
```
|
||||
|
||||
### 10.3 Prueba de registro (opcional)
|
||||
|
||||
```bash
|
||||
# Probar endpoint de registro
|
||||
curl -X POST http://localhost:3006/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "test-deploy@example.com",
|
||||
"password": "TestPassword123!",
|
||||
"first_name": "Test",
|
||||
"last_name": "Deploy"
|
||||
}'
|
||||
|
||||
# Si funciona, eliminar usuario de prueba
|
||||
# psql "$DATABASE_URL" -c "DELETE FROM auth.users WHERE email = 'test-deploy@example.com';"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SCRIPT AUTOMATIZADO COMPLETO
|
||||
|
||||
Para automatizar todo el proceso, usar el siguiente script:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# update-production.sh
|
||||
# Uso: ./scripts/update-production.sh
|
||||
|
||||
set -e
|
||||
|
||||
# Colores
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuracion
|
||||
PROJECT_DIR="/ruta/al/proyecto/gamilit"
|
||||
BACKUP_BASE="/home/gamilit/backups"
|
||||
DB_NAME="gamilit_platform"
|
||||
DB_USER="gamilit_user"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
# DB_PASSWORD debe estar en variable de entorno
|
||||
|
||||
# Crear timestamp
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="$BACKUP_BASE/$TIMESTAMP"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "=============================================="
|
||||
echo " ACTUALIZACION PRODUCCION GAMILIT"
|
||||
echo " Timestamp: $TIMESTAMP"
|
||||
echo "=============================================="
|
||||
echo -e "${NC}"
|
||||
|
||||
# 1. Detener servicios
|
||||
echo -e "${YELLOW}[1/10] Deteniendo servicios...${NC}"
|
||||
pm2 stop all
|
||||
|
||||
# 2. Crear directorios de backup
|
||||
echo -e "${YELLOW}[2/10] Creando backup de configuraciones...${NC}"
|
||||
mkdir -p "$BACKUP_DIR/config" "$BACKUP_DIR/database"
|
||||
|
||||
cp apps/backend/.env.production "$BACKUP_DIR/config/" 2>/dev/null || true
|
||||
cp apps/backend/.env "$BACKUP_DIR/config/backend.env" 2>/dev/null || true
|
||||
cp apps/frontend/.env.production "$BACKUP_DIR/config/" 2>/dev/null || true
|
||||
cp apps/frontend/.env "$BACKUP_DIR/config/frontend.env" 2>/dev/null || true
|
||||
cp ecosystem.config.js "$BACKUP_DIR/config/" 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}Configuraciones respaldadas en $BACKUP_DIR/config/${NC}"
|
||||
|
||||
# 3. Backup de base de datos
|
||||
echo -e "${YELLOW}[3/10] Creando backup de base de datos...${NC}"
|
||||
BACKUP_FILE="$BACKUP_DIR/database/gamilit_$TIMESTAMP.sql"
|
||||
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$BACKUP_FILE"
|
||||
gzip "$BACKUP_FILE"
|
||||
echo -e "${GREEN}Base de datos respaldada: ${BACKUP_FILE}.gz${NC}"
|
||||
|
||||
# 4. Pull del repositorio
|
||||
echo -e "${YELLOW}[4/10] Actualizando desde repositorio remoto...${NC}"
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
echo -e "${GREEN}Repositorio actualizado${NC}"
|
||||
|
||||
# 5. Restaurar configuraciones
|
||||
echo -e "${YELLOW}[5/10] Restaurando configuraciones...${NC}"
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/backend/ 2>/dev/null || true
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/frontend/ 2>/dev/null || true
|
||||
|
||||
# 6. Recrear base de datos
|
||||
echo -e "${YELLOW}[6/10] Recreando base de datos...${NC}"
|
||||
cd apps/database
|
||||
export DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||
./create-database.sh
|
||||
cd ../..
|
||||
|
||||
# 7. Instalar dependencias
|
||||
echo -e "${YELLOW}[7/10] Instalando dependencias backend...${NC}"
|
||||
cd apps/backend && npm install && cd ../..
|
||||
|
||||
echo -e "${YELLOW}[8/10] Instalando dependencias frontend...${NC}"
|
||||
cd apps/frontend && npm install && cd ../..
|
||||
|
||||
# 8. Build
|
||||
echo -e "${YELLOW}[9/10] Construyendo aplicaciones...${NC}"
|
||||
cd apps/backend && npm run build && cd ../..
|
||||
cd apps/frontend && npm run build && cd ../..
|
||||
|
||||
# 9. Iniciar servicios
|
||||
echo -e "${YELLOW}[10/10] Iniciando servicios...${NC}"
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
|
||||
# 10. Validar
|
||||
echo -e "${BLUE}"
|
||||
echo "=============================================="
|
||||
echo " VALIDACION"
|
||||
echo "=============================================="
|
||||
echo -e "${NC}"
|
||||
|
||||
./scripts/diagnose-production.sh
|
||||
|
||||
echo -e "${GREEN}"
|
||||
echo "=============================================="
|
||||
echo " ACTUALIZACION COMPLETADA"
|
||||
echo "=============================================="
|
||||
echo -e "${NC}"
|
||||
echo "Backup disponible en: $BACKUP_DIR"
|
||||
echo ""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ROLLBACK (Si algo falla)
|
||||
|
||||
### Restaurar configuraciones
|
||||
|
||||
```bash
|
||||
# Copiar archivos de configuracion del backup
|
||||
cp /home/gamilit/backups/YYYYMMDD_HHMMSS/config/* apps/backend/
|
||||
cp /home/gamilit/backups/YYYYMMDD_HHMMSS/config/* apps/frontend/
|
||||
```
|
||||
|
||||
### Restaurar base de datos
|
||||
|
||||
```bash
|
||||
# Restaurar desde backup
|
||||
BACKUP_FILE="/home/gamilit/backups/YYYYMMDD_HHMMSS/database/gamilit_*.sql.gz"
|
||||
|
||||
# Descomprimir
|
||||
gunzip -c "$BACKUP_FILE" > /tmp/restore.sql
|
||||
|
||||
# Restaurar
|
||||
psql "$DATABASE_URL" < /tmp/restore.sql
|
||||
|
||||
# Limpiar
|
||||
rm /tmp/restore.sql
|
||||
```
|
||||
|
||||
### Volver a commit anterior
|
||||
|
||||
```bash
|
||||
# Ver commits anteriores
|
||||
git log --oneline -10
|
||||
|
||||
# Volver a commit especifico
|
||||
git reset --hard <commit_hash>
|
||||
|
||||
# Reinstalar y reconstruir
|
||||
cd apps/backend && npm install && npm run build && cd ../..
|
||||
cd apps/frontend && npm install && npm run build && cd ../..
|
||||
|
||||
# Reiniciar
|
||||
pm2 restart all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DOCUMENTACION RELACIONADA
|
||||
|
||||
Despues del pull, el agente debe leer estas guias si necesita mas detalle:
|
||||
|
||||
| Guia | Proposito |
|
||||
|------|-----------|
|
||||
| `GUIA-DESPLIEGUE-PRODUCCION-COMPLETA.md` | Configuracion completa del servidor |
|
||||
| `GUIA-VALIDACION-PRODUCCION.md` | Validaciones y troubleshooting |
|
||||
| `GUIA-CREAR-BASE-DATOS.md` | Detalle del proceso de creacion de BD |
|
||||
|
||||
---
|
||||
|
||||
## CHECKLIST RAPIDO
|
||||
|
||||
```
|
||||
[ ] 1. pm2 stop all
|
||||
[ ] 2. Backup configuraciones a /home/gamilit/backups/
|
||||
[ ] 3. Backup base de datos (pg_dump)
|
||||
[ ] 4. git fetch && git reset --hard origin/main
|
||||
[ ] 5. Restaurar .env files
|
||||
[ ] 6. cd apps/database && ./create-database.sh
|
||||
[ ] 7. npm install (backend y frontend)
|
||||
[ ] 8. npm run build (backend y frontend)
|
||||
[ ] 9. pm2 start ecosystem.config.js
|
||||
[ ] 10. ./scripts/diagnose-production.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Ultima actualizacion:** 2025-12-18
|
||||
**Autor:** Sistema de documentacion GAMILIT
|
||||
@ -0,0 +1,157 @@
|
||||
# GUIA-CORS-PRODUCCION.md
|
||||
|
||||
## Configuración CORS para Producción - GAMILIT
|
||||
|
||||
**Fecha**: 2025-12-18
|
||||
**Problema resuelto**: Error `Access-Control-Allow-Origin contains multiple values`
|
||||
|
||||
---
|
||||
|
||||
## 1. Descripción del Problema
|
||||
|
||||
Al hacer requests desde el frontend (puerto 3005) al backend (puerto 3006) en producción con HTTPS, se recibe el error:
|
||||
|
||||
```
|
||||
Access to XMLHttpRequest at 'https://74.208.126.102:3006/api/v1/auth/register'
|
||||
from origin 'https://74.208.126.102:3005' has been blocked by CORS policy:
|
||||
The 'Access-Control-Allow-Origin' header contains multiple values
|
||||
'https://74.208.126.102:3005, https://74.208.126.102:3005', but only one is allowed.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Causa Raíz
|
||||
|
||||
El header `Access-Control-Allow-Origin` se está enviando **DOS VECES**:
|
||||
|
||||
1. **Nginx** (proxy SSL) agrega headers CORS
|
||||
2. **NestJS** (backend) también agrega headers CORS via `app.enableCors()`
|
||||
|
||||
Cuando ambos envían el header, el navegador ve valores duplicados y rechaza la respuesta.
|
||||
|
||||
---
|
||||
|
||||
## 3. Solución Definitiva
|
||||
|
||||
### Regla de Oro: **Solo NestJS maneja CORS**
|
||||
|
||||
Nginx debe actuar únicamente como proxy SSL sin agregar headers CORS.
|
||||
|
||||
### 3.1 Configuración Backend (.env.production)
|
||||
|
||||
```bash
|
||||
# CORS - CONFIGURACIÓN CRÍTICA
|
||||
# ============================================================================
|
||||
# ADVERTENCIA: HEADERS CORS DUPLICADOS
|
||||
# ============================================================================
|
||||
# Si usas Nginx como proxy SSL, NO agregar headers CORS en Nginx.
|
||||
# NestJS maneja CORS internamente en main.ts.
|
||||
# Headers duplicados causan error:
|
||||
# "The 'Access-Control-Allow-Origin' header contains multiple values"
|
||||
# ============================================================================
|
||||
|
||||
# Incluye HTTPS y HTTP para compatibilidad durante transición
|
||||
CORS_ORIGIN=https://74.208.126.102:3005,https://74.208.126.102,http://74.208.126.102:3005,http://74.208.126.102
|
||||
ENABLE_CORS=true
|
||||
```
|
||||
|
||||
### 3.2 Configuración Frontend (.env.production)
|
||||
|
||||
```bash
|
||||
# SSL CONFIGURADO - Usar HTTPS/WSS
|
||||
VITE_API_HOST=74.208.126.102:3006
|
||||
VITE_API_PROTOCOL=https
|
||||
VITE_WS_HOST=74.208.126.102:3006
|
||||
VITE_WS_PROTOCOL=wss
|
||||
```
|
||||
|
||||
### 3.3 Configuración Nginx (SIN CORS)
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/gamilit-backend
|
||||
server {
|
||||
listen 3006 ssl;
|
||||
server_name 74.208.126.102;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/gamilit.crt;
|
||||
ssl_certificate_key /etc/ssl/private/gamilit.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3007; # PM2 cluster interno
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# ⚠️ NO AGREGAR headers CORS aquí
|
||||
# NestJS los maneja internamente
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Verificación
|
||||
|
||||
### 4.1 Verificar headers con curl
|
||||
|
||||
```bash
|
||||
# Verificar que solo hay UN header Access-Control-Allow-Origin
|
||||
curl -I -X OPTIONS \
|
||||
-H "Origin: https://74.208.126.102:3005" \
|
||||
-H "Access-Control-Request-Method: POST" \
|
||||
https://74.208.126.102:3006/api/v1/auth/register
|
||||
|
||||
# Salida esperada (UN solo header):
|
||||
# Access-Control-Allow-Origin: https://74.208.126.102:3005
|
||||
# Access-Control-Allow-Credentials: true
|
||||
# Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||
```
|
||||
|
||||
### 4.2 Verificar configuración Nginx
|
||||
|
||||
```bash
|
||||
# Buscar headers CORS duplicados en configuración
|
||||
grep -r "Access-Control" /etc/nginx/
|
||||
```
|
||||
|
||||
Si encuentra líneas como `add_header Access-Control-Allow-Origin`, elimínelas.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pasos de Deploy
|
||||
|
||||
1. **Backend**: Actualizar `.env.production` con HTTPS origins
|
||||
2. **Frontend**: Actualizar `.env.production` con HTTPS/WSS
|
||||
3. **Nginx**: Remover cualquier header CORS
|
||||
4. **Reiniciar servicios**:
|
||||
```bash
|
||||
sudo systemctl reload nginx
|
||||
pm2 restart gamilit-backend
|
||||
cd /path/to/frontend && npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
### Error persiste después de la configuración
|
||||
1. Verificar que Nginx no tenga headers CORS en ningún include
|
||||
2. Revisar si hay otro proxy (CloudFlare, etc.) agregando headers
|
||||
3. Limpiar cache del navegador (F12 > Network > Disable cache)
|
||||
|
||||
### Error "CORS blocked request" en logs
|
||||
- El origen que hace la petición no está en CORS_ORIGIN
|
||||
- Verificar que el protocolo coincide (https vs http)
|
||||
|
||||
---
|
||||
|
||||
## 7. Referencias
|
||||
|
||||
- **Backend CORS**: `apps/backend/src/main.ts` líneas 27-46
|
||||
- **Config CORS**: `apps/backend/src/config/app.config.ts`
|
||||
- **Ejemplo .env**: `apps/backend/.env.production.example`
|
||||
@ -0,0 +1,483 @@
|
||||
# GUIA DE DEPLOYMENT PARA AGENTE EN PRODUCCION - GAMILIT
|
||||
|
||||
**Version:** 1.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Servidor:** 74.208.126.102
|
||||
**Proposito:** Guia estandarizada para el agente que ejecuta deployments en produccion
|
||||
|
||||
---
|
||||
|
||||
## INFORMACION DEL SERVIDOR
|
||||
|
||||
| Aspecto | Valor |
|
||||
|---------|-------|
|
||||
| **IP** | 74.208.126.102 |
|
||||
| **Usuario** | gamilit (o el usuario configurado) |
|
||||
| **Backend** | Puerto 3006 (PM2 cluster, 2 instancias) |
|
||||
| **Frontend** | Puerto 3005 (PM2 fork, 1 instancia) |
|
||||
| **Database** | PostgreSQL puerto 5432, database `gamilit_platform` |
|
||||
| **Repositorio** | git@github.com:rckrdmrd/gamilit-workspace.git |
|
||||
|
||||
---
|
||||
|
||||
## ESTRUCTURA DE BACKUPS ESTANDAR
|
||||
|
||||
### Directorio Base
|
||||
```
|
||||
/home/gamilit/backups/
|
||||
```
|
||||
|
||||
### Estructura por Deployment
|
||||
```
|
||||
/home/gamilit/backups/
|
||||
├── YYYYMMDD_HHMMSS/ # Timestamp del deployment
|
||||
│ ├── database/
|
||||
│ │ └── gamilit_YYYYMMDD_HHMMSS.sql.gz # Backup comprimido de BD
|
||||
│ ├── config/
|
||||
│ │ ├── backend.env.production # .env.production del backend
|
||||
│ │ ├── backend.env # .env del backend (si existe)
|
||||
│ │ ├── frontend.env.production # .env.production del frontend
|
||||
│ │ ├── frontend.env # .env del frontend (si existe)
|
||||
│ │ └── ecosystem.config.js # Configuracion PM2
|
||||
│ └── logs/
|
||||
│ ├── backend-error.log # Logs de error pre-deployment
|
||||
│ ├── backend-out.log # Logs de salida pre-deployment
|
||||
│ ├── frontend-error.log
|
||||
│ └── frontend-out.log
|
||||
├── latest -> YYYYMMDD_HHMMSS/ # Symlink al ultimo backup
|
||||
└── README.md # Documentacion de backups
|
||||
```
|
||||
|
||||
### Crear Estructura Inicial
|
||||
```bash
|
||||
# Ejecutar UNA VEZ para crear la estructura base
|
||||
mkdir -p /home/gamilit/backups
|
||||
chmod 700 /home/gamilit/backups
|
||||
|
||||
# Crear README
|
||||
cat > /home/gamilit/backups/README.md << 'EOF'
|
||||
# Backups de GAMILIT
|
||||
|
||||
Este directorio contiene los backups automaticos generados durante deployments.
|
||||
|
||||
## Estructura
|
||||
- Cada subdirectorio tiene formato YYYYMMDD_HHMMSS
|
||||
- `latest` es un symlink al backup mas reciente
|
||||
- Los backups de BD estan comprimidos con gzip
|
||||
|
||||
## Restaurar Base de Datos
|
||||
```bash
|
||||
gunzip -c /home/gamilit/backups/YYYYMMDD_HHMMSS/database/gamilit_*.sql.gz | psql "$DATABASE_URL"
|
||||
```
|
||||
|
||||
## Restaurar Configuraciones
|
||||
```bash
|
||||
cp /home/gamilit/backups/YYYYMMDD_HHMMSS/config/backend.env.production apps/backend/.env.production
|
||||
cp /home/gamilit/backups/YYYYMMDD_HHMMSS/config/frontend.env.production apps/frontend/.env.production
|
||||
```
|
||||
|
||||
## Retencion
|
||||
Se recomienda mantener los ultimos 10 backups y eliminar los antiguos.
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VARIABLES DE ENTORNO REQUERIDAS
|
||||
|
||||
Antes de cualquier deployment, verificar que estas variables esten configuradas:
|
||||
|
||||
```bash
|
||||
# En ~/.bashrc o /etc/environment del servidor
|
||||
|
||||
# Database
|
||||
export DB_HOST=localhost
|
||||
export DB_PORT=5432
|
||||
export DB_NAME=gamilit_platform
|
||||
export DB_USER=gamilit_user
|
||||
export DB_PASSWORD="[PASSWORD_SEGURO]"
|
||||
export DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||
|
||||
# Seguridad (GENERAR VALORES UNICOS)
|
||||
export JWT_SECRET="[VALOR_GENERADO_CON_openssl_rand_-base64_32]"
|
||||
export SESSION_SECRET="[OTRO_VALOR_GENERADO]"
|
||||
|
||||
# CORS
|
||||
export CORS_ORIGIN="https://gamilit.com,https://www.gamilit.com,http://74.208.126.102:3005"
|
||||
|
||||
# URLs
|
||||
export FRONTEND_URL="https://gamilit.com"
|
||||
export BACKEND_URL="https://gamilit.com/api"
|
||||
|
||||
# Backups
|
||||
export BACKUP_BASE="/home/gamilit/backups"
|
||||
```
|
||||
|
||||
**Generar secretos seguros:**
|
||||
```bash
|
||||
openssl rand -base64 32 # Para JWT_SECRET
|
||||
openssl rand -base64 32 # Para SESSION_SECRET
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PROCEDIMIENTO ESTANDAR DE DEPLOYMENT
|
||||
|
||||
### FASE 1: BACKUP (Antes de tocar nada)
|
||||
|
||||
```bash
|
||||
# 1.1 Crear timestamp y directorio de backup
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="${BACKUP_BASE:-/home/gamilit/backups}/$TIMESTAMP"
|
||||
mkdir -p "$BACKUP_DIR"/{database,config,logs}
|
||||
|
||||
# 1.2 Backup de base de datos
|
||||
echo "=== BACKUP DE BASE DE DATOS ==="
|
||||
PGPASSWORD="$DB_PASSWORD" pg_dump \
|
||||
-h "$DB_HOST" \
|
||||
-p "$DB_PORT" \
|
||||
-U "$DB_USER" \
|
||||
-d "$DB_NAME" \
|
||||
--format=plain \
|
||||
--no-owner \
|
||||
--no-acl \
|
||||
| gzip > "$BACKUP_DIR/database/gamilit_$TIMESTAMP.sql.gz"
|
||||
|
||||
echo "Backup creado: $BACKUP_DIR/database/gamilit_$TIMESTAMP.sql.gz"
|
||||
|
||||
# 1.3 Backup de configuraciones
|
||||
echo "=== BACKUP DE CONFIGURACIONES ==="
|
||||
cp apps/backend/.env.production "$BACKUP_DIR/config/backend.env.production" 2>/dev/null || true
|
||||
cp apps/backend/.env "$BACKUP_DIR/config/backend.env" 2>/dev/null || true
|
||||
cp apps/frontend/.env.production "$BACKUP_DIR/config/frontend.env.production" 2>/dev/null || true
|
||||
cp apps/frontend/.env "$BACKUP_DIR/config/frontend.env" 2>/dev/null || true
|
||||
cp ecosystem.config.js "$BACKUP_DIR/config/" 2>/dev/null || true
|
||||
|
||||
# 1.4 Backup de logs actuales
|
||||
echo "=== BACKUP DE LOGS ==="
|
||||
cp logs/*.log "$BACKUP_DIR/logs/" 2>/dev/null || true
|
||||
|
||||
# 1.5 Actualizar symlink 'latest'
|
||||
ln -sfn "$BACKUP_DIR" "${BACKUP_BASE:-/home/gamilit/backups}/latest"
|
||||
|
||||
echo "Backup completado en: $BACKUP_DIR"
|
||||
```
|
||||
|
||||
### FASE 2: DETENER SERVICIOS
|
||||
|
||||
```bash
|
||||
echo "=== DETENIENDO SERVICIOS ==="
|
||||
pm2 stop all
|
||||
pm2 list
|
||||
```
|
||||
|
||||
### FASE 3: PULL DEL REPOSITORIO
|
||||
|
||||
```bash
|
||||
echo "=== ACTUALIZANDO DESDE REPOSITORIO ==="
|
||||
|
||||
# Mostrar estado actual
|
||||
git status
|
||||
git branch --show-current
|
||||
|
||||
# Fetch y mostrar commits pendientes
|
||||
git fetch origin
|
||||
git log HEAD..origin/main --oneline 2>/dev/null || echo "Ya actualizado"
|
||||
|
||||
# Pull forzado (preferencia a remoto)
|
||||
git reset --hard origin/main
|
||||
|
||||
# Mostrar ultimo commit
|
||||
git log --oneline -1
|
||||
```
|
||||
|
||||
### FASE 4: RESTAURAR CONFIGURACIONES
|
||||
|
||||
```bash
|
||||
echo "=== RESTAURANDO CONFIGURACIONES ==="
|
||||
|
||||
# Restaurar .env files desde backup
|
||||
cp "$BACKUP_DIR/config/backend.env.production" apps/backend/.env.production
|
||||
cp "$BACKUP_DIR/config/frontend.env.production" apps/frontend/.env.production
|
||||
|
||||
# Crear symlinks .env -> .env.production
|
||||
cd apps/backend && ln -sf .env.production .env && cd ../..
|
||||
cd apps/frontend && ln -sf .env.production .env && cd ../..
|
||||
|
||||
echo "Configuraciones restauradas"
|
||||
```
|
||||
|
||||
### FASE 5: RECREAR BASE DE DATOS
|
||||
|
||||
```bash
|
||||
echo "=== RECREANDO BASE DE DATOS ==="
|
||||
|
||||
cd apps/database
|
||||
export DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||
|
||||
# Ejecutar script de creacion limpia
|
||||
chmod +x create-database.sh
|
||||
./create-database.sh
|
||||
|
||||
cd ../..
|
||||
echo "Base de datos recreada"
|
||||
```
|
||||
|
||||
### FASE 6: INSTALAR DEPENDENCIAS Y BUILD
|
||||
|
||||
```bash
|
||||
echo "=== INSTALANDO DEPENDENCIAS ==="
|
||||
|
||||
# Backend
|
||||
cd apps/backend
|
||||
npm install --production=false
|
||||
npm run build
|
||||
cd ../..
|
||||
|
||||
# Frontend
|
||||
cd apps/frontend
|
||||
npm install --production=false
|
||||
npm run build
|
||||
cd ../..
|
||||
|
||||
echo "Build completado"
|
||||
```
|
||||
|
||||
### FASE 7: INICIAR SERVICIOS CON PM2
|
||||
|
||||
```bash
|
||||
echo "=== INICIANDO SERVICIOS ==="
|
||||
|
||||
# Iniciar con ecosystem.config.js
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# Guardar configuracion PM2
|
||||
pm2 save
|
||||
|
||||
# Mostrar estado
|
||||
pm2 list
|
||||
```
|
||||
|
||||
### FASE 8: CONFIGURAR HTTPS CON CERTBOT (Si no esta configurado)
|
||||
|
||||
```bash
|
||||
# SOLO SI ES PRIMERA VEZ O CERTIFICADO EXPIRADO
|
||||
|
||||
echo "=== CONFIGURANDO HTTPS ==="
|
||||
|
||||
# 1. Instalar certbot si no existe
|
||||
sudo apt update
|
||||
sudo apt install -y certbot python3-certbot-nginx
|
||||
|
||||
# 2. Obtener certificado (reemplazar gamilit.com con tu dominio)
|
||||
sudo certbot --nginx -d gamilit.com -d www.gamilit.com
|
||||
|
||||
# 3. Verificar renovacion automatica
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
### FASE 9: CONFIGURAR NGINX COMO REVERSE PROXY
|
||||
|
||||
```bash
|
||||
# SOLO SI ES PRIMERA VEZ
|
||||
|
||||
# Crear configuracion Nginx
|
||||
sudo tee /etc/nginx/sites-available/gamilit << 'NGINX'
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name gamilit.com www.gamilit.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS Server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name gamilit.com www.gamilit.com;
|
||||
|
||||
# SSL Configuration (certbot lo configura automaticamente)
|
||||
ssl_certificate /etc/letsencrypt/live/gamilit.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/gamilit.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://localhost:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket
|
||||
location /socket.io {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
|
||||
# Habilitar sitio
|
||||
sudo ln -sf /etc/nginx/sites-available/gamilit /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### FASE 10: VALIDACION
|
||||
|
||||
```bash
|
||||
echo "=== VALIDANDO DEPLOYMENT ==="
|
||||
|
||||
# Ejecutar script de diagnostico
|
||||
./scripts/diagnose-production.sh
|
||||
|
||||
# O validacion manual:
|
||||
echo "--- Health Check Backend ---"
|
||||
curl -s https://gamilit.com/api/health | head -10
|
||||
|
||||
echo "--- Frontend Status ---"
|
||||
curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" https://gamilit.com
|
||||
|
||||
echo "--- PM2 Status ---"
|
||||
pm2 list
|
||||
|
||||
echo "--- Logs ---"
|
||||
pm2 logs --lines 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CONFIGURACION CORS PARA HTTPS
|
||||
|
||||
Una vez configurado HTTPS, actualizar las configuraciones:
|
||||
|
||||
### Backend .env.production
|
||||
```bash
|
||||
# Actualizar CORS para HTTPS
|
||||
CORS_ORIGIN=https://gamilit.com,https://www.gamilit.com
|
||||
FRONTEND_URL=https://gamilit.com
|
||||
```
|
||||
|
||||
### Frontend .env.production
|
||||
```bash
|
||||
# Actualizar para HTTPS
|
||||
VITE_API_PROTOCOL=https
|
||||
VITE_WS_PROTOCOL=wss
|
||||
VITE_API_HOST=gamilit.com
|
||||
VITE_WS_HOST=gamilit.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ROLLBACK (Si algo falla)
|
||||
|
||||
```bash
|
||||
# 1. Detener servicios
|
||||
pm2 stop all
|
||||
|
||||
# 2. Restaurar base de datos desde ultimo backup
|
||||
LATEST_BACKUP="${BACKUP_BASE:-/home/gamilit/backups}/latest"
|
||||
gunzip -c "$LATEST_BACKUP/database/gamilit_*.sql.gz" | \
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME"
|
||||
|
||||
# 3. Restaurar configuraciones
|
||||
cp "$LATEST_BACKUP/config/backend.env.production" apps/backend/.env.production
|
||||
cp "$LATEST_BACKUP/config/frontend.env.production" apps/frontend/.env.production
|
||||
|
||||
# 4. Revertir codigo (si es necesario)
|
||||
git reflog # Ver commits anteriores
|
||||
git reset --hard HEAD~1 # Volver un commit atras
|
||||
|
||||
# 5. Rebuild y reiniciar
|
||||
cd apps/backend && npm run build && cd ../..
|
||||
cd apps/frontend && npm run build && cd ../..
|
||||
pm2 start ecosystem.config.js --env production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TROUBLESHOOTING
|
||||
|
||||
### Error: CORS bloqueado
|
||||
```bash
|
||||
# Verificar CORS_ORIGIN en backend
|
||||
grep CORS apps/backend/.env.production
|
||||
|
||||
# Debe incluir el dominio con protocolo correcto (https://)
|
||||
```
|
||||
|
||||
### Error: Certificado SSL
|
||||
```bash
|
||||
# Renovar certificado
|
||||
sudo certbot renew
|
||||
|
||||
# Verificar certificado
|
||||
sudo certbot certificates
|
||||
```
|
||||
|
||||
### Error: PM2 no inicia
|
||||
```bash
|
||||
# Ver logs de error
|
||||
pm2 logs gamilit-backend --err --lines 50
|
||||
|
||||
# Verificar que el build existe
|
||||
ls -la apps/backend/dist/main.js
|
||||
ls -la apps/frontend/dist/
|
||||
```
|
||||
|
||||
### Error: Base de datos no conecta
|
||||
```bash
|
||||
# Verificar PostgreSQL
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# Verificar conexion
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MANTENIMIENTO
|
||||
|
||||
### Limpiar backups antiguos (mantener ultimos 10)
|
||||
```bash
|
||||
cd /home/gamilit/backups
|
||||
ls -dt */ | tail -n +11 | xargs rm -rf
|
||||
```
|
||||
|
||||
### Renovar certificados SSL
|
||||
```bash
|
||||
# Ejecutar mensualmente o cuando expire
|
||||
sudo certbot renew
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Monitorear logs
|
||||
```bash
|
||||
pm2 logs --lines 100
|
||||
pm2 monit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Guia creada para el agente de produccion de GAMILIT*
|
||||
*Ultima actualizacion: 2025-12-18*
|
||||
@ -0,0 +1,227 @@
|
||||
# GUIA RAPIDA: Deployment en Produccion
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Servidor:** 74.208.126.102
|
||||
|
||||
---
|
||||
|
||||
## CHECKLIST RAPIDO (10 PASOS)
|
||||
|
||||
```bash
|
||||
# 1. BACKUP (SIEMPRE PRIMERO)
|
||||
BACKUP_DIR="/home/gamilit/backups/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR/config"
|
||||
cp apps/backend/.env.production "$BACKUP_DIR/config/"
|
||||
cp apps/frontend/.env.production "$BACKUP_DIR/config/"
|
||||
|
||||
# 2. BACKUP BASE DE DATOS
|
||||
pg_dump "$DATABASE_URL" | gzip > "$BACKUP_DIR/gamilit.sql.gz"
|
||||
|
||||
# 3. PULL CAMBIOS
|
||||
git fetch origin && git reset --hard origin/main
|
||||
|
||||
# 4. RESTAURAR CONFIG
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/backend/
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/frontend/
|
||||
|
||||
# 5. INSTALAR DEPENDENCIAS
|
||||
npm install && cd apps/backend && npm install && cd ../frontend && npm install && cd ../..
|
||||
|
||||
# 6. BUILD
|
||||
cd apps/backend && npm run build && cd ../frontend && npm run build && cd ../..
|
||||
|
||||
# 7. RECREAR BASE DE DATOS (si hay cambios DDL)
|
||||
cd apps/database && ./drop-and-recreate-database.sh "$DATABASE_URL" && cd ..
|
||||
|
||||
# 8. DEPLOY PM2
|
||||
pm2 delete all 2>/dev/null; pm2 start ecosystem.config.js --env production; pm2 save
|
||||
|
||||
# 9. VALIDAR
|
||||
./scripts/validate-deployment.sh --ssl
|
||||
|
||||
# 10. STARTUP (solo primera vez)
|
||||
pm2 startup && pm2 save
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ESCENARIOS COMUNES
|
||||
|
||||
### A. Solo actualizar codigo (sin cambios BD)
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
cd apps/backend && npm install && npm run build && cd ..
|
||||
cd apps/frontend && npm install && npm run build && cd ..
|
||||
pm2 restart all
|
||||
```
|
||||
|
||||
### B. Cambios en frontend unicamente
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
cd apps/frontend && npm install && npm run build && cd ..
|
||||
pm2 restart gamilit-frontend
|
||||
```
|
||||
|
||||
### C. Cambios en backend unicamente
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
cd apps/backend && npm install && npm run build && cd ..
|
||||
pm2 restart gamilit-backend
|
||||
```
|
||||
|
||||
### D. Cambios en variables de entorno (.env)
|
||||
|
||||
```bash
|
||||
# Editar archivo
|
||||
nano apps/backend/.env.production
|
||||
# O
|
||||
nano apps/frontend/.env.production
|
||||
|
||||
# Rebuild frontend si cambian VITE_*
|
||||
cd apps/frontend && npm run build && cd ..
|
||||
|
||||
# Restart
|
||||
pm2 restart all
|
||||
```
|
||||
|
||||
### E. Recrear base de datos completa
|
||||
|
||||
```bash
|
||||
cd apps/database
|
||||
./drop-and-recreate-database.sh "$DATABASE_URL"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COMANDOS DE EMERGENCIA
|
||||
|
||||
### Rollback rapido
|
||||
|
||||
```bash
|
||||
# Restaurar desde backup
|
||||
pg_restore "$BACKUP_DIR/gamilit.sql.gz" | psql "$DATABASE_URL"
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/backend/
|
||||
cp "$BACKUP_DIR/config/.env.production" apps/frontend/
|
||||
pm2 restart all
|
||||
```
|
||||
|
||||
### Ver logs de errores
|
||||
|
||||
```bash
|
||||
pm2 logs --err --lines 100
|
||||
```
|
||||
|
||||
### Restart de emergencia
|
||||
|
||||
```bash
|
||||
pm2 kill && pm2 start ecosystem.config.js --env production
|
||||
```
|
||||
|
||||
### Status completo
|
||||
|
||||
```bash
|
||||
pm2 list && pm2 logs --lines 10 --nostream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VARIABLES DE ENTORNO CRITICAS
|
||||
|
||||
### Backend `.env.production`
|
||||
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
PORT=3006
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=gamilit_platform
|
||||
DB_USER=gamilit_user
|
||||
DB_PASSWORD=<PASSWORD>
|
||||
JWT_SECRET=<GENERAR: openssl rand -base64 32>
|
||||
CORS_ORIGIN=https://gamilit.com
|
||||
FRONTEND_URL=https://gamilit.com
|
||||
ENABLE_SWAGGER=false
|
||||
```
|
||||
|
||||
### Frontend `.env.production`
|
||||
|
||||
```bash
|
||||
VITE_ENV=production
|
||||
VITE_API_HOST=gamilit.com
|
||||
VITE_API_PROTOCOL=https
|
||||
VITE_WS_HOST=gamilit.com
|
||||
VITE_WS_PROTOCOL=wss
|
||||
VITE_MOCK_API=false
|
||||
VITE_ENABLE_DEBUG=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSL CON CERTBOT (NUEVO SERVIDOR)
|
||||
|
||||
> **Guia completa:** Ver [GUIA-SSL-CERTBOT-DEPLOYMENT.md](./GUIA-SSL-CERTBOT-DEPLOYMENT.md) para documentacion detallada, troubleshooting y renovacion.
|
||||
|
||||
```bash
|
||||
# Automatizado con Let's Encrypt
|
||||
sudo ./scripts/setup-ssl-certbot.sh gamilit.com www.gamilit.com
|
||||
|
||||
# Auto-firmado (sin dominio)
|
||||
sudo ./scripts/setup-ssl-certbot.sh --self-signed
|
||||
|
||||
# Ayuda
|
||||
./scripts/setup-ssl-certbot.sh --help
|
||||
|
||||
# Manual
|
||||
sudo apt install -y nginx certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d gamilit.com -d www.gamilit.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VALIDACION
|
||||
|
||||
```bash
|
||||
# Basica (sin SSL)
|
||||
./scripts/validate-deployment.sh
|
||||
|
||||
# Completa con SSL
|
||||
./scripts/validate-deployment.sh --ssl --verbose
|
||||
|
||||
# Opciones disponibles
|
||||
./scripts/validate-deployment.sh --help
|
||||
# --ssl Incluir validaciones de SSL/HTTPS
|
||||
# --verbose Mostrar informacion adicional
|
||||
|
||||
# Manual
|
||||
curl http://localhost:3006/api/health
|
||||
curl http://localhost:3005
|
||||
curl https://gamilit.com/api/health
|
||||
pm2 list
|
||||
```
|
||||
|
||||
> **Nota:** El script `validate-deployment.sh` verifica archivos .env, builds, PM2, endpoints, SSL (opcional) y base de datos.
|
||||
|
||||
---
|
||||
|
||||
## TROUBLESHOOTING
|
||||
|
||||
| Problema | Solucion |
|
||||
|----------|----------|
|
||||
| PM2 no inicia | `pm2 kill && pm2 start ecosystem.config.js --env production` |
|
||||
| CORS error | Verificar CORS_ORIGIN en backend y rebuildar frontend |
|
||||
| SSL no funciona | `sudo nginx -t && sudo systemctl restart nginx` |
|
||||
| BD no conecta | Verificar DB_PASSWORD y que PostgreSQL este corriendo |
|
||||
| Build falla | `rm -rf node_modules && npm install` |
|
||||
|
||||
---
|
||||
|
||||
## CONTACTO
|
||||
|
||||
- Logs: `/home/isem/workspace/projects/gamilit/logs/`
|
||||
- Config PM2: `ecosystem.config.js`
|
||||
- Guia completa: `docs/95-guias-desarrollo/GUIA-DESPLIEGUE-PRODUCCION-COMPLETA.md`
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,248 @@
|
||||
# GUIA: SSL Auto-firmado para Produccion (Sin Dominio)
|
||||
|
||||
**Servidor:** 74.208.126.102
|
||||
**Uso:** Cuando NO tienes dominio configurado
|
||||
|
||||
---
|
||||
|
||||
## ARQUITECTURA
|
||||
|
||||
```
|
||||
INTERNET
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Nginx :443 │ ◄── HTTPS (SSL auto-firmado)
|
||||
│ (Reverse │
|
||||
│ Proxy) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Backend :3006 │ │ Frontend :3005 │
|
||||
│ (NestJS) │ │ (Vite Preview) │
|
||||
│ /api/* │ │ /* │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
**Puertos (NO SE CAMBIAN):**
|
||||
- Frontend: 3005 (HTTP interno)
|
||||
- Backend: 3006 (HTTP interno)
|
||||
- Nginx: 443 (HTTPS externo)
|
||||
|
||||
**Acceso:**
|
||||
- https://74.208.126.102 → Frontend
|
||||
- https://74.208.126.102/api → Backend
|
||||
|
||||
---
|
||||
|
||||
## PASO 1: Generar Certificado Auto-firmado
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/nginx/ssl
|
||||
|
||||
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /etc/nginx/ssl/gamilit.key \
|
||||
-out /etc/nginx/ssl/gamilit.crt \
|
||||
-subj "/C=MX/ST=Estado/L=Ciudad/O=Gamilit/CN=74.208.126.102"
|
||||
|
||||
sudo ls -la /etc/nginx/ssl/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 2: Instalar Nginx
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 3: Configurar Nginx con SSL
|
||||
|
||||
```bash
|
||||
sudo tee /etc/nginx/sites-available/gamilit << 'NGINX'
|
||||
# =============================================================================
|
||||
# GAMILIT Production - SSL Auto-firmado
|
||||
# Acceso: https://74.208.126.102
|
||||
# =============================================================================
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name 74.208.126.102;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS Server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name 74.208.126.102;
|
||||
|
||||
# SSL con certificado auto-firmado
|
||||
ssl_certificate /etc/nginx/ssl/gamilit.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/gamilit.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# IMPORTANTE: NO agregar headers CORS aqui
|
||||
# NestJS maneja CORS internamente
|
||||
|
||||
# Frontend (default) - proxy a puerto 3005
|
||||
location / {
|
||||
proxy_pass http://localhost:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Backend API - proxy a puerto 3006
|
||||
location /api {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket
|
||||
location /socket.io {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
|
||||
sudo ln -sf /etc/nginx/sites-available/gamilit /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
sudo systemctl enable nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 4: Configurar Backend (.env.production)
|
||||
|
||||
**NO cambiar PORT.** Solo actualizar CORS:
|
||||
|
||||
```bash
|
||||
# En apps/backend/.env.production
|
||||
# Puerto se mantiene en 3006
|
||||
PORT=3006
|
||||
|
||||
# CORS apunta al acceso HTTPS via Nginx
|
||||
CORS_ORIGIN=https://74.208.126.102
|
||||
|
||||
# Frontend URL
|
||||
FRONTEND_URL=https://74.208.126.102
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 5: Configurar Frontend (.env.production)
|
||||
|
||||
```bash
|
||||
# En apps/frontend/.env.production
|
||||
# API a través de Nginx (mismo host, path /api)
|
||||
VITE_API_HOST=74.208.126.102
|
||||
VITE_API_PROTOCOL=https
|
||||
|
||||
# WebSocket
|
||||
VITE_WS_HOST=74.208.126.102
|
||||
VITE_WS_PROTOCOL=wss
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 6: Rebuild Frontend
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
npm run build
|
||||
cd ../..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 7: Reiniciar Servicios
|
||||
|
||||
```bash
|
||||
pm2 restart all
|
||||
pm2 list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 8: Validar
|
||||
|
||||
```bash
|
||||
# Verificar Nginx
|
||||
sudo systemctl status nginx
|
||||
|
||||
# Health check via HTTPS
|
||||
curl -sk https://74.208.126.102/api/v1/health
|
||||
|
||||
# Frontend via HTTPS
|
||||
curl -sk -o /dev/null -w "HTTP Status: %{http_code}\n" https://74.208.126.102
|
||||
|
||||
# PM2 status
|
||||
pm2 list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## URLs de Acceso
|
||||
|
||||
| Servicio | URL |
|
||||
|----------|-----|
|
||||
| Frontend | https://74.208.126.102 |
|
||||
| Backend API | https://74.208.126.102/api/v1 |
|
||||
| Health Check | https://74.208.126.102/api/v1/health |
|
||||
|
||||
---
|
||||
|
||||
## IMPORTANTE
|
||||
|
||||
1. **NO cambiar puertos de las apps** - Backend 3006, Frontend 3005
|
||||
2. **Solo Nginx expone HTTPS** - Puerto 443
|
||||
3. **Acceso unificado** - Todo via https://74.208.126.102
|
||||
4. **CORS apunta a Nginx** - https://74.208.126.102 (no a puertos internos)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: Puerto 443 en uso
|
||||
```bash
|
||||
sudo lsof -i :443
|
||||
sudo systemctl stop apache2 # Si Apache está corriendo
|
||||
```
|
||||
|
||||
### Error: CORS
|
||||
Verificar que CORS_ORIGIN sea `https://74.208.126.102` (sin puerto)
|
||||
|
||||
### Error: Nginx no inicia
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo journalctl -u nginx --no-pager -n 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Guia actualizada: 2025-12-18*
|
||||
@ -0,0 +1,356 @@
|
||||
# Guia SSL con Certbot - GAMILIT
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Autor:** Requirements-Analyst (SIMCO)
|
||||
|
||||
---
|
||||
|
||||
## 1. INTRODUCCION Y PROPOSITO
|
||||
|
||||
### Que hace el script `setup-ssl-certbot.sh`
|
||||
|
||||
Configura SSL/HTTPS automaticamente para la plataforma GAMILIT usando:
|
||||
- **Let's Encrypt (Certbot):** Certificados SSL gratuitos y automaticos para dominios reales
|
||||
- **Auto-firmado:** Certificados locales para servidores sin dominio publico
|
||||
|
||||
### Cuando usar cada opcion
|
||||
|
||||
| Escenario | Opcion | Comando |
|
||||
|-----------|--------|---------|
|
||||
| Servidor con dominio real (produccion) | Let's Encrypt | `./scripts/setup-ssl-certbot.sh gamilit.com` |
|
||||
| Servidor sin dominio (desarrollo/staging) | Auto-firmado | `./scripts/setup-ssl-certbot.sh --self-signed` |
|
||||
| Multiples dominios | Let's Encrypt | `./scripts/setup-ssl-certbot.sh gamilit.com www.gamilit.com` |
|
||||
|
||||
---
|
||||
|
||||
## 2. ARQUITECTURA
|
||||
|
||||
### Diagrama de Flujo HTTP a HTTPS
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Puerto 80/443 │
|
||||
│ (Nginx) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
/api/* /socket.io /*
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ Backend │ │ WebSocket │ │ Frontend │
|
||||
│ :3006 │ │ :3006 │ │ :3005 │
|
||||
└───────────┘ └───────────┘ └───────────┘
|
||||
```
|
||||
|
||||
### Puertos Utilizados
|
||||
|
||||
| Puerto | Servicio | Tipo |
|
||||
|--------|----------|------|
|
||||
| 80 | Nginx HTTP | Externo (redirect a 443) |
|
||||
| 443 | Nginx HTTPS | Externo |
|
||||
| 3005 | Frontend (PM2) | Interno |
|
||||
| 3006 | Backend (PM2) | Interno |
|
||||
| 5432 | PostgreSQL | Interno |
|
||||
|
||||
---
|
||||
|
||||
## 3. REQUISITOS PREVIOS
|
||||
|
||||
### Sistema
|
||||
- [x] Ubuntu/Debian
|
||||
- [x] Acceso root (sudo)
|
||||
- [x] Puertos 80 y 443 abiertos en firewall
|
||||
|
||||
### Servicios
|
||||
- [x] PM2 instalado y ejecutando
|
||||
- [x] `gamilit-backend` activo en PM2
|
||||
- [x] `gamilit-frontend` activo en PM2
|
||||
|
||||
### DNS (solo para Let's Encrypt)
|
||||
- [x] Dominio registrado
|
||||
- [x] DNS A record apuntando al servidor (IP: 74.208.126.102 o tu IP)
|
||||
|
||||
### Verificar requisitos
|
||||
|
||||
```bash
|
||||
# Verificar PM2
|
||||
pm2 list
|
||||
|
||||
# Verificar puertos
|
||||
sudo ufw status
|
||||
|
||||
# Verificar DNS (reemplaza con tu dominio)
|
||||
dig +short tu-dominio.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. INSTALACION RAPIDA
|
||||
|
||||
### Opcion A: Con Dominio Real (Let's Encrypt)
|
||||
|
||||
```bash
|
||||
# 1. Hacer script ejecutable
|
||||
chmod +x scripts/setup-ssl-certbot.sh
|
||||
|
||||
# 2. Ejecutar con tu dominio
|
||||
sudo ./scripts/setup-ssl-certbot.sh gamilit.com
|
||||
|
||||
# 3. Para multiples dominios
|
||||
sudo ./scripts/setup-ssl-certbot.sh gamilit.com www.gamilit.com
|
||||
```
|
||||
|
||||
### Opcion B: Sin Dominio (Auto-firmado)
|
||||
|
||||
```bash
|
||||
# 1. Hacer script ejecutable
|
||||
chmod +x scripts/setup-ssl-certbot.sh
|
||||
|
||||
# 2. Ejecutar con flag auto-firmado
|
||||
sudo ./scripts/setup-ssl-certbot.sh --self-signed
|
||||
```
|
||||
|
||||
### Ayuda
|
||||
|
||||
```bash
|
||||
./scripts/setup-ssl-certbot.sh --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. CONFIGURACION DETALLADA
|
||||
|
||||
### Paso a Paso: Let's Encrypt
|
||||
|
||||
1. **Verificacion de prerequisitos**
|
||||
- Instala Nginx si no existe
|
||||
- Instala Certbot si no existe
|
||||
- Verifica procesos PM2
|
||||
|
||||
2. **Verificacion DNS**
|
||||
- Resuelve el dominio
|
||||
- Verifica que apunte al servidor correcto
|
||||
- Si hay discrepancia, pregunta confirmacion
|
||||
|
||||
3. **Configuracion Nginx inicial (HTTP)**
|
||||
- Crea config en `/etc/nginx/sites-available/gamilit`
|
||||
- Configura proxies para Frontend, API, WebSocket
|
||||
- Habilita sitio y recarga Nginx
|
||||
|
||||
4. **Obtencion de certificado**
|
||||
- Ejecuta Certbot con el dominio
|
||||
- Configura renovacion automatica
|
||||
- Actualiza config Nginx con SSL
|
||||
|
||||
5. **Actualizacion de variables de entorno**
|
||||
- Modifica `.env.production` del backend
|
||||
- Modifica `.env.production` del frontend
|
||||
|
||||
6. **Rebuild y restart**
|
||||
- Rebuild del frontend con nuevas variables
|
||||
- Restart de todos los servicios PM2
|
||||
|
||||
7. **Validacion**
|
||||
- Verifica HTTPS en Frontend
|
||||
- Verifica HTTPS en API
|
||||
- Verifica redirect HTTP->HTTPS
|
||||
|
||||
### Paso a Paso: Auto-firmado
|
||||
|
||||
Similar al anterior pero:
|
||||
- Genera certificado con OpenSSL (365 dias)
|
||||
- Almacena en `/etc/nginx/ssl/`
|
||||
- No requiere validacion DNS
|
||||
|
||||
---
|
||||
|
||||
## 6. VARIABLES DE ENTORNO ACTUALIZADAS
|
||||
|
||||
### Backend (.env.production)
|
||||
|
||||
```env
|
||||
# ANTES
|
||||
CORS_ORIGIN=http://74.208.126.102:3005
|
||||
FRONTEND_URL=http://74.208.126.102:3005
|
||||
|
||||
# DESPUES (Let's Encrypt)
|
||||
CORS_ORIGIN=https://gamilit.com
|
||||
FRONTEND_URL=https://gamilit.com
|
||||
|
||||
# DESPUES (Auto-firmado)
|
||||
CORS_ORIGIN=https://74.208.126.102
|
||||
FRONTEND_URL=https://74.208.126.102
|
||||
```
|
||||
|
||||
### Frontend (.env.production)
|
||||
|
||||
```env
|
||||
# ANTES
|
||||
VITE_API_HOST=74.208.126.102:3006
|
||||
VITE_API_PROTOCOL=http
|
||||
VITE_WS_HOST=74.208.126.102:3006
|
||||
VITE_WS_PROTOCOL=ws
|
||||
|
||||
# DESPUES
|
||||
VITE_API_HOST=gamilit.com
|
||||
VITE_API_PROTOCOL=https
|
||||
VITE_WS_HOST=gamilit.com
|
||||
VITE_WS_PROTOCOL=wss
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. VALIDACION POST-INSTALACION
|
||||
|
||||
### Validacion Automatica
|
||||
|
||||
```bash
|
||||
./scripts/validate-deployment.sh --ssl --verbose
|
||||
```
|
||||
|
||||
### Validacion Manual
|
||||
|
||||
```bash
|
||||
# Frontend HTTPS
|
||||
curl -I https://gamilit.com
|
||||
|
||||
# API HTTPS
|
||||
curl https://gamilit.com/api/health
|
||||
|
||||
# Redirect HTTP->HTTPS
|
||||
curl -I http://gamilit.com
|
||||
|
||||
# Certificado
|
||||
echo | openssl s_client -connect gamilit.com:443 2>/dev/null | openssl x509 -noout -dates
|
||||
```
|
||||
|
||||
### URLs de Acceso (post-SSL)
|
||||
|
||||
| Servicio | URL |
|
||||
|----------|-----|
|
||||
| Frontend | https://gamilit.com |
|
||||
| API | https://gamilit.com/api |
|
||||
| Health | https://gamilit.com/api/health |
|
||||
| WebSocket | wss://gamilit.com/socket.io |
|
||||
|
||||
---
|
||||
|
||||
## 8. TROUBLESHOOTING
|
||||
|
||||
### DNS no resuelve
|
||||
|
||||
```
|
||||
Error: No se pudo resolver dominio gamilit.com
|
||||
```
|
||||
|
||||
**Solucion:**
|
||||
1. Verificar DNS: `dig +short gamilit.com`
|
||||
2. Esperar propagacion DNS (hasta 48h)
|
||||
3. Verificar A record en registrador de dominio
|
||||
|
||||
### Certbot falla
|
||||
|
||||
```
|
||||
Error: Challenge failed for domain
|
||||
```
|
||||
|
||||
**Solucion:**
|
||||
1. Verificar puerto 80 abierto: `sudo ufw allow 80`
|
||||
2. Verificar que Nginx responde: `curl http://localhost`
|
||||
3. Revisar logs: `sudo tail -f /var/log/letsencrypt/letsencrypt.log`
|
||||
|
||||
### Nginx no inicia
|
||||
|
||||
```
|
||||
Error: Configuracion Nginx invalida
|
||||
```
|
||||
|
||||
**Solucion:**
|
||||
1. Verificar sintaxis: `sudo nginx -t`
|
||||
2. Revisar config: `sudo cat /etc/nginx/sites-available/gamilit`
|
||||
3. Revisar logs: `sudo tail -f /var/log/nginx/error.log`
|
||||
|
||||
### CORS errors despues de SSL
|
||||
|
||||
```
|
||||
Error: CORS policy blocked
|
||||
```
|
||||
|
||||
**Solucion:**
|
||||
1. Verificar CORS_ORIGIN en backend: `grep CORS apps/backend/.env.production`
|
||||
2. Debe ser exactamente `https://tu-dominio.com` (sin trailing slash)
|
||||
3. Restart backend: `pm2 restart gamilit-backend`
|
||||
|
||||
### Certificado expirado
|
||||
|
||||
```
|
||||
Error: SSL certificate has expired
|
||||
```
|
||||
|
||||
**Solucion:**
|
||||
1. Renovar manualmente: `sudo certbot renew`
|
||||
2. Verificar cron de renovacion: `sudo systemctl status certbot.timer`
|
||||
3. Si falla, regenerar: `sudo certbot certonly --nginx -d gamilit.com`
|
||||
|
||||
---
|
||||
|
||||
## 9. RENOVACION Y MANTENIMIENTO
|
||||
|
||||
### Renovacion Automatica
|
||||
|
||||
Certbot configura automaticamente un cron/timer para renovacion.
|
||||
|
||||
```bash
|
||||
# Verificar timer
|
||||
sudo systemctl status certbot.timer
|
||||
|
||||
# Ver proxima renovacion
|
||||
sudo certbot certificates
|
||||
```
|
||||
|
||||
### Renovacion Manual
|
||||
|
||||
```bash
|
||||
# Dry-run (sin cambios)
|
||||
sudo certbot renew --dry-run
|
||||
|
||||
# Renovacion forzada
|
||||
sudo certbot renew --force-renewal
|
||||
```
|
||||
|
||||
### Logs de Renovacion
|
||||
|
||||
```bash
|
||||
# Logs de Certbot
|
||||
sudo tail -f /var/log/letsencrypt/letsencrypt.log
|
||||
|
||||
# Historial de renovaciones
|
||||
sudo ls -la /etc/letsencrypt/renewal-hooks/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. REFERENCIAS
|
||||
|
||||
- [Documentacion Certbot](https://certbot.eff.org/docs/)
|
||||
- [Nginx SSL Configuration](https://nginx.org/en/docs/http/configuring_https_servers.html)
|
||||
- [Let's Encrypt](https://letsencrypt.org/docs/)
|
||||
|
||||
### Documentacion Relacionada
|
||||
|
||||
- `GUIA-SSL-NGINX-PRODUCCION.md` - Configuracion manual de SSL
|
||||
- `GUIA-SSL-AUTOFIRMADO.md` - Certificados auto-firmados
|
||||
- `GUIA-DEPLOYMENT-RAPIDO.md` - Deployment general
|
||||
- `GUIA-VALIDACION-PRODUCCION.md` - Validaciones post-deployment
|
||||
|
||||
---
|
||||
|
||||
**Script:** `/scripts/setup-ssl-certbot.sh`
|
||||
**Ultima actualizacion:** 2025-12-18
|
||||
@ -0,0 +1,283 @@
|
||||
# GUIA: Configuracion SSL con Nginx para Produccion
|
||||
|
||||
**Servidor:** 74.208.126.102
|
||||
**Requisito:** Dominio apuntando al servidor (ej: gamilit.com)
|
||||
|
||||
---
|
||||
|
||||
## ARQUITECTURA
|
||||
|
||||
```
|
||||
INTERNET
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Nginx :443 │ ◄── SSL/HTTPS (certbot)
|
||||
│ (Reverse │
|
||||
│ Proxy) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Backend :3006 │ │ Frontend :3005 │
|
||||
│ (NestJS) │ │ (Vite Preview) │
|
||||
│ /api/* │ │ /* │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 1: Instalar Nginx y Certbot
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 2: Configurar DNS
|
||||
|
||||
Asegurar que el dominio apunte al servidor:
|
||||
```bash
|
||||
# Verificar DNS
|
||||
dig gamilit.com +short
|
||||
# Debe mostrar: 74.208.126.102
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 3: Configuracion Nginx (SIN SSL primero)
|
||||
|
||||
```bash
|
||||
sudo tee /etc/nginx/sites-available/gamilit << 'NGINX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name gamilit.com www.gamilit.com;
|
||||
|
||||
# Frontend (default)
|
||||
location / {
|
||||
proxy_pass http://localhost:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket
|
||||
location /socket.io {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
|
||||
# Habilitar sitio
|
||||
sudo ln -sf /etc/nginx/sites-available/gamilit /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Verificar configuracion
|
||||
sudo nginx -t
|
||||
|
||||
# Reiniciar Nginx
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 4: Obtener Certificado SSL con Certbot
|
||||
|
||||
```bash
|
||||
# Obtener certificado (reemplazar dominio)
|
||||
sudo certbot --nginx -d gamilit.com -d www.gamilit.com
|
||||
|
||||
# Certbot modifica automaticamente la configuracion de Nginx para HTTPS
|
||||
# Verificar renovacion automatica
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 5: Configuracion Nginx FINAL (con SSL)
|
||||
|
||||
Despues de certbot, la configuracion se ve asi:
|
||||
|
||||
```nginx
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name gamilit.com www.gamilit.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS Server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name gamilit.com www.gamilit.com;
|
||||
|
||||
# SSL (certbot configura esto automaticamente)
|
||||
ssl_certificate /etc/letsencrypt/live/gamilit.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/gamilit.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# IMPORTANTE: NO agregar headers CORS aqui
|
||||
# NestJS maneja CORS internamente
|
||||
# Headers duplicados causan: "multiple values" error
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://localhost:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket
|
||||
location /socket.io {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 6: Configurar Backend para HTTPS
|
||||
|
||||
Editar `apps/backend/.env.production`:
|
||||
|
||||
```bash
|
||||
# CORS con HTTPS
|
||||
CORS_ORIGIN=https://gamilit.com,https://www.gamilit.com
|
||||
|
||||
# Frontend URL
|
||||
FRONTEND_URL=https://gamilit.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 7: Configurar Frontend para HTTPS
|
||||
|
||||
Editar `apps/frontend/.env.production`:
|
||||
|
||||
```bash
|
||||
# API con HTTPS (a traves de Nginx)
|
||||
VITE_API_HOST=gamilit.com
|
||||
VITE_API_PROTOCOL=https
|
||||
VITE_API_VERSION=v1
|
||||
|
||||
# WebSocket con SSL
|
||||
VITE_WS_HOST=gamilit.com
|
||||
VITE_WS_PROTOCOL=wss
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 8: Rebuild y Reiniciar
|
||||
|
||||
```bash
|
||||
# Rebuild frontend con nueva config
|
||||
cd apps/frontend && npm run build && cd ../..
|
||||
|
||||
# Reiniciar servicios
|
||||
pm2 restart all
|
||||
|
||||
# Verificar
|
||||
curl -I https://gamilit.com
|
||||
curl https://gamilit.com/api/v1/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TROUBLESHOOTING
|
||||
|
||||
### Error: CORS multiple values
|
||||
```
|
||||
The 'Access-Control-Allow-Origin' header contains multiple values
|
||||
```
|
||||
**Causa:** Nginx y NestJS ambos agregan headers CORS
|
||||
**Solucion:** NO agregar headers CORS en Nginx. Solo NestJS los maneja.
|
||||
|
||||
### Error: SSL Certificate
|
||||
```bash
|
||||
# Verificar certificado
|
||||
sudo certbot certificates
|
||||
|
||||
# Renovar manualmente
|
||||
sudo certbot renew
|
||||
|
||||
# Ver logs
|
||||
sudo tail -f /var/log/letsencrypt/letsencrypt.log
|
||||
```
|
||||
|
||||
### Error: Nginx no inicia
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl status nginx
|
||||
sudo journalctl -u nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUERTOS FINALES
|
||||
|
||||
| Servicio | Puerto Interno | Puerto Externo | Protocolo |
|
||||
|----------|---------------|----------------|-----------|
|
||||
| Nginx | 80, 443 | 80, 443 | HTTP/HTTPS |
|
||||
| Backend | 3006 | - (via Nginx) | HTTP interno |
|
||||
| Frontend | 3005 | - (via Nginx) | HTTP interno |
|
||||
| PostgreSQL | 5432 | - (local only) | TCP |
|
||||
|
||||
---
|
||||
|
||||
## URLS DE ACCESO
|
||||
|
||||
- **Frontend:** https://gamilit.com
|
||||
- **Backend API:** https://gamilit.com/api/v1/health
|
||||
- **Swagger:** https://gamilit.com/api/v1/docs
|
||||
|
||||
---
|
||||
|
||||
*Guia creada: 2025-12-18*
|
||||
@ -0,0 +1,666 @@
|
||||
# Guia de Validacion y Troubleshooting - Produccion GAMILIT
|
||||
|
||||
> **Version:** 1.0.0
|
||||
> **Fecha:** 2025-12-18
|
||||
> **Servidor:** 74.208.126.102
|
||||
> **Proposito:** Validar carga correcta de BD y resolver errores comunes
|
||||
|
||||
---
|
||||
|
||||
## Indice
|
||||
|
||||
1. [Validacion Rapida Post-Despliegue](#1-validacion-rapida-post-despliegue)
|
||||
2. [Validacion Completa de Base de Datos](#2-validacion-completa-de-base-de-datos)
|
||||
3. [Errores Comunes y Soluciones](#3-errores-comunes-y-soluciones)
|
||||
4. [Scripts de Diagnostico](#4-scripts-de-diagnostico)
|
||||
5. [Procedimiento de Recuperacion](#5-procedimiento-de-recuperacion)
|
||||
|
||||
---
|
||||
|
||||
## 1. Validacion Rapida Post-Despliegue
|
||||
|
||||
### 1.1 Checklist de Validacion (5 minutos)
|
||||
|
||||
Ejecutar estos comandos inmediatamente despues de desplegar:
|
||||
|
||||
```bash
|
||||
# Definir conexion
|
||||
export DATABASE_URL="postgresql://gamilit_user:PASSWORD@localhost:5432/gamilit_platform"
|
||||
|
||||
# 1. Verificar conexion a BD
|
||||
psql "$DATABASE_URL" -c "SELECT version();"
|
||||
|
||||
# 2. Verificar schemas creados (deben ser 17+)
|
||||
psql "$DATABASE_URL" -c "SELECT COUNT(*) as schemas FROM information_schema.schemata WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast');"
|
||||
|
||||
# 3. Verificar tenant principal (CRITICO)
|
||||
psql "$DATABASE_URL" -c "SELECT id, name, slug, is_active FROM auth_management.tenants WHERE slug = 'gamilit-prod';"
|
||||
|
||||
# 4. Verificar usuarios cargados
|
||||
psql "$DATABASE_URL" -c "SELECT COUNT(*) as usuarios FROM auth.users;"
|
||||
|
||||
# 5. Verificar health del backend
|
||||
curl -s http://localhost:3006/api/health | head -20
|
||||
```
|
||||
|
||||
### 1.2 Resultados Esperados
|
||||
|
||||
| Validacion | Resultado Esperado | Accion si Falla |
|
||||
|------------|-------------------|-----------------|
|
||||
| Conexion BD | Version PostgreSQL 16+ | Verificar credenciales |
|
||||
| Schemas | 17 o mas | Ejecutar `create-database.sh` |
|
||||
| Tenant Principal | 1 fila con `is_active=true` | Ver seccion 3.1 |
|
||||
| Usuarios | 48 o mas | Ejecutar seeds de auth |
|
||||
| Health Backend | `{"status":"ok"}` | Ver logs PM2 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Validacion Completa de Base de Datos
|
||||
|
||||
### 2.1 Script de Validacion Completa
|
||||
|
||||
Crear y ejecutar este script para validacion exhaustiva:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# validate-production-db.sh
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-postgresql://gamilit_user:PASSWORD@localhost:5432/gamilit_platform}"
|
||||
|
||||
echo "=============================================="
|
||||
echo "VALIDACION COMPLETA - BASE DE DATOS GAMILIT"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
# Funcion para ejecutar query y mostrar resultado
|
||||
run_check() {
|
||||
local description="$1"
|
||||
local query="$2"
|
||||
local expected="$3"
|
||||
|
||||
result=$(psql "$DATABASE_URL" -t -c "$query" 2>/dev/null | tr -d ' ')
|
||||
|
||||
if [ "$result" == "$expected" ] || [ -z "$expected" ]; then
|
||||
echo "✅ $description: $result"
|
||||
else
|
||||
echo "❌ $description: $result (esperado: $expected)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== 1. SCHEMAS ==="
|
||||
run_check "Total Schemas" "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast');"
|
||||
|
||||
echo ""
|
||||
echo "=== 2. TABLAS POR SCHEMA ==="
|
||||
psql "$DATABASE_URL" -c "
|
||||
SELECT
|
||||
table_schema as schema,
|
||||
COUNT(*) as tablas
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
AND table_type = 'BASE TABLE'
|
||||
GROUP BY table_schema
|
||||
ORDER BY table_schema;
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "=== 3. TENANTS (CRITICO) ==="
|
||||
psql "$DATABASE_URL" -c "SELECT id, name, slug, is_active, subscription_tier FROM auth_management.tenants ORDER BY created_at;"
|
||||
|
||||
echo ""
|
||||
echo "=== 4. USUARIOS ==="
|
||||
run_check "Usuarios en auth.users" "SELECT COUNT(*) FROM auth.users;"
|
||||
run_check "Perfiles en auth_management.profiles" "SELECT COUNT(*) FROM auth_management.profiles;"
|
||||
|
||||
echo ""
|
||||
echo "=== 5. CONTENIDO EDUCATIVO ==="
|
||||
run_check "Modulos" "SELECT COUNT(*) FROM educational_content.modules;"
|
||||
run_check "Ejercicios" "SELECT COUNT(*) FROM educational_content.exercises;"
|
||||
|
||||
echo ""
|
||||
echo "=== 6. GAMIFICACION ==="
|
||||
run_check "Rangos Maya" "SELECT COUNT(*) FROM gamification_system.maya_ranks;"
|
||||
run_check "Logros" "SELECT COUNT(*) FROM gamification_system.achievements;"
|
||||
run_check "Categorias Tienda" "SELECT COUNT(*) FROM gamification_system.shop_categories;"
|
||||
run_check "Items Tienda" "SELECT COUNT(*) FROM gamification_system.shop_items;"
|
||||
|
||||
echo ""
|
||||
echo "=== 7. SOCIAL ==="
|
||||
run_check "Escuelas" "SELECT COUNT(*) FROM social_features.schools;"
|
||||
run_check "Aulas" "SELECT COUNT(*) FROM social_features.classrooms;"
|
||||
|
||||
echo ""
|
||||
echo "=== 8. CONFIGURACION ==="
|
||||
run_check "Feature Flags" "SELECT COUNT(*) FROM system_configuration.feature_flags;"
|
||||
run_check "Parametros Gamificacion" "SELECT COUNT(*) FROM system_configuration.gamification_parameters;"
|
||||
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
echo "VALIDACION COMPLETADA"
|
||||
echo "=============================================="
|
||||
```
|
||||
|
||||
### 2.2 Valores Esperados Post-Carga
|
||||
|
||||
| Entidad | Tabla | Cantidad Minima |
|
||||
|---------|-------|-----------------|
|
||||
| **Tenants** | `auth_management.tenants` | 14 (1 principal + 13 usuarios) |
|
||||
| **Usuarios** | `auth.users` | 48 |
|
||||
| **Perfiles** | `auth_management.profiles` | 48 |
|
||||
| **Modulos** | `educational_content.modules` | 5 |
|
||||
| **Ejercicios** | `educational_content.exercises` | 23 |
|
||||
| **Rangos Maya** | `gamification_system.maya_ranks` | 5 |
|
||||
| **Logros** | `gamification_system.achievements` | 30 |
|
||||
| **Categorias Tienda** | `gamification_system.shop_categories` | 5 |
|
||||
| **Items Tienda** | `gamification_system.shop_items` | 20 |
|
||||
| **Escuelas** | `social_features.schools` | 2 |
|
||||
| **Aulas** | `social_features.classrooms` | 4 |
|
||||
| **Feature Flags** | `system_configuration.feature_flags` | 26 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Errores Comunes y Soluciones
|
||||
|
||||
### 3.1 ERROR: "No hay tenants activos en el sistema"
|
||||
|
||||
**Sintoma:**
|
||||
```
|
||||
POST /api/v1/auth/register → 500 Internal Server Error
|
||||
"No hay tenants activos en el sistema. Contacte al administrador."
|
||||
```
|
||||
|
||||
**Causa:** La tabla `auth_management.tenants` esta vacia o no tiene tenants con `is_active=true`.
|
||||
|
||||
**Diagnostico:**
|
||||
```bash
|
||||
psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM auth_management.tenants WHERE is_active = true;"
|
||||
```
|
||||
|
||||
**Solucion Rapida (SQL directo):**
|
||||
```sql
|
||||
-- Conectar a la BD
|
||||
psql "$DATABASE_URL"
|
||||
|
||||
-- Insertar tenant principal
|
||||
INSERT INTO auth_management.tenants (
|
||||
id, name, slug, domain, logo_url, subscription_tier,
|
||||
max_users, max_storage_gb, is_active, settings, metadata,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::uuid,
|
||||
'GAMILIT Platform',
|
||||
'gamilit-prod',
|
||||
'gamilit.com',
|
||||
'/assets/logo-gamilit.png',
|
||||
'enterprise',
|
||||
10000,
|
||||
100,
|
||||
true,
|
||||
'{"theme": "detective", "language": "es", "timezone": "America/Mexico_City", "features": {"analytics_enabled": true, "gamification_enabled": true, "social_features_enabled": true}}'::jsonb,
|
||||
'{"environment": "production", "version": "2.0"}'::jsonb,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
is_active = true,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Verificar
|
||||
SELECT id, name, slug, is_active FROM auth_management.tenants;
|
||||
```
|
||||
|
||||
**Solucion Completa (Seeds):**
|
||||
```bash
|
||||
cd /path/to/gamilit/apps/database
|
||||
psql "$DATABASE_URL" -f seeds/prod/auth_management/01-tenants.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/auth_management/02-tenants-production.sql
|
||||
```
|
||||
|
||||
### 3.2 ERROR: "relation does not exist"
|
||||
|
||||
**Sintoma:**
|
||||
```
|
||||
ERROR: relation "auth_management.tenants" does not exist
|
||||
```
|
||||
|
||||
**Causa:** El DDL no se ejecuto correctamente.
|
||||
|
||||
**Solucion:**
|
||||
```bash
|
||||
cd /path/to/gamilit/apps/database
|
||||
./create-database.sh "$DATABASE_URL"
|
||||
```
|
||||
|
||||
### 3.3 ERROR: "password authentication failed"
|
||||
|
||||
**Sintoma:**
|
||||
```
|
||||
FATAL: password authentication failed for user "gamilit_user"
|
||||
```
|
||||
|
||||
**Solucion:**
|
||||
```bash
|
||||
# Como usuario postgres
|
||||
sudo -u postgres psql
|
||||
|
||||
# Resetear password
|
||||
ALTER USER gamilit_user WITH PASSWORD 'nueva_password_segura';
|
||||
|
||||
# Verificar
|
||||
\du gamilit_user
|
||||
```
|
||||
|
||||
### 3.4 ERROR: "CORS blocked"
|
||||
|
||||
**Sintoma:**
|
||||
```
|
||||
Access to fetch at 'http://74.208.126.102:3006/api' from origin 'http://74.208.126.102:3005' has been blocked by CORS policy
|
||||
```
|
||||
|
||||
**Diagnostico:**
|
||||
```bash
|
||||
grep CORS_ORIGIN apps/backend/.env.production
|
||||
```
|
||||
|
||||
**Solucion:**
|
||||
```bash
|
||||
# Editar apps/backend/.env.production
|
||||
CORS_ORIGIN=http://74.208.126.102:3005,http://74.208.126.102,https://74.208.126.102
|
||||
|
||||
# Reiniciar backend
|
||||
pm2 restart gamilit-backend
|
||||
```
|
||||
|
||||
### 3.5 ERROR: "Cannot find module"
|
||||
|
||||
**Sintoma:**
|
||||
```
|
||||
Error: Cannot find module '/path/to/dist/main.js'
|
||||
```
|
||||
|
||||
**Solucion:**
|
||||
```bash
|
||||
cd apps/backend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 restart gamilit-backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Scripts de Diagnostico
|
||||
|
||||
### 4.1 Script: Diagnostico Completo del Sistema
|
||||
|
||||
Guardar como `diagnose-production.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# diagnose-production.sh - Diagnostico completo del sistema GAMILIT
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-postgresql://gamilit_user:PASSWORD@localhost:5432/gamilit_platform}"
|
||||
BACKEND_URL="http://localhost:3006"
|
||||
FRONTEND_URL="http://localhost:3005"
|
||||
|
||||
echo "=============================================="
|
||||
echo " DIAGNOSTICO SISTEMA GAMILIT PRODUCCION"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
# 1. PM2 Status
|
||||
echo -e "${YELLOW}=== 1. ESTADO PM2 ===${NC}"
|
||||
pm2 list 2>/dev/null || echo -e "${RED}PM2 no disponible${NC}"
|
||||
echo ""
|
||||
|
||||
# 2. Backend Health
|
||||
echo -e "${YELLOW}=== 2. HEALTH BACKEND ===${NC}"
|
||||
health=$(curl -s "$BACKEND_URL/api/health" 2>/dev/null)
|
||||
if [ -n "$health" ]; then
|
||||
echo -e "${GREEN}Backend respondiendo:${NC}"
|
||||
echo "$health" | head -5
|
||||
else
|
||||
echo -e "${RED}Backend NO responde${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 3. Frontend
|
||||
echo -e "${YELLOW}=== 3. FRONTEND ===${NC}"
|
||||
frontend_status=$(curl -s -o /dev/null -w "%{http_code}" "$FRONTEND_URL" 2>/dev/null)
|
||||
if [ "$frontend_status" == "200" ]; then
|
||||
echo -e "${GREEN}Frontend OK (HTTP $frontend_status)${NC}"
|
||||
else
|
||||
echo -e "${RED}Frontend ERROR (HTTP $frontend_status)${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 4. Database Connection
|
||||
echo -e "${YELLOW}=== 4. CONEXION BASE DE DATOS ===${NC}"
|
||||
db_version=$(psql "$DATABASE_URL" -t -c "SELECT version();" 2>/dev/null | head -1)
|
||||
if [ -n "$db_version" ]; then
|
||||
echo -e "${GREEN}BD conectada:${NC} $db_version"
|
||||
else
|
||||
echo -e "${RED}No se puede conectar a la BD${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 5. Critical Tables
|
||||
echo -e "${YELLOW}=== 5. TABLAS CRITICAS ===${NC}"
|
||||
|
||||
check_table() {
|
||||
local table=$1
|
||||
local count=$(psql "$DATABASE_URL" -t -c "SELECT COUNT(*) FROM $table;" 2>/dev/null | tr -d ' ')
|
||||
if [ -n "$count" ] && [ "$count" -gt 0 ]; then
|
||||
echo -e "${GREEN}✅ $table: $count registros${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ $table: VACIO o ERROR${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_table "auth_management.tenants"
|
||||
check_table "auth.users"
|
||||
check_table "auth_management.profiles"
|
||||
check_table "educational_content.modules"
|
||||
check_table "educational_content.exercises"
|
||||
check_table "gamification_system.maya_ranks"
|
||||
check_table "gamification_system.achievements"
|
||||
echo ""
|
||||
|
||||
# 6. Tenant Principal
|
||||
echo -e "${YELLOW}=== 6. TENANT PRINCIPAL (CRITICO) ===${NC}"
|
||||
tenant=$(psql "$DATABASE_URL" -t -c "SELECT slug, is_active FROM auth_management.tenants WHERE slug = 'gamilit-prod';" 2>/dev/null)
|
||||
if [ -n "$tenant" ]; then
|
||||
echo -e "${GREEN}Tenant encontrado:${NC} $tenant"
|
||||
else
|
||||
echo -e "${RED}❌ TENANT PRINCIPAL NO EXISTE - REGISTRO NO FUNCIONARA${NC}"
|
||||
echo -e "${YELLOW}Ejecutar: psql \$DATABASE_URL -f seeds/prod/auth_management/01-tenants.sql${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 7. Disk Space
|
||||
echo -e "${YELLOW}=== 7. ESPACIO EN DISCO ===${NC}"
|
||||
df -h / | tail -1
|
||||
echo ""
|
||||
|
||||
# 8. Memory
|
||||
echo -e "${YELLOW}=== 8. MEMORIA ===${NC}"
|
||||
free -h | head -2
|
||||
echo ""
|
||||
|
||||
echo "=============================================="
|
||||
echo " DIAGNOSTICO COMPLETADO"
|
||||
echo "=============================================="
|
||||
```
|
||||
|
||||
### 4.2 Script: Reparar Datos Faltantes
|
||||
|
||||
Guardar como `repair-missing-data.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# repair-missing-data.sh - Reparar datos faltantes en produccion
|
||||
|
||||
set -e
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-postgresql://gamilit_user:PASSWORD@localhost:5432/gamilit_platform}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DB_DIR="$SCRIPT_DIR/../apps/database"
|
||||
|
||||
echo "=============================================="
|
||||
echo " REPARACION DE DATOS FALTANTES"
|
||||
echo "=============================================="
|
||||
|
||||
# 1. Verificar y reparar tenants
|
||||
echo ""
|
||||
echo "=== Verificando Tenants ==="
|
||||
tenant_count=$(psql "$DATABASE_URL" -t -c "SELECT COUNT(*) FROM auth_management.tenants WHERE is_active = true;" | tr -d ' ')
|
||||
|
||||
if [ "$tenant_count" -eq 0 ]; then
|
||||
echo "❌ No hay tenants activos. Cargando seeds..."
|
||||
psql "$DATABASE_URL" -f "$DB_DIR/seeds/prod/auth_management/01-tenants.sql"
|
||||
psql "$DATABASE_URL" -f "$DB_DIR/seeds/prod/auth_management/02-tenants-production.sql"
|
||||
echo "✅ Tenants cargados"
|
||||
else
|
||||
echo "✅ Tenants OK ($tenant_count activos)"
|
||||
fi
|
||||
|
||||
# 2. Verificar modulos
|
||||
echo ""
|
||||
echo "=== Verificando Modulos ==="
|
||||
module_count=$(psql "$DATABASE_URL" -t -c "SELECT COUNT(*) FROM educational_content.modules;" | tr -d ' ')
|
||||
|
||||
if [ "$module_count" -lt 5 ]; then
|
||||
echo "❌ Modulos incompletos ($module_count). Cargando..."
|
||||
psql "$DATABASE_URL" -f "$DB_DIR/seeds/prod/educational_content/01-modules.sql"
|
||||
echo "✅ Modulos cargados"
|
||||
else
|
||||
echo "✅ Modulos OK ($module_count)"
|
||||
fi
|
||||
|
||||
# 3. Verificar rangos maya
|
||||
echo ""
|
||||
echo "=== Verificando Rangos Maya ==="
|
||||
rank_count=$(psql "$DATABASE_URL" -t -c "SELECT COUNT(*) FROM gamification_system.maya_ranks;" | tr -d ' ')
|
||||
|
||||
if [ "$rank_count" -lt 5 ]; then
|
||||
echo "❌ Rangos Maya incompletos ($rank_count). Cargando..."
|
||||
psql "$DATABASE_URL" -f "$DB_DIR/seeds/prod/gamification_system/03-maya_ranks.sql"
|
||||
echo "✅ Rangos Maya cargados"
|
||||
else
|
||||
echo "✅ Rangos Maya OK ($rank_count)"
|
||||
fi
|
||||
|
||||
# 4. Verificar feature flags
|
||||
echo ""
|
||||
echo "=== Verificando Feature Flags ==="
|
||||
flag_count=$(psql "$DATABASE_URL" -t -c "SELECT COUNT(*) FROM system_configuration.feature_flags;" | tr -d ' ')
|
||||
|
||||
if [ "$flag_count" -lt 20 ]; then
|
||||
echo "❌ Feature flags incompletos ($flag_count). Cargando..."
|
||||
psql "$DATABASE_URL" -f "$DB_DIR/seeds/prod/system_configuration/01-feature_flags_seeds.sql"
|
||||
echo "✅ Feature flags cargados"
|
||||
else
|
||||
echo "✅ Feature flags OK ($flag_count)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
echo " REPARACION COMPLETADA"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
echo "Reiniciar backend para aplicar cambios:"
|
||||
echo " pm2 restart gamilit-backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Procedimiento de Recuperacion
|
||||
|
||||
### 5.1 Recuperacion Completa (Reset Total)
|
||||
|
||||
Si la base de datos esta corrupta o incompleta, seguir estos pasos:
|
||||
|
||||
```bash
|
||||
# 1. Detener aplicaciones
|
||||
pm2 stop all
|
||||
|
||||
# 2. Ir al directorio de database
|
||||
cd /path/to/gamilit/apps/database
|
||||
|
||||
# 3. Configurar conexion
|
||||
export DATABASE_URL="postgresql://gamilit_user:PASSWORD@localhost:5432/gamilit_platform"
|
||||
|
||||
# 4. OPCION A: Drop y recrear (ELIMINA TODO)
|
||||
./drop-and-recreate-database.sh "$DATABASE_URL"
|
||||
|
||||
# 4. OPCION B: Solo recrear estructura (si BD nueva)
|
||||
./create-database.sh "$DATABASE_URL"
|
||||
|
||||
# 5. Reiniciar aplicaciones
|
||||
pm2 start all
|
||||
|
||||
# 6. Verificar
|
||||
curl http://localhost:3006/api/health
|
||||
```
|
||||
|
||||
### 5.2 Recuperacion Parcial (Solo Seeds)
|
||||
|
||||
Si el DDL esta correcto pero faltan datos:
|
||||
|
||||
```bash
|
||||
cd /path/to/gamilit/apps/database
|
||||
export DATABASE_URL="postgresql://gamilit_user:PASSWORD@localhost:5432/gamilit_platform"
|
||||
|
||||
# Cargar seeds en orden
|
||||
# 1. System Configuration (sin dependencias)
|
||||
psql "$DATABASE_URL" -f seeds/prod/system_configuration/01-system_settings.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/system_configuration/01-feature_flags_seeds.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/system_configuration/02-gamification_parameters_seeds.sql
|
||||
|
||||
# 2. Auth Management (tenants y auth_providers)
|
||||
psql "$DATABASE_URL" -f seeds/prod/auth_management/01-tenants.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/auth_management/02-tenants-production.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/auth_management/02-auth_providers.sql
|
||||
|
||||
# 3. Auth (usuarios)
|
||||
psql "$DATABASE_URL" -f seeds/prod/auth/01-demo-users.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/auth/02-production-users.sql
|
||||
|
||||
# 4. Educational Content (modulos ANTES de profiles)
|
||||
psql "$DATABASE_URL" -f seeds/prod/educational_content/01-modules.sql
|
||||
|
||||
# 5. Profiles (dispara trigger initialize_user_stats)
|
||||
psql "$DATABASE_URL" -f seeds/prod/auth_management/04-profiles-complete.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/auth_management/06-profiles-production.sql
|
||||
|
||||
# 6. Social Features
|
||||
psql "$DATABASE_URL" -f seeds/prod/social_features/00-schools-default.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/social_features/01-schools.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/social_features/02-classrooms.sql
|
||||
|
||||
# 7. Educational Content (ejercicios)
|
||||
psql "$DATABASE_URL" -f seeds/prod/educational_content/02-exercises-module1.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/educational_content/03-exercises-module2.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/educational_content/04-exercises-module3.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/educational_content/05-exercises-module4.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/educational_content/06-exercises-module5.sql
|
||||
|
||||
# 8. Gamification
|
||||
psql "$DATABASE_URL" -f seeds/prod/gamification_system/01-achievement_categories.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/gamification_system/03-maya_ranks.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/gamification_system/04-achievements.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/gamification_system/12-shop_categories.sql
|
||||
psql "$DATABASE_URL" -f seeds/prod/gamification_system/13-shop_items.sql
|
||||
```
|
||||
|
||||
### 5.3 Orden de Carga de Seeds (Dependencias)
|
||||
|
||||
```
|
||||
ORDEN DE CARGA DE SEEDS
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 1. system_configuration (sin dependencias) │
|
||||
│ - system_settings │
|
||||
│ - feature_flags │
|
||||
│ - gamification_parameters │
|
||||
└─────────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 2. auth_management/tenants │
|
||||
│ - 01-tenants.sql (tenant principal) │
|
||||
│ - 02-tenants-production.sql (usuarios) │
|
||||
└─────────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 3. auth/users │
|
||||
│ - 01-demo-users.sql │
|
||||
│ - 02-production-users.sql │
|
||||
└─────────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 4. educational_content/modules │
|
||||
│ - 01-modules.sql (ANTES de profiles!) │
|
||||
└─────────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 5. auth_management/profiles │
|
||||
│ - 04-profiles-complete.sql │
|
||||
│ - 06-profiles-production.sql │
|
||||
│ (Dispara trigger initialize_user_stats) │
|
||||
└─────────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 6. social_features │
|
||||
│ - schools → classrooms → members │
|
||||
└─────────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 7. educational_content/exercises │
|
||||
│ - exercises-module1 a module5 │
|
||||
└─────────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 8. gamification_system │
|
||||
│ - maya_ranks, achievements, shop │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Apendice: Queries de Verificacion Rapida
|
||||
|
||||
```sql
|
||||
-- Verificar tenant principal
|
||||
SELECT id, name, slug, is_active
|
||||
FROM auth_management.tenants
|
||||
WHERE slug = 'gamilit-prod';
|
||||
|
||||
-- Contar entidades principales
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM auth_management.tenants) as tenants,
|
||||
(SELECT COUNT(*) FROM auth.users) as users,
|
||||
(SELECT COUNT(*) FROM auth_management.profiles) as profiles,
|
||||
(SELECT COUNT(*) FROM educational_content.modules) as modules,
|
||||
(SELECT COUNT(*) FROM educational_content.exercises) as exercises,
|
||||
(SELECT COUNT(*) FROM gamification_system.maya_ranks) as ranks,
|
||||
(SELECT COUNT(*) FROM gamification_system.achievements) as achievements;
|
||||
|
||||
-- Verificar usuarios por rol
|
||||
SELECT role, COUNT(*)
|
||||
FROM auth_management.profiles
|
||||
GROUP BY role;
|
||||
|
||||
-- Verificar ejercicios por modulo
|
||||
SELECT m.name, COUNT(e.id) as exercises
|
||||
FROM educational_content.modules m
|
||||
LEFT JOIN educational_content.exercises e ON e.module_id = m.id
|
||||
GROUP BY m.name
|
||||
ORDER BY m.order_index;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contacto
|
||||
|
||||
Para problemas no cubiertos en esta guia:
|
||||
1. Revisar logs: `pm2 logs`
|
||||
2. Consultar `GUIA-DESPLIEGUE-PRODUCCION-COMPLETA.md`
|
||||
3. Revisar `docs/DEPLOYMENT.md`
|
||||
|
||||
---
|
||||
|
||||
> **Ultima actualizacion:** 2025-12-18
|
||||
> **Version:** 1.0.0
|
||||
@ -0,0 +1,58 @@
|
||||
# REFERENCIA: Deployment en Produccion
|
||||
|
||||
**Ubicacion de Documentacion Completa:**
|
||||
|
||||
La documentacion completa para el agente de produccion se encuentra en el **workspace de produccion** (VIEJO), ya que es donde se ejecuta el deployment.
|
||||
|
||||
## Archivos en Workspace de Produccion
|
||||
|
||||
```
|
||||
~/workspace-old/wsl-ubuntu/workspace/workspace-gamilit/gamilit/projects/gamilit/
|
||||
├── PROMPT-AGENTE-PRODUCCION.md # Prompts para usar con el agente
|
||||
├── PRODUCTION-UPDATE.md # Instrucciones rapidas post-pull
|
||||
├── docs/95-guias-desarrollo/
|
||||
│ └── GUIA-DEPLOYMENT-AGENTE-PRODUCCION.md # Guia completa de deployment
|
||||
└── scripts/
|
||||
├── update-production.sh # Script automatizado de deployment
|
||||
└── diagnose-production.sh # Script de diagnostico
|
||||
```
|
||||
|
||||
## Resumen del Proceso
|
||||
|
||||
1. **Backup**: BD + configs a `/home/gamilit/backups/TIMESTAMP/`
|
||||
2. **Pull**: `git reset --hard origin/main`
|
||||
3. **Restaurar**: Configs desde backup
|
||||
4. **Recrear BD**: `./create-database.sh`
|
||||
5. **Build**: `npm install && npm run build`
|
||||
6. **Deploy**: `pm2 start ecosystem.config.js`
|
||||
7. **HTTPS**: Certbot + Nginx (si aplica)
|
||||
8. **Validar**: `./scripts/diagnose-production.sh`
|
||||
|
||||
## Prompt Basico para Agente
|
||||
|
||||
```
|
||||
Ejecuta el deployment de GAMILIT siguiendo el procedimiento en
|
||||
docs/95-guias-desarrollo/GUIA-DEPLOYMENT-AGENTE-PRODUCCION.md
|
||||
|
||||
1. Backup BD y configs
|
||||
2. pm2 stop all
|
||||
3. git reset --hard origin/main
|
||||
4. Restaurar configs
|
||||
5. Recrear BD
|
||||
6. Build backend y frontend
|
||||
7. pm2 start
|
||||
8. Validar
|
||||
|
||||
Ejecuta paso a paso mostrando outputs.
|
||||
```
|
||||
|
||||
## Ver Documentacion Completa
|
||||
|
||||
Para ver la guia completa, acceder al workspace de produccion:
|
||||
```bash
|
||||
cat ~/workspace-old/wsl-ubuntu/workspace/workspace-gamilit/gamilit/projects/gamilit/docs/95-guias-desarrollo/GUIA-DEPLOYMENT-AGENTE-PRODUCCION.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Este archivo es solo una referencia. La documentacion real esta en el workspace de produccion.*
|
||||
@ -0,0 +1,292 @@
|
||||
# Funcion: validate_rueda_inferencias
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Schema:** educational_content
|
||||
**Ubicacion:** `apps/database/ddl/schemas/educational_content/functions/14-validate_rueda_inferencias.sql`
|
||||
|
||||
---
|
||||
|
||||
## PROPOSITO
|
||||
|
||||
Validar respuestas abiertas para ejercicios de tipo "Rueda de Inferencias", soportando multiples estructuras de datos y proporcionando retroalimentacion granular.
|
||||
|
||||
---
|
||||
|
||||
## FIRMA
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION educational_content.validate_rueda_inferencias(
|
||||
p_student_response JSONB,
|
||||
p_correct_answer JSONB,
|
||||
p_exercise_config JSONB DEFAULT '{}'::JSONB
|
||||
) RETURNS RECORD AS $$
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PARAMETROS
|
||||
|
||||
| Parametro | Tipo | Descripcion |
|
||||
|-----------|------|-------------|
|
||||
| p_student_response | JSONB | Respuesta del estudiante |
|
||||
| p_correct_answer | JSONB | Respuesta correcta esperada |
|
||||
| p_exercise_config | JSONB | Configuracion adicional (opcional) |
|
||||
|
||||
---
|
||||
|
||||
## RETORNO
|
||||
|
||||
```sql
|
||||
RECORD (
|
||||
is_correct BOOLEAN, -- Si la respuesta es correcta
|
||||
score INTEGER, -- Puntaje obtenido (0-100)
|
||||
feedback TEXT, -- Retroalimentacion para el estudiante
|
||||
details JSONB -- Detalles de evaluacion por categoria
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ESTRUCTURAS SOPORTADAS
|
||||
|
||||
### Estructura Nueva: categoryExpectations
|
||||
|
||||
```json
|
||||
{
|
||||
"categoryExpectations": {
|
||||
"category_id_1": {
|
||||
"keywords": ["palabra1", "palabra2"],
|
||||
"minLength": 10,
|
||||
"maxLength": 500
|
||||
},
|
||||
"category_id_2": {
|
||||
"keywords": ["palabra3", "palabra4"],
|
||||
"minLength": 20
|
||||
}
|
||||
},
|
||||
"fragmentStates": {
|
||||
"fragment_id_1": {
|
||||
"categoryId": "category_id_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Estructura Legacy: flat
|
||||
|
||||
```json
|
||||
{
|
||||
"keywords": ["palabra1", "palabra2", "palabra3"],
|
||||
"minLength": 10,
|
||||
"maxLength": 500,
|
||||
"minKeywords": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LOGICA DE VALIDACION
|
||||
|
||||
### 1. Deteccion de Estructura
|
||||
|
||||
```sql
|
||||
IF p_correct_answer ? 'categoryExpectations' THEN
|
||||
-- Usar estructura nueva
|
||||
ELSE
|
||||
-- Usar estructura legacy (flat)
|
||||
END IF;
|
||||
```
|
||||
|
||||
### 2. Normalizacion de Texto
|
||||
|
||||
```sql
|
||||
v_normalized_text := lower(
|
||||
translate(
|
||||
p_student_response->>'text',
|
||||
'áéíóúÁÉÍÓÚñÑ',
|
||||
'aeiouAEIOUnN'
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Validacion de Longitud
|
||||
|
||||
```sql
|
||||
v_text_length := length(p_student_response->>'text');
|
||||
|
||||
IF v_text_length < v_min_length THEN
|
||||
v_feedback := 'Respuesta muy corta. Minimo ' || v_min_length || ' caracteres.';
|
||||
v_is_correct := false;
|
||||
END IF;
|
||||
|
||||
IF v_max_length IS NOT NULL AND v_text_length > v_max_length THEN
|
||||
v_feedback := 'Respuesta muy larga. Maximo ' || v_max_length || ' caracteres.';
|
||||
v_is_correct := false;
|
||||
END IF;
|
||||
```
|
||||
|
||||
### 4. Conteo de Keywords
|
||||
|
||||
```sql
|
||||
v_keyword_count := 0;
|
||||
|
||||
FOR v_keyword IN SELECT jsonb_array_elements_text(v_keywords) LOOP
|
||||
IF v_normalized_text LIKE '%' || lower(v_keyword) || '%' THEN
|
||||
v_keyword_count := v_keyword_count + 1;
|
||||
END IF;
|
||||
END LOOP;
|
||||
```
|
||||
|
||||
### 5. Calculo de Score
|
||||
|
||||
```sql
|
||||
-- Puntuacion parcial basada en keywords encontradas
|
||||
v_keyword_ratio := v_keyword_count::FLOAT / v_total_keywords::FLOAT;
|
||||
v_score := LEAST(100, ROUND(v_keyword_ratio * 100));
|
||||
|
||||
-- Bonus por longitud adecuada
|
||||
IF v_text_length >= v_ideal_length THEN
|
||||
v_score := v_score + 10;
|
||||
END IF;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## EJEMPLOS DE USO
|
||||
|
||||
### Estructura categoryExpectations
|
||||
|
||||
```sql
|
||||
SELECT educational_content.validate_rueda_inferencias(
|
||||
'{"text": "El texto habla sobre la importancia de la lectura critica"}'::JSONB,
|
||||
'{
|
||||
"categoryExpectations": {
|
||||
"cat-inferencias": {
|
||||
"keywords": ["lectura", "critica", "importancia", "texto"],
|
||||
"minLength": 20
|
||||
}
|
||||
}
|
||||
}'::JSONB
|
||||
);
|
||||
-- Retorna: (true, 75, 'Respuesta aceptable', {"keywords_found": 3, "keywords_total": 4})
|
||||
```
|
||||
|
||||
### Estructura Legacy
|
||||
|
||||
```sql
|
||||
SELECT educational_content.validate_rueda_inferencias(
|
||||
'{"text": "Pienso que el autor quiere transmitir un mensaje sobre la sociedad"}'::JSONB,
|
||||
'{
|
||||
"keywords": ["autor", "mensaje", "sociedad", "transmitir"],
|
||||
"minLength": 30,
|
||||
"minKeywords": 2
|
||||
}'::JSONB
|
||||
);
|
||||
-- Retorna: (true, 100, 'Excelente respuesta', {"keywords_found": 4, "keywords_total": 4})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MANEJO DE ERRORES
|
||||
|
||||
### Categoria No Encontrada
|
||||
|
||||
Si un fragmentId no tiene categoryId mapeado, se usa fallback:
|
||||
|
||||
```sql
|
||||
v_category_id := COALESCE(
|
||||
p_correct_answer->'fragmentStates'->v_fragment_id->>'categoryId',
|
||||
'cat-literal' -- Fallback
|
||||
);
|
||||
```
|
||||
|
||||
### Respuesta Vacia
|
||||
|
||||
```sql
|
||||
IF p_student_response IS NULL OR p_student_response->>'text' = '' THEN
|
||||
RETURN (false, 0, 'No se proporciono respuesta', '{}'::JSONB);
|
||||
END IF;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RETROALIMENTACION
|
||||
|
||||
### Mensajes Predefinidos
|
||||
|
||||
| Condicion | Mensaje |
|
||||
|-----------|---------|
|
||||
| Score >= 90 | "Excelente respuesta" |
|
||||
| Score >= 70 | "Buena respuesta" |
|
||||
| Score >= 50 | "Respuesta aceptable, considera agregar mas detalles" |
|
||||
| Score < 50 | "Respuesta insuficiente, revisa los conceptos clave" |
|
||||
| Muy corta | "Respuesta muy corta. Minimo X caracteres" |
|
||||
| Muy larga | "Respuesta muy larga. Maximo X caracteres" |
|
||||
|
||||
---
|
||||
|
||||
## DETALLES DE RETORNO
|
||||
|
||||
### Estructura de details
|
||||
|
||||
```json
|
||||
{
|
||||
"keywords_found": 3,
|
||||
"keywords_total": 5,
|
||||
"text_length": 85,
|
||||
"min_length": 20,
|
||||
"max_length": 500,
|
||||
"categories_evaluated": [
|
||||
{
|
||||
"category_id": "cat-inferencias",
|
||||
"keywords_found": 2,
|
||||
"keywords_total": 3,
|
||||
"passed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## INTEGRACION
|
||||
|
||||
### Trigger de Validacion
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER trg_validate_rueda_response
|
||||
BEFORE INSERT ON educational_content.exercise_attempts
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.exercise_type = 'rueda_inferencias')
|
||||
EXECUTE FUNCTION educational_content.validate_rueda_response_trigger();
|
||||
```
|
||||
|
||||
### Uso desde Backend
|
||||
|
||||
```typescript
|
||||
const result = await db.query(`
|
||||
SELECT * FROM educational_content.validate_rueda_inferencias($1, $2)
|
||||
`, [studentResponse, correctAnswer]);
|
||||
|
||||
const { is_correct, score, feedback, details } = result.rows[0];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HISTORIAL DE CAMBIOS
|
||||
|
||||
| Fecha | Version | Cambio |
|
||||
|-------|---------|--------|
|
||||
| 2025-12-15 | 1.0.0 | Version inicial con soporte dual |
|
||||
|
||||
---
|
||||
|
||||
## REFERENCIAS
|
||||
|
||||
- [04-FUNCTIONS-INVENTORY.md](../../90-transversal/inventarios-database/inventarios/04-FUNCTIONS-INVENTORY.md)
|
||||
- Ejercicios de Rueda de Inferencias en Modulo 2
|
||||
|
||||
---
|
||||
|
||||
**Ultima actualizacion:** 2025-12-18
|
||||
@ -0,0 +1,438 @@
|
||||
# Arquitectura de Componentes de Alertas - Admin Portal
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Modulo:** Admin Portal - Sistema de Alertas
|
||||
|
||||
---
|
||||
|
||||
## ESTRUCTURA GENERAL
|
||||
|
||||
```
|
||||
apps/frontend/src/apps/admin/
|
||||
├── hooks/
|
||||
│ └── useAlerts.ts # Hook principal
|
||||
├── components/alerts/
|
||||
│ ├── alertUtils.ts # Utilidades compartidas
|
||||
│ ├── AlertsStats.tsx # Cards de estadisticas
|
||||
│ ├── AlertFilters.tsx # Panel de filtros
|
||||
│ ├── AlertsList.tsx # Lista paginada
|
||||
│ ├── AlertCard.tsx # Card individual
|
||||
│ ├── AlertDetailsModal.tsx # Modal de detalles
|
||||
│ ├── AcknowledgeAlertModal.tsx # Modal reconocer
|
||||
│ └── ResolveAlertModal.tsx # Modal resolver
|
||||
└── pages/
|
||||
└── AdminAlertsPage.tsx # Pagina principal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HOOK PRINCIPAL: useAlerts
|
||||
|
||||
**Ubicacion:** `hooks/useAlerts.ts`
|
||||
|
||||
### Proposito
|
||||
|
||||
Gestion completa del ciclo de vida de alertas:
|
||||
- Fetch con filtros y paginacion
|
||||
- Estadisticas en tiempo real
|
||||
- Acciones de gestion (acknowledge, resolve, suppress)
|
||||
|
||||
### API Retornada
|
||||
|
||||
```typescript
|
||||
const {
|
||||
// Data
|
||||
alerts,
|
||||
stats,
|
||||
selectedAlert,
|
||||
|
||||
// Estado
|
||||
isLoading,
|
||||
isLoadingStats,
|
||||
error,
|
||||
|
||||
// Filtros & Paginacion
|
||||
filters,
|
||||
setFilters,
|
||||
pagination,
|
||||
|
||||
// Acciones
|
||||
fetchAlerts,
|
||||
fetchStats,
|
||||
refreshAlerts,
|
||||
acknowledgeAlert,
|
||||
resolveAlert,
|
||||
suppressAlert,
|
||||
nextPage,
|
||||
prevPage,
|
||||
goToPage
|
||||
} = useAlerts();
|
||||
```
|
||||
|
||||
### Pagination Object
|
||||
|
||||
```typescript
|
||||
interface Pagination {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
limit: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Validaciones de Acciones
|
||||
|
||||
| Accion | Validacion |
|
||||
|--------|------------|
|
||||
| acknowledgeAlert | Nota opcional |
|
||||
| resolveAlert | Nota requerida (min 10 chars) |
|
||||
| suppressAlert | Sin validacion adicional |
|
||||
|
||||
---
|
||||
|
||||
## UTILITY MODULE: alertUtils.ts
|
||||
|
||||
**Ubicacion:** `components/alerts/alertUtils.ts`
|
||||
|
||||
### Funciones Exportadas
|
||||
|
||||
#### getSeverityColor(severity) -> string
|
||||
|
||||
Retorna clases Tailwind con fondo solido para badges prominentes.
|
||||
|
||||
```typescript
|
||||
getSeverityColor('critical'); // 'bg-red-500 text-white'
|
||||
getSeverityColor('high'); // 'bg-orange-500 text-white'
|
||||
getSeverityColor('medium'); // 'bg-yellow-500 text-black'
|
||||
getSeverityColor('low'); // 'bg-blue-500 text-white'
|
||||
```
|
||||
|
||||
#### getSeverityColorWithBorder(severity) -> string
|
||||
|
||||
Retorna clases con fondo transparente + borde para badges sutiles.
|
||||
|
||||
#### getStatusColor(status) -> string
|
||||
|
||||
```typescript
|
||||
getStatusColor('open'); // Rojo con borde
|
||||
getStatusColor('acknowledged'); // Naranja con borde
|
||||
getStatusColor('resolved'); // Verde con borde
|
||||
getStatusColor('suppressed'); // Gris con borde
|
||||
```
|
||||
|
||||
#### getStatusTextColor(status) -> string
|
||||
|
||||
Solo colores de texto para etiquetas inline.
|
||||
|
||||
#### getSeverityLabel(severity) -> string
|
||||
|
||||
```typescript
|
||||
getSeverityLabel('critical'); // 'Critica'
|
||||
getSeverityLabel('high'); // 'Alta'
|
||||
getSeverityLabel('medium'); // 'Media'
|
||||
getSeverityLabel('low'); // 'Baja'
|
||||
```
|
||||
|
||||
#### getStatusLabel(status) -> string
|
||||
|
||||
```typescript
|
||||
getStatusLabel('open'); // 'Abierta'
|
||||
getStatusLabel('acknowledged'); // 'Reconocida'
|
||||
getStatusLabel('resolved'); // 'Resuelta'
|
||||
getStatusLabel('suppressed'); // 'Suprimida'
|
||||
```
|
||||
|
||||
#### formatAlertTimestamp(timestamp) -> string
|
||||
|
||||
Formato compacto para espacios limitados.
|
||||
|
||||
```typescript
|
||||
formatAlertTimestamp(fecha); // 'Hace 5m', 'Hace 2h', 'Hace 3d', 'dic 15'
|
||||
```
|
||||
|
||||
#### formatAlertTimestampDetailed(timestamp) -> string
|
||||
|
||||
Formato detallado para mas informacion.
|
||||
|
||||
```typescript
|
||||
formatAlertTimestampDetailed(fecha); // 'Hace 5 min', 'Hace 2 horas'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COMPONENTES
|
||||
|
||||
### 1. AlertsStats
|
||||
|
||||
**Ubicacion:** `components/alerts/AlertsStats.tsx`
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface AlertsStatsProps {
|
||||
stats: AlertsStatsType | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Render:**
|
||||
- Grid de 4 cards con metricas:
|
||||
1. Alertas Abiertas (icono AlertTriangle)
|
||||
2. Reconocidas (icono AlertCircle)
|
||||
3. Resueltas (icono CheckCircle)
|
||||
4. Tiempo Promedio Resolucion (icono Clock)
|
||||
|
||||
---
|
||||
|
||||
### 2. AlertFilters
|
||||
|
||||
**Ubicacion:** `components/alerts/AlertFilters.tsx`
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface AlertFiltersProps {
|
||||
filters: AlertFiltersType;
|
||||
onFiltersChange: (filters: AlertFiltersType) => void;
|
||||
onRefresh: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Campos de Filtro:**
|
||||
| Campo | Tipo | Opciones |
|
||||
|-------|------|----------|
|
||||
| Severidad | select | low, medium, high, critical |
|
||||
| Estado | select | open, acknowledged, resolved, suppressed |
|
||||
| Tipo Alerta | select | performance_degradation, high_error_rate, etc. |
|
||||
| Desde | date | Date picker |
|
||||
| Hasta | date | Date picker |
|
||||
|
||||
---
|
||||
|
||||
### 3. AlertsList
|
||||
|
||||
**Ubicacion:** `components/alerts/AlertsList.tsx`
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface AlertsListProps {
|
||||
alerts: SystemAlert[];
|
||||
isLoading: boolean;
|
||||
pagination: Pagination;
|
||||
onAlertClick: (alert: SystemAlert) => void;
|
||||
onAcknowledge: (alert: SystemAlert) => void;
|
||||
onResolve: (alert: SystemAlert) => void;
|
||||
onSuppress: (alert: SystemAlert) => void;
|
||||
onNextPage: () => void;
|
||||
onPrevPage: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Estados:**
|
||||
- Loading: skeleton rows animados
|
||||
- Empty: mensaje con icono
|
||||
- Data: grid de AlertCard con paginacion
|
||||
|
||||
---
|
||||
|
||||
### 4. AlertCard
|
||||
|
||||
**Ubicacion:** `components/alerts/AlertCard.tsx`
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface AlertCardProps {
|
||||
alert: SystemAlert;
|
||||
onViewDetails: (alert: SystemAlert) => void;
|
||||
onAcknowledge: (alert: SystemAlert) => void;
|
||||
onResolve: (alert: SystemAlert) => void;
|
||||
onSuppress: (alert: SystemAlert) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Secciones:**
|
||||
1. **Badges** (max 3): Severidad, Estado, Tipo
|
||||
2. **Contenido:** Titulo, Descripcion (clamp 2 lineas)
|
||||
3. **Metadata:** Usuarios afectados, Timestamp
|
||||
4. **Acciones:** Botones dinamicos segun estado
|
||||
|
||||
**Logica de Botones:**
|
||||
| Estado | Detalles | Reconocer | Resolver | Suprimir |
|
||||
|--------|----------|-----------|----------|----------|
|
||||
| open | Si | Si | Si | Si |
|
||||
| acknowledged | Si | No | Si | Si |
|
||||
| resolved | Si | No | No | No |
|
||||
| suppressed | Si | No | No | No |
|
||||
|
||||
---
|
||||
|
||||
### 5. AlertDetailsModal
|
||||
|
||||
**Ubicacion:** `components/alerts/AlertDetailsModal.tsx`
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface AlertDetailsModalProps {
|
||||
alert: SystemAlert | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Secciones:**
|
||||
1. Header con titulo y boton cerrar
|
||||
2. Badges (severidad, estado)
|
||||
3. Titulo y descripcion completa
|
||||
4. Grid de informacion clave
|
||||
5. Seccion Sistema (si aplica)
|
||||
6. Seccion Gestion (si aplica)
|
||||
7. JSON expandible (contexto, metricas)
|
||||
|
||||
---
|
||||
|
||||
### 6. AcknowledgeAlertModal
|
||||
|
||||
**Ubicacion:** `components/alerts/AcknowledgeAlertModal.tsx`
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface AcknowledgeAlertModalProps {
|
||||
alert: SystemAlert | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (note?: string) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Campos:**
|
||||
- Titulo de alerta (readonly)
|
||||
- Textarea para nota (opcional)
|
||||
- Botones: Cancelar, Reconocer
|
||||
|
||||
---
|
||||
|
||||
### 7. ResolveAlertModal
|
||||
|
||||
**Ubicacion:** `components/alerts/ResolveAlertModal.tsx`
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface ResolveAlertModalProps {
|
||||
alert: SystemAlert | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (note: string) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Validacion:**
|
||||
- Nota REQUERIDA
|
||||
- Minimo 10 caracteres
|
||||
- Contador en vivo: "5/10"
|
||||
- Boton deshabilitado si < 10 chars
|
||||
|
||||
---
|
||||
|
||||
## TIPOS PRINCIPALES
|
||||
|
||||
### SystemAlert
|
||||
|
||||
```typescript
|
||||
interface SystemAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
status: 'open' | 'acknowledged' | 'resolved' | 'suppressed';
|
||||
alert_type: SystemAlertType;
|
||||
affected_users: number;
|
||||
triggered_at: string;
|
||||
source_system?: string;
|
||||
source_module?: string;
|
||||
error_code?: string;
|
||||
escalation_level?: number;
|
||||
acknowledged_by_name?: string;
|
||||
acknowledged_at?: string;
|
||||
acknowledgment_note?: string;
|
||||
resolved_by_name?: string;
|
||||
resolved_at?: string;
|
||||
resolution_note?: string;
|
||||
context_data?: Record<string, unknown>;
|
||||
metrics?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### AlertFilters
|
||||
|
||||
```typescript
|
||||
interface AlertFilters {
|
||||
severity?: SystemAlertSeverity;
|
||||
status?: SystemAlertStatus;
|
||||
alert_type?: SystemAlertType;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### AlertsStats
|
||||
|
||||
```typescript
|
||||
interface AlertsStats {
|
||||
open_alerts: number;
|
||||
acknowledged_alerts: number;
|
||||
resolved_alerts: number;
|
||||
avg_resolution_time_hours: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DIAGRAMA DE FLUJO
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ AdminAlertsPage │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ AlertsStats │ │
|
||||
│ │ [Abiertas] [Reconocidas] [Resueltas] [Avg] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ AlertFilters │ │
|
||||
│ │ [Severidad] [Estado] [Tipo] [Fechas] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ AlertsList │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ │ │
|
||||
│ │ │ AlertCard │ │ AlertCard │ │ │
|
||||
│ │ │ - Badges │ │ - Badges │ │ │
|
||||
│ │ │ - Content │ │ - Content │ │ │
|
||||
│ │ │ - Actions │ │ - Actions │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ │ │
|
||||
│ │ [< Prev] 1 2 3 4 5 [Next >] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ │
|
||||
│ │ AlertDetailsModal│ │ AcknowledgeModal │ │
|
||||
│ └──────────────────┘ └────────────────────┘ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ ResolveAlertModal │ │
|
||||
│ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## REFERENCIAS
|
||||
|
||||
- `AdminAlertsPage.tsx` - Implementacion de pagina
|
||||
- `useAlerts.ts` - Hook de gestion
|
||||
- `adminTypes.ts` - Tipos compartidos
|
||||
|
||||
---
|
||||
|
||||
**Ultima actualizacion:** 2025-12-18
|
||||
@ -0,0 +1,165 @@
|
||||
# Hook: useClassroomsList
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Ubicacion:** `apps/frontend/src/apps/admin/hooks/useClassroomsList.ts`
|
||||
|
||||
---
|
||||
|
||||
## PROPOSITO
|
||||
|
||||
Hook simple y especializado para obtener lista de aulas (classrooms) para selectores en el modulo de progreso administrativo. Reemplaza datos mock con datos reales del API.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### Parametros de Entrada
|
||||
|
||||
```typescript
|
||||
interface UseClassroomsListParams {
|
||||
schoolId?: string; // Filtrar por institucion especifica
|
||||
enabled?: boolean; // Control de ejecucion (default: true)
|
||||
}
|
||||
```
|
||||
|
||||
### Retorno
|
||||
|
||||
```typescript
|
||||
interface UseClassroomsListReturn {
|
||||
classrooms: ClassroomBasic[]; // Lista de aulas
|
||||
isLoading: boolean; // Estado de carga
|
||||
error: Error | null; // Error si aplica
|
||||
refetch: () => void; // Funcion para recargar
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CONFIGURACION DE REACT QUERY
|
||||
|
||||
| Configuracion | Valor |
|
||||
|---------------|-------|
|
||||
| Query Key | `['admin', 'classrooms', schoolId]` |
|
||||
| staleTime | 5 minutos |
|
||||
| gcTime | 10 minutos |
|
||||
| refetchOnFocus | false |
|
||||
| retry | 2 intentos |
|
||||
|
||||
---
|
||||
|
||||
## EJEMPLO DE USO
|
||||
|
||||
### Basico
|
||||
|
||||
```typescript
|
||||
import { useClassroomsList } from '../hooks/useClassroomsList';
|
||||
|
||||
function ClassroomSelector() {
|
||||
const { classrooms, isLoading, error } = useClassroomsList();
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
|
||||
return (
|
||||
<select>
|
||||
{classrooms.map(classroom => (
|
||||
<option key={classroom.id} value={classroom.id}>
|
||||
{classroom.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Con Filtro por Institucion
|
||||
|
||||
```typescript
|
||||
function ClassroomSelectorBySchool({ schoolId }: { schoolId: string }) {
|
||||
const { classrooms, isLoading, error, refetch } = useClassroomsList({
|
||||
schoolId,
|
||||
enabled: !!schoolId
|
||||
});
|
||||
|
||||
// Solo ejecuta query cuando schoolId esta disponible
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => refetch()}>Actualizar</button>
|
||||
<ClassroomList classrooms={classrooms} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Control Manual de Ejecucion
|
||||
|
||||
```typescript
|
||||
function ConditionalClassrooms() {
|
||||
const [shouldFetch, setShouldFetch] = useState(false);
|
||||
|
||||
const { classrooms, isLoading } = useClassroomsList({
|
||||
enabled: shouldFetch
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setShouldFetch(true)}>
|
||||
Cargar Aulas
|
||||
</button>
|
||||
{isLoading && <Spinner />}
|
||||
{classrooms.length > 0 && <ClassroomList classrooms={classrooms} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DEPENDENCIAS
|
||||
|
||||
| Dependencia | Uso |
|
||||
|-------------|-----|
|
||||
| @tanstack/react-query | Gestion de queries |
|
||||
| @/services/api/adminAPI | Cliente API |
|
||||
| @/services/api/adminTypes | Tipos (ClassroomBasic) |
|
||||
|
||||
---
|
||||
|
||||
## TIPOS RELACIONADOS
|
||||
|
||||
### ClassroomBasic
|
||||
|
||||
```typescript
|
||||
interface ClassroomBasic {
|
||||
id: string;
|
||||
name: string;
|
||||
grade?: string;
|
||||
section?: string;
|
||||
schoolId?: string;
|
||||
teacherId?: string;
|
||||
studentCount?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NOTAS DE IMPLEMENTACION
|
||||
|
||||
1. **Cache inteligente:** El query se invalida automaticamente cuando cambia `schoolId`
|
||||
2. **Fallback a array vacio:** Si el API falla, retorna array vacio en lugar de undefined
|
||||
3. **Retry limitado:** Maximo 2 reintentos para evitar sobrecarga
|
||||
|
||||
---
|
||||
|
||||
## REFERENCIAS
|
||||
|
||||
- `AdminProgressPage.tsx` - Pagina que usa este hook
|
||||
- `adminAPI.ts` - Cliente API
|
||||
- `adminTypes.ts` - Definiciones de tipos
|
||||
|
||||
---
|
||||
|
||||
**Ultima actualizacion:** 2025-12-18
|
||||
@ -0,0 +1,345 @@
|
||||
# Hook: useGamificationConfig
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Fecha:** 2025-12-18
|
||||
**Ubicacion:** `apps/frontend/src/apps/admin/hooks/useGamificationConfig.ts`
|
||||
|
||||
---
|
||||
|
||||
## PROPOSITO
|
||||
|
||||
Hook de React personalizado que gestiona toda la configuracion de gamificacion del sistema, proporcionando:
|
||||
- Parametros de gamificacion (XP, coins, multiplicadores)
|
||||
- Rangos Maya (niveles, multiplicadores, perks)
|
||||
- Estadisticas del sistema
|
||||
- Operaciones CRUD completas con cache inteligente mediante React Query
|
||||
|
||||
---
|
||||
|
||||
## API EXPUESTA
|
||||
|
||||
### Objeto Retornado
|
||||
|
||||
```typescript
|
||||
const {
|
||||
// QUERIES
|
||||
useParameters,
|
||||
useParameter,
|
||||
useMayaRanks,
|
||||
useMayaRank,
|
||||
useStats,
|
||||
|
||||
// MUTATIONS
|
||||
updateParameter,
|
||||
resetParameter,
|
||||
bulkUpdateParameters,
|
||||
updateMayaRank,
|
||||
previewImpact,
|
||||
restoreDefaults
|
||||
} = useGamificationConfig();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## QUERIES
|
||||
|
||||
### useParameters(query?: ListParametersQuery)
|
||||
|
||||
Obtiene lista paginada de parametros de gamificacion.
|
||||
|
||||
```typescript
|
||||
interface ListParametersQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: 'points' | 'coins' | 'levels' | 'ranks' | 'penalties' | 'bonuses';
|
||||
}
|
||||
|
||||
const { data, isLoading, error } = useParameters({ category: 'points' });
|
||||
|
||||
// Retorno
|
||||
{
|
||||
data: Parameter[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number
|
||||
}
|
||||
```
|
||||
|
||||
**Cache:** 5 minutos
|
||||
|
||||
### useParameter(key: string, enabled?: boolean)
|
||||
|
||||
Obtiene un parametro especifico por clave.
|
||||
|
||||
```typescript
|
||||
const { data, isLoading, error } = useParameter('xp_per_exercise', true);
|
||||
```
|
||||
|
||||
### useMayaRanks()
|
||||
|
||||
Obtiene todos los rangos Maya configurados.
|
||||
|
||||
```typescript
|
||||
const { data, isLoading, error } = useMayaRanks();
|
||||
|
||||
// Retorno: MayaRankConfig[]
|
||||
```
|
||||
|
||||
**Cache:** 10 minutos
|
||||
|
||||
### useMayaRank(id: string, enabled?: boolean)
|
||||
|
||||
Obtiene un rango Maya especifico.
|
||||
|
||||
```typescript
|
||||
const { data, isLoading, error } = useMayaRank('rank-uuid', true);
|
||||
```
|
||||
|
||||
### useStats()
|
||||
|
||||
Obtiene estadisticas del sistema de gamificacion.
|
||||
|
||||
```typescript
|
||||
const { data, isLoading, error } = useStats();
|
||||
|
||||
// Retorno
|
||||
{
|
||||
totalParameters: number,
|
||||
activeParameters: number,
|
||||
totalRanks: number,
|
||||
activeRanks: number,
|
||||
lastModified: string
|
||||
}
|
||||
```
|
||||
|
||||
**Cache:** 2 minutos
|
||||
|
||||
---
|
||||
|
||||
## MUTATIONS
|
||||
|
||||
### updateParameter
|
||||
|
||||
Actualiza un parametro individual.
|
||||
|
||||
```typescript
|
||||
const { mutate, isPending, error } = updateParameter;
|
||||
|
||||
mutate({
|
||||
key: 'xp_per_exercise',
|
||||
data: {
|
||||
value: 50,
|
||||
reason: 'Ajuste de balanceo'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Efectos:**
|
||||
- Invalida queries de parametros
|
||||
- Invalida query de stats
|
||||
- Toast de exito/error automatico
|
||||
|
||||
### resetParameter
|
||||
|
||||
Restaura un parametro a su valor por defecto.
|
||||
|
||||
```typescript
|
||||
resetParameter.mutate('xp_per_exercise');
|
||||
```
|
||||
|
||||
### bulkUpdateParameters
|
||||
|
||||
Actualiza multiples parametros en una sola operacion.
|
||||
|
||||
```typescript
|
||||
bulkUpdateParameters.mutate({
|
||||
updates: [
|
||||
{ key: 'xp_per_exercise', value: 50 },
|
||||
{ key: 'coins_per_streak', value: 10 }
|
||||
],
|
||||
reason: 'Rebalanceo de economia'
|
||||
});
|
||||
```
|
||||
|
||||
### updateMayaRank
|
||||
|
||||
Modifica umbrales de un rango Maya.
|
||||
|
||||
```typescript
|
||||
updateMayaRank.mutate({
|
||||
id: 'rank-uuid',
|
||||
data: {
|
||||
minXp: 500,
|
||||
maxXp: 999
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### previewImpact
|
||||
|
||||
Genera preview del impacto de cambios sin aplicarlos.
|
||||
|
||||
```typescript
|
||||
previewImpact.mutate({
|
||||
parameterKey: 'xp_multiplier',
|
||||
newValue: 1.5
|
||||
});
|
||||
|
||||
// Retorno: Estadisticas de usuarios afectados
|
||||
```
|
||||
|
||||
### restoreDefaults
|
||||
|
||||
Restaura TODOS los parametros a valores por defecto.
|
||||
|
||||
```typescript
|
||||
restoreDefaults.mutate();
|
||||
|
||||
// Retorno: { restored_count: number }
|
||||
```
|
||||
|
||||
**Efectos:**
|
||||
- Invalida todas las queries bajo 'gamification'
|
||||
- Requiere confirmacion del usuario (implementada en UI)
|
||||
|
||||
---
|
||||
|
||||
## CONFIGURACION DE REACT QUERY
|
||||
|
||||
| Query | staleTime | gcTime |
|
||||
|-------|-----------|--------|
|
||||
| parameters | 5 min | 10 min |
|
||||
| parameter (single) | 5 min | 10 min |
|
||||
| mayaRanks | 10 min | 15 min |
|
||||
| stats | 2 min | 5 min |
|
||||
|
||||
---
|
||||
|
||||
## VALIDACION DEFENSIVA
|
||||
|
||||
El hook implementa validaciones robustas para manejar respuestas inesperadas del backend:
|
||||
|
||||
```typescript
|
||||
// Ejemplo de validacion en useParameters
|
||||
const data = response?.data;
|
||||
if (!data || !Array.isArray(data.parameters)) {
|
||||
console.warn('Unexpected response structure');
|
||||
return { data: [], total: 0, page: 1, limit: 10 };
|
||||
}
|
||||
```
|
||||
|
||||
**Caracteristicas:**
|
||||
- Valida estructura de objetos antes de usar
|
||||
- Proporciona fallbacks sensatos (arrays vacios, valores por defecto)
|
||||
- Log de advertencias en consola para debugging
|
||||
- Maneja campos snake_case y camelCase del backend
|
||||
|
||||
---
|
||||
|
||||
## EJEMPLO DE USO
|
||||
|
||||
### En AdminGamificationPage
|
||||
|
||||
```typescript
|
||||
import { useGamificationConfig } from '../hooks/useGamificationConfig';
|
||||
|
||||
function AdminGamificationPage() {
|
||||
const {
|
||||
useParameters,
|
||||
useMayaRanks,
|
||||
useStats,
|
||||
updateParameter,
|
||||
resetParameter
|
||||
} = useGamificationConfig();
|
||||
|
||||
const { data: stats, isLoading: loadingStats } = useStats();
|
||||
const { data: parametersData, isLoading: loadingParams } = useParameters();
|
||||
const { data: mayaRanks, isLoading: loadingRanks } = useMayaRanks();
|
||||
|
||||
const handleUpdateParameter = (key: string, value: any) => {
|
||||
updateParameter.mutate({
|
||||
key,
|
||||
data: { value, reason: 'Manual update' }
|
||||
});
|
||||
};
|
||||
|
||||
if (loadingStats || loadingParams || loadingRanks) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StatsCards stats={stats} />
|
||||
<ParametersList
|
||||
parameters={parametersData?.data || []}
|
||||
onUpdate={handleUpdateParameter}
|
||||
/>
|
||||
<MayaRanksList ranks={mayaRanks || []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DEPENDENCIAS
|
||||
|
||||
| Dependencia | Uso |
|
||||
|-------------|-----|
|
||||
| @tanstack/react-query | Gestion de estado y cache |
|
||||
| @/services/api/admin/gamificationConfigApi | Cliente API |
|
||||
| react-hot-toast | Notificaciones |
|
||||
| @/types/admin/gamification.types | Tipos TypeScript |
|
||||
|
||||
---
|
||||
|
||||
## TIPOS RELACIONADOS
|
||||
|
||||
### Parameter
|
||||
|
||||
```typescript
|
||||
interface Parameter {
|
||||
id: string;
|
||||
key: string;
|
||||
value: any;
|
||||
category: 'points' | 'coins' | 'levels' | 'ranks' | 'penalties' | 'bonuses';
|
||||
dataType: string;
|
||||
description?: string;
|
||||
defaultValue?: any;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### MayaRankConfig
|
||||
|
||||
```typescript
|
||||
interface MayaRankConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
minXp: number;
|
||||
maxXp?: number | null;
|
||||
multiplierXp: number;
|
||||
multiplierMlCoins: number;
|
||||
bonusMlCoins: number;
|
||||
color: string;
|
||||
icon?: string | null;
|
||||
description?: string;
|
||||
perks?: string[];
|
||||
isActive: boolean;
|
||||
order?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## REFERENCIAS
|
||||
|
||||
- `AdminGamificationPage.tsx` - Pagina que usa este hook
|
||||
- `gamificationConfigApi.ts` - Cliente API
|
||||
- `gamification.types.ts` - Definiciones de tipos
|
||||
|
||||
---
|
||||
|
||||
**Ultima actualizacion:** 2025-12-18
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user