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:
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);
|
||||
Reference in New Issue
Block a user