Files
econ/backend/cmd/server/main.go
Renato d31575a143 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
2026-02-12 01:30:57 +01:00

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
}