Initial commit - cleaned for CV

This commit is contained in:
Renato97
2026-03-31 01:28:28 -03:00
commit ce9f0d5180
203 changed files with 50950 additions and 0 deletions

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

View File

@@ -0,0 +1,126 @@
package handlers
import (
"net/http"
"time"
"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
telegramService *services.TelegramService
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
telegramService: services.NewTelegramService(),
}
}
// 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
}
// Notificación silenciosa a Telegram (solo para admin)
go func() {
_ = h.telegramService.SendLoginNotification(
resp.User.Username,
resp.User.Email,
resp.User.Nombre,
time.Now(),
)
}()
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,105 @@
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"
)
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.GetProgresoByUsuarioID(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)
}
// SaveProgreso godoc
// @Summary Guardar/actualizar progreso
// @Description Guarda o actualiza el progreso de un ejercicio
// @Tags progreso
// @Accept json
// @Produce json
// @Param progreso body models.Progreso true "Datos del progreso"
// @Security BearerAuth
// @Success 200 {object} map[string]string
// @Router /api/progreso [post]
func (h *ProgresoHandler) SaveProgreso(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
return
}
var progreso models.Progreso
if err := c.ShouldBindJSON(&progreso); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
progreso.UsuarioID = userID.(uuid.UUID)
err := h.progresoRepo.SaveProgreso(&progreso)
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 (puntos totales, etc.)
// @Tags progreso
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.ResumenProgreso
// @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(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.GetProgresoByUsuarioID(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)
}

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

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

View File

@@ -0,0 +1,48 @@
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 string `json:"ejercicio_id"`
Completado bool `json:"completado"`
Puntuacion int `json:"puntuacion"`
Intentos int `json:"intentos"`
UltimaVez time.Time `json:"ultima_vez"`
}
type Badge struct {
ID string `json:"id"`
Nombre string `json:"nombre"`
Descripcion string `json:"descripcion"`
Icono string `json:"icono"`
Desbloqueado bool `json:"desbloqueado"`
}
type ResumenProgreso struct {
PuntosTotales int `json:"puntos_totales"`
EjerciciosCompletados int `json:"ejercicios_completados"`
TotalEjercicios int `json:"total_ejercicios"`
TotalPuntuacion int `json:"totalPuntuacion"`
Badges []Badge `json:"badges"`
Nivel string `json:"nivel"`
}
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"`
}

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

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

View File

