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:
Renato
2026-02-12 01:30:57 +01:00
commit d31575a143
57 changed files with 7017 additions and 0 deletions

View 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"})
}

View 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)
}

View 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)
}

View 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)
}