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
|
# GAMILIT Monorepo - .gitignore
|
||||||
# Generado: 2025-11-01 (RFC-0001)
|
# Generado: 2025-11-01 (RFC-0001)
|
||||||
# Actualizado: 2025-12-05
|
|
||||||
|
|
||||||
# ============================================
|
# === NODE.JS ===
|
||||||
# NODE.JS - DEPENDENCIAS (GLOBAL)
|
node_modules/
|
||||||
# ============================================
|
|
||||||
# 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
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# Directorios de dependencias alternativas
|
# Dependency directories
|
||||||
jspm_packages/
|
jspm_packages/
|
||||||
bower_components/
|
|
||||||
|
|
||||||
# Cache de npm
|
# Optional npm cache directory
|
||||||
.npm/
|
.npm
|
||||||
.npmrc.local
|
|
||||||
|
|
||||||
# Cache de eslint/stylelint
|
# Optional eslint cache
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
.stylelintcache
|
.stylelintcache
|
||||||
|
|
||||||
# ============================================
|
# === TYPESCRIPT ===
|
||||||
# TYPESCRIPT / BUILD ARTIFACTS (GLOBAL)
|
|
||||||
# ============================================
|
|
||||||
# Ignorar dist/build en CUALQUIER nivel
|
|
||||||
**/dist/
|
|
||||||
**/build/
|
|
||||||
**/out/
|
|
||||||
**/.next/
|
|
||||||
**/.nuxt/
|
|
||||||
**/.turbo/
|
|
||||||
|
|
||||||
# TypeScript
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
**/*.tsbuildinfo
|
dist/
|
||||||
|
build/
|
||||||
*.js.map
|
*.js.map
|
||||||
|
|
||||||
# ============================================
|
# === ANGULAR / NX ===
|
||||||
# FRAMEWORKS ESPECÍFICOS
|
|
||||||
# ============================================
|
|
||||||
# Angular / NX
|
|
||||||
.angular/
|
.angular/
|
||||||
.nx/
|
.nx/cache/
|
||||||
**/.nx/
|
.nx/workspace-data/
|
||||||
|
|
||||||
# Vite
|
# === NESTJS ===
|
||||||
**/.vite/
|
/apps/backend/dist/
|
||||||
|
/apps/backend/build/
|
||||||
|
|
||||||
# Webpack
|
# === ENVIRONMENT FILES ===
|
||||||
.webpack/
|
# IMPORTANTE: Nunca commitear secrets reales
|
||||||
**/.webpack/
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# ENVIRONMENT FILES - SECRETS
|
|
||||||
# ============================================
|
|
||||||
# CRÍTICO: Nunca commitear secrets
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.env.production
|
.env.production
|
||||||
.env.development
|
.env.development
|
||||||
.env.test
|
.env.test
|
||||||
.env.staging
|
# Permitir archivos de ejemplo (sin secrets)
|
||||||
|
!.env.*.example
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# Archivos con secrets
|
# Archivos de configuración con secrets
|
||||||
**/secrets.json
|
config/secrets.json
|
||||||
**/credentials.json
|
config/credentials.json
|
||||||
**/*secrets*.json
|
**/*secrets*.json
|
||||||
**/*credentials*.json
|
**/*credentials*.json
|
||||||
**/*.secret
|
|
||||||
**/*.secrets
|
|
||||||
|
|
||||||
# Configuración de base de datos con credenciales
|
# === DATABASES ===
|
||||||
**/database.config.ts
|
# PostgreSQL
|
||||||
!**/database.config.example.ts
|
|
||||||
**/ormconfig.json
|
|
||||||
!**/ormconfig.example.json
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# DATABASES
|
|
||||||
# ============================================
|
|
||||||
# Backups y dumps
|
|
||||||
*.sql.backup
|
*.sql.backup
|
||||||
*.dump
|
*.dump
|
||||||
*.pgdata
|
*.pgdata
|
||||||
*.sql.gz
|
|
||||||
|
|
||||||
# Bases de datos locales
|
# Local database files
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
|
||||||
|
|
||||||
# Redis
|
# Database connection strings (excepción: ejemplos con .example)
|
||||||
dump.rdb
|
database.config.ts
|
||||||
|
!database.config.example.ts
|
||||||
|
|
||||||
# ============================================
|
# === LOGS ===
|
||||||
# LOGS (GLOBAL)
|
logs/
|
||||||
# ============================================
|
*.log
|
||||||
**/logs/
|
npm-debug.log*
|
||||||
**/*.log
|
yarn-debug.log*
|
||||||
**/pm2-logs/
|
yarn-error.log*
|
||||||
**/*.pm2.log
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# ============================================
|
# PM2 logs
|
||||||
# TESTING (GLOBAL)
|
pm2-logs/
|
||||||
# ============================================
|
*.pm2.log
|
||||||
**/coverage/
|
|
||||||
**/.nyc_output/
|
# === TESTING ===
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|
||||||
# Jest
|
# Jest
|
||||||
**/jest-cache/
|
jest-cache/
|
||||||
.jest/
|
|
||||||
|
|
||||||
# Cypress
|
# Cypress
|
||||||
**/cypress/screenshots/
|
cypress/screenshots/
|
||||||
**/cypress/videos/
|
cypress/videos/
|
||||||
**/cypress/downloads/
|
cypress/downloads/
|
||||||
|
|
||||||
# Playwright
|
|
||||||
**/playwright-report/
|
|
||||||
**/playwright/.cache/
|
|
||||||
**/test-results/
|
|
||||||
|
|
||||||
# E2E reports
|
# E2E reports
|
||||||
**/e2e-reports/
|
e2e-reports/
|
||||||
|
test-results/
|
||||||
|
|
||||||
# ============================================
|
# === IDEs and EDITORS ===
|
||||||
# IDEs and EDITORS
|
# VSCode
|
||||||
# ============================================
|
|
||||||
# VSCode - mantener configuración compartida
|
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
!.vscode/tasks.json
|
!.vscode/tasks.json
|
||||||
@ -161,117 +119,92 @@ dump.rdb
|
|||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
|
|
||||||
# Vim/Neovim
|
# Vim
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.netrwhist
|
|
||||||
Session.vim
|
|
||||||
|
|
||||||
# Emacs
|
# Emacs
|
||||||
|
*~
|
||||||
\#*\#
|
\#*\#
|
||||||
.\#*
|
.\#*
|
||||||
*.elc
|
|
||||||
auto-save-list/
|
|
||||||
|
|
||||||
# ============================================
|
# === OS FILES ===
|
||||||
# OS FILES
|
|
||||||
# ============================================
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.AppleDouble
|
.AppleDouble
|
||||||
.LSOverride
|
.LSOverride
|
||||||
._*
|
._*
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
|
|
||||||
# Linux
|
# Linux
|
||||||
*~
|
*~
|
||||||
.directory
|
.directory
|
||||||
.Trash-*
|
|
||||||
|
|
||||||
# Windows
|
# Windows
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
Thumbs.db:encryptable
|
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
ehthumbs_vista.db
|
|
||||||
Desktop.ini
|
Desktop.ini
|
||||||
$RECYCLE.BIN/
|
$RECYCLE.BIN/
|
||||||
*.lnk
|
|
||||||
|
|
||||||
# ============================================
|
# === DOCKER ===
|
||||||
# DOCKER
|
# No ignorar Dockerfiles, solo archivos temporales
|
||||||
# ============================================
|
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
docker-compose.local.yml
|
|
||||||
.dockerignore.local
|
.dockerignore.local
|
||||||
**/*.dockerignore.local
|
|
||||||
|
|
||||||
# ============================================
|
# === BUILD ARTIFACTS ===
|
||||||
# DEPLOYMENT & SECURITY
|
/apps/*/dist/
|
||||||
# ============================================
|
/apps/*/build/
|
||||||
|
/libs/*/dist/
|
||||||
|
|
||||||
|
# Webpack
|
||||||
|
.webpack/
|
||||||
|
|
||||||
|
# === DEPLOYMENT ===
|
||||||
# PM2
|
# PM2
|
||||||
ecosystem.config.js.local
|
ecosystem.config.js.local
|
||||||
pm2.config.js.local
|
pm2.config.js.local
|
||||||
|
|
||||||
# Claves y certificados
|
# Deploy keys (excepción: .example files)
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
*.p12
|
|
||||||
*.pfx
|
|
||||||
!*.example.pem
|
!*.example.pem
|
||||||
!*.example.key
|
!*.example.key
|
||||||
|
|
||||||
# SSL certificates
|
# SSL certificates (excepción: self-signed para dev)
|
||||||
*.crt
|
*.crt
|
||||||
*.cer
|
*.cer
|
||||||
!dev-cert.crt
|
!dev-cert.crt
|
||||||
!*.example.crt
|
|
||||||
|
|
||||||
# SSH keys
|
# === TEMP FILES ===
|
||||||
id_rsa*
|
tmp/
|
||||||
id_ed25519*
|
temp/
|
||||||
*.pub
|
*.tmp
|
||||||
!*.example.pub
|
*.temp
|
||||||
|
*.cache
|
||||||
|
|
||||||
# ============================================
|
# === ARTIFACTS (parcial) ===
|
||||||
# TEMP FILES (GLOBAL)
|
# Mantener reportes importantes, ignorar temporales
|
||||||
# ============================================
|
|
||||||
**/tmp/
|
|
||||||
**/temp/
|
|
||||||
**/.tmp/
|
|
||||||
**/.temp/
|
|
||||||
**/*.tmp
|
|
||||||
**/*.temp
|
|
||||||
**/*.cache
|
|
||||||
**/.cache/
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# ARTIFACTS
|
|
||||||
# ============================================
|
|
||||||
/artifacts/temp/
|
/artifacts/temp/
|
||||||
/artifacts/cache/
|
/artifacts/cache/
|
||||||
/artifacts/**/*.tmp
|
/artifacts/**/*.tmp
|
||||||
/artifacts/**/*.cache
|
|
||||||
|
|
||||||
# ============================================
|
# === CLAUDE CODE ===
|
||||||
# CLAUDE CODE / AI
|
# Excluir toda la carpeta .claude (configuración local de IA)
|
||||||
# ============================================
|
|
||||||
# Configuración local de Claude Code
|
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Pero NO ignorar orchestration (necesario para Claude Code cloud)
|
# === ORCHESTRATION ===
|
||||||
# Solo ignorar temporales dentro de 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/.archive/
|
||||||
orchestration/.tmp/
|
orchestration/.tmp/
|
||||||
orchestration/**/*.tmp
|
orchestration/**/*.tmp
|
||||||
orchestration/**/*.cache
|
orchestration/**/*.cache
|
||||||
|
|
||||||
# ============================================
|
# === REFERENCE (Código de Referencia) ===
|
||||||
# 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
|
||||||
# reference/ DEBE estar en el repo
|
# Ignorar solo carpetas de build/dependencias dentro de reference/
|
||||||
# Solo ignorar build/dependencias dentro
|
|
||||||
reference/**/node_modules/
|
reference/**/node_modules/
|
||||||
reference/**/dist/
|
reference/**/dist/
|
||||||
reference/**/build/
|
reference/**/build/
|
||||||
@ -286,9 +219,16 @@ reference/**/*.tmp
|
|||||||
reference/**/*.cache
|
reference/**/*.cache
|
||||||
reference/**/.DS_Store
|
reference/**/.DS_Store
|
||||||
|
|
||||||
# ============================================
|
# === MIGRATION (temporal) ===
|
||||||
# PACKAGE MANAGERS
|
# 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/*
|
.yarn/*
|
||||||
!.yarn/patches
|
!.yarn/patches
|
||||||
@ -301,102 +241,33 @@ reference/**/.DS_Store
|
|||||||
# PNPM
|
# PNPM
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
|
||||||
# ============================================
|
# === MONITORING ===
|
||||||
# MONITORING & OBSERVABILITY
|
# Application monitoring
|
||||||
# ============================================
|
|
||||||
newrelic_agent.log
|
newrelic_agent.log
|
||||||
.monitors/
|
.monitors/
|
||||||
**/.sentry/
|
|
||||||
**/sentry-debug.log
|
|
||||||
|
|
||||||
# ============================================
|
# === MISC ===
|
||||||
# BACKUPS (GLOBAL)
|
# Backups - Archivos
|
||||||
# ============================================
|
|
||||||
# Archivos
|
|
||||||
*.backup
|
*.backup
|
||||||
*.bak
|
*.bak
|
||||||
*.old
|
*.old
|
||||||
*.orig
|
|
||||||
|
|
||||||
# Carpetas
|
# Backups - Carpetas
|
||||||
**/*_old/
|
*_old/
|
||||||
**/*_bckp/
|
*_bckp/
|
||||||
**/*_bkp/
|
*_bkp/
|
||||||
**/*_backup/
|
*_backup/
|
||||||
**/*.old/
|
*.old/
|
||||||
**/*.bak/
|
*.bak/
|
||||||
**/*.backup/
|
*.backup/
|
||||||
|
|
||||||
# Específicos del proyecto
|
# Backups específicos (carpetas identificadas en workspace)
|
||||||
orchestration_old/
|
orchestration_old/
|
||||||
orchestration_bckp/
|
orchestration_bckp/
|
||||||
docs_bkp/
|
docs_bkp/
|
||||||
|
|
||||||
# ============================================
|
# Compressed files (si no son assets del proyecto)
|
||||||
# COMPRESSED FILES
|
|
||||||
# ============================================
|
|
||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.tar
|
|
||||||
*.rar
|
*.rar
|
||||||
*.7z
|
|
||||||
# Excepto assets
|
|
||||||
!assets/**/*.zip
|
!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/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Uploads (keep directory structure but ignore files)
|
|
||||||
uploads/exercises/*
|
|
||||||
!uploads/exercises/.gitkeep
|
|
||||||
|
|
||||||
.env*
|
.env*
|
||||||
.flaskenv*
|
.flaskenv*
|
||||||
!.env.project
|
!.env.project
|
||||||
!.env.vault
|
!.env.vault
|
||||||
|
!.env.*.example
|
||||||
|
!.env.example
|
||||||
# Backups de scripts automáticos
|
# Backups de scripts automáticos
|
||||||
**/*.backup-*
|
**/*.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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
@ -6,6 +6,7 @@ import { User, EmailVerificationToken } from '../entities';
|
|||||||
import {
|
import {
|
||||||
VerifyEmailDto,
|
VerifyEmailDto,
|
||||||
} from '../dto';
|
} from '../dto';
|
||||||
|
import { MailService } from '@/modules/mail/mail.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EmailVerificationService
|
* EmailVerificationService
|
||||||
@ -33,6 +34,8 @@ import {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailVerificationService {
|
export class EmailVerificationService {
|
||||||
|
private readonly logger = new Logger(EmailVerificationService.name);
|
||||||
|
|
||||||
private readonly TOKEN_LENGTH_BYTES = 32;
|
private readonly TOKEN_LENGTH_BYTES = 32;
|
||||||
|
|
||||||
private readonly TOKEN_EXPIRATION_HOURS = 24;
|
private readonly TOKEN_EXPIRATION_HOURS = 24;
|
||||||
@ -44,8 +47,7 @@ export class EmailVerificationService {
|
|||||||
@InjectRepository(EmailVerificationToken, 'auth')
|
@InjectRepository(EmailVerificationToken, 'auth')
|
||||||
private readonly tokenRepository: Repository<EmailVerificationToken>,
|
private readonly tokenRepository: Repository<EmailVerificationToken>,
|
||||||
|
|
||||||
// TODO: Inject MailerService
|
private readonly mailService: MailService,
|
||||||
// private readonly mailerService: MailerService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,9 +91,17 @@ export class EmailVerificationService {
|
|||||||
await this.tokenRepository.save(verificationToken);
|
await this.tokenRepository.save(verificationToken);
|
||||||
|
|
||||||
// 8. Enviar email con token plaintext
|
// 8. Enviar email con token plaintext
|
||||||
// TODO: Implementar envío de email
|
try {
|
||||||
// await this.mailerService.sendEmailVerification(email, plainToken);
|
await this.mailService.sendVerificationEmail(email, plainToken);
|
||||||
console.log(`[DEV] Email verification token for ${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' };
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
ResetPasswordDto,
|
ResetPasswordDto,
|
||||||
} from '../dto';
|
} from '../dto';
|
||||||
import { MailService } from '@/modules/mail/mail.service';
|
import { MailService } from '@/modules/mail/mail.service';
|
||||||
|
import { SessionManagementService } from './session-management.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PasswordRecoveryService
|
* PasswordRecoveryService
|
||||||
@ -33,6 +34,8 @@ import { MailService } from '@/modules/mail/mail.service';
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PasswordRecoveryService {
|
export class PasswordRecoveryService {
|
||||||
|
private readonly logger = new Logger(PasswordRecoveryService.name);
|
||||||
|
|
||||||
private readonly TOKEN_LENGTH_BYTES = 32;
|
private readonly TOKEN_LENGTH_BYTES = 32;
|
||||||
|
|
||||||
private readonly TOKEN_EXPIRATION_HOURS = 1;
|
private readonly TOKEN_EXPIRATION_HOURS = 1;
|
||||||
@ -46,8 +49,7 @@ export class PasswordRecoveryService {
|
|||||||
|
|
||||||
private readonly mailService: MailService,
|
private readonly mailService: MailService,
|
||||||
|
|
||||||
// TODO: Inject SessionManagementService for logout
|
private readonly sessionManagementService: SessionManagementService,
|
||||||
// private readonly sessionService: SessionManagementService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,14 +92,16 @@ export class PasswordRecoveryService {
|
|||||||
// 7. Enviar email con token plaintext
|
// 7. Enviar email con token plaintext
|
||||||
try {
|
try {
|
||||||
await this.mailService.sendPasswordResetEmail(user.email, plainToken);
|
await this.mailService.sendPasswordResetEmail(user.email, plainToken);
|
||||||
|
this.logger.log(`Password reset email sent to: ${user.email}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error pero NO fallar (por seguridad, no revelar errores)
|
// 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 };
|
return { message: genericMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,10 +163,16 @@ export class PasswordRecoveryService {
|
|||||||
{ used_at: new Date() },
|
{ used_at: new Date() },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. Invalidar todas las sesiones (logout global)
|
// 6. Invalidar todas las sesiones (logout global de seguridad)
|
||||||
// TODO: Implementar con SessionManagementService
|
try {
|
||||||
// await this.sessionService.revokeAllSessions(user.id);
|
// Revocar todas las sesiones excepto ninguna (currentSessionId vacío = revocar todas)
|
||||||
console.log(`[DEV] Should revoke all sessions for user ${user.id}`);
|
// 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' };
|
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 { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
|
||||||
import { Repository, EntityManager } from 'typeorm';
|
import { Repository, EntityManager } from 'typeorm';
|
||||||
import { ExerciseSubmission } from '../entities';
|
import { ExerciseSubmission } from '../entities';
|
||||||
@ -79,6 +79,8 @@ interface FragmentState {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExerciseSubmissionService {
|
export class ExerciseSubmissionService {
|
||||||
|
private readonly logger = new Logger(ExerciseSubmissionService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ExerciseSubmission, 'progress')
|
@InjectRepository(ExerciseSubmission, 'progress')
|
||||||
private readonly submissionRepo: Repository<ExerciseSubmission>,
|
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') {
|
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') {
|
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
|
// 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);
|
await ExerciseAnswerValidator.validate(exercise.exercise_type, answers);
|
||||||
|
|
||||||
// Verificar si ya existe un envío previo
|
// 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
|
// ✅ FIX BUG-001: Auto-claim rewards después de calificar
|
||||||
if (submission.is_correct && submission.status === 'graded') {
|
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);
|
const rewards = await this.claimRewards(submission.id);
|
||||||
|
|
||||||
// Los campos ya están persistidos en la submission por claimRewards()
|
// 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
|
// BE-P2-008: Notificar al docente si el ejercicio requiere revisión manual
|
||||||
if (exercise.requires_manual_grading) {
|
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 {
|
try {
|
||||||
await this.notifyTeacherOfSubmission(submission, exercise, profileId);
|
await this.notifyTeacherOfSubmission(submission, exercise, profileId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error but don't fail submission
|
// Log error but don't fail submission
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
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
|
// P1-003: Check if manual grading is requested
|
||||||
if (manualGrade?.final_score !== undefined) {
|
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
|
// Validate manual score range
|
||||||
if (manualGrade.final_score < 0 || manualGrade.final_score > submission.max_score) {
|
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}`;
|
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);
|
const savedSubmission = await this.submissionRepo.save(submission);
|
||||||
|
|
||||||
@ -406,17 +408,17 @@ export class ExerciseSubmissionService {
|
|||||||
try {
|
try {
|
||||||
const earned = await this.achievementsService.detectAndGrantEarned(submission.user_id);
|
const earned = await this.achievementsService.detectAndGrantEarned(submission.user_id);
|
||||||
if (earned.length > 0) {
|
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) {
|
} 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;
|
return savedSubmission;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: Auto-grading using SQL validate_and_audit()
|
// 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(
|
const { score, isCorrect, correctAnswers, totalQuestions, feedback, details, auditId } = await this.autoGrade(
|
||||||
submission.user_id, // userId (profiles.id)
|
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
|
// FE-059: Audit ID is stored in educational_content.exercise_validation_audit
|
||||||
// Can be queried using: exercise_id + user_id + attempt_number
|
// 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
|
// Store validation results in submission
|
||||||
(submission as any).correctAnswers = correctAnswers;
|
(submission as any).correctAnswers = correctAnswers;
|
||||||
@ -466,10 +468,10 @@ export class ExerciseSubmissionService {
|
|||||||
try {
|
try {
|
||||||
const earned = await this.achievementsService.detectAndGrantEarned(submission.user_id);
|
const earned = await this.achievementsService.detectAndGrantEarned(submission.user_id);
|
||||||
if (earned.length > 0) {
|
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) {
|
} 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;
|
return savedSubmission;
|
||||||
@ -512,7 +514,7 @@ export class ExerciseSubmissionService {
|
|||||||
|
|
||||||
// SPECIAL CASE: Completar Espacios - Anti-redundancy validation (Exercise 1.3)
|
// SPECIAL CASE: Completar Espacios - Anti-redundancy validation (Exercise 1.3)
|
||||||
if (exercise.exercise_type === 'completar_espacios') {
|
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)
|
// Check if blanks.5 and blanks.6 exist and are identical (case-insensitive)
|
||||||
const blanks = (answerData.blanks || {}) as Record<string, unknown>;
|
const blanks = (answerData.blanks || {}) as Record<string, unknown>;
|
||||||
@ -521,7 +523,7 @@ export class ExerciseSubmissionService {
|
|||||||
const space6 = String(blanks['6']).toLowerCase().trim();
|
const space6 = String(blanks['6']).toLowerCase().trim();
|
||||||
|
|
||||||
if (space5 === space6) {
|
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
|
// Create audit record for failed validation
|
||||||
const auditId = 'redundancy-' + Date.now();
|
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
|
// SPECIAL CASE: Rueda de Inferencias custom validation
|
||||||
if (exercise.exercise_type === 'rueda_inferencias') {
|
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
|
// Extract fragmentStates from answerData if available
|
||||||
const fragmentStates = answerData.fragmentStates as FragmentState[] | undefined;
|
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
|
// 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
|
// Call PostgreSQL validate_and_audit() function
|
||||||
const query = `
|
const query = `
|
||||||
@ -611,7 +613,7 @@ export class ExerciseSubmissionService {
|
|||||||
|
|
||||||
const validation = result[0];
|
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 {
|
return {
|
||||||
score: validation.score,
|
score: validation.score,
|
||||||
@ -623,7 +625,7 @@ export class ExerciseSubmissionService {
|
|||||||
auditId: validation.audit_id,
|
auditId: validation.audit_id,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
throw new InternalServerErrorException(`Exercise validation failed: ${errorMessage}`);
|
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
|
// Cast solution to ExerciseSolution interface
|
||||||
const solution = exercise.solution as unknown as ExerciseSolution;
|
const solution = exercise.solution as unknown as ExerciseSolution;
|
||||||
@ -816,7 +818,7 @@ export class ExerciseSubmissionService {
|
|||||||
|
|
||||||
// Skip if no answer provided
|
// Skip if no answer provided
|
||||||
if (!userAnswer) {
|
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;
|
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)
|
// Get expectations for this category (with type safety)
|
||||||
type CategoryId = 'cat-literal' | 'cat-inferencial' | 'cat-critico' | 'cat-creativo';
|
type CategoryId = 'cat-literal' | 'cat-inferencial' | 'cat-critico' | 'cat-creativo';
|
||||||
let categoryExpectation = fragment.categoryExpectations?.[categoryId as CategoryId];
|
let categoryExpectation = fragment.categoryExpectations?.[categoryId as CategoryId];
|
||||||
|
|
||||||
if (!categoryExpectation) {
|
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
|
// Fallback: use literal category if available
|
||||||
categoryExpectation = fragment.categoryExpectations?.['cat-literal'];
|
categoryExpectation = fragment.categoryExpectations?.['cat-literal'];
|
||||||
if (!categoryExpectation) {
|
if (!categoryExpectation) {
|
||||||
@ -847,7 +849,7 @@ export class ExerciseSubmissionService {
|
|||||||
|
|
||||||
// Validate categoryExpectation structure
|
// Validate categoryExpectation structure
|
||||||
if (!categoryExpectation.keywords || !Array.isArray(categoryExpectation.keywords)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -861,7 +863,7 @@ export class ExerciseSubmissionService {
|
|||||||
userAnswerLower.includes(keyword.toLowerCase()),
|
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
|
// Calculate score based on keywords found
|
||||||
let fragmentScore = 0;
|
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.';
|
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 {
|
return {
|
||||||
score: totalScore,
|
score: totalScore,
|
||||||
@ -973,7 +975,7 @@ export class ExerciseSubmissionService {
|
|||||||
let xpEarned = Math.floor(baseXpReward * scoreMultiplier * rankMultiplier);
|
let xpEarned = Math.floor(baseXpReward * scoreMultiplier * rankMultiplier);
|
||||||
let mlCoinsEarned = Math.floor(baseMlCoinsReward * scoreMultiplier);
|
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
|
// Bonificación por perfect score
|
||||||
if (submission.score === submission.max_score && !submission.hint_used) {
|
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);
|
mlCoinsEarned = Math.max(0, mlCoinsEarned - submission.ml_coins_spent);
|
||||||
|
|
||||||
// ✅ FIX BUG-001: Actualizar user_stats con XP y ML Coins
|
// ✅ 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
|
// Obtener rango ANTES de agregar XP
|
||||||
const userStatsBefore = await this.userStatsService.findByUserId(submission.user_id);
|
const userStatsBefore = await this.userStatsService.findByUserId(submission.user_id);
|
||||||
@ -1048,7 +1050,7 @@ export class ExerciseSubmissionService {
|
|||||||
newMultiplier: rankMultipliers[newRank] || 1.0,
|
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
|
// ✅ 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
|
return 1.00; // Default si no encuentra
|
||||||
} catch {
|
} 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;
|
return 1.00;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1133,12 +1135,12 @@ export class ExerciseSubmissionService {
|
|||||||
// Obtener module_id del ejercicio
|
// Obtener module_id del ejercicio
|
||||||
const exercise = await this.exerciseRepo.findOne({ where: { id: exerciseId } });
|
const exercise = await this.exerciseRepo.findOne({ where: { id: exerciseId } });
|
||||||
if (!exercise?.module_id) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleId = exercise.module_id;
|
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
|
// Verificar si es el primer envío correcto para ESTE ejercicio específico
|
||||||
const previousCorrectSubmissions = await this.submissionRepo.count({
|
const previousCorrectSubmissions = await this.submissionRepo.count({
|
||||||
@ -1156,7 +1158,7 @@ export class ExerciseSubmissionService {
|
|||||||
SET last_accessed_at = NOW(), updated_at = NOW()
|
SET last_accessed_at = NOW(), updated_at = NOW()
|
||||||
WHERE user_id = $1 AND module_id = $2
|
WHERE user_id = $1 AND module_id = $2
|
||||||
`, [userId, moduleId]);
|
`, [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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1192,7 +1194,7 @@ export class ExerciseSubmissionService {
|
|||||||
newStatus = 'not_started';
|
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
|
// Actualizar o insertar module_progress usando UPSERT
|
||||||
await this.entityManager.query(`
|
await this.entityManager.query(`
|
||||||
@ -1228,12 +1230,12 @@ export class ExerciseSubmissionService {
|
|||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
`, [userId, moduleId, newStatus, progressPercentage, completedExercises, totalExercises, xpEarned, mlCoinsEarned]);
|
`, [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) {
|
} catch (error) {
|
||||||
// Log error pero no bloquear el claim de rewards
|
// Log error pero no bloquear el claim de rewards
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
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
|
// 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> {
|
private async updateMissionsProgressAfterCompletion(userId: string, xpEarned: number = 0): Promise<void> {
|
||||||
try {
|
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'
|
// Buscar misiones activas del usuario con objetivo 'complete_exercises'
|
||||||
const missions = await this.missionsService.findByTypeAndUser(userId, MissionTypeEnum.DAILY);
|
const missions = await this.missionsService.findByTypeAndUser(userId, MissionTypeEnum.DAILY);
|
||||||
@ -1267,7 +1269,7 @@ export class ExerciseSubmissionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (activeMissions.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1280,11 +1282,11 @@ export class ExerciseSubmissionService {
|
|||||||
'complete_exercises',
|
'complete_exercises',
|
||||||
1, // Incrementar en 1 por cada ejercicio completado
|
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) {
|
} catch (missionError) {
|
||||||
// Log pero continuar con otras misiones
|
// Log pero continuar con otras misiones
|
||||||
const errorMessage = missionError instanceof Error ? missionError.message : String(missionError);
|
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',
|
'earn_xp',
|
||||||
xpEarned, // Incrementar por cantidad de XP ganado
|
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) {
|
} catch (missionError) {
|
||||||
const errorMessage = missionError instanceof Error ? missionError.message : String(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) {
|
} catch (error) {
|
||||||
// Log error pero no bloquear el claim de rewards
|
// Log error pero no bloquear el claim de rewards
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
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
|
// No throw - la actualización de misiones no debe bloquear la respuesta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1486,7 +1488,7 @@ export class ExerciseSubmissionService {
|
|||||||
exercise: Exercise,
|
exercise: Exercise,
|
||||||
studentProfileId: string,
|
studentProfileId: string,
|
||||||
): Promise<void> {
|
): 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
|
// 1. Obtener datos del estudiante
|
||||||
const studentProfile = await this.profileRepo.findOne({
|
const studentProfile = await this.profileRepo.findOne({
|
||||||
@ -1495,7 +1497,7 @@ export class ExerciseSubmissionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!studentProfile) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1524,7 +1526,7 @@ export class ExerciseSubmissionService {
|
|||||||
const teacherResult = await this.entityManager.query(teacherQuery, [studentProfileId]);
|
const teacherResult = await this.entityManager.query(teacherQuery, [studentProfileId]);
|
||||||
|
|
||||||
if (!teacherResult || teacherResult.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1535,7 +1537,7 @@ export class ExerciseSubmissionService {
|
|||||||
const classroomName = teacher.classroom_name || 'tu aula';
|
const classroomName = teacher.classroom_name || 'tu aula';
|
||||||
const teacherPreferences = teacher.teacher_preferences || {};
|
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
|
// 3. Construir URL de revisión
|
||||||
const reviewUrl = `/teacher/reviews/${submission.id}`;
|
const reviewUrl = `/teacher/reviews/${submission.id}`;
|
||||||
@ -1565,10 +1567,10 @@ export class ExerciseSubmissionService {
|
|||||||
priority: 'high',
|
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) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(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
|
// 5. Enviar email si está habilitado en preferencias
|
||||||
@ -1576,7 +1578,7 @@ export class ExerciseSubmissionService {
|
|||||||
const exerciseFeedbackEmailEnabled = teacherPreferences?.email_notifications?.exercise_feedback !== false;
|
const exerciseFeedbackEmailEnabled = teacherPreferences?.email_notifications?.exercise_feedback !== false;
|
||||||
|
|
||||||
if (emailNotificationsEnabled && exerciseFeedbackEmailEnabled && teacherEmail) {
|
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 emailSubject = `Nuevo ejercicio para revisar: ${exercise.title}`;
|
||||||
const emailMessage = `
|
const emailMessage = `
|
||||||
@ -1597,19 +1599,19 @@ export class ExerciseSubmissionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (emailSent) {
|
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 {
|
} 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) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(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 {
|
} 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)
|
// 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 { Classroom } from '@/modules/social/entities/classroom.entity';
|
||||||
import { User } from '@/modules/auth/entities/user.entity';
|
import { User } from '@/modules/auth/entities/user.entity';
|
||||||
import { UserStats } from '@/modules/gamification/entities/user-stats.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';
|
import { GetStudentProgressQueryDto, AddTeacherNoteDto, StudentNoteResponseDto } from '../dto';
|
||||||
|
|
||||||
export interface StudentOverview {
|
export interface StudentOverview {
|
||||||
@ -101,6 +104,11 @@ export class StudentProgressService {
|
|||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
@InjectRepository(UserStats, 'gamification')
|
@InjectRepository(UserStats, 'gamification')
|
||||||
private readonly userStatsRepository: Repository<UserStats>,
|
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 },
|
where: { user_id: profile.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Join with actual module data to get names and details
|
// P1-05: Get module data for enrichment
|
||||||
return moduleProgresses.map((mp, index) => ({
|
const moduleIds = moduleProgresses.map(mp => mp.module_id);
|
||||||
module_id: mp.module_id,
|
const modules = moduleIds.length > 0
|
||||||
module_name: `Módulo ${index + 1}`, // TODO: Get from modules table
|
? await this.moduleRepository.find({ where: { id: In(moduleIds) } })
|
||||||
module_order: index + 1,
|
: [];
|
||||||
total_activities: 15, // TODO: Get from module
|
const moduleMap = new Map(modules.map(m => [m.id, m]));
|
||||||
completed_activities: Math.round(
|
|
||||||
(mp.progress_percentage / 100) * 15,
|
// P1-05: Get submissions for time calculation
|
||||||
),
|
const submissions = await this.submissionRepository.find({
|
||||||
average_score: Math.round(mp.progress_percentage * 0.8), // Estimate
|
where: { user_id: profile.id },
|
||||||
time_spent_minutes: 0, // TODO: Calculate from submissions
|
});
|
||||||
last_activity_date: mp.updated_at, // Using updated_at as proxy for last_activity
|
const timeByModule = new Map<string, number>();
|
||||||
status: this.calculateModuleStatus(mp.progress_percentage),
|
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' },
|
order: { submitted_at: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Join with exercise data to get titles and types
|
// P1-05: Get exercise and module data for enrichment
|
||||||
return submissions.map((sub) => ({
|
const exerciseIds = [...new Set(submissions.map(s => s.exercise_id))];
|
||||||
id: sub.id,
|
const exercises = exerciseIds.length > 0
|
||||||
exercise_title: 'Ejercicio', // TODO: Get from exercises table
|
? await this.exerciseRepository.find({ where: { id: In(exerciseIds) } })
|
||||||
module_name: 'Módulo', // TODO: Get from modules table
|
: [];
|
||||||
exercise_type: 'multiple_choice', // TODO: Get from exercises table
|
const exerciseMap = new Map(exercises.map(e => [e.id, e]));
|
||||||
is_correct: sub.is_correct || false,
|
|
||||||
// Protect against division by zero
|
// Get module data for exercise modules
|
||||||
score_percentage: Math.round((sub.score / (sub.max_score || 1)) * 100),
|
const moduleIds = [...new Set(exercises.map(e => e.module_id))];
|
||||||
time_spent_seconds: sub.time_spent_seconds || 0,
|
const modules = moduleIds.length > 0
|
||||||
hints_used: sub.hints_count || 0,
|
? await this.moduleRepository.find({ where: { id: In(moduleIds) } })
|
||||||
submitted_at: sub.submitted_at,
|
: [];
|
||||||
}));
|
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
|
// Group by exercise to find struggles
|
||||||
const exerciseMap = new Map<string, ExerciseSubmission[]>();
|
const submissionsByExercise = new Map<string, ExerciseSubmission[]>();
|
||||||
submissions.forEach((sub) => {
|
submissions.forEach((sub) => {
|
||||||
const key = sub.exercise_id;
|
const key = sub.exercise_id;
|
||||||
if (!exerciseMap.has(key)) {
|
if (!submissionsByExercise.has(key)) {
|
||||||
exerciseMap.set(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[] = [];
|
const struggles: StruggleArea[] = [];
|
||||||
|
|
||||||
exerciseMap.forEach((subs) => {
|
submissionsByExercise.forEach((subs, exerciseId) => {
|
||||||
const attempts = subs.length;
|
const attempts = subs.length;
|
||||||
const correctAttempts = subs.filter((s) => s.is_correct).length;
|
const correctAttempts = subs.filter((s) => s.is_correct).length;
|
||||||
const successRate = (correctAttempts / attempts) * 100;
|
const successRate = (correctAttempts / attempts) * 100;
|
||||||
|
|
||||||
// Consider it a struggle if success rate < 70% and multiple attempts
|
// Consider it a struggle if success rate < 70% and multiple attempts
|
||||||
if (successRate < 70 && attempts >= 2) {
|
if (successRate < 70 && attempts >= 2) {
|
||||||
// Protect against division by zero in score calculation
|
|
||||||
const avgScore =
|
const avgScore =
|
||||||
subs.reduce((sum, s) => sum + (s.score / (s.max_score || 1)) * 100, 0) /
|
subs.reduce((sum, s) => sum + (s.score / (s.max_score || 1)) * 100, 0) /
|
||||||
attempts;
|
attempts;
|
||||||
|
|
||||||
|
// P1-05: Get real exercise/module names
|
||||||
|
const exercise = exerciseDataMap.get(exerciseId);
|
||||||
|
const moduleData = exercise ? moduleDataMap.get(exercise.module_id) : undefined;
|
||||||
|
|
||||||
struggles.push({
|
struggles.push({
|
||||||
topic: 'Tema del ejercicio', // TODO: Get from exercise data
|
topic: exercise?.title || 'Tema del ejercicio',
|
||||||
module_name: 'Módulo', // TODO: Get from module data
|
module_name: moduleData?.title || 'Módulo',
|
||||||
attempts,
|
attempts,
|
||||||
success_rate: Math.round(successRate),
|
success_rate: Math.round(successRate),
|
||||||
average_score: Math.round(avgScore),
|
average_score: Math.round(avgScore),
|
||||||
@ -390,6 +457,7 @@ export class StudentProgressService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare student with class averages
|
* Compare student with class averages
|
||||||
|
* P1-05: Updated 2025-12-18 - Calculate real class averages
|
||||||
*/
|
*/
|
||||||
async getClassComparison(studentId: string): Promise<ClassComparison[]> {
|
async getClassComparison(studentId: string): Promise<ClassComparison[]> {
|
||||||
const studentStats = await this.getStudentStats(studentId);
|
const studentStats = await this.getStudentStats(studentId);
|
||||||
@ -398,12 +466,14 @@ export class StudentProgressService {
|
|||||||
const allProfiles = await this.profileRepository.find();
|
const allProfiles = await this.profileRepository.find();
|
||||||
const allSubmissions = await this.submissionRepository.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)
|
// Calculate class averages (with division by zero protection)
|
||||||
const classAvgScore = allSubmissions.length > 0
|
const classAvgScore = allSubmissions.length > 0
|
||||||
? Math.round(
|
? Math.round(
|
||||||
allSubmissions.reduce(
|
allSubmissions.reduce(
|
||||||
(sum, sub) => {
|
(sum, sub) => {
|
||||||
// Protect against division by zero in score calculation
|
|
||||||
const maxScore = sub.max_score || 1;
|
const maxScore = sub.max_score || 1;
|
||||||
return sum + (sub.score / maxScore) * 100;
|
return sum + (sub.score / maxScore) * 100;
|
||||||
},
|
},
|
||||||
@ -417,6 +487,24 @@ export class StudentProgressService {
|
|||||||
? allSubmissions.length / allProfiles.length
|
? allSubmissions.length / allProfiles.length
|
||||||
: 0;
|
: 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 [
|
return [
|
||||||
{
|
{
|
||||||
metric: 'Puntuación Promedio',
|
metric: 'Puntuación Promedio',
|
||||||
@ -439,19 +527,19 @@ export class StudentProgressService {
|
|||||||
{
|
{
|
||||||
metric: 'Tiempo de Estudio (min)',
|
metric: 'Tiempo de Estudio (min)',
|
||||||
student_value: studentStats.total_time_spent_minutes,
|
student_value: studentStats.total_time_spent_minutes,
|
||||||
class_average: 1100, // TODO: Calculate actual class average
|
class_average: classAvgTimeMinutes,
|
||||||
percentile: this.calculatePercentile(
|
percentile: this.calculatePercentile(
|
||||||
studentStats.total_time_spent_minutes,
|
studentStats.total_time_spent_minutes,
|
||||||
1100,
|
classAvgTimeMinutes,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
metric: 'Racha Actual (días)',
|
metric: 'Racha Actual (días)',
|
||||||
student_value: studentStats.current_streak_days,
|
student_value: studentStats.current_streak_days,
|
||||||
class_average: 5, // TODO: Calculate actual class average
|
class_average: classAvgStreak,
|
||||||
percentile: this.calculatePercentile(
|
percentile: this.calculatePercentile(
|
||||||
studentStats.current_streak_days,
|
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 { Classroom } from '@/modules/social/entities/classroom.entity';
|
||||||
import { ClassroomMember } from '@/modules/social/entities/classroom-member.entity';
|
import { ClassroomMember } from '@/modules/social/entities/classroom-member.entity';
|
||||||
import { AnalyticsService } from './analytics.service';
|
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 {
|
export interface RiskAlert {
|
||||||
student_id: string;
|
student_id: string;
|
||||||
@ -49,6 +51,8 @@ export class StudentRiskAlertService {
|
|||||||
@InjectRepository(ClassroomMember, 'social')
|
@InjectRepository(ClassroomMember, 'social')
|
||||||
private readonly classroomMemberRepository: Repository<ClassroomMember>,
|
private readonly classroomMemberRepository: Repository<ClassroomMember>,
|
||||||
private readonly analyticsService: AnalyticsService,
|
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
|
* 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> {
|
private async sendTeacherAlert(teacherId: string, alerts: RiskAlert[]): Promise<void> {
|
||||||
const highRiskCount = alerts.filter(a => a.risk_level === 'high').length;
|
const highRiskCount = alerts.filter(a => a.risk_level === 'high').length;
|
||||||
const mediumRiskCount = alerts.filter(a => a.risk_level === 'medium').length;
|
const mediumRiskCount = alerts.filter(a => a.risk_level === 'medium').length;
|
||||||
|
const totalAlerts = highRiskCount + mediumRiskCount;
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[NOTIFICATION] Teacher ${teacherId}: ${highRiskCount} high-risk, ${mediumRiskCount} medium-risk students`,
|
`[NOTIFICATION] Teacher ${teacherId}: ${highRiskCount} high-risk, ${mediumRiskCount} medium-risk students`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Integrate with NotificationService
|
try {
|
||||||
// Example:
|
// P0-04: Send notification via NotificationsService
|
||||||
// await this.notificationService.create({
|
await this.notificationsService.sendNotification({
|
||||||
// recipient_id: teacherId,
|
userId: teacherId,
|
||||||
// type: 'student_risk_alert',
|
type: NotificationTypeEnum.SYSTEM_ANNOUNCEMENT,
|
||||||
// title: `${highRiskCount + mediumRiskCount} estudiantes requieren atención`,
|
title: highRiskCount > 0
|
||||||
// message: this.formatAlertMessage(alerts),
|
? `⚠️ Alerta: ${totalAlerts} estudiantes requieren atención urgente`
|
||||||
// priority: highRiskCount > 0 ? 'high' : 'medium',
|
: `📊 ${totalAlerts} estudiantes requieren seguimiento`,
|
||||||
// action_url: '/teacher/alerts',
|
message: this.formatAlertMessage(alerts),
|
||||||
// metadata: { 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) {
|
for (const alert of alerts) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
` - ${alert.student_name}: ${alert.risk_level} risk, ${alert.overall_score}% score, ${alert.dropout_risk * 100}% dropout risk`,
|
` - ${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
|
* 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> {
|
private async sendAdminSummary(highRiskAlerts: RiskAlert[]): Promise<void> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[ADMIN SUMMARY] ${highRiskAlerts.length} high-risk students detected across platform`,
|
`[ADMIN SUMMARY] ${highRiskAlerts.length} high-risk students detected across platform`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Integrate with NotificationService for admins
|
try {
|
||||||
// Example:
|
// Get all super admins
|
||||||
// await this.notificationService.createForRole({
|
const admins = await this.profileRepository.find({
|
||||||
// role: GamilityRoleEnum.SUPER_ADMIN,
|
where: { role: GamilityRoleEnum.SUPER_ADMIN },
|
||||||
// type: 'platform_risk_summary',
|
});
|
||||||
// title: `Alerta: ${highRiskAlerts.length} estudiantes en alto riesgo`,
|
|
||||||
// message: this.formatAdminSummary(highRiskAlerts),
|
if (admins.length === 0) {
|
||||||
// priority: 'high'
|
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
|
// External modules
|
||||||
import { ProgressModule } from '@modules/progress/progress.module';
|
import { ProgressModule } from '@modules/progress/progress.module';
|
||||||
|
// P0-04: Added 2025-12-18 - NotificationsModule for StudentRiskAlertService
|
||||||
|
import { NotificationsModule } from '@modules/notifications/notifications.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TeacherModule
|
* TeacherModule
|
||||||
@ -124,6 +126,9 @@ import { ProgressModule } from '@modules/progress/progress.module';
|
|||||||
// Import ProgressModule for ExerciseSubmissionService (needed for reward distribution)
|
// Import ProgressModule for ExerciseSubmissionService (needed for reward distribution)
|
||||||
ProgressModule,
|
ProgressModule,
|
||||||
|
|
||||||
|
// P0-04: Import NotificationsModule for StudentRiskAlertService
|
||||||
|
NotificationsModule,
|
||||||
|
|
||||||
// Entities from 'auth' datasource
|
// Entities from 'auth' datasource
|
||||||
TypeOrmModule.forFeature([Profile, User], 'auth'),
|
TypeOrmModule.forFeature([Profile, User], 'auth'),
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,14 @@ import {
|
|||||||
import { Logger, UseGuards } from '@nestjs/common';
|
import { Logger, UseGuards } from '@nestjs/common';
|
||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
import { WsJwtGuard, AuthenticatedSocket } from './guards/ws-jwt.guard';
|
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({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
@ -175,4 +182,166 @@ implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
getUserSocketCount(userId: string): number {
|
getUserSocketCount(userId: string): number {
|
||||||
return this.userSockets.get(userId)?.size || 0;
|
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
|
// Missions
|
||||||
MISSION_COMPLETED = 'mission:completed',
|
MISSION_COMPLETED = 'mission:completed',
|
||||||
MISSION_PROGRESS = 'mission:progress',
|
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 {
|
export interface SocketUserData {
|
||||||
@ -51,3 +60,60 @@ export interface LeaderboardPayload {
|
|||||||
leaderboard: any[]; // Will be typed from gamification module
|
leaderboard: any[]; // Will be typed from gamification module
|
||||||
timestamp: string;
|
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', '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', '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 ('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 ('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, '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');
|
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 ('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 ('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 ('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 ('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);
|
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,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,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
|
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
|
,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
|
,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,
|
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,
|
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,
|
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,
|
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,
|
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', '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', '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 ('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 ('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, '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');
|
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 ('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 ('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 ('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 ('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);
|
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 auth GRANT EXECUTE ON FUNCTIONS TO gamilit_user;
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public 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
|
-- 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_attempts ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE progress_tracking.exercise_submissions ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE progress_tracking.exercise_submissions ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE progress_tracking.learning_sessions 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
|
-- Comentarios
|
||||||
COMMENT ON TABLE progress_tracking.module_progress IS 'RLS enabled: Progreso de módulos - lectura propia + teacher + admin';
|
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_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.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.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)..."
|
log_info "Cargando seeds de DEVELOPMENT (todos los datos)..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Base config
|
||||||
execute_seed "$SEED_DIR/01-achievement_categories.sql" || exit 1
|
execute_seed "$SEED_DIR/01-achievement_categories.sql" || exit 1
|
||||||
execute_seed "$SEED_DIR/02-achievements.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/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 ""
|
echo ""
|
||||||
log_success "Seeds de DEV cargados exitosamente"
|
log_success "Seeds de DEV cargados exitosamente"
|
||||||
log_info "Total de archivos: 4"
|
log_info "Total de archivos: 8+ (todos disponibles)"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
staging)
|
staging)
|
||||||
@ -138,16 +158,43 @@ case $ENV in
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
production)
|
production)
|
||||||
log_info "Cargando seeds de PRODUCTION (solo configuración esencial)..."
|
log_info "Cargando seeds de PRODUCTION (configuración completa)..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Base config
|
||||||
execute_seed "$SEED_DIR/01-achievement_categories.sql" || exit 1
|
execute_seed "$SEED_DIR/01-achievement_categories.sql" || exit 1
|
||||||
execute_seed "$SEED_DIR/02-leaderboard_metadata.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 ""
|
echo ""
|
||||||
log_success "Seeds de PRODUCTION cargados exitosamente"
|
log_success "Seeds de PRODUCTION cargados exitosamente"
|
||||||
log_info "Total de archivos: 2"
|
log_info "Total de archivos: 13 (base + shop + user data)"
|
||||||
log_warning "NOTA: No se cargaron achievements demo ni datos de prueba"
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@ SET search_path TO auth, public;
|
|||||||
-- PASSWORDS ENCRYPTED WITH BCRYPT
|
-- PASSWORDS ENCRYPTED WITH BCRYPT
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- Password: "Test1234" (todos los usuarios)
|
-- 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,
|
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid,
|
||||||
'00000000-0000-0000-0000-000000000000'::uuid,
|
'00000000-0000-0000-0000-000000000000'::uuid,
|
||||||
'admin@gamilit.com',
|
'admin@gamilit.com',
|
||||||
'$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga',
|
crypt('Test1234', gen_salt('bf', 10)),
|
||||||
gamilit.now_mexico(),
|
gamilit.now_mexico(),
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
'provider', 'email',
|
'provider', 'email',
|
||||||
@ -90,7 +90,7 @@ INSERT INTO auth.users (
|
|||||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::uuid,
|
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::uuid,
|
||||||
'00000000-0000-0000-0000-000000000000'::uuid,
|
'00000000-0000-0000-0000-000000000000'::uuid,
|
||||||
'teacher@gamilit.com',
|
'teacher@gamilit.com',
|
||||||
'$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga',
|
crypt('Test1234', gen_salt('bf', 10)),
|
||||||
gamilit.now_mexico(),
|
gamilit.now_mexico(),
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
'provider', 'email',
|
'provider', 'email',
|
||||||
@ -118,7 +118,7 @@ INSERT INTO auth.users (
|
|||||||
'cccccccc-cccc-cccc-cccc-cccccccccccc'::uuid,
|
'cccccccc-cccc-cccc-cccc-cccccccccccc'::uuid,
|
||||||
'00000000-0000-0000-0000-000000000000'::uuid,
|
'00000000-0000-0000-0000-000000000000'::uuid,
|
||||||
'student@gamilit.com',
|
'student@gamilit.com',
|
||||||
'$2b$10$pkqX0/v7H3F5TBTuDTaoYeBjH581pXpjlcNcYmMtXofd/2HjfTuga',
|
crypt('Test1234', gen_salt('bf', 10)),
|
||||||
gamilit.now_mexico(),
|
gamilit.now_mexico(),
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
'provider', 'email',
|
'provider', 'email',
|
||||||
|
|||||||
@ -15,7 +15,6 @@
|
|||||||
-- - Lote 3 (2025-12-08 y 2025-12-17): 2 usuarios
|
-- - Lote 3 (2025-12-08 y 2025-12-17): 2 usuarios
|
||||||
--
|
--
|
||||||
-- TOTAL: 44 usuarios estudiantes
|
-- TOTAL: 44 usuarios estudiantes
|
||||||
-- EXCLUIDO: rckrdmrd@gmail.com (usuario de pruebas del owner)
|
|
||||||
--
|
--
|
||||||
-- POLÍTICA DE CARGA LIMPIA:
|
-- POLÍTICA DE CARGA LIMPIA:
|
||||||
-- ✅ UUIDs originales del servidor preservados
|
-- ✅ UUIDs originales del servidor preservados
|
||||||
@ -833,9 +832,6 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RAISE NOTICE '========================================';
|
RAISE NOTICE '========================================';
|
||||||
RAISE NOTICE 'NOTA: Usuario rckrdmrd@gmail.com EXCLUIDO';
|
|
||||||
RAISE NOTICE '(Usuario de pruebas del owner)';
|
|
||||||
RAISE NOTICE '========================================';
|
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
@ -855,7 +851,7 @@ END $$;
|
|||||||
-- CHANGELOG
|
-- CHANGELOG
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- v2.0 (2025-12-18): Actualización completa desde backup producción
|
-- 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 1: 13 usuarios (2025-11-18)
|
||||||
-- - Lote 2: 23 usuarios (2025-11-24)
|
-- - Lote 2: 23 usuarios (2025-11-24)
|
||||||
-- - Lote 3: 6 usuarios (2025-11-25)
|
-- - 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)
|
-- Seed: auth_management.tenants (DEV)
|
||||||
-- Description: Tenant principal de producción
|
-- Description: Tenants de desarrollo para testing y demos
|
||||||
-- Environment: PRODUCTION
|
-- Environment: DEVELOPMENT
|
||||||
-- Dependencies: None
|
-- Dependencies: None
|
||||||
-- Order: 01
|
-- Order: 01
|
||||||
-- Created: 2025-11-11
|
-- Validated: 2025-11-02
|
||||||
-- Version: 2.0 (reescrito para carga limpia)
|
-- Score: 100/100
|
||||||
-- =====================================================
|
|
||||||
--
|
|
||||||
-- 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
|
|
||||||
--
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
SET search_path TO auth_management, public;
|
SET search_path TO auth_management, public;
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- INSERT: Tenant Principal de Producción
|
-- INSERT: Default Test Tenant
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
INSERT INTO auth_management.tenants (
|
INSERT INTO auth_management.tenants (
|
||||||
@ -42,58 +29,100 @@ INSERT INTO auth_management.tenants (
|
|||||||
metadata,
|
metadata,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (
|
) VALUES
|
||||||
-- UUID real en lugar de STRING
|
-- Tenant 1: Gamilit Test Organization
|
||||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::uuid,
|
(
|
||||||
'GAMILIT Platform',
|
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||||
'gamilit-prod', -- NUEVO: slug requerido NOT NULL
|
'Gamilit Test Organization',
|
||||||
'gamilit.com',
|
'gamilit-test',
|
||||||
'/assets/logo-gamilit.png', -- NUEVO: logo_url
|
'test.gamilit.com',
|
||||||
'enterprise', -- NUEVO: subscription_tier
|
NULL,
|
||||||
10000, -- NUEVO: max_users
|
'enterprise',
|
||||||
100, -- NUEVO: max_storage_gb
|
1000,
|
||||||
true, -- NUEVO: is_active
|
100,
|
||||||
NULL, -- NUEVO: trial_ends_at (sin trial en producción)
|
true,
|
||||||
jsonb_build_object(
|
NULL,
|
||||||
'theme', 'detective',
|
'{
|
||||||
'language', 'es',
|
"theme": "detective",
|
||||||
'timezone', 'America/Mexico_City',
|
"language": "es",
|
||||||
'features', jsonb_build_object(
|
"timezone": "America/Mexico_City",
|
||||||
'analytics_enabled', true,
|
"features": {
|
||||||
'gamification_enabled', true,
|
"analytics_enabled": true,
|
||||||
'social_features_enabled', true,
|
"gamification_enabled": true,
|
||||||
'assessments', true,
|
"social_features_enabled": true
|
||||||
'progress_tracking', true
|
}
|
||||||
),
|
}'::jsonb,
|
||||||
'limits', jsonb_build_object(
|
'{
|
||||||
'daily_api_calls', 100000,
|
"description": "Default tenant for test users",
|
||||||
'storage_gb', 100,
|
"environment": "development",
|
||||||
'max_file_size_mb', 50
|
"created_by": "seed_script"
|
||||||
),
|
}'::jsonb,
|
||||||
'contact', jsonb_build_object(
|
gamilit.now_mexico(),
|
||||||
'support_email', 'soporte@gamilit.com',
|
gamilit.now_mexico()
|
||||||
'admin_email', 'admin@gamilit.com'
|
),
|
||||||
),
|
-- Tenant 2: Demo School
|
||||||
'branding', jsonb_build_object(
|
(
|
||||||
'logo_url', '/assets/logo-gamilit.png',
|
'00000000-0000-0000-0000-000000000002'::uuid,
|
||||||
'primary_color', '#4F46E5',
|
'Demo School - Escuela Primaria',
|
||||||
'secondary_color', '#10B981'
|
'demo-school-primary',
|
||||||
)
|
'demo-primary.gamilit.com',
|
||||||
),
|
NULL,
|
||||||
jsonb_build_object( -- NUEVO: metadata
|
'professional',
|
||||||
'description', 'Tenant principal de producción',
|
500,
|
||||||
'environment', 'production',
|
50,
|
||||||
'created_by', 'seed_script_v2',
|
true,
|
||||||
'version', '2.0'
|
(gamilit.now_mexico() + INTERVAL '90 days'),
|
||||||
),
|
'{
|
||||||
gamilit.now_mexico(), -- CORREGIDO: gamilit.now_mexico() en lugar de NOW()
|
"theme": "detective",
|
||||||
gamilit.now_mexico() -- CORREGIDO: gamilit.now_mexico() en lugar de NOW()
|
"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
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
slug = EXCLUDED.slug,
|
|
||||||
domain = EXCLUDED.domain,
|
domain = EXCLUDED.domain,
|
||||||
logo_url = EXCLUDED.logo_url,
|
|
||||||
subscription_tier = EXCLUDED.subscription_tier,
|
subscription_tier = EXCLUDED.subscription_tier,
|
||||||
max_users = EXCLUDED.max_users,
|
max_users = EXCLUDED.max_users,
|
||||||
max_storage_gb = EXCLUDED.max_storage_gb,
|
max_storage_gb = EXCLUDED.max_storage_gb,
|
||||||
@ -110,52 +139,7 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
tenant_count INTEGER;
|
tenant_count INTEGER;
|
||||||
tenant_name TEXT;
|
|
||||||
tenant_slug TEXT;
|
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT COUNT(*), MAX(name), MAX(slug)
|
SELECT COUNT(*) INTO tenant_count FROM auth_management.tenants;
|
||||||
INTO tenant_count, tenant_name, tenant_slug
|
RAISE NOTICE '✓ Tenants insertados correctamente: % registros', tenant_count;
|
||||||
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;
|
|
||||||
END $$;
|
END $$;
|
||||||
|
|||||||
@ -1,34 +1,17 @@
|
|||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- Seed: auth_management.auth_providers (PROD)
|
-- Seed: auth_management.auth_providers (DEV)
|
||||||
-- Description: Configuración de proveedores de autenticación para producción
|
-- Description: Configuración de proveedores de autenticación
|
||||||
-- Environment: PRODUCTION
|
-- Environment: DEVELOPMENT
|
||||||
-- Dependencies: None
|
-- Dependencies: None
|
||||||
-- Order: 02
|
-- Order: 02
|
||||||
-- Created: 2025-11-11
|
-- Validated: 2025-11-02
|
||||||
-- Version: 2.0 (reescrito para carga limpia)
|
-- Score: 100/100
|
||||||
-- =====================================================
|
|
||||||
--
|
|
||||||
-- 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
|
|
||||||
--
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
SET search_path TO auth_management, public;
|
SET search_path TO auth_management, public;
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- INSERT: Auth Providers Configuration (PRODUCTION)
|
-- INSERT: Auth Providers Configuration
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
INSERT INTO auth_management.auth_providers (
|
INSERT INTO auth_management.auth_providers (
|
||||||
@ -48,9 +31,9 @@ INSERT INTO auth_management.auth_providers (
|
|||||||
config,
|
config,
|
||||||
metadata
|
metadata
|
||||||
) VALUES
|
) VALUES
|
||||||
-- Local Auth (email/password) - ENABLED
|
-- Local Auth (email/password)
|
||||||
(
|
(
|
||||||
'local'::auth_management.auth_provider,
|
'local',
|
||||||
'Email y Contraseña',
|
'Email y Contraseña',
|
||||||
true,
|
true,
|
||||||
NULL,
|
NULL,
|
||||||
@ -63,53 +46,47 @@ INSERT INTO auth_management.auth_providers (
|
|||||||
NULL,
|
NULL,
|
||||||
'#4F46E5',
|
'#4F46E5',
|
||||||
1,
|
1,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'requires_email_verification', true, -- PROD: email verification required
|
"requires_email_verification": false,
|
||||||
'password_min_length', 12, -- PROD: stronger password (12 vs 8)
|
"password_min_length": 8,
|
||||||
'password_requires_uppercase', true,
|
"password_requires_uppercase": true,
|
||||||
'password_requires_number', true,
|
"password_requires_number": true,
|
||||||
'password_requires_special', true,
|
"password_requires_special": true
|
||||||
'password_max_age_days', 90, -- PROD: password expiration
|
}'::jsonb,
|
||||||
'failed_login_attempts_max', 5, -- PROD: rate limiting
|
'{
|
||||||
'account_lockout_duration_minutes', 30
|
"description": "Local authentication using email and password",
|
||||||
),
|
"environment": "development"
|
||||||
jsonb_build_object(
|
}'::jsonb
|
||||||
'description', 'Local authentication using email and password',
|
|
||||||
'environment', 'production',
|
|
||||||
'security_level', 'high'
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
-- Google OAuth - ENABLED
|
-- Google OAuth (ENABLED for dev)
|
||||||
(
|
(
|
||||||
'google'::auth_management.auth_provider,
|
'google',
|
||||||
'Continuar con Google',
|
'Continuar con Google',
|
||||||
true,
|
true,
|
||||||
'GOOGLE_CLIENT_ID_PLACEHOLDER', -- ⚠️ REEMPLAZAR con valor real
|
'dev-google-client-id.apps.googleusercontent.com',
|
||||||
'GOOGLE_CLIENT_SECRET_PLACEHOLDER', -- ⚠️ REEMPLAZAR con valor real
|
'dev-google-client-secret',
|
||||||
'https://accounts.google.com/o/oauth2/v2/auth',
|
'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
'https://oauth2.googleapis.com/token',
|
'https://oauth2.googleapis.com/token',
|
||||||
'https://www.googleapis.com/oauth2/v2/userinfo',
|
'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||||
ARRAY['openid', 'profile', 'email'],
|
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',
|
'https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg',
|
||||||
'#4285F4',
|
'#4285F4',
|
||||||
10,
|
10,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'prompt', 'select_account',
|
"prompt": "select_account",
|
||||||
'access_type', 'offline',
|
"access_type": "offline"
|
||||||
'include_granted_scopes', true
|
}'::jsonb,
|
||||||
),
|
'{
|
||||||
jsonb_build_object(
|
"description": "Google OAuth authentication for development",
|
||||||
'description', 'Google OAuth authentication for production',
|
"environment": "development"
|
||||||
'environment', 'production',
|
}'::jsonb
|
||||||
'status', 'credentials_pending'
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
-- Facebook OAuth - DISABLED (pending configuration)
|
-- Facebook OAuth (DISABLED for dev)
|
||||||
(
|
(
|
||||||
'facebook'::auth_management.auth_provider,
|
'facebook',
|
||||||
'Continuar con Facebook',
|
'Continuar con Facebook',
|
||||||
false, -- DISABLED hasta configurar credentials
|
false,
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
'https://www.facebook.com/v12.0/dialog/oauth',
|
'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',
|
'https://www.facebook.com/images/fb_icon_325x325.png',
|
||||||
'#1877F2',
|
'#1877F2',
|
||||||
20,
|
20,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'fields', 'id,name,email,picture'
|
"fields": "id,name,email,picture"
|
||||||
),
|
}'::jsonb,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'description', 'Facebook OAuth authentication (disabled - pending configuration)',
|
"description": "Facebook OAuth authentication (disabled in development)",
|
||||||
'environment', 'production',
|
"environment": "development"
|
||||||
'status', 'pending_configuration'
|
}'::jsonb
|
||||||
)
|
|
||||||
),
|
),
|
||||||
-- Apple Sign In - DISABLED (pending configuration)
|
-- Apple Sign In (DISABLED for dev)
|
||||||
(
|
(
|
||||||
'apple'::auth_management.auth_provider,
|
'apple',
|
||||||
'Continuar con Apple',
|
'Continuar con Apple',
|
||||||
false, -- DISABLED hasta configurar credentials
|
false,
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
'https://appleid.apple.com/auth/authorize',
|
'https://appleid.apple.com/auth/authorize',
|
||||||
@ -144,21 +120,20 @@ INSERT INTO auth_management.auth_providers (
|
|||||||
'https://appleid.cdn-apple.com/appleid/button',
|
'https://appleid.cdn-apple.com/appleid/button',
|
||||||
'#000000',
|
'#000000',
|
||||||
15,
|
15,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'response_mode', 'form_post',
|
"response_mode": "form_post",
|
||||||
'response_type', 'code id_token'
|
"response_type": "code id_token"
|
||||||
),
|
}'::jsonb,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'description', 'Apple Sign In (disabled - pending configuration)',
|
"description": "Apple Sign In (disabled in development)",
|
||||||
'environment', 'production',
|
"environment": "development"
|
||||||
'status', 'pending_configuration'
|
}'::jsonb
|
||||||
)
|
|
||||||
),
|
),
|
||||||
-- Microsoft OAuth - DISABLED (pending configuration)
|
-- Microsoft OAuth (DISABLED for dev)
|
||||||
(
|
(
|
||||||
'microsoft'::auth_management.auth_provider,
|
'microsoft',
|
||||||
'Continuar con Microsoft',
|
'Continuar con Microsoft',
|
||||||
false, -- DISABLED hasta configurar credentials
|
false,
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
'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',
|
'https://docs.microsoft.com/en-us/azure/active-directory/develop/media/howto-add-branding-in-azure-ad-apps/ms-symbollockup_mssymbol_19.png',
|
||||||
'#00A4EF',
|
'#00A4EF',
|
||||||
30,
|
30,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'tenant', 'common'
|
"tenant": "common"
|
||||||
),
|
}'::jsonb,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'description', 'Microsoft OAuth authentication (disabled - pending configuration)',
|
"description": "Microsoft OAuth authentication (disabled in development)",
|
||||||
'environment', 'production',
|
"environment": "development"
|
||||||
'status', 'pending_configuration'
|
}'::jsonb
|
||||||
)
|
|
||||||
),
|
),
|
||||||
-- GitHub OAuth - DISABLED (not needed in production)
|
-- GitHub OAuth (ENABLED for dev)
|
||||||
(
|
(
|
||||||
'github'::auth_management.auth_provider,
|
'github',
|
||||||
'Continuar con GitHub',
|
'Continuar con GitHub',
|
||||||
false, -- DISABLED in production (developer-focused)
|
true,
|
||||||
NULL,
|
'dev-github-client-id',
|
||||||
NULL,
|
'dev-github-client-secret',
|
||||||
'https://github.com/login/oauth/authorize',
|
'https://github.com/login/oauth/authorize',
|
||||||
'https://github.com/login/oauth/access_token',
|
'https://github.com/login/oauth/access_token',
|
||||||
'https://api.github.com/user',
|
'https://api.github.com/user',
|
||||||
ARRAY['user:email', 'read:user'],
|
ARRAY['user:email', 'read:user'],
|
||||||
NULL,
|
'http://localhost:3000/auth/callback/github',
|
||||||
'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png',
|
'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png',
|
||||||
'#24292e',
|
'#24292e',
|
||||||
40,
|
40,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'allow_signup', 'true'
|
"allow_signup": "true"
|
||||||
),
|
}'::jsonb,
|
||||||
jsonb_build_object(
|
'{
|
||||||
'description', 'GitHub OAuth authentication (disabled in production - developer use only)',
|
"description": "GitHub OAuth authentication for development",
|
||||||
'environment', 'production',
|
"environment": "development"
|
||||||
'status', 'not_needed'
|
}'::jsonb
|
||||||
)
|
|
||||||
)
|
)
|
||||||
ON CONFLICT (provider_name) DO UPDATE SET
|
ON CONFLICT (provider_name) DO UPDATE SET
|
||||||
display_name = EXCLUDED.display_name,
|
display_name = EXCLUDED.display_name,
|
||||||
@ -227,53 +200,8 @@ DO $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
provider_count INTEGER;
|
provider_count INTEGER;
|
||||||
enabled_count INTEGER;
|
enabled_count INTEGER;
|
||||||
pending_credentials_count INTEGER;
|
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT COUNT(*) INTO provider_count FROM auth_management.auth_providers;
|
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 enabled_count FROM auth_management.auth_providers WHERE is_enabled = true;
|
||||||
SELECT COUNT(*) INTO pending_credentials_count
|
RAISE NOTICE '✓ Auth providers insertados: % total (% habilitados)', provider_count, enabled_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;
|
|
||||||
END $$;
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
User,
|
User,
|
||||||
@ -28,6 +29,15 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Maximize2,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Video,
|
||||||
|
Music,
|
||||||
|
Download,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAttemptDetail } from '@apps/teacher/hooks/useExerciseResponses';
|
import { useAttemptDetail } from '@apps/teacher/hooks/useExerciseResponses';
|
||||||
@ -66,26 +76,510 @@ const formatDate = (dateString: string): string => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if an exercise requires manual grading
|
* 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 requiresManualGrading = (exerciseType: string): boolean => {
|
||||||
const manualGradingTypes = [
|
const manualGradingTypes = [
|
||||||
// Módulo 3
|
// Módulo 2 - Manual
|
||||||
|
'prediccion_narrativa',
|
||||||
|
// Módulo 3 - Críticos/Argumentativos
|
||||||
|
'tribunal_opiniones',
|
||||||
'podcast_argumentativo',
|
'podcast_argumentativo',
|
||||||
// Módulo 4
|
'debate_digital',
|
||||||
'verificador_fake_news',
|
// Módulo 4 - Alfabetización Mediática (creativos)
|
||||||
'quiz_tiktok',
|
'analisis_memes', // Semi-auto but needs review
|
||||||
'analisis_memes',
|
// Módulo 5 - Creación de Contenido
|
||||||
'infografia_interactiva',
|
|
||||||
'navegacion_hipertextual',
|
|
||||||
// Módulo 5
|
|
||||||
'diario_multimedia',
|
|
||||||
'comic_digital',
|
'comic_digital',
|
||||||
'video_carta',
|
'video_carta',
|
||||||
|
'diario_multimedia',
|
||||||
|
// Auxiliares
|
||||||
|
'collage_prensa',
|
||||||
|
'call_to_action',
|
||||||
|
'texto_en_movimiento',
|
||||||
];
|
];
|
||||||
return manualGradingTypes.includes(exerciseType);
|
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
|
// SUB-COMPONENTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -379,7 +873,7 @@ export const ResponseDetailModal: React.FC<ResponseDetailModalProps> = ({
|
|||||||
{/* Answer Comparison */}
|
{/* Answer Comparison */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-bold text-gray-800">
|
<h3 className="mb-3 text-lg font-bold text-gray-800">
|
||||||
Comparación de Respuestas
|
Comparaci\u00f3n de Respuestas
|
||||||
</h3>
|
</h3>
|
||||||
<AnswerComparison
|
<AnswerComparison
|
||||||
studentAnswer={attempt.submitted_answers}
|
studentAnswer={attempt.submitted_answers}
|
||||||
@ -388,6 +882,14 @@ export const ResponseDetailModal: React.FC<ResponseDetailModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* P2-03: Multimedia Content Section */}
|
||||||
|
{hasMultimediaContent(attempt.exercise_type) && (
|
||||||
|
<MultimediaContent
|
||||||
|
answerData={attempt.submitted_answers}
|
||||||
|
exerciseType={attempt.exercise_type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
|
|||||||
@ -36,3 +36,22 @@ export type { UseAssignmentsReturn } from './useAssignments';
|
|||||||
export type { UseInterventionAlertsReturn, AlertFilters } from './useInterventionAlerts';
|
export type { UseInterventionAlertsReturn, AlertFilters } from './useInterventionAlerts';
|
||||||
export type { UseTeacherMessagesReturn, MessageFilters } from './useTeacherMessages';
|
export type { UseTeacherMessagesReturn, MessageFilters } from './useTeacherMessages';
|
||||||
export type { UseGrantBonusReturn } from './useGrantBonus';
|
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
|
* - Filtros y búsqueda
|
||||||
* - Paginación
|
* - Paginación
|
||||||
*
|
*
|
||||||
* ESTADO: Descartada para Fase 2 (no depende de actividad del estudiante)
|
* ESTADO: HABILITADO (2025-12-18)
|
||||||
* La funcionalidad está implementada pero deshabilitada temporalmente.
|
* Funcionalidad completa disponible.
|
||||||
* Cambiar SHOW_UNDER_CONSTRUCTION a false para habilitar.
|
|
||||||
*
|
*
|
||||||
* @module apps/teacher/pages/TeacherCommunicationPage
|
* @module apps/teacher/pages/TeacherCommunicationPage
|
||||||
*/
|
*/
|
||||||
@ -34,9 +33,9 @@ import { Message } from '../../../services/api/teacher/teacherMessagesApi';
|
|||||||
import { classroomsApi } from '../../../services/api/teacher/classroomsApi';
|
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
|
// TYPES
|
||||||
|
|||||||
@ -5,16 +5,15 @@ import TeacherContentManagement from './TeacherContentManagement';
|
|||||||
import { UnderConstruction } from '@shared/components/UnderConstruction';
|
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
|
* TeacherContentPage - Página de gestión de contenido educativo
|
||||||
*
|
*
|
||||||
* ESTADO: Descartada para Fase 2 (no depende de actividad del estudiante)
|
* ESTADO: HABILITADO (2025-12-18)
|
||||||
* La funcionalidad está implementada pero deshabilitada temporalmente.
|
* Funcionalidad completa disponible.
|
||||||
* Cambiar SHOW_UNDER_CONSTRUCTION a false para habilitar.
|
|
||||||
*/
|
*/
|
||||||
export default function TeacherContentPage() {
|
export default function TeacherContentPage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import { FeedbackModal } from '@shared/components/mechanics/FeedbackModal';
|
|||||||
import { MatchingCard } from './MatchingCard';
|
import { MatchingCard } from './MatchingCard';
|
||||||
import { EmparejamientoExerciseProps } from './emparejamientoTypes';
|
import { EmparejamientoExerciseProps } from './emparejamientoTypes';
|
||||||
import { calculateScore, FeedbackData } from '@shared/components/mechanics/mechanicsTypes';
|
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> = ({
|
export const EmparejamientoExercise: React.FC<EmparejamientoExerciseProps> = ({
|
||||||
exercise,
|
exercise,
|
||||||
@ -18,6 +22,10 @@ export const EmparejamientoExercise: React.FC<EmparejamientoExerciseProps> = ({
|
|||||||
const [startTime] = useState(new Date());
|
const [startTime] = useState(new Date());
|
||||||
const [hintsUsed] = useState(0);
|
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
|
// FE-055: Notify parent of progress updates WITH user answers
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (onProgressUpdate) {
|
if (onProgressUpdate) {
|
||||||
@ -91,15 +99,70 @@ export const EmparejamientoExercise: React.FC<EmparejamientoExerciseProps> = ({
|
|||||||
const isComplete = matched === total;
|
const isComplete = matched === total;
|
||||||
const score = calculateScore(matched / 2, total / 2);
|
const score = calculateScore(matched / 2, total / 2);
|
||||||
|
|
||||||
setFeedback({
|
// P0-02: Submit to backend when complete
|
||||||
type: isComplete ? 'success' : 'error',
|
if (isComplete && user?.id) {
|
||||||
title: isComplete ? '¡Completado!' : 'Faltan parejas',
|
try {
|
||||||
message: isComplete
|
// Prepare matched pairs for submission
|
||||||
? '¡Emparejaste todas las tarjetas correctamente!'
|
const matchedCards = cards.filter((c) => c.isMatched);
|
||||||
: `Emparejaste ${matched / 2} de ${total / 2} parejas.`,
|
const matchGroups: Record<string, typeof cards> = {};
|
||||||
score: isComplete ? score : undefined,
|
matchedCards.forEach((card) => {
|
||||||
showConfetti: isComplete,
|
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);
|
setShowFeedback(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,10 @@ import { MatchingDragDrop, MatchingPair } from './MatchingDragDrop';
|
|||||||
import { calculateScore, saveProgress } from '@shared/components/mechanics/mechanicsTypes';
|
import { calculateScore, saveProgress } from '@shared/components/mechanics/mechanicsTypes';
|
||||||
import { Check, RotateCcw } from 'lucide-react';
|
import { Check, RotateCcw } from 'lucide-react';
|
||||||
import type { FeedbackData } from '@shared/components/mechanics/mechanicsTypes';
|
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 {
|
export interface EmparejamientoDragDropData {
|
||||||
id: string;
|
id: string;
|
||||||
@ -57,6 +61,10 @@ export const EmparejamientoExerciseDragDrop: React.FC<EmparejamientoDragDropProp
|
|||||||
const [currentScore, setCurrentScore] = useState(0);
|
const [currentScore, setCurrentScore] = useState(0);
|
||||||
const [checkClicked, setCheckClicked] = useState(false);
|
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 handleConnect = (itemAId: string, itemBId: string) => {
|
||||||
const newConnections = new Map(connections);
|
const newConnections = new Map(connections);
|
||||||
newConnections.set(itemBId, itemAId);
|
newConnections.set(itemBId, itemAId);
|
||||||
@ -78,7 +86,7 @@ export const EmparejamientoExerciseDragDrop: React.FC<EmparejamientoDragDropProp
|
|||||||
alert(`Pista: ${hint.text}`);
|
alert(`Pista: ${hint.text}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheck = () => {
|
const handleCheck = async () => {
|
||||||
setCheckClicked(true);
|
setCheckClicked(true);
|
||||||
const allConnected = connections.size === exercise.pairs.length;
|
const allConnected = connections.size === exercise.pairs.length;
|
||||||
|
|
||||||
@ -106,15 +114,61 @@ export const EmparejamientoExerciseDragDrop: React.FC<EmparejamientoDragDropProp
|
|||||||
|
|
||||||
const isSuccess = correctCount === exercise.pairs.length;
|
const isSuccess = correctCount === exercise.pairs.length;
|
||||||
|
|
||||||
setFeedback({
|
// P0-02-B: Submit to backend when complete
|
||||||
type: isSuccess ? 'success' : 'error',
|
if (isSuccess && user?.id) {
|
||||||
title: isSuccess ? '¡Emparejamiento Completado!' : 'Revisa tus Parejas',
|
try {
|
||||||
message: isSuccess
|
// Prepare connections for submission
|
||||||
? '¡Excelente trabajo! Has emparejado todos los elementos correctamente.'
|
const matchesData = Array.from(connections.entries()).map(([itemBId, itemAId]) => ({
|
||||||
: `Has emparejado ${correctCount} de ${exercise.pairs.length} elementos correctamente. Revisa los marcados en rojo.`,
|
itemBId,
|
||||||
score: isSuccess ? score : undefined,
|
itemAId,
|
||||||
showConfetti: isSuccess,
|
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);
|
setShowFeedback(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '@/services/api/apiClient';
|
import { apiClient } from '@/services/api/apiClient';
|
||||||
|
import { handleAPIError } from '@/services/api/apiErrorHandler';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Usar Mission de @/features/gamification/missions/types/missionsTypes.ts
|
* @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)
|
* Get 3 daily missions (auto-generates if needed)
|
||||||
*/
|
*/
|
||||||
getDailyMissions: async (): Promise<Mission[]> => {
|
getDailyMissions: async (): Promise<Mission[]> => {
|
||||||
const response = await apiClient.get('/gamification/missions/daily');
|
try {
|
||||||
return response.data.data.missions;
|
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)
|
* Get 5 weekly missions (auto-generates if needed)
|
||||||
*/
|
*/
|
||||||
getWeeklyMissions: async (): Promise<Mission[]> => {
|
getWeeklyMissions: async (): Promise<Mission[]> => {
|
||||||
const response = await apiClient.get('/gamification/missions/weekly');
|
try {
|
||||||
return response.data.data.missions;
|
const response = await apiClient.get('/gamification/missions/weekly');
|
||||||
|
return response.data.data.missions;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleAPIError(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get active special missions (events)
|
* Get active special missions (events)
|
||||||
*/
|
*/
|
||||||
getSpecialMissions: async (): Promise<Mission[]> => {
|
getSpecialMissions: async (): Promise<Mission[]> => {
|
||||||
const response = await apiClient.get('/gamification/missions/special');
|
try {
|
||||||
return response.data.data.missions;
|
const response = await apiClient.get('/gamification/missions/special');
|
||||||
|
return response.data.data.missions;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleAPIError(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claim mission rewards
|
* Claim mission rewards
|
||||||
*/
|
*/
|
||||||
claimRewards: async (missionId: string) => {
|
claimRewards: async (missionId: string) => {
|
||||||
const response = await apiClient.post(`/gamification/missions/${missionId}/claim`);
|
try {
|
||||||
return response.data.data;
|
const response = await apiClient.post(`/gamification/missions/${missionId}/claim`);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleAPIError(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get mission progress
|
* Get mission progress
|
||||||
*/
|
*/
|
||||||
getMissionProgress: async (missionId: string) => {
|
getMissionProgress: async (missionId: string) => {
|
||||||
const response = await apiClient.get(`/gamification/missions/${missionId}/progress`);
|
try {
|
||||||
return response.data.data;
|
const response = await apiClient.get(`/gamification/missions/${missionId}/progress`);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleAPIError(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user mission statistics
|
* Get user mission statistics
|
||||||
*/
|
*/
|
||||||
getMissionStats: async (userId: string) => {
|
getMissionStats: async (userId: string) => {
|
||||||
const response = await apiClient.get(`/gamification/missions/stats/${userId}`);
|
try {
|
||||||
return response.data.data;
|
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 { apiClient } from './apiClient';
|
||||||
|
import { handleAPIError } from './apiErrorHandler';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -71,8 +72,12 @@ export const passwordAPI = {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
requestPasswordReset: async (email: string): Promise<PasswordResetRequestResponse> => {
|
requestPasswordReset: async (email: string): Promise<PasswordResetRequestResponse> => {
|
||||||
const response = await apiClient.post('/auth/reset-password/request', { email });
|
try {
|
||||||
return response.data;
|
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> => {
|
resetPassword: async (token: string, newPassword: string): Promise<PasswordResetResponse> => {
|
||||||
const response = await apiClient.post('/auth/reset-password', {
|
try {
|
||||||
token,
|
const response = await apiClient.post('/auth/reset-password', {
|
||||||
new_password: newPassword,
|
token,
|
||||||
});
|
new_password: newPassword,
|
||||||
return response.data;
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleAPIError(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from './apiClient';
|
import { apiClient } from './apiClient';
|
||||||
|
import { handleAPIError } from './apiErrorHandler';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -101,8 +102,12 @@ export const profileAPI = {
|
|||||||
* @returns Updated profile data
|
* @returns Updated profile data
|
||||||
*/
|
*/
|
||||||
updateProfile: async (userId: string, data: UpdateProfileDto): Promise<ProfileUpdateResponse> => {
|
updateProfile: async (userId: string, data: UpdateProfileDto): Promise<ProfileUpdateResponse> => {
|
||||||
const response = await apiClient.put(`/users/${userId}/profile`, data);
|
try {
|
||||||
return response.data;
|
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
|
* @returns User preferences data
|
||||||
*/
|
*/
|
||||||
getPreferences: async (): Promise<{ preferences: Record<string, unknown> }> => {
|
getPreferences: async (): Promise<{ preferences: Record<string, unknown> }> => {
|
||||||
const response = await apiClient.get('/users/preferences');
|
try {
|
||||||
return response.data;
|
const response = await apiClient.get('/users/preferences');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleAPIError(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -126,8 +135,12 @@ export const profileAPI = {
|
|||||||
userId: string,
|
userId: string,
|
||||||
preferences: UpdatePreferencesDto,
|
preferences: UpdatePreferencesDto,
|
||||||
): Promise<PreferencesUpdateResponse> => {
|
): Promise<PreferencesUpdateResponse> => {
|
||||||
const response = await apiClient.put(`/users/${userId}/preferences`, { preferences });
|
try {
|
||||||
return response.data;
|
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
|
* @returns Avatar URL
|
||||||
*/
|
*/
|
||||||
uploadAvatar: async (userId: string, file: File): Promise<AvatarUploadResponse> => {
|
uploadAvatar: async (userId: string, file: File): Promise<AvatarUploadResponse> => {
|
||||||
const formData = new FormData();
|
try {
|
||||||
formData.append('avatar', file);
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
|
||||||
const response = await apiClient.post(`/users/${userId}/avatar`, formData, {
|
const response = await apiClient.post(`/users/${userId}/avatar`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
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,
|
userId: string,
|
||||||
passwords: UpdatePasswordDto,
|
passwords: UpdatePasswordDto,
|
||||||
): Promise<PasswordUpdateResponse> => {
|
): Promise<PasswordUpdateResponse> => {
|
||||||
const response = await apiClient.put(`/users/${userId}/password`, passwords);
|
try {
|
||||||
return response.data;
|
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 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 {
|
interface ExerciseContentRendererProps {
|
||||||
exerciseType: string;
|
exerciseType: string;
|
||||||
@ -35,10 +35,22 @@ export const ExerciseContentRenderer: React.FC<ExerciseContentRendererProps> = (
|
|||||||
return <PodcastRenderer data={answerData} />;
|
return <PodcastRenderer data={answerData} />;
|
||||||
|
|
||||||
case 'verdadero_falso':
|
case 'verdadero_falso':
|
||||||
return <VerdaderoFalsoRenderer data={answerData} correct={correctAnswer} showComparison={showComparison} />;
|
return (
|
||||||
|
<VerdaderoFalsoRenderer
|
||||||
|
data={answerData}
|
||||||
|
correct={correctAnswer}
|
||||||
|
showComparison={showComparison}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'completar_espacios':
|
case 'completar_espacios':
|
||||||
return <CompletarEspaciosRenderer data={answerData} correct={correctAnswer} showComparison={showComparison} />;
|
return (
|
||||||
|
<CompletarEspaciosRenderer
|
||||||
|
data={answerData}
|
||||||
|
correct={correctAnswer}
|
||||||
|
showComparison={showComparison}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'crucigrama':
|
case 'crucigrama':
|
||||||
return <CrucigramaRenderer data={answerData} />;
|
return <CrucigramaRenderer data={answerData} />;
|
||||||
@ -52,22 +64,47 @@ export const ExerciseContentRenderer: React.FC<ExerciseContentRendererProps> = (
|
|||||||
case 'timeline':
|
case 'timeline':
|
||||||
return <TimelineRenderer data={answerData} />;
|
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 'lectura_inferencial':
|
||||||
case 'prediccion_narrativa':
|
|
||||||
case 'puzzle_contexto':
|
case 'puzzle_contexto':
|
||||||
case 'detective_textual':
|
case 'detective_textual':
|
||||||
case 'rueda_inferencias':
|
case 'rueda_inferencias':
|
||||||
case 'causa_efecto':
|
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 'analisis_fuentes':
|
||||||
case 'debate_digital':
|
case 'debate_digital':
|
||||||
case 'matriz_perspectivas':
|
case 'matriz_perspectivas':
|
||||||
case 'tribunal_opiniones':
|
case 'tribunal_opiniones':
|
||||||
return <TextResponseRenderer data={answerData} />;
|
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)
|
// Módulo 4 y 5 (creativos con multimedia)
|
||||||
case 'verificador_fake_news':
|
case 'verificador_fake_news':
|
||||||
case 'quiz_tiktok':
|
case 'quiz_tiktok':
|
||||||
@ -104,7 +141,7 @@ const PodcastRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data })
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg bg-purple-50 p-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" />
|
<FileText className="h-5 w-5 text-purple-600" />
|
||||||
<span className="font-semibold text-purple-800">Tema seleccionado</span>
|
<span className="font-semibold text-purple-800">Tema seleccionado</span>
|
||||||
</div>
|
</div>
|
||||||
@ -112,16 +149,16 @@ const PodcastRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-blue-50 p-4">
|
<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" />
|
<Type className="h-5 w-5 text-blue-600" />
|
||||||
<span className="font-semibold text-blue-800">Guión del Podcast</span>
|
<span className="font-semibold text-blue-800">Guión del Podcast</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-700 whitespace-pre-wrap">{script}</p>
|
<p className="whitespace-pre-wrap text-gray-700">{script}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{audioUrl && (
|
{audioUrl && (
|
||||||
<div className="rounded-lg bg-green-50 p-4">
|
<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" />
|
<Music className="h-5 w-5 text-green-600" />
|
||||||
<span className="font-semibold text-green-800">Audio del Podcast</span>
|
<span className="font-semibold text-green-800">Audio del Podcast</span>
|
||||||
</div>
|
</div>
|
||||||
@ -178,16 +215,19 @@ const VerdaderoFalsoRenderer: React.FC<{
|
|||||||
|
|
||||||
const rawCorrectAnswers = correct?.statements || correct?.answers || correct;
|
const rawCorrectAnswers = correct?.statements || correct?.answers || correct;
|
||||||
const correctAnswers: Record<string, boolean> | undefined = rawCorrectAnswers
|
const correctAnswers: Record<string, boolean> | undefined = rawCorrectAnswers
|
||||||
? Object.entries(rawCorrectAnswers as Record<string, unknown>).reduce((acc, [key, val]) => {
|
? Object.entries(rawCorrectAnswers as Record<string, unknown>).reduce(
|
||||||
if (typeof val === 'string') {
|
(acc, [key, val]) => {
|
||||||
acc[key] = val.toLowerCase() === 'true';
|
if (typeof val === 'string') {
|
||||||
} else if (typeof val === 'boolean') {
|
acc[key] = val.toLowerCase() === 'true';
|
||||||
acc[key] = val;
|
} else if (typeof val === 'boolean') {
|
||||||
} else {
|
acc[key] = val;
|
||||||
acc[key] = Boolean(val);
|
} else {
|
||||||
}
|
acc[key] = Boolean(val);
|
||||||
return acc;
|
}
|
||||||
}, {} as Record<string, boolean>)
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, boolean>,
|
||||||
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
console.log('[VerdaderoFalsoRenderer] Normalized:', { answers, correctAnswers });
|
console.log('[VerdaderoFalsoRenderer] Normalized:', { answers, correctAnswers });
|
||||||
@ -202,8 +242,8 @@ const VerdaderoFalsoRenderer: React.FC<{
|
|||||||
className={`flex items-center gap-3 rounded-lg p-3 ${
|
className={`flex items-center gap-3 rounded-lg p-3 ${
|
||||||
showComparison && isCorrect !== undefined
|
showComparison && isCorrect !== undefined
|
||||||
? isCorrect
|
? isCorrect
|
||||||
? 'bg-green-50 border border-green-200'
|
? 'border border-green-200 bg-green-50'
|
||||||
: 'bg-red-50 border border-red-200'
|
: 'border border-red-200 bg-red-50'
|
||||||
: 'bg-gray-50'
|
: 'bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -215,7 +255,7 @@ const VerdaderoFalsoRenderer: React.FC<{
|
|||||||
<span className="font-medium">Pregunta {key}:</span>
|
<span className="font-medium">Pregunta {key}:</span>
|
||||||
<span>{value ? 'Verdadero' : 'Falso'}</span>
|
<span>{value ? 'Verdadero' : 'Falso'}</span>
|
||||||
{showComparison && isCorrect === false && correctAnswers && (
|
{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'})
|
(Correcto: {correctAnswers[key] ? 'Verdadero' : 'Falso'})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -250,17 +290,15 @@ const CompletarEspaciosRenderer: React.FC<{
|
|||||||
className={`flex items-center gap-3 rounded-lg p-3 ${
|
className={`flex items-center gap-3 rounded-lg p-3 ${
|
||||||
showComparison && isCorrect !== undefined
|
showComparison && isCorrect !== undefined
|
||||||
? isCorrect
|
? isCorrect
|
||||||
? 'bg-green-50 border border-green-200'
|
? 'border border-green-200 bg-green-50'
|
||||||
: 'bg-red-50 border border-red-200'
|
: 'border border-red-200 bg-red-50'
|
||||||
: 'bg-gray-50'
|
: 'bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-gray-600">Espacio {key}:</span>
|
<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 && (
|
{showComparison && isCorrect === false && correctBlanks && (
|
||||||
<span className="text-sm text-green-600 ml-2">
|
<span className="ml-2 text-sm text-green-600">→ {correctBlanks[key]}</span>
|
||||||
→ {correctBlanks[key]}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -278,13 +316,13 @@ const CrucigramaRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-gray-50 p-4">
|
<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" />
|
<Grid3X3 className="h-5 w-5 text-gray-600" />
|
||||||
<span className="font-semibold">Palabras del Crucigrama</span>
|
<span className="font-semibold">Palabras del Crucigrama</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{Object.entries(words).map(([key, value]) => (
|
{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="text-sm text-gray-500">{key}:</span>
|
||||||
<span className="font-mono font-medium">{value}</span>
|
<span className="font-mono font-medium">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -303,13 +341,13 @@ const SopaLetrasRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-gray-50 p-4">
|
<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" />
|
<ListChecks className="h-5 w-5 text-gray-600" />
|
||||||
<span className="font-semibold">Palabras Encontradas ({foundWords.length})</span>
|
<span className="font-semibold">Palabras Encontradas ({foundWords.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{foundWords.map((word, idx) => (
|
{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}
|
{word}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@ -323,19 +361,27 @@ const SopaLetrasRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data
|
|||||||
* Muestra las conexiones entre nodos
|
* Muestra las conexiones entre nodos
|
||||||
*/
|
*/
|
||||||
const MapaConceptualRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data }) => {
|
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 (
|
return (
|
||||||
<div className="rounded-lg bg-gray-50 p-4">
|
<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">
|
<div className="space-y-2">
|
||||||
{Array.isArray(connections) ? connections.map((conn, idx) => (
|
{Array.isArray(connections) ? (
|
||||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
connections.map((conn, idx) => (
|
||||||
<span className="bg-blue-100 px-2 py-1 rounded">{conn.from || `Nodo ${idx}`}</span>
|
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||||
<span className="text-gray-400">→</span>
|
<span className="rounded bg-blue-100 px-2 py-1">{conn.from || `Nodo ${idx}`}</span>
|
||||||
<span className="bg-green-100 px-2 py-1 rounded">{conn.to || conn.label || 'conecta'}</span>
|
<span className="text-gray-400">→</span>
|
||||||
</div>
|
<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>
|
<pre className="text-sm">{JSON.stringify(data, null, 2)}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -348,20 +394,26 @@ const MapaConceptualRenderer: React.FC<{ data: Record<string, unknown> }> = ({ d
|
|||||||
* Muestra los eventos en orden cronológico
|
* Muestra los eventos en orden cronológico
|
||||||
*/
|
*/
|
||||||
const TimelineRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data }) => {
|
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 (
|
return (
|
||||||
<div className="rounded-lg bg-gray-50 p-4">
|
<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">
|
<div className="space-y-2">
|
||||||
{Array.isArray(events) ? events.map((event, idx) => (
|
{Array.isArray(events) ? (
|
||||||
<div key={idx} className="flex items-center gap-3">
|
events.map((event, idx) => (
|
||||||
<span className="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold">
|
<div key={idx} className="flex items-center gap-3">
|
||||||
{event.position || idx + 1}
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-sm font-bold text-white">
|
||||||
</span>
|
{event.position || idx + 1}
|
||||||
<span>{event.text || event.id || `Evento ${idx + 1}`}</span>
|
</span>
|
||||||
</div>
|
<span>{event.text || event.id || `Evento ${idx + 1}`}</span>
|
||||||
)) : (
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
<pre className="text-sm">{JSON.stringify(data, null, 2)}</pre>
|
<pre className="text-sm">{JSON.stringify(data, null, 2)}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
* Renderiza respuestas de ejercicios de opción múltiple
|
||||||
* Usado para ejercicios del Módulo 2 (inferenciales)
|
* Usado para ejercicios del Módulo 2 (inferenciales)
|
||||||
@ -391,14 +507,14 @@ const MultipleChoiceRenderer: React.FC<{
|
|||||||
className={`rounded-lg p-3 ${
|
className={`rounded-lg p-3 ${
|
||||||
showComparison && isCorrect !== undefined
|
showComparison && isCorrect !== undefined
|
||||||
? isCorrect
|
? isCorrect
|
||||||
? 'bg-green-50 border border-green-200'
|
? 'border border-green-200 bg-green-50'
|
||||||
: 'bg-red-50 border border-red-200'
|
: 'border border-red-200 bg-red-50'
|
||||||
: 'bg-gray-50'
|
: 'bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{key}:</span> {String(value)}
|
<span className="font-medium">{key}:</span> {String(value)}
|
||||||
{showComparison && isCorrect === false && correctAnswers && (
|
{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])})
|
(Correcto: {String(correctAnswers[key])})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -418,10 +534,10 @@ const TextResponseRenderer: React.FC<{ data: Record<string, unknown> }> = ({ dat
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Object.entries(data).map(([key, value]) => (
|
{Object.entries(data).map(([key, value]) => (
|
||||||
<div key={key} className="rounded-lg bg-gray-50 p-4">
|
<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, ' ')}
|
{key.replace(/_/g, ' ')}
|
||||||
</span>
|
</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)}
|
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -435,7 +551,10 @@ const TextResponseRenderer: React.FC<{ data: Record<string, unknown> }> = ({ dat
|
|||||||
* Usado para ejercicios de Módulos 4 y 5 (creativos)
|
* Usado para ejercicios de Módulos 4 y 5 (creativos)
|
||||||
* Detecta y renderiza imágenes, videos y audio inline
|
* 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Object.entries(data).map(([key, value]) => {
|
{Object.entries(data).map(([key, value]) => {
|
||||||
@ -447,12 +566,12 @@ const MultimediaRenderer: React.FC<{ data: Record<string, unknown>; type: string
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="rounded-lg bg-gray-50 p-4">
|
<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, ' ')}
|
{key.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{isImageUrl && typeof value === 'string' ? (
|
{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' ? (
|
) : isVideoUrl && typeof value === 'string' ? (
|
||||||
<video controls className="max-w-full rounded-lg">
|
<video controls className="max-w-full rounded-lg">
|
||||||
<source src={value} />
|
<source src={value} />
|
||||||
@ -462,9 +581,9 @@ const MultimediaRenderer: React.FC<{ data: Record<string, unknown>; type: string
|
|||||||
<source src={value} />
|
<source src={value} />
|
||||||
</audio>
|
</audio>
|
||||||
) : typeof value === 'string' ? (
|
) : 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)}
|
{JSON.stringify(value, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@ -482,7 +601,7 @@ const MultimediaRenderer: React.FC<{ data: Record<string, unknown>; type: string
|
|||||||
const FallbackRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data }) => {
|
const FallbackRenderer: React.FC<{ data: Record<string, unknown> }> = ({ data }) => {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-gray-100 p-4">
|
<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)}
|
{JSON.stringify(data, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,9 +9,9 @@
|
|||||||
| **Título** | Sistema de Rangos Maya - Especificación Técnica |
|
| **Título** | Sistema de Rangos Maya - Especificación Técnica |
|
||||||
| **Prioridad** | Alta |
|
| **Prioridad** | Alta |
|
||||||
| **Estado** | ✅ Implementado |
|
| **Estado** | ✅ Implementado |
|
||||||
| **Versión** | 2.3.0 |
|
| **Versión** | 2.4.0 |
|
||||||
| **Fecha Creación** | 2025-11-07 |
|
| **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 |
|
| **Sistema Actual** | [docs/sistema-recompensas/](../../../sistema-recompensas/) v2.3.0 |
|
||||||
| **Autor** | Backend Team |
|
| **Autor** | Backend Team |
|
||||||
| **Stakeholders** | Backend Team, Frontend Team, Database 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)
|
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).
|
> **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
|
### 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.';
|
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
|
```sql
|
||||||
-- apps/database/ddl/schemas/gamification_system/functions/check_rank_promotion.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(
|
CREATE OR REPLACE FUNCTION gamification_system.check_rank_promotion(
|
||||||
p_user_id UUID
|
p_user_id UUID
|
||||||
@ -214,7 +217,9 @@ SECURITY DEFINER -- Ejecuta con permisos del owner
|
|||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_current_rank gamification_system.maya_rank;
|
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;
|
v_promoted BOOLEAN := false;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Obtener datos actuales del usuario
|
-- Obtener datos actuales del usuario
|
||||||
@ -229,47 +234,164 @@ BEGIN
|
|||||||
RETURN false;
|
RETURN false;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Verificar promociones según rango actual
|
-- v2.1: Leer siguiente rango y umbral dinámicamente desde maya_ranks
|
||||||
CASE v_current_rank
|
SELECT mr.next_rank, next_mr.min_xp_required
|
||||||
WHEN 'Ajaw' THEN
|
INTO v_next_rank, v_next_rank_min_xp
|
||||||
IF v_total_xp >= 500 THEN
|
FROM gamification_system.maya_ranks mr
|
||||||
PERFORM gamification_system.promote_to_next_rank(p_user_id, 'Nacom');
|
LEFT JOIN gamification_system.maya_ranks next_mr
|
||||||
v_promoted := true;
|
ON next_mr.rank_name = mr.next_rank
|
||||||
END IF;
|
WHERE mr.rank_name = v_current_rank
|
||||||
|
AND mr.is_active = true;
|
||||||
|
|
||||||
WHEN 'Nacom' THEN
|
-- Si no hay siguiente rango (ya está en máximo), no promocionar
|
||||||
IF v_total_xp >= 1000 THEN
|
IF v_next_rank IS NULL THEN
|
||||||
PERFORM gamification_system.promote_to_next_rank(p_user_id, 'Ah K''in');
|
RETURN false;
|
||||||
v_promoted := true;
|
END IF;
|
||||||
END IF;
|
|
||||||
|
|
||||||
WHEN 'Ah K''in' THEN
|
-- Verificar si el usuario tiene suficiente XP para el siguiente rango
|
||||||
IF v_total_xp >= 1500 THEN
|
IF v_total_xp >= v_next_rank_min_xp THEN
|
||||||
PERFORM gamification_system.promote_to_next_rank(p_user_id, 'Halach Uinic');
|
PERFORM gamification_system.promote_to_next_rank(p_user_id, v_next_rank);
|
||||||
v_promoted := true;
|
v_promoted := true;
|
||||||
END IF;
|
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;
|
|
||||||
|
|
||||||
RETURN v_promoted;
|
RETURN v_promoted;
|
||||||
END;
|
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.
|
'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.
|
Retorna true si el usuario fue promovido, false en caso contrario.
|
||||||
Se ejecuta automáticamente mediante trigger después de actualizar total_xp.';
|
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
|
### 4. Función: promote_to_next_rank
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@ -602,12 +724,13 @@ export const RANK_ORDER = [
|
|||||||
MayaRankEnum.KUKULKAN,
|
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 }> = {
|
export const RANK_THRESHOLDS: Record<MayaRankEnum, { min: number; max: number | null }> = {
|
||||||
[MayaRankEnum.AJAW]: { min: 0, max: 499 },
|
[MayaRankEnum.AJAW]: { min: 0, max: 499 },
|
||||||
[MayaRankEnum.NACOM]: { min: 500, max: 999 },
|
[MayaRankEnum.NACOM]: { min: 500, max: 999 },
|
||||||
[MayaRankEnum.AH_KIN]: { min: 1000, max: 1499 },
|
[MayaRankEnum.AH_KIN]: { min: 1000, max: 1499 },
|
||||||
[MayaRankEnum.HALACH_UINIC]: { min: 1500, max: 2249 },
|
[MayaRankEnum.HALACH_UINIC]: { min: 1500, max: 1899 }, // v2.1: max reducido de 2249 a 1899
|
||||||
[MayaRankEnum.KUKULKAN]: { min: 2250, max: null }, // Sin límite superior
|
[MayaRankEnum.KUKULKAN]: { min: 1900, max: null }, // v2.1: min reducido de 2250 a 1900
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RANK_MULTIPLIERS: Record<MayaRankEnum, number> = {
|
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