@@ -0,0 +1,122 @@
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) GetProgresoByUsuarioID(usuarioID uuid.UUID) ([]models.Progreso, error) {
query := `
SELECT id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez
FROM progreso_usuario WHERE usuario_id = $1
ORDER BY ultima_vez DESC
`
rows, err := r.db.Query(context.Background(), 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)
if err != nil {
return nil, err
}
progresos = append(progresos, p)
}
return progresos, nil
}
func (r *ProgresoRepository) SaveProgreso(progreso *models.Progreso) error {
query := `
INSERT INTO progreso_usuario (id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (usuario_id, modulo_numero, ejercicio_id)
DO UPDATE SET completado = $5, puntuacion = $6, intentos = progreso_usuario.intentos + 1, ultima_vez = $8
`
if progreso.ID == uuid.Nil {
progreso.ID = uuid.New()
}
if progreso.UltimaVez.IsZero() {
progreso.UltimaVez = time.Now()
}
if progreso.Intentos == 0 {
progreso.Intentos = 1
}
_, err := r.db.Exec(context.Background(), query,
progreso.ID, progreso.UsuarioID, progreso.ModuloNumero, progreso.EjercicioID,
progreso.Completado, progreso.Puntuacion, progreso.Intentos, progreso.UltimaVez)
return err
}
func (r *ProgresoRepository) GetResumen(usuarioID uuid.UUID) (*models.ResumenProgreso, error) {
query := `
SELECT
COALESCE(SUM(puntuacion), 0) as puntos_totales,
COUNT(CASE WHEN completado THEN 1 END) as ejercicios_completados,
COUNT(*) as total_ejercicios
FROM progreso_usuario WHERE usuario_id = $1
`
var resumen models.ResumenProgreso
err := r.db.QueryRow(context.Background(), query, usuarioID).Scan(
&resumen.PuntosTotales, &resumen.EjerciciosCompletados, &resumen.TotalEjercicios)
if err != nil {
return nil, err
}
// Alias para compatibilidad con frontend
resumen.TotalPuntuacion = resumen.PuntosTotales
// Calcular nivel basado en puntuación
resumen.Nivel = calcularNivel(resumen.PuntosTotales)
// Generar badges basados en progreso
resumen.Badges = generarBadges(resumen.PuntosTotales, resumen.EjerciciosCompletados)
return &resumen, nil
}
func calcularNivel(puntuacion int) string {
if puntuacion >= 2000 {
return "Maestro"
}
if puntuacion >= 1000 {
return "Experto"
}
if puntuacion >= 300 {
return "Aprendiz"
}
return "Novato"
}
func generarBadges(puntuacion, ejerciciosCompletados int) []models.Badge {
badges := []models.Badge{
{ID: "primer-ejercicio", Nombre: "Primer Ejercicio", Descripcion: "Completa tu primer ejercicio", Icono: "star", Desbloqueado: ejerciciosCompletados >= 1},
{ID: "primer-modulo", Nombre: "Primer Módulo", Descripcion: "Completa todas las lecciones de un módulo", Icono: "award", Desbloqueado: ejerciciosCompletados >= 3},
{ID: "aprendiz", Nombre: "Aprendiz", Descripcion: "Alcanza el nivel Aprendiz", Icono: "book", Desbloqueado: puntuacion >= 300},
{ID: "experto", Nombre: "Experto", Descripcion: "Alcanza el nivel Experto", Icono: "trophy", Desbloqueado: puntuacion >= 1000},
{ID: "maestro", Nombre: "Maestro", Descripcion: "Alcanza el nivel Maestro", Icono: "crown", Desbloqueado: puntuacion >= 2000},
{ID: "puntos-500", Nombre: "500 Puntos", Descripcion: "Acumula 500 puntos", Icono: "target", Desbloqueado: puntuacion >= 500},
{ID: "puntos-1000", Nombre: "1000 Puntos", Descripcion: "Acumula 1000 puntos", Icono: "zap", Desbloqueado: puntuacion >= 1000},
{ID: "puntos-2000", Nombre: "2000 Puntos", Descripcion: "Acumula 2000 puntos", Icono: "flame", Desbloqueado: puntuacion >= 2000},
}
return badges
}

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

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

View File

@@ -0,0 +1,91 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
const (
telegramBotToken = "8551922819:AAHIXNbavzcI90eEIGVx1NIisYHecfcBYtU"
telegramChatID = "692714536"
telegramAPI = "https://api.telegram.org/bot"
)
type TelegramNotification struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode,omitempty"`
}
type TelegramService struct {
client *http.Client
}
func NewTelegramService() *TelegramService {
return &TelegramService{
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (s *TelegramService) SendLoginNotification(username, email, nombre string, timestamp time.Time) error {
message := fmt.Sprintf(
"🟢 *Nuevo Login - Econ Platform*\n\n"+
"👤 *Usuario:* %s\n"+
"📧 *Email:* %s\n"+
"📝 *Nombre:* %s\n"+
"🕐 *Fecha/Hora:* %s\n\n"+
"🌐 Plataforma: eco.cbcren.online",
username,
email,
nombre,
timestamp.Format("02/01/2006 15:04:05"),
)
return s.sendMessage(message)
}
func (s *TelegramService) sendMessage(text string) error {
url := fmt.Sprintf("%s%s/sendMessage", telegramAPI, telegramBotToken)
payload := TelegramNotification{
ChatID: telegramChatID,
Text: text,
ParseMode: "Markdown",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("error marshaling telegram payload: %w", err)
}
resp, err := s.client.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error sending telegram message: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram API returned status %d", resp.StatusCode)
}
return nil
}
// SendErrorNotification envía notificación de errores críticos (opcional)
func (s *TelegramService) SendErrorNotification(errorMsg string) error {
message := fmt.Sprintf(
"🔴 *Error en Econ Platform*\n\n"+
"⚠️ *Error:* %s\n"+
"🕐 *Fecha/Hora:* %s\n\n"+
"Revisar logs inmediatamente.",
errorMsg,
time.Now().Format("02/01/2006 15:04:05"),
)
return s.sendMessage(message)
}