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
260 lines
8.2 KiB
Go
260 lines
8.2 KiB
Go
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
|
|
}
|