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 }