Initial commit: Plataforma de Economía
Features: - React 18 + TypeScript frontend with Vite - Go + Gin backend API - PostgreSQL database - JWT authentication with refresh tokens - User management (admin panel) - Docker containerization - Progress tracking system - 4 economic modules structure Fixed: - Login with username or email - User creation without required email - Database nullable timestamps - API response field naming
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Database
|
||||||
|
DB_USER=econ_user
|
||||||
|
DB_PASSWORD=change_this_password_in_production
|
||||||
|
DB_NAME=econ_db
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production-min-32-chars
|
||||||
|
|
||||||
|
# Server
|
||||||
|
SERVER_PORT=8080
|
||||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Dependencies
|
||||||
|
frontend/node_modules/
|
||||||
|
backend/vendor/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
frontend/dist/
|
||||||
|
backend/server
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
*.cover
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
113
README.md
Normal file
113
README.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Plataforma de Aprendizaje de Economía
|
||||||
|
|
||||||
|
## 📚 Descripción
|
||||||
|
Plataforma web interactiva para enseñar economía a través de 4 módulos basados en material académico PDF.
|
||||||
|
|
||||||
|
## 🎯 Objetivo
|
||||||
|
Crear una experiencia de aprendizaje gamificada con ejercicios interactivos, visualizaciones dinámicas y seguimiento de progreso.
|
||||||
|
|
||||||
|
## 📖 Módulos Educativos
|
||||||
|
|
||||||
|
### Módulo 1: Fundamentos de Economía
|
||||||
|
- **Temas**: Definición de economía, agentes económicos, factores de producción, flujo circular
|
||||||
|
- **Ejercicios**: Simulador de disyuntivas, Quiz de clasificación de bienes, Juego del flujo circular
|
||||||
|
|
||||||
|
### Módulo 2: Oferta, Demanda y Equilibrio
|
||||||
|
- **Temas**: Curvas de oferta/demanda, equilibrio de mercado, controles de precios
|
||||||
|
- **Ejercicios**: Constructor de curvas interactivo, Simulador de precios máximos/mínimos
|
||||||
|
|
||||||
|
### Módulo 3: Utilidad y Elasticidad
|
||||||
|
- **Temas**: Utilidad marginal, elasticidades, clasificación de bienes
|
||||||
|
- **Ejercicios**: Calculadora de elasticidad, Ejercicios tipo examen, Clasificador de bienes
|
||||||
|
|
||||||
|
### Módulo 4: Teoría del Productor
|
||||||
|
- **Temas**: Costos, producción, competencia perfecta, maximización de beneficios
|
||||||
|
- **Ejercicios**: Simulador de decisión de producción, Calculadora de costos
|
||||||
|
|
||||||
|
## 🏗️ Arquitectura Técnica
|
||||||
|
|
||||||
|
### Stack Tecnológico
|
||||||
|
- **Frontend**: React 18 + TypeScript + Tailwind CSS
|
||||||
|
- **Visualización**: D3.js + Recharts
|
||||||
|
- **Estado**: Zustand
|
||||||
|
- **Routing**: React Router v6
|
||||||
|
- **Build**: Vite
|
||||||
|
- **Container**: Docker + Docker Compose
|
||||||
|
|
||||||
|
### Estructura de Carpetas
|
||||||
|
```
|
||||||
|
econ-learning/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Componentes reutilizables
|
||||||
|
│ │ ├── charts/ # Gráficos interactivos
|
||||||
|
│ │ ├── exercises/ # Ejercicios específicos
|
||||||
|
│ │ └── ui/ # Componentes UI base
|
||||||
|
│ ├── modules/ # Módulos educativos
|
||||||
|
│ │ ├── clase1/
|
||||||
|
│ │ ├── clase2/
|
||||||
|
│ │ ├── clase3/
|
||||||
|
│ │ └── clase4/
|
||||||
|
│ ├── hooks/ # Custom hooks
|
||||||
|
│ ├── stores/ # Estado global
|
||||||
|
│ └── utils/ # Utilidades
|
||||||
|
├── public/ # Assets estáticos
|
||||||
|
├── docker/ # Configuración Docker
|
||||||
|
└── docs/ # Documentación técnica
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Instrucciones de Despliegue
|
||||||
|
|
||||||
|
### Desarrollo Local
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Iniciar servidor de desarrollo
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Producción con Docker
|
||||||
|
```bash
|
||||||
|
# Construir imagen
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Ver logs
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Roadmap
|
||||||
|
|
||||||
|
### Fase 1: Fundamentos (Semana 1-2)
|
||||||
|
- [ ] Setup del proyecto con Vite + React + TS
|
||||||
|
- [ ] Configuración de Docker
|
||||||
|
- [ ] Componentes base UI
|
||||||
|
- [ ] Estructura de routing
|
||||||
|
|
||||||
|
### Fase 2: Módulo 1 (Semana 3)
|
||||||
|
- [ ] Contenido teórico del Módulo 1
|
||||||
|
- [ ] Simulador de disyuntivas
|
||||||
|
- [ ] Quiz de clasificación de bienes
|
||||||
|
- [ ] Juego del flujo circular
|
||||||
|
|
||||||
|
### Fase 3: Módulo 2 (Semana 4)
|
||||||
|
- [ ] Constructor de curvas interactivo
|
||||||
|
- [ ] Simulador de precios
|
||||||
|
- [ ] Ejercicios de equilibrio
|
||||||
|
|
||||||
|
### Fase 4: Módulos 3-4 (Semana 5-6)
|
||||||
|
- [ ] Calculadora de elasticidad
|
||||||
|
- [ ] Simulador de costos
|
||||||
|
- [ ] Sistema de puntuación
|
||||||
|
|
||||||
|
### Fase 5: Pulido (Semana 7)
|
||||||
|
- [ ] Tests
|
||||||
|
- [ ] Optimización de rendimiento
|
||||||
|
- [ ] Documentación final
|
||||||
|
|
||||||
|
## 🔧 Requisitos del Sistema
|
||||||
|
- Node.js 18+
|
||||||
|
- Docker (opcional)
|
||||||
|
- Navegador moderno con soporte ES6+
|
||||||
|
|
||||||
|
## 📄 Licencia
|
||||||
|
Proyecto educativo personal.
|
||||||
168
TECH_SPECS.md
Normal file
168
TECH_SPECS.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Especificaciones Técnicas - Plataforma Economía
|
||||||
|
|
||||||
|
## 1. Arquitectura Frontend
|
||||||
|
|
||||||
|
### Tecnologías Principales
|
||||||
|
- **Framework**: React 18.2+
|
||||||
|
- **Lenguaje**: TypeScript 5.0+
|
||||||
|
- **Styling**: Tailwind CSS 3.4+
|
||||||
|
- **Build Tool**: Vite 5.0+
|
||||||
|
- **Package Manager**: npm
|
||||||
|
|
||||||
|
### Dependencias Clave
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"d3": "^7.8.0",
|
||||||
|
"recharts": "^2.10.0",
|
||||||
|
"zustand": "^4.4.0",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"framer-motion": "^10.16.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Componentes Interactivos Planificados
|
||||||
|
|
||||||
|
### 2.1 GraficoCurva (Módulos 2, 3, 4)
|
||||||
|
```typescript
|
||||||
|
interface GraficoCurvaProps {
|
||||||
|
tipo: 'oferta' | 'demanda' | 'equilibrio' | 'costos';
|
||||||
|
datos: Punto[];
|
||||||
|
interactivo: boolean;
|
||||||
|
onPuntoClick?: (punto: Punto) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 SimuladorPrecios (Módulo 2)
|
||||||
|
- Sliders para ajustar curvas
|
||||||
|
- Visualización de excedentes
|
||||||
|
- Animaciones de transición
|
||||||
|
|
||||||
|
### 2.3 CalculadoraElasticidad (Módulo 3)
|
||||||
|
- Inputs para valores Q1, Q2, P1, P2
|
||||||
|
- Cálculo automático con fórmula
|
||||||
|
- Visualización del resultado
|
||||||
|
|
||||||
|
### 2.4 JuegoFlujoCircular (Módulo 1)
|
||||||
|
- Drag & drop de elementos
|
||||||
|
- Conexiones entre agentes económicos
|
||||||
|
- Validación de respuestas
|
||||||
|
|
||||||
|
## 3. Estructura de Estado
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AppState {
|
||||||
|
progreso: {
|
||||||
|
modulo1: { completado: number; ejercicios: boolean[] };
|
||||||
|
modulo2: { completado: number; ejercicios: boolean[] };
|
||||||
|
modulo3: { completado: number; ejercicios: boolean[] };
|
||||||
|
modulo4: { completado: number; ejercicios: boolean[] };
|
||||||
|
};
|
||||||
|
usuario: {
|
||||||
|
nombre: string;
|
||||||
|
puntuacion: number;
|
||||||
|
logros: string[];
|
||||||
|
};
|
||||||
|
preferencias: {
|
||||||
|
modoOscuro: boolean;
|
||||||
|
notificaciones: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Configuración Docker
|
||||||
|
|
||||||
|
### Dockerfile
|
||||||
|
```dockerfile
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
```
|
||||||
|
|
||||||
|
### docker-compose.yml
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
econ-learning:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Rutas de la Aplicación
|
||||||
|
|
||||||
|
```
|
||||||
|
/ → Landing page
|
||||||
|
/modulos → Lista de módulos
|
||||||
|
/modulo/1 → Módulo 1: Fundamentos
|
||||||
|
/modulo/2 → Módulo 2: Oferta/Demanda
|
||||||
|
/modulo/3 → Módulo 3: Elasticidad
|
||||||
|
/modulo/4 → Módulo 4: Productor
|
||||||
|
/ejercicios/:id → Ejercicio específico
|
||||||
|
/progreso → Dashboard de progreso
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Diseño UI/UX
|
||||||
|
|
||||||
|
### Paleta de Colores
|
||||||
|
- Primary: #2563eb (Azul)
|
||||||
|
- Secondary: #7c3aed (Violeta)
|
||||||
|
- Success: #10b981 (Verde)
|
||||||
|
- Warning: #f59e0b (Naranja)
|
||||||
|
- Error: #ef4444 (Rojo)
|
||||||
|
- Background: #f8fafc (Gris claro)
|
||||||
|
- Surface: #ffffff (Blanco)
|
||||||
|
|
||||||
|
### Tipografía
|
||||||
|
- **Headings**: Inter, 600-700 weight
|
||||||
|
- **Body**: Inter, 400 weight
|
||||||
|
- **Monospace**: JetBrains Mono (para fórmulas)
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
- Mobile: < 640px
|
||||||
|
- Tablet: 640px - 1024px
|
||||||
|
- Desktop: > 1024px
|
||||||
|
|
||||||
|
## 7. Optimizaciones Planificadas
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Lazy loading de módulos
|
||||||
|
- Code splitting por ruta
|
||||||
|
- Virtualización de listas largas
|
||||||
|
- Caché de assets con service worker
|
||||||
|
|
||||||
|
### Accesibilidad
|
||||||
|
- ARIA labels en elementos interactivos
|
||||||
|
- Soporte para navegación por teclado
|
||||||
|
- Contraste WCAG AA
|
||||||
|
- Screen reader compatible
|
||||||
|
|
||||||
|
## 8. Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (Vitest)
|
||||||
|
- Lógica de cálculos económicos
|
||||||
|
- Hooks personalizados
|
||||||
|
- Utilidades
|
||||||
|
|
||||||
|
### Integration Tests (React Testing Library)
|
||||||
|
- Flujo de navegación
|
||||||
|
- Interacción con gráficos
|
||||||
|
- Formularios
|
||||||
|
|
||||||
|
### E2E Tests (Playwright)
|
||||||
|
- Rutas críticas
|
||||||
|
- Ejercicios completos
|
||||||
|
- Persistencia de datos
|
||||||
117
TODO.md
Normal file
117
TODO.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Plan de Desarrollo - Plataforma Economía
|
||||||
|
|
||||||
|
## ✅ Completado
|
||||||
|
- [x] Extracción de contenido de los 4 PDFs
|
||||||
|
- [x] Análisis de temas y ejercicios potenciales
|
||||||
|
- [x] Creación de carpeta econ en VPS
|
||||||
|
- [x] Subida de PDFs al servidor
|
||||||
|
- [x] Documentación inicial del proyecto
|
||||||
|
|
||||||
|
## 📋 Tareas Pendientes
|
||||||
|
|
||||||
|
### Fase 1: Setup Inicial
|
||||||
|
- [ ] Inicializar proyecto Vite + React + TypeScript
|
||||||
|
- [ ] Configurar Tailwind CSS
|
||||||
|
- [ ] Instalar dependencias principales
|
||||||
|
- [ ] Configurar estructura de carpetas
|
||||||
|
- [ ] Setup de ESLint + Prettier
|
||||||
|
- [ ] Crear Dockerfile
|
||||||
|
- [ ] Crear docker-compose.yml
|
||||||
|
- [ ] Configurar Nginx
|
||||||
|
|
||||||
|
### Fase 2: Componentes Base
|
||||||
|
- [ ] Crear Layout principal con navegación
|
||||||
|
- [ ] Componente Button con variantes
|
||||||
|
- [ ] Componente Card para contenido
|
||||||
|
- [ ] Sistema de progreso/visualización
|
||||||
|
- [ ] Tema claro/oscuro
|
||||||
|
- [ ] Componente Quiz base
|
||||||
|
- [ ] Componente de feedback (correcto/incorrecto)
|
||||||
|
|
||||||
|
### Fase 3: Módulo 1 - Fundamentos
|
||||||
|
- [ ] Página de introducción
|
||||||
|
- [ ] Contenido: Definición de economía
|
||||||
|
- [ ] Contenido: Agentes económicos
|
||||||
|
- [ ] Contenido: Factores de producción
|
||||||
|
- [ ] Contenido: Flujo circular
|
||||||
|
- [ ] Ejercicio 1: Simulador de disyuntivas
|
||||||
|
- [ ] Ejercicio 2: Quiz de bienes (normal/inferior/etc)
|
||||||
|
- [ ] Ejercicio 3: Juego del flujo circular (drag & drop)
|
||||||
|
- [ ] Test del módulo
|
||||||
|
|
||||||
|
### Fase 4: Módulo 2 - Oferta/Demanda
|
||||||
|
- [ ] Página de introducción
|
||||||
|
- [ ] Contenido: Ley de la demanda
|
||||||
|
- [ ] Contenido: Ley de la oferta
|
||||||
|
- [ ] Contenido: Equilibrio de mercado
|
||||||
|
- [ ] Contenido: Precios máximos y mínimos
|
||||||
|
- [ ] Ejercicio 1: Constructor de curvas
|
||||||
|
- [ ] Ejercicio 2: Simulador de precios intervenidos
|
||||||
|
- [ ] Ejercicio 3: Identificar shocks (¿qué curva se mueve?)
|
||||||
|
- [ ] Test del módulo
|
||||||
|
|
||||||
|
### Fase 5: Módulo 3 - Elasticidad
|
||||||
|
- [ ] Página de introducción
|
||||||
|
- [ ] Contenido: Tipos de elasticidad
|
||||||
|
- [ ] Contenido: Fórmulas y cálculos
|
||||||
|
- [ ] Ejercicio 1: Calculadora de elasticidad paso a paso
|
||||||
|
- [ ] Ejercicio 2: Clasificar bienes según elasticidad
|
||||||
|
- [ ] Ejercicio 3: Ejercicios tipo examen
|
||||||
|
- [ ] Test del módulo
|
||||||
|
|
||||||
|
### Fase 6: Módulo 4 - Productor
|
||||||
|
- [ ] Página de introducción
|
||||||
|
- [ ] Contenido: Costos y producción
|
||||||
|
- [ ] Contenido: Competencia perfecta
|
||||||
|
- [ ] Ejercicio 1: Simulador de decisión de producción
|
||||||
|
- [ ] Ejercicio 2: Calculadora de costos
|
||||||
|
- [ ] Ejercicio 3: Visualización de excedentes
|
||||||
|
- [ ] Test del módulo
|
||||||
|
|
||||||
|
### Fase 7: Gamificación
|
||||||
|
- [ ] Sistema de puntuación
|
||||||
|
- [ ] Logros/badges
|
||||||
|
- [ ] Barra de progreso global
|
||||||
|
- [ ] Dashboard de estadísticas
|
||||||
|
- [ ] Ranking (opcional)
|
||||||
|
|
||||||
|
### Fase 8: Testing y Optimización
|
||||||
|
- [ ] Tests unitarios (Vitest)
|
||||||
|
- [ ] Tests de integración
|
||||||
|
- [ ] Tests E2E con Playwright
|
||||||
|
- [ ] Optimización de imágenes/assets
|
||||||
|
- [ ] Performance audit (Lighthouse)
|
||||||
|
- [ ] Accesibilidad audit
|
||||||
|
|
||||||
|
### Fase 9: Deploy
|
||||||
|
- [ ] Configurar dominio (si aplica)
|
||||||
|
- [ ] SSL/TLS
|
||||||
|
- [ ] CI/CD pipeline
|
||||||
|
- [ ] Backup automático
|
||||||
|
- [ ] Monitoreo
|
||||||
|
|
||||||
|
## 🎯 Prioridades
|
||||||
|
|
||||||
|
### Alta Prioridad
|
||||||
|
1. Setup inicial y estructura base
|
||||||
|
2. Módulo 1 completamente funcional
|
||||||
|
3. Componentes de gráficos interactivos
|
||||||
|
4. Docker funcionando
|
||||||
|
|
||||||
|
### Media Prioridad
|
||||||
|
5. Módulos 2, 3 y 4
|
||||||
|
6. Sistema de progreso
|
||||||
|
7. Tests básicos
|
||||||
|
|
||||||
|
### Baja Prioridad
|
||||||
|
8. Gamificación avanzada
|
||||||
|
9. Tests E2E completos
|
||||||
|
10. Optimizaciones finales
|
||||||
|
|
||||||
|
## 📝 Notas
|
||||||
|
|
||||||
|
- Usar D3.js para gráficos complejos (curvas personalizables)
|
||||||
|
- Usar Recharts para gráficos simples (barras, líneas)
|
||||||
|
- Framer Motion para animaciones suaves
|
||||||
|
- Zustand para estado global simple
|
||||||
|
- LocalStorage para persistencia de progreso (fase 1)
|
||||||
33
backend/Dockerfile
Normal file
33
backend/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install ca-certificates for HTTPS
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /server .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run the binary
|
||||||
|
CMD ["./server"]
|
||||||
259
backend/cmd/server/main.go
Normal file
259
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
|
"github.com/ren/econ/backend/internal/config"
|
||||||
|
"github.com/ren/econ/backend/internal/handlers"
|
||||||
|
"github.com/ren/econ/backend/internal/middleware"
|
||||||
|
"github.com/ren/econ/backend/internal/repository"
|
||||||
|
"github.com/ren/econ/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load .env file if exists
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// Connect to PostgreSQL
|
||||||
|
connStr := fmt.Sprintf(
|
||||||
|
"postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||||
|
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPool, err := pgxpool.New(ctx, connStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error connecting to database: %v", err)
|
||||||
|
}
|
||||||
|
defer dbPool.Close()
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
if err := dbPool.Ping(ctx); err != nil {
|
||||||
|
log.Fatalf("Error pinging database: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Connected to PostgreSQL")
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
runMigrations(ctx, dbPool)
|
||||||
|
|
||||||
|
// Initialize repositories
|
||||||
|
userRepo := repository.NewUserRepository(dbPool)
|
||||||
|
progresoRepo := repository.NewProgresoRepository(dbPool)
|
||||||
|
contenidoRepo := repository.NewContenidoRepository(dbPool)
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
authService := services.NewAuthService(cfg, userRepo)
|
||||||
|
|
||||||
|
// Initialize handlers
|
||||||
|
authHandler := handlers.NewAuthHandler(authService)
|
||||||
|
usersHandler := handlers.NewUsersHandler(userRepo, progresoRepo, authService)
|
||||||
|
progresoHandler := handlers.NewProgresoHandler(progresoRepo)
|
||||||
|
contenidoHandler := handlers.NewContenidoHandler(contenidoRepo)
|
||||||
|
|
||||||
|
// Setup Gin router
|
||||||
|
router := gin.Default()
|
||||||
|
|
||||||
|
// CORS middleware
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
router.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
// Auth routes (public)
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
{
|
||||||
|
auth.POST("/login", authHandler.Login)
|
||||||
|
auth.POST("/refresh", authHandler.RefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(middleware.AuthMiddleware(authService))
|
||||||
|
{
|
||||||
|
// Auth logout
|
||||||
|
auth.POST("/auth/logout", authHandler.Logout)
|
||||||
|
|
||||||
|
// Progreso routes
|
||||||
|
progreso := protected.Group("/progreso")
|
||||||
|
{
|
||||||
|
progreso.GET("", progresoHandler.GetProgreso)
|
||||||
|
progreso.GET("/modulo/:numero", progresoHandler.GetProgresoModulo)
|
||||||
|
progreso.PUT("/:ejercicioId", progresoHandler.UpdateProgreso)
|
||||||
|
progreso.GET("/resumen", progresoHandler.GetResumen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contenido routes
|
||||||
|
contenido := protected.Group("/contenido")
|
||||||
|
{
|
||||||
|
contenido.GET("/modulos", contenidoHandler.GetModulos)
|
||||||
|
contenido.GET("/modulos/:numero", contenidoHandler.GetModulo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin routes (requires admin role)
|
||||||
|
admin := protected.Group("/admin")
|
||||||
|
admin.Use(middleware.AdminMiddleware())
|
||||||
|
{
|
||||||
|
admin.GET("/usuarios", usersHandler.ListUsers)
|
||||||
|
admin.POST("/usuarios", usersHandler.CreateUser)
|
||||||
|
admin.GET("/usuarios/:id", usersHandler.GetUser)
|
||||||
|
admin.PUT("/usuarios/:id", usersHandler.UpdateUser)
|
||||||
|
admin.DELETE("/usuarios/:id", usersHandler.DeleteUser)
|
||||||
|
admin.GET("/usuarios/:id/progreso", usersHandler.GetUserProgreso)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
port := getEnv("PORT", "8080")
|
||||||
|
log.Printf("Server starting on port %s", port)
|
||||||
|
if err := router.Run(":" + port); err != nil {
|
||||||
|
log.Fatalf("Error starting server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrations(ctx context.Context, dbPool *pgxpool.Pool) {
|
||||||
|
// Inline migrations
|
||||||
|
migrations := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS usuarios (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
nombre VARCHAR(100) NOT NULL,
|
||||||
|
rol VARCHAR(20) DEFAULT 'estudiante' CHECK (rol IN ('admin', 'estudiante')),
|
||||||
|
creado_en TIMESTAMP DEFAULT NOW(),
|
||||||
|
ultimo_login TIMESTAMP,
|
||||||
|
activo BOOLEAN DEFAULT TRUE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS progreso_usuario (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
usuario_id UUID REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||||
|
modulo_numero INTEGER NOT NULL CHECK (modulo_numero BETWEEN 1 AND 4),
|
||||||
|
ejercicio_id VARCHAR(50) NOT NULL,
|
||||||
|
completado BOOLEAN DEFAULT FALSE,
|
||||||
|
puntuacion INTEGER DEFAULT 0,
|
||||||
|
intentos INTEGER DEFAULT 0,
|
||||||
|
ultima_vez TIMESTAMP DEFAULT NOW(),
|
||||||
|
respuesta_json JSONB,
|
||||||
|
UNIQUE(usuario_id, modulo_numero, ejercicio_id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS ejercicios (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
modulo_numero INTEGER NOT NULL,
|
||||||
|
titulo VARCHAR(200) NOT NULL,
|
||||||
|
tipo VARCHAR(50) NOT NULL,
|
||||||
|
contenido JSONB NOT NULL,
|
||||||
|
orden INTEGER DEFAULT 0
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
usuario_id UUID REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
expira TIMESTAMP NOT NULL,
|
||||||
|
creado_en TIMESTAMP DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
if _, err := dbPool.Exec(ctx, m); err != nil {
|
||||||
|
log.Printf("Migration warning: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed ejercicios if empty
|
||||||
|
var count int
|
||||||
|
dbPool.QueryRow(ctx, "SELECT COUNT(*) FROM ejercicios").Scan(&count)
|
||||||
|
if count == 0 {
|
||||||
|
seedEjercicios(ctx, dbPool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin user if not exists
|
||||||
|
var adminCount int
|
||||||
|
dbPool.QueryRow(ctx, "SELECT COUNT(*) FROM usuarios WHERE rol = 'admin'").Scan(&adminCount)
|
||||||
|
if adminCount == 0 {
|
||||||
|
// Default admin: renato97 / wlillidan1 (bcrypt hash)
|
||||||
|
_, err := dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO usuarios (email, username, password_hash, nombre, rol)
|
||||||
|
VALUES ('renato97@econ.local', 'renato97', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.rsW4WzOFbMB3dHI.Bu', 'Renato', 'admin')
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: could not create admin user: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Admin user created: renato97 / wlillidan1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Migrations completed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedEjercicios(ctx context.Context, pool *pgxpool.Pool) {
|
||||||
|
ejercicios := []struct {
|
||||||
|
ID string
|
||||||
|
ModuloNumero int
|
||||||
|
Titulo string
|
||||||
|
Tipo string
|
||||||
|
Contenido string
|
||||||
|
Orden int
|
||||||
|
}{
|
||||||
|
// Módulo 1
|
||||||
|
{"m1e1", 1, "Simulador de Disyuntivas", "interactivo", `{"tipo":"slider","descripcion":"Elige cuanto producir de cada bien"}`, 1},
|
||||||
|
{"m1e2", 1, "Clasificación de Bienes", "quiz", `{"preguntas":[{"id":"q1","pregunta":"La leche es un bien...","opciones":["normal","inferior","de lujo"],"respuesta":"normal"}]}`, 2},
|
||||||
|
{"m1e3", 1, "Flujo Circular", "dragdrop", `{"agentes":["familias","empresas"]}`, 3},
|
||||||
|
// Módulo 2
|
||||||
|
{"m2e1", 2, "Constructor de Curvas", "interactivo", `{"curvas":["oferta","demanda"]}`, 1},
|
||||||
|
{"m2e2", 2, "Precios Máximos y Mínimos", "simulador", `{"tipo_precio":"seleccionar"}`, 2},
|
||||||
|
{"m2e3", 2, "Shocks de Mercado", "quiz", `{"escenarios":[{"id":"s1","evento":"Sequía","pregunta":"¿Qué curva se ve afectada?","respuesta":"oferta"}]}`, 3},
|
||||||
|
// Módulo 3
|
||||||
|
{"m3e1", 3, "Calculadora de Elasticidad", "calculadora", `{"formula":"elasticidad"}`, 1},
|
||||||
|
{"m3e2", 3, "Clasificador de Bienes", "quiz", `{"bienes":[{"nombre":"Pan","elasticidad":"inelastico"}]}`, 2},
|
||||||
|
{"m3e3", 3, "Ejercicios tipo Examen", "quiz", `{"preguntas":[]}`, 3},
|
||||||
|
// Módulo 4
|
||||||
|
{"m4e1", 4, "Simulador de Producción", "simulador", `{"max_produccion":100}`, 1},
|
||||||
|
{"m4e2", 4, "Calculadora de Costos", "calculadora", `{"tipos":["fijo","variable"]}`, 2},
|
||||||
|
{"m4e3", 4, "Excedentes", "interactivo", `{"excedentes":["productor","consumidor"]}`, 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range ejercicios {
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO ejercicios (id, modulo_numero, titulo, tipo, contenido, orden)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
`, e.ID, e.ModuloNumero, e.Titulo, e.Tipo, e.Contenido, e.Orden)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: could not seed ejercicio %s: %v", e.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Println("Ejercicios seeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
if value, exists := os.LookupEnv(key); exists {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
44
backend/go.mod
Normal file
44
backend/go.mod
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
module github.com/ren/econ/backend
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
|
github.com/google/uuid v1.5.0
|
||||||
|
github.com/jackc/pgx/v5 v5.5.2
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
golang.org/x/crypto v0.18.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
|
golang.org/x/net v0.10.0 // indirect
|
||||||
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
|
golang.org/x/sys v0.26.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
110
backend/go.sum
Normal file
110
backend/go.sum
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
|
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||||
|
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||||
|
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.2 h1:iLlpgp4Cp/gC9Xuscl7lFL1PhhW+ZLtXZcrfCt4C3tA=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.2/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||||
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||||
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
|
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||||
|
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||||
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
47
backend/internal/config/config.go
Normal file
47
backend/internal/config/config.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DBHost string
|
||||||
|
DBPort int
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
|
||||||
|
JWTSecret string
|
||||||
|
JWTExpirationHours int
|
||||||
|
RefreshExpDays int
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
return &Config{
|
||||||
|
DBHost: getEnv("DB_HOST", "localhost"),
|
||||||
|
DBPort: getEnvAsInt("DB_PORT", 5432),
|
||||||
|
DBUser: getEnv("DB_USER", "econ_user"),
|
||||||
|
DBPassword: getEnv("DB_PASSWORD", "econ_pass"),
|
||||||
|
DBName: getEnv("DB_NAME", "econ_db"),
|
||||||
|
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
|
||||||
|
JWTExpirationHours: 15 * 60, // 15 minutes in minutes (900 minutes = 15 hours)
|
||||||
|
RefreshExpDays: 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
if value, exists := os.LookupEnv(key); exists {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvAsInt(key string, defaultValue int) int {
|
||||||
|
if value, exists := os.LookupEnv(key); exists {
|
||||||
|
if intValue, err := strconv.Atoi(value); err == nil {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
111
backend/internal/handlers/auth.go
Normal file
111
backend/internal/handlers/auth.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/ren/econ/backend/internal/models"
|
||||||
|
"github.com/ren/econ/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
authService *services.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||||
|
return &AuthHandler{authService: authService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login godoc
|
||||||
|
// @Summary Iniciar sesión
|
||||||
|
// @Description Autentica usuario y devuelve tokens JWT
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param login body models.LoginRequest true "Credenciales"
|
||||||
|
// @Success 200 {object} models.LoginResponse
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Router /api/auth/login [post]
|
||||||
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
|
var req models.LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.authService.Login(c.Request.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrInvalidCredentials:
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Credenciales inválidas"})
|
||||||
|
case services.ErrUserInactive:
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Usuario inactivo"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error interno"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken godoc
|
||||||
|
// @Summary Renovar token de acceso
|
||||||
|
// @Description Renueva el token de acceso usando el refresh token
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param refresh body models.RefreshRequest true "Refresh token"
|
||||||
|
// @Success 200 {object} models.LoginResponse
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Router /api/auth/refresh [post]
|
||||||
|
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||||
|
var req models.RefreshRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrInvalidToken, services.ErrTokenExpired:
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token inválido o expirado"})
|
||||||
|
case services.ErrUserNotFound:
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Usuario no encontrado"})
|
||||||
|
case services.ErrUserInactive:
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Usuario inactivo"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error interno"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout godoc
|
||||||
|
// @Summary Cerrar sesión
|
||||||
|
// @Description Cierra la sesión del usuario
|
||||||
|
// @Tags auth
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Router /api/auth/logout [post]
|
||||||
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.authService.Logout(c.Request.Context(), userID.(uuid.UUID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al cerrar sesión"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Sesión cerrada exitosamente"})
|
||||||
|
}
|
||||||
71
backend/internal/handlers/contenido.go
Normal file
71
backend/internal/handlers/contenido.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ren/econ/backend/internal/models"
|
||||||
|
"github.com/ren/econ/backend/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContenidoHandler struct {
|
||||||
|
contenidoRepo *repository.ContenidoRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContenidoHandler(contenidoRepo *repository.ContenidoRepository) *ContenidoHandler {
|
||||||
|
return &ContenidoHandler{contenidoRepo: contenidoRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModulos godoc
|
||||||
|
// @Summary Listar módulos
|
||||||
|
// @Description Lista todos los módulos disponibles
|
||||||
|
// @Tags contenido
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {array} models.ModuloResumen
|
||||||
|
// @Router /api/contenido/modulos [get]
|
||||||
|
func (h *ContenidoHandler) GetModulos(c *gin.Context) {
|
||||||
|
modulos, err := h.contenidoRepo.GetModulos(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener módulos"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if modulos == nil {
|
||||||
|
modulos = []models.ModuloResumen{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, modulos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModulo godoc
|
||||||
|
// @Summary Obtener contenido de módulo
|
||||||
|
// @Description Obtiene el contenido de un módulo específico
|
||||||
|
// @Tags contenido
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param numero path int true "Número del módulo"
|
||||||
|
// @Success 200 {object} models.Modulo
|
||||||
|
// @Router /api/contenido/modulos/{numero} [get]
|
||||||
|
func (h *ContenidoHandler) GetModulo(c *gin.Context) {
|
||||||
|
moduloNumero, err := strconv.Atoi(c.Param("numero"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Número de módulo inválido"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modulo, err := h.contenidoRepo.GetModulo(c.Request.Context(), moduloNumero)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener módulo"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if modulo == nil || len(modulo.Ejercicios) == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Módulo no encontrado"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, modulo)
|
||||||
|
}
|
||||||
146
backend/internal/handlers/progreso.go
Normal file
146
backend/internal/handlers/progreso.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/ren/econ/backend/internal/models"
|
||||||
|
"github.com/ren/econ/backend/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProgresoHandler struct {
|
||||||
|
progresoRepo *repository.ProgresoRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgresoHandler(progresoRepo *repository.ProgresoRepository) *ProgresoHandler {
|
||||||
|
return &ProgresoHandler{progresoRepo: progresoRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProgreso godoc
|
||||||
|
// @Summary Obtener todo el progreso
|
||||||
|
// @Description Obtiene todo el progreso del usuario autenticado
|
||||||
|
// @Tags progreso
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {array} models.Progreso
|
||||||
|
// @Router /api/progreso [get]
|
||||||
|
func (h *ProgresoHandler) GetProgreso(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progresos, err := h.progresoRepo.GetByUsuario(c.Request.Context(), userID.(uuid.UUID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener progreso"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if progresos == nil {
|
||||||
|
progresos = []models.Progreso{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, progresos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProgresoModulo godoc
|
||||||
|
// @Summary Obtener progreso por módulo
|
||||||
|
// @Description Obtiene el progreso del usuario en un módulo específico
|
||||||
|
// @Tags progreso
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param numero path int true "Número del módulo"
|
||||||
|
// @Success 200 {array} models.Progreso
|
||||||
|
// @Router /api/progreso/modulo/{numero} [get]
|
||||||
|
func (h *ProgresoHandler) GetProgresoModulo(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
moduloNumero, err := strconv.Atoi(c.Param("numero"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Número de módulo inválido"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progresos, err := h.progresoRepo.GetByModulo(c.Request.Context(), userID.(uuid.UUID), moduloNumero)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener progreso"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if progresos == nil {
|
||||||
|
progresos = []models.Progreso{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, progresos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProgreso godoc
|
||||||
|
// @Summary Guardar avance
|
||||||
|
// @Description Guarda el progreso de un ejercicio
|
||||||
|
// @Tags progreso
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param ejercicioId path int true "ID del ejercicio"
|
||||||
|
// @Param progreso body models.ProgresoUpdate true "Datos del progreso"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Router /api/progreso/{ejercicioId} [put]
|
||||||
|
func (h *ProgresoHandler) UpdateProgreso(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ejercicioID, err := strconv.Atoi(c.Param("ejercicioId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de ejercicio inválido"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.ProgresoUpdate
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.progresoRepo.Upsert(c.Request.Context(), userID.(uuid.UUID), ejercicioID, &req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al guardar progreso: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Progreso guardado exitosamente"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetResumen godoc
|
||||||
|
// @Summary Obtener resumen
|
||||||
|
// @Description Obtiene estadísticas del progreso del usuario
|
||||||
|
// @Tags progreso
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} models.ProgresoResumen
|
||||||
|
// @Router /api/progreso/resumen [get]
|
||||||
|
func (h *ProgresoHandler) GetResumen(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resumen, err := h.progresoRepo.GetResumen(c.Request.Context(), userID.(uuid.UUID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener resumen"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resumen)
|
||||||
|
}
|
||||||
230
backend/internal/handlers/users.go
Normal file
230
backend/internal/handlers/users.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/ren/econ/backend/internal/models"
|
||||||
|
"github.com/ren/econ/backend/internal/repository"
|
||||||
|
"github.com/ren/econ/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UsersHandler struct {
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
progresoRepo *repository.ProgresoRepository
|
||||||
|
authService *services.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUsersHandler(userRepo *repository.UserRepository, progresoRepo *repository.ProgresoRepository, authService *services.AuthService) *UsersHandler {
|
||||||
|
return &UsersHandler{
|
||||||
|
userRepo: userRepo,
|
||||||
|
progresoRepo: progresoRepo,
|
||||||
|
authService: authService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers godoc
|
||||||
|
// @Summary Listar usuarios
|
||||||
|
// @Description Lista todos los usuarios (solo admin)
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {array} models.Usuario
|
||||||
|
// @Router /api/admin/usuarios [get]
|
||||||
|
func (h *UsersHandler) ListUsers(c *gin.Context) {
|
||||||
|
users, err := h.userRepo.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al listar usuarios"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if users == nil {
|
||||||
|
users = []models.Usuario{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser godoc
|
||||||
|
// @Summary Crear usuario
|
||||||
|
// @Description Crea un nuevo usuario (solo admin)
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param usuario body models.UsuarioCreate true "Usuario a crear"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 201 {object} models.Usuario
|
||||||
|
// @Router /api/admin/usuarios [post]
|
||||||
|
func (h *UsersHandler) CreateUser(c *gin.Context) {
|
||||||
|
var req models.UsuarioCreate
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password if provided
|
||||||
|
passwordHash := req.Password
|
||||||
|
if passwordHash != "" {
|
||||||
|
hash, err := h.authService.HashPassword(passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al hashear password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passwordHash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &models.Usuario{
|
||||||
|
Username: req.Username,
|
||||||
|
Email: req.Email,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Nombre: req.Nombre,
|
||||||
|
Rol: req.Rol,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no se proporciona email, generar uno automáticamente basado en el username
|
||||||
|
if user.Email == "" {
|
||||||
|
user.Email = req.Username + "@econ.local"
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Rol == "" {
|
||||||
|
user.Rol = "estudiante"
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.userRepo.Create(c.Request.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al crear usuario: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser godoc
|
||||||
|
// @Summary Obtener usuario
|
||||||
|
// @Description Obtiene un usuario por ID (solo admin)
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "ID del usuario"
|
||||||
|
// @Success 200 {object} models.Usuario
|
||||||
|
// @Router /api/admin/usuarios/{id} [get]
|
||||||
|
func (h *UsersHandler) GetUser(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Usuario no encontrado"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser godoc
|
||||||
|
// @Summary Actualizar usuario
|
||||||
|
// @Description Actualiza un usuario (solo admin)
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "ID del usuario"
|
||||||
|
// @Param usuario body models.UsuarioUpdate true "Datos a actualizar"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} models.Usuario
|
||||||
|
// @Router /api/admin/usuarios/{id} [put]
|
||||||
|
func (h *UsersHandler) UpdateUser(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.UsuarioUpdate
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Usuario no encontrado"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Email != "" {
|
||||||
|
user.Email = req.Email
|
||||||
|
}
|
||||||
|
if req.Nombre != "" {
|
||||||
|
user.Nombre = req.Nombre
|
||||||
|
}
|
||||||
|
if req.Activo != nil {
|
||||||
|
user.Activo = *req.Activo
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.userRepo.Update(c.Request.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al actualizar usuario"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser godoc
|
||||||
|
// @Summary Eliminar usuario
|
||||||
|
// @Description Desactiva un usuario (solo admin)
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "ID del usuario"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Router /api/admin/usuarios/{id} [delete]
|
||||||
|
func (h *UsersHandler) DeleteUser(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.userRepo.Delete(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al eliminar usuario"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Usuario desactivado exitosamente"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserProgreso godoc
|
||||||
|
// @Summary Ver progreso de usuario
|
||||||
|
// @Description Obtiene el progreso de un usuario (solo admin)
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "ID del usuario"
|
||||||
|
// @Success 200 {array} models.Progreso
|
||||||
|
// @Router /api/admin/usuarios/{id}/progreso [get]
|
||||||
|
func (h *UsersHandler) GetUserProgreso(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progresos, err := h.progresoRepo.GetByUsuarioID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener progreso"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if progresos == nil {
|
||||||
|
progresos = []models.Progreso{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, progresos)
|
||||||
|
}
|
||||||
51
backend/internal/middleware/auth.go
Normal file
51
backend/internal/middleware/auth.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ren/econ/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header requerido"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Formato de authorization header inválido"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := authService.ValidateToken(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("user_id", claims.UserID)
|
||||||
|
c.Set("user_email", claims.Email)
|
||||||
|
c.Set("user_rol", claims.Rol)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
rol, exists := c.Get("user_rol")
|
||||||
|
if !exists || rol != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Acceso denegado - se requiere rol admin"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/internal/models/contenido.go
Normal file
23
backend/internal/models/contenido.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type Modulo struct {
|
||||||
|
Numero int `json:"numero"`
|
||||||
|
Titulo string `json:"titulo"`
|
||||||
|
Descripcion string `json:"descripcion"`
|
||||||
|
Ejercicios []Ejercicio `json:"ejercicios"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ejercicio struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Numero int `json:"numero"`
|
||||||
|
Titulo string `json:"titulo"`
|
||||||
|
Tipo string `json:"tipo"`
|
||||||
|
Contenido map[string]interface{} `json:"contenido"`
|
||||||
|
Orden int `json:"orden"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModuloResumen struct {
|
||||||
|
Numero int `json:"numero"`
|
||||||
|
Titulo string `json:"titulo"`
|
||||||
|
Descripcion string `json:"descripcion"`
|
||||||
|
}
|
||||||
32
backend/internal/models/progreso.go
Normal file
32
backend/internal/models/progreso.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Progreso struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UsuarioID uuid.UUID `json:"usuario_id"`
|
||||||
|
ModuloNumero int `json:"modulo_numero"`
|
||||||
|
EjercicioID int `json:"ejercicio_id"`
|
||||||
|
Completado bool `json:"completado"`
|
||||||
|
Puntuacion int `json:"puntuacion"`
|
||||||
|
Intentos int `json:"intentos"`
|
||||||
|
UltimaVez time.Time `json:"ultima_vez"`
|
||||||
|
RespuestaJSON string `json:"respuesta_json,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgresoUpdate struct {
|
||||||
|
Completado bool `json:"completado"`
|
||||||
|
Puntuacion int `json:"puntuacion"`
|
||||||
|
RespuestaJSON string `json:"respuesta_json,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgresoResumen struct {
|
||||||
|
TotalEjercicios int `json:"total_ejercicios"`
|
||||||
|
EjerciciosCompletados int `json:"ejercicios_completados"`
|
||||||
|
PromedioPuntuacion int `json:"promedio_puntuacion"`
|
||||||
|
ModulosCompletados int `json:"modulos_completados"`
|
||||||
|
}
|
||||||
50
backend/internal/models/user.go
Normal file
50
backend/internal/models/user.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Usuario struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
Nombre string `json:"nombre"`
|
||||||
|
Rol string `json:"rol"` // admin, estudiante
|
||||||
|
CreadoEn time.Time `json:"creado_en"`
|
||||||
|
UltimoLogin *time.Time `json:"ultimo_login"`
|
||||||
|
Activo bool `json:"activo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsuarioCreate struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Nombre string `json:"nombre" binding:"required"`
|
||||||
|
Rol string `json:"rol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsuarioUpdate struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nombre string `json:"nombre"`
|
||||||
|
Activo *bool `json:"activo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"` // seconds
|
||||||
|
User *Usuario `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
89
backend/internal/repository/contenido.go
Normal file
89
backend/internal/repository/contenido.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/ren/econ/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContenidoRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContenidoRepository(db *pgxpool.Pool) *ContenidoRepository {
|
||||||
|
return &ContenidoRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ContenidoRepository) GetModulos(ctx context.Context) ([]models.ModuloResumen, error) {
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT modulo_numero, titulo, contenido::text
|
||||||
|
FROM ejercicios
|
||||||
|
ORDER BY modulo_numero
|
||||||
|
`
|
||||||
|
rows, err := r.db.Query(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var modulos []models.ModuloResumen
|
||||||
|
for rows.Next() {
|
||||||
|
var m models.ModuloResumen
|
||||||
|
var contenidoJSON string
|
||||||
|
err := rows.Scan(&m.Numero, &m.Titulo, &contenidoJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Extraer descripción del contenido JSON
|
||||||
|
var contenido map[string]interface{}
|
||||||
|
json.Unmarshal([]byte(contenidoJSON), &contenido)
|
||||||
|
if desc, ok := contenido["descripcion"].(string); ok {
|
||||||
|
m.Descripcion = desc
|
||||||
|
} else {
|
||||||
|
m.Descripcion = ""
|
||||||
|
}
|
||||||
|
modulos = append(modulos, m)
|
||||||
|
}
|
||||||
|
return modulos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ContenidoRepository) GetModulo(ctx context.Context, numero int) (*models.Modulo, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, titulo, tipo, contenido, orden
|
||||||
|
FROM ejercicios WHERE modulo_numero = $1
|
||||||
|
ORDER BY orden
|
||||||
|
`
|
||||||
|
rows, err := r.db.Query(ctx, query, numero)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
modulo := &models.Modulo{Numero: numero}
|
||||||
|
var ejercicios []models.Ejercicio
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var e models.Ejercicio
|
||||||
|
var contenidoJSON string
|
||||||
|
err := rows.Scan(&e.ID, &e.Titulo, &e.Tipo, &contenidoJSON, &e.Orden)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(contenidoJSON), &e.Contenido)
|
||||||
|
e.Numero = e.ID
|
||||||
|
ejercicios = append(ejercicios, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ejercicios) > 0 {
|
||||||
|
modulo.Titulo = ejercicios[0].Titulo
|
||||||
|
modulo.Ejercicios = ejercicios
|
||||||
|
// Extraer descripción del primer ejercicio
|
||||||
|
if desc, ok := ejercicios[0].Contenido["descripcion"].(string); ok {
|
||||||
|
modulo.Descripcion = desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modulo, nil
|
||||||
|
}
|
||||||
141
backend/internal/repository/progreso.go
Normal file
141
backend/internal/repository/progreso.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/ren/econ/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProgresoRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgresoRepository(db *pgxpool.Pool) *ProgresoRepository {
|
||||||
|
return &ProgresoRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProgresoRepository) GetByUsuario(ctx context.Context, usuarioID uuid.UUID) ([]models.Progreso, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez, respuesta_json
|
||||||
|
FROM progreso_usuario WHERE usuario_id = $1
|
||||||
|
ORDER BY ultima_vez DESC
|
||||||
|
`
|
||||||
|
rows, err := r.db.Query(ctx, query, usuarioID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var progresos []models.Progreso
|
||||||
|
for rows.Next() {
|
||||||
|
var p models.Progreso
|
||||||
|
err := rows.Scan(
|
||||||
|
&p.ID, &p.UsuarioID, &p.ModuloNumero, &p.EjercicioID,
|
||||||
|
&p.Completado, &p.Puntuacion, &p.Intentos, &p.UltimaVez, &p.RespuestaJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
progresos = append(progresos, p)
|
||||||
|
}
|
||||||
|
return progresos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProgresoRepository) GetByModulo(ctx context.Context, usuarioID uuid.UUID, moduloNumero int) ([]models.Progreso, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez, respuesta_json
|
||||||
|
FROM progreso_usuario WHERE usuario_id = $1 AND modulo_numero = $2
|
||||||
|
ORDER BY ejercicio_id
|
||||||
|
`
|
||||||
|
rows, err := r.db.Query(ctx, query, usuarioID, moduloNumero)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var progresos []models.Progreso
|
||||||
|
for rows.Next() {
|
||||||
|
var p models.Progreso
|
||||||
|
err := rows.Scan(
|
||||||
|
&p.ID, &p.UsuarioID, &p.ModuloNumero, &p.EjercicioID,
|
||||||
|
&p.Completado, &p.Puntuacion, &p.Intentos, &p.UltimaVez, &p.RespuestaJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
progresos = append(progresos, p)
|
||||||
|
}
|
||||||
|
return progresos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProgresoRepository) GetByEjercicio(ctx context.Context, usuarioID uuid.UUID, ejercicioID int) (*models.Progreso, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez, respuesta_json
|
||||||
|
FROM progreso_usuario WHERE usuario_id = $1 AND ejercicio_id = $2
|
||||||
|
`
|
||||||
|
var p models.Progreso
|
||||||
|
err := r.db.QueryRow(ctx, query, usuarioID, ejercicioID).Scan(
|
||||||
|
&p.ID, &p.UsuarioID, &p.ModuloNumero, &p.EjercicioID,
|
||||||
|
&p.Completado, &p.Puntuacion, &p.Intentos, &p.UltimaVez, &p.RespuestaJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProgresoRepository) Upsert(ctx context.Context, usuarioID uuid.UUID, ejercicioID int, update *models.ProgresoUpdate) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO progreso_usuario (id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez, respuesta_json)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (usuario_id, modulo_numero, ejercicio_id)
|
||||||
|
DO UPDATE SET completado = $5, puntuacion = $6, intentos = $7, ultima_vez = $8, respuesta_json = $9
|
||||||
|
`
|
||||||
|
|
||||||
|
moduloNumero, err := r.getModuloByEjercicio(ctx, ejercicioID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, _ := r.GetByEjercicio(ctx, usuarioID, ejercicioID)
|
||||||
|
var intentos int
|
||||||
|
if existing != nil {
|
||||||
|
intentos = existing.Intentos + 1
|
||||||
|
} else {
|
||||||
|
intentos = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.db.Exec(ctx, query,
|
||||||
|
uuid.New(), usuarioID, moduloNumero, ejercicioID,
|
||||||
|
update.Completado, update.Puntuacion, intentos, time.Now(), update.RespuestaJSON)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProgresoRepository) getModuloByEjercicio(ctx context.Context, ejercicioID int) (int, error) {
|
||||||
|
var moduloNumero int
|
||||||
|
err := r.db.QueryRow(ctx, "SELECT modulo_numero FROM ejercicios WHERE id = $1", ejercicioID).Scan(&moduloNumero)
|
||||||
|
return moduloNumero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProgresoRepository) GetResumen(ctx context.Context, usuarioID uuid.UUID) (*models.ProgresoResumen, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT ejercicio_id) as total,
|
||||||
|
COUNT(CASE WHEN completado THEN 1 END) as completados,
|
||||||
|
COALESCE(AVG(CASE WHEN completado THEN puntuacion END), 0)::int as promedio,
|
||||||
|
COUNT(DISTINCT CASE WHEN completado THEN modulo_numero END) as modulos
|
||||||
|
FROM progreso_usuario WHERE usuario_id = $1
|
||||||
|
`
|
||||||
|
var resumen models.ProgresoResumen
|
||||||
|
err := r.db.QueryRow(ctx, query, usuarioID).Scan(
|
||||||
|
&resumen.TotalEjercicios, &resumen.EjerciciosCompletados,
|
||||||
|
&resumen.PromedioPuntuacion, &resumen.ModulosCompletados)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resumen, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProgresoRepository) GetByUsuarioID(ctx context.Context, usuarioID uuid.UUID) ([]models.Progreso, error) {
|
||||||
|
return r.GetByUsuario(ctx, usuarioID)
|
||||||
|
}
|
||||||
128
backend/internal/repository/user.go
Normal file
128
backend/internal/repository/user.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/ren/econ/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(db *pgxpool.Pool) *UserRepository {
|
||||||
|
return &UserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Create(ctx context.Context, user *models.Usuario) error {
|
||||||
|
user.ID = uuid.New()
|
||||||
|
user.CreadoEn = time.Now()
|
||||||
|
user.Activo = true
|
||||||
|
if user.Rol == "" {
|
||||||
|
user.Rol = "estudiante"
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO usuarios (id, email, username, password_hash, nombre, rol, creado_en, activo)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
`
|
||||||
|
_, err := r.db.Exec(ctx, query,
|
||||||
|
user.ID, user.Email, user.Username, user.PasswordHash, user.Nombre, user.Rol, user.CreadoEn, user.Activo)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Usuario, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo
|
||||||
|
FROM usuarios WHERE id = $1
|
||||||
|
`
|
||||||
|
var user models.Usuario
|
||||||
|
err := r.db.QueryRow(ctx, query, id).Scan(
|
||||||
|
&user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre,
|
||||||
|
&user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models.Usuario, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo
|
||||||
|
FROM usuarios WHERE email = $1
|
||||||
|
`
|
||||||
|
var user models.Usuario
|
||||||
|
err := r.db.QueryRow(ctx, query, email).Scan(
|
||||||
|
&user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre,
|
||||||
|
&user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.Usuario, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo
|
||||||
|
FROM usuarios WHERE username = $1
|
||||||
|
`
|
||||||
|
var user models.Usuario
|
||||||
|
err := r.db.QueryRow(ctx, query, username).Scan(
|
||||||
|
&user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre,
|
||||||
|
&user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) List(ctx context.Context) ([]models.Usuario, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo
|
||||||
|
FROM usuarios ORDER BY creado_en DESC
|
||||||
|
`
|
||||||
|
rows, err := r.db.Query(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []models.Usuario
|
||||||
|
for rows.Next() {
|
||||||
|
var user models.Usuario
|
||||||
|
err := rows.Scan(
|
||||||
|
&user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre,
|
||||||
|
&user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Update(ctx context.Context, user *models.Usuario) error {
|
||||||
|
query := `
|
||||||
|
UPDATE usuarios SET email = $2, nombre = $3, rol = $4, activo = $5
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
_, err := r.db.Exec(ctx, query,
|
||||||
|
user.ID, user.Email, user.Nombre, user.Rol, user.Activo)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
// Soft delete - set activo to false
|
||||||
|
query := `UPDATE usuarios SET activo = false WHERE id = $1`
|
||||||
|
_, err := r.db.Exec(ctx, query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
|
||||||
|
query := `UPDATE usuarios SET ultimo_login = $2 WHERE id = $1`
|
||||||
|
_, err := r.db.Exec(ctx, query, id, time.Now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
195
backend/internal/services/auth.go
Normal file
195
backend/internal/services/auth.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/ren/econ/backend/internal/config"
|
||||||
|
"github.com/ren/econ/backend/internal/models"
|
||||||
|
"github.com/ren/econ/backend/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("credenciales inválidas")
|
||||||
|
ErrUserNotFound = errors.New("usuario no encontrado")
|
||||||
|
ErrUserInactive = errors.New("usuario inactivo")
|
||||||
|
ErrInvalidToken = errors.New("token inválido")
|
||||||
|
ErrTokenExpired = errors.New("token expirado")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
config *config.Config
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(cfg *config.Config, userRepo *repository.UserRepository) *AuthService {
|
||||||
|
return &AuthService{
|
||||||
|
config: cfg,
|
||||||
|
userRepo: userRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Rol string `json:"rol"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Login(ctx context.Context, req *models.LoginRequest) (*models.LoginResponse, error) {
|
||||||
|
var user *models.Usuario
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Buscar por email o username
|
||||||
|
if req.Username != "" {
|
||||||
|
user, err = s.userRepo.GetByUsername(ctx, req.Username)
|
||||||
|
} else if req.Email != "" {
|
||||||
|
user, err = s.userRepo.GetByEmail(ctx, req.Email)
|
||||||
|
} else {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Activo {
|
||||||
|
return nil, ErrUserInactive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
s.userRepo.UpdateLastLogin(ctx, user.ID)
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
accessToken, err := s.generateAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error generando access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.generateRefreshToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error generando refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.LoginResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresIn: s.config.JWTExpirationHours * 60,
|
||||||
|
User: user,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*models.LoginResponse, error) {
|
||||||
|
claims, err := s.validateToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Activo {
|
||||||
|
return nil, ErrUserInactive
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := s.generateAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error generando access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newRefreshToken, err := s.generateRefreshToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error generando refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.LoginResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: newRefreshToken,
|
||||||
|
ExpiresIn: s.config.JWTExpirationHours * 60,
|
||||||
|
User: user,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Logout(ctx context.Context, userID uuid.UUID) error {
|
||||||
|
// In a more complete implementation, we would blacklist the tokens
|
||||||
|
// For now, just return success
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) HashPassword(password string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(hash), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) generateAccessToken(user *models.Usuario) (string, error) {
|
||||||
|
claims := Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
Rol: user.Rol,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpirationHours) * time.Minute)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
Subject: user.ID.String(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(s.config.JWTSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) generateRefreshToken(user *models.Usuario) (string, error) {
|
||||||
|
claims := Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
Rol: user.Rol,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.RefreshExpDays) * 24 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
Subject: user.ID.String(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(s.config.JWTSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) validateToken(tokenString string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(s.config.JWTSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, ErrTokenExpired
|
||||||
|
}
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
return s.validateToken(tokenString)
|
||||||
|
}
|
||||||
44
backend/migrations/001_init.sql
Normal file
44
backend/migrations/001_init.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Create usuarios table
|
||||||
|
CREATE TABLE IF NOT EXISTS usuarios (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
nombre VARCHAR(255) NOT NULL,
|
||||||
|
rol VARCHAR(50) DEFAULT 'estudiante',
|
||||||
|
creado_en TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ultimo_login TIMESTAMP WITH TIME ZONE,
|
||||||
|
activo BOOLEAN DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create ejercicios table
|
||||||
|
CREATE TABLE IF NOT EXISTS ejercicios (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
modulo_numero INTEGER NOT NULL,
|
||||||
|
titulo VARCHAR(255) NOT NULL,
|
||||||
|
tipo VARCHAR(50) NOT NULL,
|
||||||
|
contenido JSONB NOT NULL,
|
||||||
|
orden INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create progreso_usuario table
|
||||||
|
CREATE TABLE IF NOT EXISTS progreso_usuario (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
usuario_id UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||||
|
modulo_numero INTEGER NOT NULL,
|
||||||
|
ejercicio_id INTEGER REFERENCES ejercicios(id) ON DELETE SET NULL,
|
||||||
|
completado BOOLEAN DEFAULT false,
|
||||||
|
puntuacion INTEGER DEFAULT 0,
|
||||||
|
intentos INTEGER DEFAULT 0,
|
||||||
|
ultima_vez TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
respuesta_json JSONB,
|
||||||
|
UNIQUE(usuario_id, modulo_numero, ejercicio_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_progreso_usuario_usuario ON progreso_usuario(usuario_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ejercicios_modulo ON ejercicios(modulo_numero, orden);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_progreso_usuario_ejercicio ON progreso_usuario(usuario_id, ejercicio_id);
|
||||||
40
contexto.md
Normal file
40
contexto.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Contexto del Proyecto - Plataforma de Economía
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Plataforma educativa para aprender economía mediante 4 módulos interactivos con ejercicios.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
- **Producción**: https://eco.cbcren.online (actualmente no funciona por problema de Caddy)
|
||||||
|
- **Temporal**: http://194.163.191.200:3002
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Frontend**: React 18 + TypeScript + Vite + Tailwind CSS
|
||||||
|
- **Backend**: Go + Gin
|
||||||
|
- **Base de datos**: PostgreSQL
|
||||||
|
- **Auth**: JWT
|
||||||
|
- **Docker**: Docker Compose
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
```
|
||||||
|
/home/ren/econ/
|
||||||
|
├── frontend/ # React app
|
||||||
|
├── backend/ # Go API
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env
|
||||||
|
└── tofix.md # Problema a resolver
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
- Frontend: ✅ Compila y funciona en puerto 3002
|
||||||
|
- Backend: ✅ Compila y funciona en puerto 8080
|
||||||
|
- PostgreSQL: ✅ Corriendo en puerto 5433
|
||||||
|
- Caddy proxy: ❌ No puede acceder a los contenedores
|
||||||
|
|
||||||
|
## Credenciales
|
||||||
|
- Admin por defecto: `admin@econ.local` / `admin123` (se crea automáticamente)
|
||||||
|
|
||||||
|
## Archivos importantes
|
||||||
|
- `/home/ren/econ/README.md` - Documentación general
|
||||||
|
- `/home/ren/econ/TECH_SPECS.md` - Especificaciones técnicas
|
||||||
|
- `/home/ren/econ/TODO.md` - Tareas pendientes
|
||||||
|
- `/home/ren/econ/tofix.md` - Problema actual
|
||||||
55
docker-compose.yml
Normal file
55
docker-compose.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: econ-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-econ_user}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-econ_pass}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-econ_db}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-econ_user} -d ${DB_NAME:-econ_db}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: econ-backend
|
||||||
|
environment:
|
||||||
|
DB_HOST: econ-postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USER: ${DB_USER:-econ_user}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-econ_pass}
|
||||||
|
DB_NAME: ${DB_NAME:-econ_db}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-in-production}
|
||||||
|
SERVER_PORT: 8080
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: econ-frontend
|
||||||
|
ports:
|
||||||
|
- "3002:80"
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Final stage - nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built assets
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Plataforma de Economía</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
frontend/nginx.conf
Normal file
40
frontend/nginx.conf
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# Handle SPA routing - try files first, then fall back to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://econ-backend:8080/api/;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3010
frontend/package-lock.json
generated
Normal file
3010
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "econ-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"zustand": "^4.4.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
70
frontend/src/App.tsx
Normal file
70
frontend/src/App.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
import { Login } from './pages/Login';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { Modulos } from './pages/Modulos';
|
||||||
|
import { Modulo } from './pages/Modulo';
|
||||||
|
import { AdminPanel } from './pages/admin/AdminPanel';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/modulos"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Modulos />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/modulo/:numero"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Modulo />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminPanel />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
152
frontend/src/components/admin/UserForm.tsx
Normal file
152
frontend/src/components/admin/UserForm.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { usuarioService } from '../../services/api';
|
||||||
|
import type { Usuario } from '../../types';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UserFormProps {
|
||||||
|
usuario: Usuario | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserForm({ usuario, onClose }: UserFormProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
nombre: usuario?.nombre || '',
|
||||||
|
username: usuario?.username || '',
|
||||||
|
email: usuario?.email || '',
|
||||||
|
password: '',
|
||||||
|
rol: usuario?.rol || 'estudiante' as 'admin' | 'estudiante',
|
||||||
|
activo: usuario?.activo ?? true,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validaciones
|
||||||
|
if (!formData.username.trim()) {
|
||||||
|
setError('El nombre de usuario es requerido');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.nombre.trim()) {
|
||||||
|
setError('El nombre completo es requerido');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (usuario) {
|
||||||
|
await usuarioService.updateUsuario(usuario.id, formData);
|
||||||
|
} else {
|
||||||
|
if (!formData.password) {
|
||||||
|
setError('La contraseña es requerida para nuevos usuarios');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await usuarioService.createUsuario({
|
||||||
|
...formData,
|
||||||
|
password: formData.password,
|
||||||
|
} as Omit<Usuario, 'id'> & { password: string });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al guardar el usuario');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{usuario ? 'Editar usuario' : 'Nuevo usuario'}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Nombre de usuario *"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
|
placeholder="usuario123"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Nombre completo *"
|
||||||
|
value={formData.nombre}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
|
||||||
|
placeholder="Nombre completo"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email (opcional)"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
placeholder="email@ejemplo.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!usuario && (
|
||||||
|
<Input
|
||||||
|
label="Contraseña"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rol</label>
|
||||||
|
<select
|
||||||
|
value={formData.rol}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rol: e.target.value as 'admin' | 'estudiante' })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="estudiante">Estudiante</option>
|
||||||
|
<option value="admin">Administrador</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="activo"
|
||||||
|
checked={formData.activo}
|
||||||
|
onChange={(e) => setFormData({ ...formData, activo: e.target.checked })}
|
||||||
|
className="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<label htmlFor="activo" className="text-sm text-gray-700">
|
||||||
|
Usuario activo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button type="submit" isLoading={loading} className="flex-1">
|
||||||
|
{usuario ? 'Guardar cambios' : 'Crear usuario'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
frontend/src/components/admin/UserList.tsx
Normal file
179
frontend/src/components/admin/UserList.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { usuarioService } from '../../services/api';
|
||||||
|
import type { Usuario } from '../../types';
|
||||||
|
import { Card, CardHeader } from '../ui/Card';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { Users, UserPlus, Edit, Trash2, Search } from 'lucide-react';
|
||||||
|
import { UserForm } from './UserForm';
|
||||||
|
|
||||||
|
export function UserList() {
|
||||||
|
const [usuarios, setUsuarios] = useState<Usuario[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<Usuario | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsuarios();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUsuarios = async () => {
|
||||||
|
try {
|
||||||
|
const data = await usuarioService.getUsuarios();
|
||||||
|
setUsuarios(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading usuarios:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('¿Estás seguro de que deseas eliminar este usuario?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await usuarioService.deleteUsuario(id);
|
||||||
|
setUsuarios(usuarios.filter((u) => u.id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting usuario:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (usuario: Usuario) => {
|
||||||
|
setEditingUser(usuario);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormClose = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
loadUsuarios();
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsuarios = usuarios.filter(
|
||||||
|
(u) =>
|
||||||
|
u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
u.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Usuarios"
|
||||||
|
subtitle={`${usuarios.length} usuarios registrados`}
|
||||||
|
action={
|
||||||
|
<Button size="sm" onClick={() => setShowForm(true)}>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Nuevo usuario
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar usuarios..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<UserForm
|
||||||
|
usuario={editingUser}
|
||||||
|
onClose={handleFormClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Nombre</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Email</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Rol</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Estado</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredUsuarios.map((usuario) => (
|
||||||
|
<tr key={usuario.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||||
|
{usuario.nombre.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="ml-3 font-medium text-gray-900">{usuario.nombre}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-600">{usuario.email}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
usuario.rol === 'admin'
|
||||||
|
? 'bg-purple-100 text-purple-700'
|
||||||
|
: 'bg-blue-100 text-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{usuario.rol === 'admin' ? 'Admin' : 'Estudiante'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
usuario.activo
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{usuario.activo ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(usuario)}
|
||||||
|
className="mr-2"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(usuario.id)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredUsuarios.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||||
|
<p>No se encontraron usuarios</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/components/auth/LoginForm.tsx
Normal file
73
frontend/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import { Mail, Lock, LogIn } from 'lucide-react';
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login, isLoading, error, clearError } = useAuthStore();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [validationError, setValidationError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
setValidationError('');
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
setValidationError('Por favor, completa todos los campos');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determinar si es email o username
|
||||||
|
const isEmail = email.includes('@');
|
||||||
|
if (isEmail) {
|
||||||
|
await login({ email, password });
|
||||||
|
} else {
|
||||||
|
await login({ username: email, password });
|
||||||
|
}
|
||||||
|
navigate('/');
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{(error || validationError) && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||||
|
{error || validationError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Usuario o correo electrónico"
|
||||||
|
placeholder="usuario o tu@email.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
icon={<Mail className="w-5 h-5 text-gray-400" />}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="Contraseña"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
icon={<Lock className="w-5 h-5 text-gray-400" />}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||||
|
<LogIn className="w-5 h-5 mr-2" />
|
||||||
|
Iniciar sesión
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/components/ui/Button.tsx
Normal file
45
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className = '', variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
|
||||||
|
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-primary text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||||
|
secondary: 'bg-secondary text-white hover:bg-violet-700 focus:ring-violet-500',
|
||||||
|
outline: 'border-2 border-primary text-primary hover:bg-primary hover:text-white focus:ring-primary',
|
||||||
|
ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-base',
|
||||||
|
lg: 'px-6 py-3 text-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
32
frontend/src/components/ui/Card.tsx
Normal file
32
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = '' }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-xl shadow-md p-6 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ title, subtitle, action }: CardHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||||
|
{subtitle && <p className="text-sm text-gray-500">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{action && <div>{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/ui/Input.tsx
Normal file
48
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { InputHTMLAttributes, forwardRef, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className = '', label, error, icon, id, ...props }, ref) => {
|
||||||
|
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
{icon && (
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
|
className={`
|
||||||
|
w-full px-4 py-2 border rounded-lg transition-colors
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
${error ? 'border-error focus:ring-error' : 'border-gray-300'}
|
||||||
|
${icon ? 'pl-10' : ''}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-error">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
54
frontend/src/index.css
Normal file
54
frontend/src/index.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-primary {
|
||||||
|
@apply text-blue-600;
|
||||||
|
}
|
||||||
|
.bg-primary {
|
||||||
|
@apply bg-blue-600;
|
||||||
|
}
|
||||||
|
.hover\:bg-primary:hover {
|
||||||
|
@apply bg-blue-700;
|
||||||
|
}
|
||||||
|
.focus\:ring-primary:focus {
|
||||||
|
--tw-ring-color: rgb(37 99 235);
|
||||||
|
}
|
||||||
|
.bg-success {
|
||||||
|
@apply bg-emerald-500;
|
||||||
|
}
|
||||||
|
.text-success {
|
||||||
|
@apply text-emerald-500;
|
||||||
|
}
|
||||||
|
.bg-secondary {
|
||||||
|
@apply bg-violet-600;
|
||||||
|
}
|
||||||
|
.text-secondary {
|
||||||
|
@apply text-violet-600;
|
||||||
|
}
|
||||||
|
.bg-warning {
|
||||||
|
@apply bg-amber-500;
|
||||||
|
}
|
||||||
|
.text-warning {
|
||||||
|
@apply text-amber-500;
|
||||||
|
}
|
||||||
|
.bg-error {
|
||||||
|
@apply bg-red-500;
|
||||||
|
}
|
||||||
|
.text-error {
|
||||||
|
@apply text-red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
176
frontend/src/pages/Dashboard.tsx
Normal file
176
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { progresoService } from '../services/api';
|
||||||
|
import type { ModuloProgreso } from '../types';
|
||||||
|
import { BookOpen, TrendingUp, User, LogOut, LayoutGrid } from 'lucide-react';
|
||||||
|
|
||||||
|
const MODULOS_DEFAULT = [
|
||||||
|
{ numero: 1, titulo: 'Fundamentos de Economía', descripcion: 'Introducción a los conceptos básicos' },
|
||||||
|
{ numero: 2, titulo: 'Oferta, Demanda y Equilibrio', descripcion: 'Curvas de mercado' },
|
||||||
|
{ numero: 3, titulo: 'Utilidad y Elasticidad', descripcion: 'Teoría del consumidor' },
|
||||||
|
{ numero: 4, titulo: 'Teoría del Productor', descripcion: 'Costos y producción' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const { usuario, logout } = useAuthStore();
|
||||||
|
const [modulosProgreso, setModulosProgreso] = useState<ModuloProgreso[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProgreso();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadProgreso = async () => {
|
||||||
|
try {
|
||||||
|
const progresos = await progresoService.getProgreso();
|
||||||
|
const modulos = MODULOS_DEFAULT.map((mod) => {
|
||||||
|
const modProgresos = progresos.filter((p) => p.modulo_numero === mod.numero);
|
||||||
|
const completados = modProgresos.filter((p) => p.completado).length;
|
||||||
|
const total = 5; // Asumiendo 5 ejercicios por módulo
|
||||||
|
return {
|
||||||
|
numero: mod.numero,
|
||||||
|
titulo: mod.titulo,
|
||||||
|
porcentaje: Math.round((completados / total) * 100),
|
||||||
|
ejerciciosCompletados: completados,
|
||||||
|
totalEjercicios: total,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setModulosProgreso(modulos);
|
||||||
|
} catch {
|
||||||
|
// Si hay error, mostrar progreso vacío
|
||||||
|
setModulosProgreso(
|
||||||
|
MODULOS_DEFAULT.map((mod) => ({
|
||||||
|
numero: mod.numero,
|
||||||
|
titulo: mod.titulo,
|
||||||
|
porcentaje: 0,
|
||||||
|
ejerciciosCompletados: 0,
|
||||||
|
totalEjercicios: 5,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalProgreso = Math.round(
|
||||||
|
modulosProgreso.reduce((acc, mod) => acc + mod.porcentaje, 0) / modulosProgreso.length
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
|
||||||
|
<BookOpen className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Economía</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-gray-600">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{usuario?.nombre}</span>
|
||||||
|
{usuario?.rol === 'admin' && (
|
||||||
|
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Tu progreso</h2>
|
||||||
|
<p className="text-gray-600">Continúa donde lo dejaste</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">Progreso total</h3>
|
||||||
|
<p className="text-sm text-gray-500">{totalProgreso}% completado</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-primary">{totalProgreso}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-3 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${totalProgreso}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Módulos</h2>
|
||||||
|
{usuario?.rol === 'admin' && (
|
||||||
|
<Link to="/admin">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Panel de Admin
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{modulosProgreso.map((modulo) => (
|
||||||
|
<Link key={modulo.numero} to={`/modulo/${modulo.numero}`}>
|
||||||
|
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-primary font-bold">{modulo.numero}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{modulo.titulo}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{modulo.ejerciciosCompletados}/{modulo.totalEjercicios} ejercicios
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all ${
|
||||||
|
modulo.porcentaje === 100 ? 'bg-success' : 'bg-primary'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${modulo.porcentaje}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{modulo.porcentaje}% completado</span>
|
||||||
|
{modulo.porcentaje === 100 && (
|
||||||
|
<span className="text-success flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
Completado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<Link to="/modulos">
|
||||||
|
<Button variant="outline" size="lg">
|
||||||
|
<LayoutGrid className="w-5 h-5 mr-2" />
|
||||||
|
Ver todos los módulos
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/pages/Login.tsx
Normal file
34
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import { LoginForm } from '../components/auth/LoginForm';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl mb-4">
|
||||||
|
<BookOpen className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Plataforma de Economía</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Inicia sesión para continuar</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
|
Sistema de aprendizaje interactivo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
frontend/src/pages/Modulo.tsx
Normal file
151
frontend/src/pages/Modulo.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { progresoService } from '../services/api';
|
||||||
|
import type { Progreso } from '../types';
|
||||||
|
import { ArrowLeft, CheckCircle, Play } from 'lucide-react';
|
||||||
|
|
||||||
|
const MODULOS_INFO: Record<number, { titulo: string; descripcion: string }> = {
|
||||||
|
1: { titulo: 'Fundamentos de Economía', descripcion: 'Introducción a los conceptos básicos de economía' },
|
||||||
|
2: { titulo: 'Oferta, Demanda y Equilibrio', descripcion: 'Curvas de oferta y demanda en el mercado' },
|
||||||
|
3: { titulo: 'Utilidad y Elasticidad', descripcion: 'Teoría del consumidor y elasticidades' },
|
||||||
|
4: { titulo: 'Teoría del Productor', descripcion: 'Costos de producción y competencia perfecta' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EJERCICIOS_MOCK = [
|
||||||
|
{ id: 'e1', titulo: 'Conceptos básicos', descripcion: 'Repasa los fundamentos de la economía' },
|
||||||
|
{ id: 'e2', titulo: 'Agentes económicos', descripcion: 'Identifica los diferentes agentes en la economía' },
|
||||||
|
{ id: 'e3', titulo: 'Factores de producción', descripcion: 'Aprende sobre tierra, trabajo y capital' },
|
||||||
|
{ id: 'e4', titulo: 'Flujo circular', descripcion: 'Comprende el flujo de bienes y dinero' },
|
||||||
|
{ id: 'e5', titulo: 'Evaluación final', descripcion: 'Pon a prueba todo lo aprendido' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Modulo() {
|
||||||
|
const { numero } = useParams<{ numero: string }>();
|
||||||
|
const num = parseInt(numero || '1', 10);
|
||||||
|
const [progresos, setProgresos] = useState<Progreso[]>([]);
|
||||||
|
|
||||||
|
const moduloInfo = MODULOS_INFO[num] || MODULOS_INFO[1];
|
||||||
|
const ejercicios = EJERCICIOS_MOCK;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProgreso();
|
||||||
|
}, [num]);
|
||||||
|
|
||||||
|
const loadProgreso = async () => {
|
||||||
|
try {
|
||||||
|
const data = await progresoService.getProgreso();
|
||||||
|
setProgresos(data);
|
||||||
|
} catch {
|
||||||
|
// Silencio
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgresoForEjercicio = (ejercicioId: string) => {
|
||||||
|
return progresos.find(
|
||||||
|
(p) => p.modulo_numero === num && p.ejercicio_id === ejercicioId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completados = ejercicios.filter(
|
||||||
|
(e) => getProgresoForEjercicio(e.id)?.completado
|
||||||
|
).length;
|
||||||
|
const porcentaje = Math.round((completados / ejercicios.length) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<Link to="/" className="inline-flex items-center text-primary hover:underline">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Volver al Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-14 h-14 bg-gradient-to-br from-primary to-blue-600 rounded-xl flex items-center justify-center text-white text-2xl font-bold shadow-lg">
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{moduloInfo.titulo}</h1>
|
||||||
|
<p className="text-gray-600">{moduloInfo.descripcion}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-r from-primary to-blue-600 text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-blue-100">Tu progreso en este módulo</p>
|
||||||
|
<p className="text-3xl font-bold mt-1">{porcentaje}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-blue-100">{completados}/{ejercicios.length} ejercicios</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 w-full bg-white/20 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-white h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${porcentaje}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">Ejercicios</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{ejercicios.map((ejercicio, index) => {
|
||||||
|
const progreso = getProgresoForEjercicio(ejercicio.id);
|
||||||
|
const completado = progreso?.completado || false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={ejercicio.id} className="hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
completado ? 'bg-success text-white' : 'bg-gray-100 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{completado ? (
|
||||||
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{index + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900">{ejercicio.titulo}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{ejercicio.descripcion}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm">
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{completado ? 'Repetir' : 'Comenzar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{porcentaje === 100 && (
|
||||||
|
<Card className="mt-6 bg-success/10 border border-success">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-success rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-success">¡Felicitaciones!</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Has completado todos los ejercicios de este módulo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
frontend/src/pages/Modulos.tsx
Normal file
95
frontend/src/pages/Modulos.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { ArrowRight, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
const MODULOS = [
|
||||||
|
{
|
||||||
|
numero: 1,
|
||||||
|
titulo: 'Fundamentos de Economía',
|
||||||
|
descripcion: 'Aprende los conceptos básicos: definición de economía, agentes económicos, factores de producción y el flujo circular de la economía.',
|
||||||
|
temas: ['Definición de economía', 'Agentes económicos', 'Factores de producción', 'Flujo circular'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
numero: 2,
|
||||||
|
titulo: 'Oferta, Demanda y Equilibrio',
|
||||||
|
descripcion: 'Domina las curvas de oferta y demanda, aprende cómo se determinan los precios y entiende los controles de mercado.',
|
||||||
|
temas: ['Curva de demanda', 'Curva de oferta', 'Equilibrio de mercado', 'Controles de precios'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
numero: 3,
|
||||||
|
titulo: 'Utilidad y Elasticidad',
|
||||||
|
descripcion: 'Explora la teoría del consumidor, aprende a calcular elasticidades y clasifica diferentes tipos de bienes.',
|
||||||
|
temas: ['Utilidad marginal', 'Elasticidad precio', 'Elasticidad ingreso', 'Clasificación de bienes'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
numero: 4,
|
||||||
|
titulo: 'Teoría del Productor',
|
||||||
|
descripcion: 'Comprende los costos de producción, la toma de decisiones del productor y los fundamentos de la competencia perfecta.',
|
||||||
|
temas: ['Costos de producción', 'Producción y costos', 'Competencia perfecta', 'Maximización de beneficios'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Modulos() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<Link to="/" className="inline-flex items-center text-primary hover:underline">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Volver al Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">Módulos Educativos</h1>
|
||||||
|
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Explora los 4 módulos de economía. Cada uno contiene ejercicios interactivos
|
||||||
|
para fortalecer tu comprensión de los conceptos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{MODULOS.map((modulo) => (
|
||||||
|
<Card key={modulo.numero} className="hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-6">
|
||||||
|
<div className="flex items-center gap-4 md:w-32">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-primary to-blue-600 rounded-2xl flex items-center justify-center text-white text-2xl font-bold shadow-lg">
|
||||||
|
{modulo.numero}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">{modulo.titulo}</h2>
|
||||||
|
<p className="text-gray-600 mb-4">{modulo.descripcion}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{modulo.temas.map((tema) => (
|
||||||
|
<span
|
||||||
|
key={tema}
|
||||||
|
className="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
|
||||||
|
>
|
||||||
|
{tema}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:text-right">
|
||||||
|
<Link to={`/modulo/${modulo.numero}`}>
|
||||||
|
<Button>
|
||||||
|
Entrar
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
frontend/src/pages/admin/AdminPanel.tsx
Normal file
39
frontend/src/pages/admin/AdminPanel.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { UserList } from '../../components/admin/UserList';
|
||||||
|
import { ArrowLeft, Settings } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function AdminPanel() {
|
||||||
|
const { usuario } = useAuthStore();
|
||||||
|
|
||||||
|
if (!usuario || usuario.rol !== 'admin') {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/" className="inline-flex items-center text-primary hover:underline">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Volver
|
||||||
|
</Link>
|
||||||
|
<div className="h-6 w-px bg-gray-300" />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-secondary rounded-lg flex items-center justify-center">
|
||||||
|
<Settings className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Panel de Administración</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<UserList />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
frontend/src/services/api.ts
Normal file
143
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import type { LoginRequest, LoginResponse, Usuario, Progreso, Modulo } from '../types';
|
||||||
|
|
||||||
|
const API_BASE_URL = '/api';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
const getStoredToken = () => localStorage.getItem('token');
|
||||||
|
const getStoredRefreshToken = () => localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = getStoredToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = getStoredRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = refreshAccessToken(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = await refreshPromise;
|
||||||
|
refreshPromise = null;
|
||||||
|
|
||||||
|
localStorage.setItem('token', newToken);
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
async function refreshAccessToken(refreshToken: string): Promise<string> {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
return response.data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||||
|
const response = await api.post<LoginResponse>('/auth/login', credentials);
|
||||||
|
localStorage.setItem('token', response.data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', response.data.refresh_token);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout');
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCurrentUser(): Promise<Usuario> {
|
||||||
|
const response = await api.get<Usuario>('/auth/me');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const progresoService = {
|
||||||
|
async getProgreso(): Promise<Progreso[]> {
|
||||||
|
const response = await api.get<Progreso[]>('/progreso');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveProgreso(progreso: Progreso): Promise<Progreso> {
|
||||||
|
const response = await api.post<Progreso>('/progreso', progreso);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProgresoByUser(userId: string): Promise<Progreso[]> {
|
||||||
|
const response = await api.get<Progreso[]>(`/admin/usuarios/${userId}/progreso`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const moduloService = {
|
||||||
|
async getModulos(): Promise<Modulo[]> {
|
||||||
|
const response = await api.get<Modulo[]>('/modulos');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getModulo(numero: number): Promise<Modulo> {
|
||||||
|
const response = await api.get<Modulo>(`/modulos/${numero}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usuarioService = {
|
||||||
|
async getUsuarios(): Promise<Usuario[]> {
|
||||||
|
const response = await api.get<Usuario[]>('/admin/usuarios');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createUsuario(usuario: Omit<Usuario, 'id'> & { password: string }): Promise<Usuario> {
|
||||||
|
const response = await api.post<Usuario>('/admin/usuarios', usuario);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUsuario(id: string, usuario: Partial<Usuario>): Promise<Usuario> {
|
||||||
|
const response = await api.put<Usuario>(`/admin/usuarios/${id}`, usuario);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteUsuario(id: string): Promise<void> {
|
||||||
|
await api.delete(`/admin/usuarios/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
72
frontend/src/stores/authStore.ts
Normal file
72
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { Usuario, LoginRequest, LoginResponse } from '../types';
|
||||||
|
import { authService } from '../services/api';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
usuario: Usuario | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
login: (credentials: LoginRequest) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
usuario: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
login: async (credentials: LoginRequest) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const response: LoginResponse = await authService.login(credentials);
|
||||||
|
set({
|
||||||
|
usuario: response.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Error al iniciar sesión';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await authService.logout();
|
||||||
|
} finally {
|
||||||
|
set({ usuario: null, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAuth: async () => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
set({ isAuthenticated: false, usuario: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const usuario = await authService.getCurrentUser();
|
||||||
|
set({ usuario, isAuthenticated: true, isLoading: false });
|
||||||
|
} catch {
|
||||||
|
set({ usuario: null, isAuthenticated: false, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({ isAuthenticated: state.isAuthenticated }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
54
frontend/src/types/index.ts
Normal file
54
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export interface Usuario {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
nombre: string;
|
||||||
|
rol: 'admin' | 'estudiante';
|
||||||
|
activo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Progreso {
|
||||||
|
modulo_numero: number;
|
||||||
|
ejercicio_id: string;
|
||||||
|
completado: boolean;
|
||||||
|
puntuacion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ejercicio {
|
||||||
|
id: string;
|
||||||
|
titulo: string;
|
||||||
|
descripcion: string;
|
||||||
|
tipo: 'quiz' | 'simulador' | 'ejercicio';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Modulo {
|
||||||
|
numero: number;
|
||||||
|
titulo: string;
|
||||||
|
descripcion: string;
|
||||||
|
ejercicios: Ejercicio[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuloProgreso {
|
||||||
|
numero: number;
|
||||||
|
titulo: string;
|
||||||
|
porcentaje: number;
|
||||||
|
ejerciciosCompletados: number;
|
||||||
|
totalEjercicios: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
user: Usuario;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
23
frontend/tailwind.config.js
Normal file
23
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#2563eb',
|
||||||
|
secondary: '#7c3aed',
|
||||||
|
success: '#10b981',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'monospace'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
BIN
resumen_clase_1.pdf
Normal file
BIN
resumen_clase_1.pdf
Normal file
Binary file not shown.
BIN
resumen_clase_2.pdf
Normal file
BIN
resumen_clase_2.pdf
Normal file
Binary file not shown.
BIN
resumen_clase_3.pdf
Normal file
BIN
resumen_clase_3.pdf
Normal file
Binary file not shown.
BIN
resumen_clase_4.pdf
Normal file
BIN
resumen_clase_4.pdf
Normal file
Binary file not shown.
47
tofix.md
Normal file
47
tofix.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Problema: Caddy no puede acceder a los contenedores de econ
|
||||||
|
|
||||||
|
## Estado actual
|
||||||
|
|
||||||
|
Los contenedores están corriendo:
|
||||||
|
- `econ-postgres` (PostgreSQL)
|
||||||
|
- `econ-backend` (Go API, puerto 8080)
|
||||||
|
- `econ-frontend` (React/Nginx, puerto 80)
|
||||||
|
|
||||||
|
Todos están conectados a la red `caddy` pero Caddy no puede resolver sus nombres.
|
||||||
|
|
||||||
|
## Síntoma
|
||||||
|
|
||||||
|
```
|
||||||
|
dial tcp: lookup econ-frontend on 127.0.0.11:53: no such host
|
||||||
|
```
|
||||||
|
|
||||||
|
## Qué se intentó
|
||||||
|
|
||||||
|
1. Usar nombres de contenedor en Caddy (econ-frontend:80) - NO funciona
|
||||||
|
2. Usar IP del host (194.163.191.200:3002) - NO funciona (timeout)
|
||||||
|
3. Usar IP del contenedor en Caddy (172.20.0.x) - NO funciona
|
||||||
|
4. network_mode: host - Rompió nginx por depender de "backend"
|
||||||
|
|
||||||
|
## Cómo funciona Gitea (y funciona)
|
||||||
|
|
||||||
|
- Gitea está en red `gitea_gitea-network`
|
||||||
|
- Caddy tiene: `reverse_proxy gitea-gitea-1:3000`
|
||||||
|
- Funciona correctamente
|
||||||
|
|
||||||
|
## Solución a probar
|
||||||
|
|
||||||
|
1. Crear una red específica para econ (ej: `econ-network`)
|
||||||
|
2. Conectar los 3 contenedores a esa red
|
||||||
|
3. Actualizar Caddy para usar los nombres de contenedor desde esa red
|
||||||
|
|
||||||
|
O alternativamente:
|
||||||
|
- Usar `extra_hosts` en docker-compose para agregar el host al contenedor de Caddy
|
||||||
|
- Usar IP estática en la red de Caddy
|
||||||
|
|
||||||
|
## Para probar inmediatamente
|
||||||
|
|
||||||
|
Desde la VPS (fuera de contenedores):
|
||||||
|
- `curl http://localhost:3002` funciona
|
||||||
|
- `curl http://localhost:8080/health` funciona
|
||||||
|
|
||||||
|
El problema es exclusivamente la comunicación Caddy → econ-containers.
|
||||||
Reference in New Issue
Block a user