diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 784cd7c..8e5db47 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -33,6 +33,9 @@ jobs: - name: Build (warnings as errors) run: dotnet build "EpycusApp.csproj" --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore -warnaserror + - name: Run unit tests + run: dotnet test "EpycusApp.Tests/EpycusApp.Tests.csproj" --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore --verbosity normal + # ── JOB 2: Build y publish ────────────────────────────────────────── build: name: Build & Publish @@ -117,8 +120,21 @@ jobs: target: ${{ env.VPS_APP_PATH }} strip_components: 1 + # Aplicar migraciones EF Core + - name: Apply EF Core migrations + uses: appleboy/ssh-action@v1.0.3 + continue-on-error: true + with: + host: ${{ env.VPS_HOST }} + username: ${{ env.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: ${{ env.VPS_PORT }} + script: | + cd ${{ env.VPS_APP_PATH }} + sudo dotnet ef database update --no-build 2>&1 || echo "⚠️ Migraciones no aplicadas (pueden no requerirse)" + echo "✅ Migraciones ejecutadas" + # Reinicio seguro - - name: Restart service uses: appleboy/ssh-action@v1.0.3 with: host: ${{ env.VPS_HOST }} diff --git a/Ayudantes/ConstantesGamificacion.cs b/Ayudantes/ConstantesGamificacion.cs index 6412bec..14c1ede 100644 --- a/Ayudantes/ConstantesGamificacion.cs +++ b/Ayudantes/ConstantesGamificacion.cs @@ -12,8 +12,7 @@ public static class ConstantesGamificacion public const int XP_MISION_BAJA = 30; public const int XP_MISION_MEDIA = 50; public const int XP_MISION_ALTA = 80; - public const int XP_BASE_POMODORO = 15; - public const int XP_POMODORO_POR_CICLO = 15; + public const int XpBasePomodoro = 15; public const int NIVEL_MAXIMO = 20; } } diff --git a/Ayudantes/DebeSerVerdaderoAttribute.cs b/Ayudantes/DebeSerVerdaderoAttribute.cs index c88c7d3..65268d7 100644 --- a/Ayudantes/DebeSerVerdaderoAttribute.cs +++ b/Ayudantes/DebeSerVerdaderoAttribute.cs @@ -4,8 +4,8 @@ namespace EpycusApp.Ayudantes; /// -/// Valida que un checkbox booleano esté marcado como true. -/// Reemplaza [Range(typeof(bool), "true", "true")] que falla en validación +/// Valida que un checkbox booleano esté marcado como true. +/// Reemplaza [Range(typeof(bool), "true", "true")] que falla en validación /// cliente por incompatibilidad entre "True" (.NET) y "true" (HTML/JS). /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] @@ -13,7 +13,7 @@ public class DebeSerVerdaderoAttribute : ValidationAttribute, IClientModelValida { public DebeSerVerdaderoAttribute() { - ErrorMessage = "Debe aceptar los términos"; + ErrorMessage = "Debe aceptar los términos"; } public override bool IsValid(object? value) diff --git a/Ayudantes/FormateadorTiempo.cs b/Ayudantes/FormateadorTiempo.cs new file mode 100644 index 0000000..5e5035b --- /dev/null +++ b/Ayudantes/FormateadorTiempo.cs @@ -0,0 +1,14 @@ +namespace EpycusApp.Ayudantes +{ + public static class FormateadorTiempo + { + public static string FormatearSegundos(int segundos) + { + if (segundos < 60) return $"{segundos}s"; + if (segundos < 3600) return $"{segundos / 60}min"; + int horas = segundos / 3600; + int mins = (segundos % 3600) / 60; + return mins > 0 ? $"{horas}h {mins}m" : $"{horas}h"; + } + } +} diff --git a/Controllers/AdminController.cs b/Controllers/AdminController.cs index 1688fb1..ce2260c 100644 --- a/Controllers/AdminController.cs +++ b/Controllers/AdminController.cs @@ -120,7 +120,7 @@ public async Task ActivarSuscripcion(int usuarioId) { var adminId = ObtenerUsuarioId(); await _servicioAdmin.ActivarSuscripcion(usuarioId, adminId); - TempData["Exito"] = "Suscripción activada correctamente."; + TempData["Exito"] = "Suscripción activada correctamente."; return RedirectToAction(nameof(Usuarios)); } @@ -130,7 +130,7 @@ public async Task ActivarSuscripcion(int usuarioId) public async Task DesactivarSuscripcion(int usuarioId) { await _servicioAdmin.DesactivarSuscripcion(usuarioId); - TempData["Exito"] = "Suscripción desactivada correctamente."; + TempData["Exito"] = "Suscripción desactivada correctamente."; return RedirectToAction(nameof(Usuarios)); } diff --git a/Controllers/AjustesController.cs b/Controllers/AjustesController.cs index 1116af6..db1886f 100644 --- a/Controllers/AjustesController.cs +++ b/Controllers/AjustesController.cs @@ -1,7 +1,7 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using EpycusApp.Servicios.Interfaces; +using EpycusApp.Servicios.Interfaces; using EpycusApp.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using System.Security.Claims; namespace EpycusApp.Controllers; @@ -18,19 +18,10 @@ public AjustesController(IServicioPerfil servicioPerfil, IServicioAutenticacion _servicioAutenticacion = servicioAutenticacion; } - public async Task Index() + [HttpGet] + public IActionResult Index() { - var usuarioId = ObtenerUsuarioId(); - var perfil = await _servicioPerfil.ObtenerPerfilCompletoAsync(usuarioId); - - if (perfil == null) - { - return NotFound(); - } - - perfil.PersonajesDisponibles = await _servicioPerfil.ObtenerPersonajesDisponiblesAsync(usuarioId); - ViewBag.Carreras = await _servicioAutenticacion.ObtenerCarrerasActivas(); - return View(perfil); + return RedirectToAction("Index", "Perfil"); } [HttpPost] @@ -41,7 +32,7 @@ public async Task ActualizarPerfil(ActualizarPerfilViewModel mode TempData["Error"] = string.Join("; ", ModelState.Values .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage)); - return RedirectToAction(nameof(Index)); + return RedirectToAction("Index", "Perfil"); } var usuarioId = ObtenerUsuarioId(); @@ -49,14 +40,14 @@ public async Task ActualizarPerfil(ActualizarPerfilViewModel mode if (resultado.EsExitoso) { - TempData["Mensaje"] = "Perfil actualizado correctamente."; + TempData["Exito"] = "Perfil actualizado correctamente."; } else { TempData["Error"] = resultado.Mensaje ?? "No se pudo actualizar el perfil."; } - return RedirectToAction(nameof(Index)); + return RedirectToAction("Index", "Perfil"); } [HttpPost] @@ -65,10 +56,9 @@ public async Task CambiarContrasena(CambiarContrasenaViewModel mo if (!ModelState.IsValid) { TempData["Error"] = "Por favor verifica los datos ingresados."; - return RedirectToAction(nameof(Index)); + return RedirectToAction("Index", "Perfil"); } - var usuarioId = ObtenerUsuarioId(); var correo = User.FindFirstValue(ClaimTypes.Email); var resultado = await _servicioAutenticacion.CambiarContrasenaAsync( @@ -79,14 +69,14 @@ public async Task CambiarContrasena(CambiarContrasenaViewModel mo if (resultado.EsExitoso) { - TempData["Mensaje"] = "Contraseña cambiada correctamente."; + TempData["Exito"] = "Contraseña cambiada correctamente."; } else { - TempData["Error"] = resultado.Mensaje ?? "No se pudo cambiar la contraseña."; + TempData["Error"] = resultado.Mensaje ?? "No se pudo cambiar la contraseña."; } - return RedirectToAction(nameof(Index)); + return RedirectToAction("Index", "Perfil"); } [HttpPost] diff --git a/Controllers/Api/ApiAdminController.cs b/Controllers/Api/ApiAdminController.cs new file mode 100644 index 0000000..6ace7c2 --- /dev/null +++ b/Controllers/Api/ApiAdminController.cs @@ -0,0 +1,101 @@ +using EpycusApp.Ayudantes; +using EpycusApp.DTOs; +using EpycusApp.Servicios.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace EpycusApp.Controllers.Api +{ + [ApiController] + [Route("api/v1/admin")] + [Authorize] + [EnableRateLimiting("Api")] + public class ApiAdminController : BaseApiController + { + private readonly IServicioAdmin _servicioAdmin; + private readonly IServicioAutenticacion _servicioAutenticacion; + + public ApiAdminController(IServicioAdmin servicioAdmin, IServicioAutenticacion servicioAutenticacion) + { + _servicioAdmin = servicioAdmin; + _servicioAutenticacion = servicioAutenticacion; + } + + [AllowAnonymous] + [HttpPost("login")] + public async Task Login([FromBody] AdminLoginRequest request) + { + var (exito, mensaje, token, refreshToken) = await _servicioAutenticacion.Login(request.Correo, request.Contrasena); + + if (!exito) + return Ok(RespuestaApi.Fallida(mensaje)); + + return Ok(RespuestaApi.Exitosa(new AdminLoginResponseDto { Token = token!, RefreshToken = refreshToken!, Mensaje = mensaje })); + } + + [HttpGet("usuarios")] + public async Task Usuarios() + { + var usuarios = await _servicioAdmin.ObtenerTodosUsuarios(); + return Ok(RespuestaApi.Exitosa(usuarios)); + } + + [HttpGet("usuarios/{id}")] + public async Task UsuarioPorId(int id) + { + var usuario = await _servicioAdmin.ObtenerUsuarioPorId(id); + if (usuario == null) + return NotFound(RespuestaApi.Fallida("Usuario no encontrado")); + return Ok(RespuestaApi.Exitosa(usuario)); + } + + [HttpPost("usuarios/{usuarioId}/suscripcion/activar")] + public async Task ActivarSuscripcion(int usuarioId) + { + var adminId = ObtenerUsuarioId()!.Value; + await _servicioAdmin.ActivarSuscripcion(usuarioId, adminId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpPost("usuarios/{usuarioId}/suscripcion/desactivar")] + public async Task DesactivarSuscripcion(int usuarioId) + { + await _servicioAdmin.DesactivarSuscripcion(usuarioId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpGet("frases")] + public async Task Frases() + { + var frases = await _servicioAdmin.ObtenerFrases(); + return Ok(RespuestaApi.Exitosa(frases)); + } + + [HttpPost("frases")] + public async Task CrearFrase([FromBody] CrearFraseRequest request) + { + await _servicioAdmin.CrearFrase(request.Frase, request.Autor ?? "Anonimo"); + return Created(string.Empty, RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpDelete("frases/{id}")] + public async Task EliminarFrase(int id) + { + await _servicioAdmin.EliminarFrase(id); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + } + + public class AdminLoginRequest + { + public string Correo { get; set; } = string.Empty; + public string Contrasena { get; set; } = string.Empty; + } + + public class CrearFraseRequest + { + public string Frase { get; set; } = string.Empty; + public string? Autor { get; set; } + } +} diff --git a/Controllers/Api/ApiAuthController.cs b/Controllers/Api/ApiAuthController.cs index 07e8408..5b95083 100644 --- a/Controllers/Api/ApiAuthController.cs +++ b/Controllers/Api/ApiAuthController.cs @@ -1,12 +1,17 @@ using EpycusApp.Ayudantes; +using EpycusApp.DTOs; +using EpycusApp.Models.Entidades; using EpycusApp.Servicios.Interfaces; +using EpycusApp.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EpycusApp.Controllers.Api { [ApiController] - [Route("api/auth")] + [Route("api/v1/auth")] + [EnableRateLimiting("Auth")] public class ApiAuthController : BaseApiController { private readonly IServicioAutenticacion _servicioAutenticacion; @@ -23,10 +28,10 @@ public async Task Login([FromBody] LoginDto request) var (exito, mensaje, token, refreshToken) = await _servicioAutenticacion.Login(request.Correo, request.Contrasena); if (!exito || token == null || refreshToken == null) { - return BadRequest(RespuestaApi.Fallida(mensaje)); + return BadRequest(RespuestaApi.Fallida(mensaje)); } - return Ok(RespuestaApi.Exitosa(new { token, refreshToken })); + return Ok(RespuestaApi.Exitosa(new AuthResponseDto { Token = token, RefreshToken = refreshToken })); } [HttpPost("refresh")] @@ -36,10 +41,10 @@ public async Task Refresh([FromBody] RefreshDto request) var (exito, mensaje, token, refreshToken) = await _servicioAutenticacion.RenovarToken(request.RefreshToken); if (!exito || token == null || refreshToken == null) { - return BadRequest(RespuestaApi.Fallida(mensaje)); + return BadRequest(RespuestaApi.Fallida(mensaje)); } - return Ok(RespuestaApi.Exitosa(new { token, refreshToken })); + return Ok(RespuestaApi.Exitosa(new AuthResponseDto { Token = token, RefreshToken = refreshToken })); } [HttpPost("logout")] @@ -48,7 +53,118 @@ public async Task Logout() { var usuarioId = ObtenerUsuarioId()!.Value; await _servicioAutenticacion.CerrarSesion(usuarioId); - return Ok(RespuestaApi.Exitosa(new { success = true })); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpPost("registro")] + [AllowAnonymous] + public async Task Registro([FromBody] RegistroRequestDto request) + { + var modelo = new RegistroViewModel + { + Nombre = request.Nombre, + CorreoElectronico = request.CorreoElectronico, + Contrasena = request.Contrasena, + ConfirmarContrasena = request.ConfirmarContrasena, + FechaNacimiento = request.FechaNacimiento, + Genero = request.Genero, + CarreraId = request.CarreraId, + AceptoTerminos = request.AceptoTerminos + }; + + var (exito, mensaje, token, refreshToken) = await _servicioAutenticacion.RegistrarUsuario(modelo); + if (!exito || token == null || refreshToken == null) + { + return BadRequest(RespuestaApi.Fallida(mensaje)); + } + + return Ok(RespuestaApi.Exitosa(new AuthResponseDto { Token = token, RefreshToken = refreshToken })); + } + + [HttpGet("verificar-correo")] + [AllowAnonymous] + public async Task VerificarCorreo([FromQuery] string token) + { + var resultado = await _servicioAutenticacion.VerificarCorreo(token); + if (!resultado) + { + return BadRequest(RespuestaApi.Fallida("No se pudo verificar el correo")); + } + + return Ok(RespuestaApi.Exitosa(new MensajeResponseDto { Mensaje = "Correo verificado exitosamente" })); + } + + [HttpPost("recuperar-contrasena")] + [AllowAnonymous] + public async Task RecuperarContrasena([FromBody] RecuperarContrasenaDto request) + { + var resultado = await _servicioAutenticacion.EnviarCorreoRecuperacion(request.Correo); + if (!resultado) + { + return BadRequest(RespuestaApi.Fallida("No se pudo enviar el correo de recuperación")); + } + + return Ok(RespuestaApi.Exitosa(new MensajeResponseDto { Mensaje = "Correo de recuperación enviado" })); + } + + [HttpPost("restablecer-contrasena")] + [AllowAnonymous] + public async Task RestablecerContrasena([FromBody] RestablecerContrasenaDto request) + { + var resultado = await _servicioAutenticacion.RestablecerContrasena(request.Token, request.NuevaContrasena); + if (!resultado) + { + return BadRequest(RespuestaApi.Fallida("No se pudo restablecer la contraseña")); + } + + return Ok(RespuestaApi.Exitosa(new MensajeResponseDto { Mensaje = "Contraseña restablecida exitosamente" })); + } + + [HttpPost("google")] + [AllowAnonymous] + public async Task GoogleAuth([FromBody] GoogleAuthDto request) + { + var (exito, mensaje, token, refreshToken) = await _servicioAutenticacion.ProcesarAutenticacionGoogleAsync( + request.GoogleId, request.Correo, request.Nombre, request.FotoUrl); + if (!exito || token == null || refreshToken == null) + { + return BadRequest(RespuestaApi.Fallida(mensaje)); + } + + return Ok(RespuestaApi.Exitosa(new AuthResponseDto { Token = token, RefreshToken = refreshToken })); + } + + [HttpPost("completar-registro-google")] + [AllowAnonymous] + public async Task CompletarRegistroGoogle([FromBody] CompletarRegistroGoogleDto request) + { + var modelo = new CompletarRegistroGoogleViewModel + { + Nombre = request.Nombre, + CorreoElectronico = request.CorreoElectronico, + FechaNacimiento = request.FechaNacimiento, + Genero = request.Genero, + CarreraId = request.CarreraId, + AceptoTerminos = request.AceptoTerminos, + GoogleId = request.GoogleId, + FotoGoogleUrl = request.FotoGoogleUrl + }; + + var (exito, mensaje, token, refreshToken) = await _servicioAutenticacion.CompletarRegistroGoogleAsync(modelo); + if (!exito || token == null || refreshToken == null) + { + return BadRequest(RespuestaApi.Fallida(mensaje)); + } + + return Ok(RespuestaApi.Exitosa(new AuthResponseDto { Token = token, RefreshToken = refreshToken })); + } + + [HttpGet("carreras")] + [AllowAnonymous] + public async Task Carreras() + { + var carreras = await _servicioAutenticacion.ObtenerCarrerasActivas(); + return Ok(RespuestaApi>.Exitosa(carreras)); } public class LoginDto @@ -61,5 +177,48 @@ public class RefreshDto { public string RefreshToken { get; set; } = string.Empty; } + + public class RegistroRequestDto + { + public string Nombre { get; set; } = string.Empty; + public string CorreoElectronico { get; set; } = string.Empty; + public string Contrasena { get; set; } = string.Empty; + public string ConfirmarContrasena { get; set; } = string.Empty; + public DateOnly FechaNacimiento { get; set; } + public string Genero { get; set; } = string.Empty; + public int CarreraId { get; set; } + public bool AceptoTerminos { get; set; } + } + + public class RecuperarContrasenaDto + { + public string Correo { get; set; } = string.Empty; + } + + public class RestablecerContrasenaDto + { + public string Token { get; set; } = string.Empty; + public string NuevaContrasena { get; set; } = string.Empty; + } + + public class GoogleAuthDto + { + public string GoogleId { get; set; } = string.Empty; + public string Correo { get; set; } = string.Empty; + public string Nombre { get; set; } = string.Empty; + public string? FotoUrl { get; set; } + } + + public class CompletarRegistroGoogleDto + { + public string Nombre { get; set; } = string.Empty; + public string CorreoElectronico { get; set; } = string.Empty; + public DateOnly FechaNacimiento { get; set; } + public string Genero { get; set; } = string.Empty; + public int CarreraId { get; set; } + public bool AceptoTerminos { get; set; } + public string GoogleId { get; set; } = string.Empty; + public string? FotoGoogleUrl { get; set; } + } } } diff --git a/Controllers/Api/ApiBienestarController.cs b/Controllers/Api/ApiBienestarController.cs new file mode 100644 index 0000000..0c7da7e --- /dev/null +++ b/Controllers/Api/ApiBienestarController.cs @@ -0,0 +1,110 @@ +using EpycusApp.Ayudantes; +using EpycusApp.DTOs; +using EpycusApp.Servicios.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace EpycusApp.Controllers.Api +{ + [ApiController] + [Route("api/v1/bienestar")] + [Authorize] + [EnableRateLimiting("Mobile")] + public class ApiBienestarController : BaseApiController + { + private readonly IServicioBienestar _servicioBienestar; + + public ApiBienestarController(IServicioBienestar servicioBienestar) + { + _servicioBienestar = servicioBienestar; + } + + [HttpGet("resumen")] + public async Task ObtenerResumen() + { + var usuarioId = ObtenerUsuarioId()!.Value; + + var alertasTask = _servicioBienestar.ObtenerAlertasActivas(usuarioId); + var fraseTask = _servicioBienestar.ObtenerFraseMotivacionalAleatoria(); + var estadoHoyTask = _servicioBienestar.ObtenerEstadoHoy(usuarioId); + var habitosTask = _servicioBienestar.ObtenerHabitosPendientesAsync(usuarioId); + var misionesTask = _servicioBienestar.ObtenerMisionesPendientesAsync(usuarioId); + + await Task.WhenAll(alertasTask, fraseTask, estadoHoyTask, habitosTask, misionesTask); + + var resultado = new BienestarResumenResponse + { + Alertas = alertasTask.Result, + Frase = fraseTask.Result, + EstadoHoy = estadoHoyTask.Result, + HabitosPendientes = habitosTask.Result, + MisionesPendientes = misionesTask.Result + }; + + return Ok(RespuestaApi.Exitosa(resultado)); + } + + [HttpGet("alertas")] + public async Task ObtenerAlertas() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var alertas = await _servicioBienestar.ObtenerAlertasActivas(usuarioId); + return Ok(RespuestaApi.Exitosa(new AlertasResponse { Alertas = alertas })); + } + + [HttpGet("frase")] + public async Task ObtenerFrase() + { + var frase = await _servicioBienestar.ObtenerFraseMotivacionalAleatoria(); + return Ok(RespuestaApi.Exitosa(new { frase })); + } + + [HttpGet("estado-hoy")] + public async Task ObtenerEstadoHoy() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var estado = await _servicioBienestar.ObtenerEstadoHoy(usuarioId); + return Ok(RespuestaApi.Exitosa(new EstadoHoyResponse { Estado = estado })); + } + + [HttpGet("historial-animo")] + public async Task ObtenerHistorialAnimo([FromQuery] int dias = 30) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var historial = await _servicioBienestar.ObtenerHistorialAnimo(usuarioId, dias); + return Ok(RespuestaApi.Exitosa(new HistorialAnimoResponse { Historial = historial })); + } + + [HttpGet("habitos-pendientes")] + public async Task ObtenerHabitosPendientes() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var cantidad = await _servicioBienestar.ObtenerHabitosPendientesAsync(usuarioId); + return Ok(RespuestaApi.Exitosa(new CantidadResponse { Cantidad = cantidad })); + } + + [HttpGet("misiones-pendientes")] + public async Task ObtenerMisionesPendientes() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var cantidad = await _servicioBienestar.ObtenerMisionesPendientesAsync(usuarioId); + return Ok(RespuestaApi.Exitosa(new CantidadResponse { Cantidad = cantidad })); + } + + [HttpPost("pausa-activa")] + public IActionResult ObtenerPausaActiva([FromBody] PausaActivaDto dto) + { + if (!ModelState.IsValid) + return BadRequest(RespuestaApi.Fallida("Datos inválidos")); + + var recomendacion = _servicioBienestar.RecomendacionPausaActiva(dto.CiclosCompletados); + return Ok(RespuestaApi.Exitosa(new PausaActivaResponse { Recomendacion = recomendacion })); + } + + public class PausaActivaDto + { + public int CiclosCompletados { get; set; } + } + } +} diff --git a/Controllers/Api/ApiDashboardController.cs b/Controllers/Api/ApiDashboardController.cs index cab649e..85c6009 100644 --- a/Controllers/Api/ApiDashboardController.cs +++ b/Controllers/Api/ApiDashboardController.cs @@ -1,13 +1,16 @@ using EpycusApp.Ayudantes; +using EpycusApp.DTOs; using EpycusApp.Servicios.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EpycusApp.Controllers.Api { [ApiController] - [Route("api/dashboard")] + [Route("api/v1/dashboard")] [Authorize] + [EnableRateLimiting("Mobile")] public class ApiDashboardController : BaseApiController { private readonly IServicioBienestar _servicioBienestar; @@ -26,23 +29,23 @@ public async Task Resumen() var misionesPendientes = await _servicioBienestar.ObtenerMisionesPendientesAsync(usuarioId); var frase = await _servicioBienestar.ObtenerFraseMotivacionalAleatoria(); - var respuesta = new + var respuesta = new DashboardResumenResponse { - kpis = new { habitosPendientes, misionesPendientes }, - habitosPendientes, - misionesPendientes, - frase = frase == null ? null : new { frase = frase.Frase, autor = frase.Autor } + Kpis = new DashboardKpis { HabitosPendientes = habitosPendientes, MisionesPendientes = misionesPendientes }, + HabitosPendientes = habitosPendientes, + MisionesPendientes = misionesPendientes, + Frase = frase == null ? null : new FraseResponseDto { Frase = frase.Frase, Autor = frase.Autor } }; - return Ok(RespuestaApi.Exitosa(respuesta)); + return Ok(RespuestaApi.Exitosa(respuesta)); } [HttpGet("frase-del-dia")] public async Task FraseDelDia() { var frase = await _servicioBienestar.ObtenerFraseMotivacionalAleatoria(); - var resultado = frase == null ? null : new { frase = frase.Frase, autor = frase.Autor }; - return Ok(RespuestaApi.Exitosa(resultado)); + FraseResponseDto? resultado = frase == null ? null : new FraseResponseDto { Frase = frase.Frase, Autor = frase.Autor }; + return Ok(RespuestaApi.Exitosa(resultado)); } } } diff --git a/Controllers/Api/ApiDiarioController.cs b/Controllers/Api/ApiDiarioController.cs new file mode 100644 index 0000000..ed42a9b --- /dev/null +++ b/Controllers/Api/ApiDiarioController.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using EpycusApp.Ayudantes; +using EpycusApp.DTOs; +using EpycusApp.Servicios.Interfaces; +using EpycusApp.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace EpycusApp.Controllers.Api +{ + [ApiController] + [Route("api/v1/diario")] + [Authorize] + [EnableRateLimiting("Mobile")] + public class ApiDiarioController : BaseApiController + { + private readonly IServicioDiarioAnimo _servicioDiario; + + public ApiDiarioController(IServicioDiarioAnimo servicioDiario) + { + _servicioDiario = servicioDiario; + } + + [HttpGet("hoy")] + public async Task ObtenerHoy() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var entrada = await _servicioDiario.ObtenerEntradaHoy(usuarioId); + return Ok(RespuestaApi.Exitosa(new DiarioEntradaResponse { Entrada = entrada })); + } + + [HttpGet("fecha")] + public async Task ObtenerPorFecha([FromQuery] string fecha) + { + if (!DateOnly.TryParseExact(fecha, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fechaParsed)) + return BadRequest(RespuestaApi.Fallida("Formato de fecha inválido. Use yyyy-MM-dd.")); + + var usuarioId = ObtenerUsuarioId()!.Value; + var entrada = await _servicioDiario.ObtenerEntradaPorFecha(usuarioId, fechaParsed); + return Ok(RespuestaApi.Exitosa(new DiarioEntradaResponse { Entrada = entrada })); + } + + [HttpGet("mes")] + public async Task ObtenerMes([FromQuery] int anio, [FromQuery] int mes) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var entradas = await _servicioDiario.ObtenerEntradasMes(usuarioId, anio, mes); + return Ok(RespuestaApi.Exitosa(new DiarioEntradasResponse { Entradas = entradas })); + } + + [HttpPost] + public async Task Registrar([FromBody] RegistrarEntradaDiarioViewModel model) + { + if (!ModelState.IsValid) + return BadRequest(RespuestaApi.Fallida("Datos inválidos")); + + var usuarioId = ObtenerUsuarioId()!.Value; + var preguntaGuia = _servicioDiario.ObtenerPreguntaGuia(); + var entrada = await _servicioDiario.RegistrarEntrada(usuarioId, model, preguntaGuia); + return CreatedAtAction(nameof(ObtenerHoy), null, RespuestaApi.Exitosa(new DiarioEntradaResponse { Entrada = entrada }, "Entrada registrada correctamente")); + } + + [HttpPut("{fecha}")] + public async Task Actualizar(string fecha, [FromBody] RegistrarEntradaDiarioViewModel model) + { + if (!ModelState.IsValid) + return BadRequest(RespuestaApi.Fallida("Datos inválidos")); + + if (!DateOnly.TryParseExact(fecha, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fechaParsed)) + return BadRequest(RespuestaApi.Fallida("Formato de fecha inválido. Use yyyy-MM-dd.")); + + var usuarioId = ObtenerUsuarioId()!.Value; + var entrada = await _servicioDiario.ActualizarEntrada(usuarioId, fechaParsed, model); + + if (entrada is null) + return NotFound(RespuestaApi.Fallida("No se encontró una entrada para esa fecha")); + + return Ok(RespuestaApi.Exitosa(new DiarioEntradaResponse { Entrada = entrada }, "Entrada actualizada correctamente")); + } + + [HttpGet("racha")] + public async Task ObtenerRacha() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var diasConsecutivos = await _servicioDiario.ObtenerDiasConsecutivos(usuarioId); + return Ok(RespuestaApi.Exitosa(new DiarioRachaResponse { DiasConsecutivos = diasConsecutivos })); + } + + [HttpGet("promedio-mes")] + public async Task ObtenerPromedioMes([FromQuery] int anio, [FromQuery] int mes) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var promedio = await _servicioDiario.ObtenerPromedioAnimoMes(usuarioId, anio, mes); + return Ok(RespuestaApi.Exitosa(new DiarioPromedioMesResponse { Promedio = promedio })); + } + + [HttpGet("pregunta-guia")] + public IActionResult ObtenerPreguntaGuia() + { + var pregunta = _servicioDiario.ObtenerPreguntaGuia(); + return Ok(RespuestaApi.Exitosa(new PreguntaGuiaResponse { Pregunta = pregunta })); + } + } +} diff --git a/Controllers/Api/ApiEstadoAnimoController.cs b/Controllers/Api/ApiEstadoAnimoController.cs index f482601..9f6eb0f 100644 --- a/Controllers/Api/ApiEstadoAnimoController.cs +++ b/Controllers/Api/ApiEstadoAnimoController.cs @@ -1,15 +1,21 @@ -using EpycusApp.Ayudantes; +using System.ComponentModel.DataAnnotations; +using EpycusApp.Ayudantes; +using EpycusApp.DTOs; using EpycusApp.Servicios.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EpycusApp.Controllers.Api { [ApiController] - [Route("api/estado-animo")] + [Route("api/v1/estado-animo")] [Authorize] + [EnableRateLimiting("Mobile")] public class ApiEstadoAnimoController : BaseApiController { + private static readonly HashSet EstadosPermitidos = ["Genial", "Bien", "Normal", "Cansado", "Estresado"]; + private readonly IServicioBienestar _servicioBienestar; public ApiEstadoAnimoController(IServicioBienestar servicioBienestar) @@ -20,6 +26,12 @@ public ApiEstadoAnimoController(IServicioBienestar servicioBienestar) [HttpPost] public async Task Registrar([FromBody] EstadoAnimoDto dto) { + if (!ModelState.IsValid) + return BadRequest(RespuestaApi.Fallida("Datos inválidos")); + + if (!EstadosPermitidos.Contains(dto.Estado)) + return BadRequest(RespuestaApi.Fallida($"Estado no válido. Permitidos: {string.Join(", ", EstadosPermitidos)}")); + var usuarioId = ObtenerUsuarioId()!.Value; var alerta = await _servicioBienestar.RegistrarEstadoAnimo(usuarioId, dto.Estado, dto.Nota); return Ok(RespuestaApi.Exitosa(new { success = true, alertaBienestar = alerta })); @@ -36,7 +48,11 @@ public async Task Historial() public class EstadoAnimoDto { + [Required(ErrorMessage = "El estado es obligatorio")] + [StringLength(20, MinimumLength = 2, ErrorMessage = "El estado debe tener entre 2 y 20 caracteres")] public string Estado { get; set; } = string.Empty; + + [StringLength(500, ErrorMessage = "La nota no puede superar los 500 caracteres")] public string? Nota { get; set; } } } diff --git a/Controllers/Api/ApiGamificacionController.cs b/Controllers/Api/ApiGamificacionController.cs index 141d34a..057c110 100644 --- a/Controllers/Api/ApiGamificacionController.cs +++ b/Controllers/Api/ApiGamificacionController.cs @@ -1,13 +1,16 @@ using EpycusApp.Ayudantes; +using EpycusApp.DTOs; using EpycusApp.Servicios.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EpycusApp.Controllers.Api { [ApiController] - [Route("api/gamificacion")] + [Route("api/v1/gamificacion")] [Authorize] + [EnableRateLimiting("Mobile")] public class ApiGamificacionController : BaseApiController { private readonly IServicioGamificacion _servicioGamificacion; @@ -29,18 +32,18 @@ public async Task ObtenerProgreso() var xpParaSiguiente = CalculadorXP.XpParaSiguienteNivel(nivelActual.Numero); var imagen = await _servicioProgreso.ObtenerImagenPersonaje(usuarioId, nivelActual.Numero); - var respuesta = new + var respuesta = new GamificacionProgresoResponse { - xpTotal = progreso.XpTotal, - nivel = nivelActual.Numero, - titulo = nivelActual.Titulo, - rachaActual = progreso.RachaActual, - xpParaSiguienteNivel = xpParaSiguiente, - porcentajeProgreso = porcentaje, - imagenPersonaje = imagen + XpTotal = progreso.XpTotal, + Nivel = nivelActual.Numero, + Titulo = nivelActual.Titulo, + RachaActual = progreso.RachaActual, + XpParaSiguienteNivel = xpParaSiguiente, + PorcentajeProgreso = (double)porcentaje, + ImagenPersonaje = ConvertirUrlAbsoluta(imagen) }; - return Ok(RespuestaApi.Exitosa(respuesta)); + return Ok(RespuestaApi.Exitosa(respuesta)); } [HttpGet("logros")] @@ -50,15 +53,15 @@ public async Task ObtenerLogros() var logros = await _servicioProgreso.ObtenerTodosLosLogros(); var desbloqueados = await _servicioProgreso.ObtenerLogrosUsuario(usuarioId); - var respuesta = logros.Select(l => new + var respuesta = logros.Select(l => new LogroConProgresoResponse { - logro = l, - desbloqueado = desbloqueados.Any(d => d.LogroId == l.Id), - progreso = 0, - meta = l.CondicionValor - }); + Logro = l, + Desbloqueado = desbloqueados.Any(d => d.LogroId == l.Id), + Progreso = 0, + Meta = l.CondicionValor + }).ToList(); - return Ok(RespuestaApi.Exitosa(respuesta)); + return Ok(RespuestaApi>.Exitosa(respuesta)); } } } diff --git a/Controllers/Api/ApiHabitosController.cs b/Controllers/Api/ApiHabitosController.cs index e521640..7151f4c 100644 --- a/Controllers/Api/ApiHabitosController.cs +++ b/Controllers/Api/ApiHabitosController.cs @@ -1,14 +1,18 @@ -using EpycusApp.Ayudantes; +using System.Linq; +using EpycusApp.Ayudantes; using EpycusApp.DTOs; using EpycusApp.Servicios.Interfaces; +using EpycusApp.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EpycusApp.Controllers.Api { [ApiController] - [Route("api/[controller]")] + [Route("api/v1/habitos")] [Authorize] + [EnableRateLimiting("Mobile")] public class ApiHabitosController : BaseApiController { private readonly IServicioHabitos _servicioHabitos; @@ -57,7 +61,15 @@ public async Task Completar(int id) var resultado = await _servicioHabitos.CompletarHabito(id, usuarioId.Value); if (!resultado.Exito) - return BadRequest(RespuestaApi.Fallida("No se pudo completar el hábito")); + { + var mensaje = resultado.XpGanado switch + { + -1 => "Hábito no encontrado", + -2 => "Este hábito ya fue completado hoy", + _ => "No se pudo completar el hábito" + }; + return BadRequest(RespuestaApi.Fallida(mensaje)); + } var respuesta = new CompletarHabitoRespuestaDto { XpGanado = resultado.XpGanado }; return Ok(RespuestaApi.Exitosa(respuesta)); @@ -96,5 +108,129 @@ public async Task Semana(int id) return Ok(RespuestaApi.Exitosa(registros)); } + + [HttpGet("{id}")] + public async Task ObtenerPorId(int id) + { + var usuarioId = ObtenerUsuarioId(); + if (!usuarioId.HasValue) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var habito = await _servicioHabitos.ObtenerPorIdViewModel(id); + if (habito == null) + return NotFound(RespuestaApi.Fallida("Hábito no encontrado")); + + return Ok(RespuestaApi.Exitosa(habito)); + } + + [HttpPost] + public async Task Crear([FromBody] CrearHabitoDto dto) + { + var usuarioId = ObtenerUsuarioId(); + if (!usuarioId.HasValue) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + int categoriaId = dto.CategoriaId; + if (categoriaId <= 0 && !string.IsNullOrWhiteSpace(dto.Categoria)) + { + var categorias = await _servicioHabitos.ObtenerCategoriasActivas(); + var cat = categorias.FirstOrDefault(c => + c.Nombre.Equals(dto.Categoria, StringComparison.OrdinalIgnoreCase)); + if (cat != null) categoriaId = cat.Id; + } + + var modelo = new CrearHabitoViewModel + { + Nombre = dto.Nombre, + Descripcion = dto.Descripcion, + CategoriaId = categoriaId, + Frecuencia = dto.Frecuencia, + DiasSemana = dto.DiasSemana?.ToList(), + ConPomodoro = dto.ConPomodoro ?? false, + RecordatorioHora = dto.RecordatorioHora != null ? TimeSpan.Parse(dto.RecordatorioHora) : null, + EstaActivo = dto.EstaActivo ?? true + }; + + await _servicioHabitos.CrearHabito(modelo, usuarioId.Value); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpPut("{id}")] + public async Task Editar(int id, [FromBody] EditarHabitoDto dto) + { + var usuarioId = ObtenerUsuarioId(); + if (!usuarioId.HasValue) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var modelo = new EditarHabitoViewModel + { + Id = id, + Nombre = dto.Nombre, + Descripcion = dto.Descripcion, + CategoriaId = dto.CategoriaId, + Frecuencia = dto.Frecuencia, + DiasSemana = dto.DiasSemana?.ToList(), + ConPomodoro = dto.ConPomodoro ?? false, + RecordatorioHora = dto.RecordatorioHora != null ? TimeSpan.Parse(dto.RecordatorioHora) : null, + EstaActivo = dto.EstaActivo ?? true + }; + + await _servicioHabitos.EditarHabito(modelo, usuarioId.Value); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpDelete("{id}")] + public async Task Eliminar(int id) + { + var usuarioId = ObtenerUsuarioId(); + if (!usuarioId.HasValue) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + await _servicioHabitos.EliminarHabito(id, usuarioId.Value); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpGet("dashboard")] + public async Task Dashboard() + { + var usuarioId = ObtenerUsuarioId(); + if (!usuarioId.HasValue) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var dashboard = await _servicioHabitos.ObtenerDashboard(usuarioId.Value); + return Ok(RespuestaApi.Exitosa(dashboard)); + } + + [HttpGet("categorias")] + public async Task Categorias() + { + var categorias = await _servicioHabitos.ObtenerCategoriasActivas(); + return Ok(RespuestaApi.Exitosa(categorias)); + } + } + + public class CrearHabitoDto + { + public string Nombre { get; set; } = string.Empty; + public string? Descripcion { get; set; } + public int CategoriaId { get; set; } + public string? Categoria { get; set; } + public string Frecuencia { get; set; } = "Diaria"; + public int[]? DiasSemana { get; set; } + public bool? ConPomodoro { get; set; } + public string? RecordatorioHora { get; set; } + public bool? EstaActivo { get; set; } + } + + public class EditarHabitoDto + { + public string Nombre { get; set; } = string.Empty; + public string? Descripcion { get; set; } + public int CategoriaId { get; set; } + public string Frecuencia { get; set; } = "Diaria"; + public int[]? DiasSemana { get; set; } + public bool? ConPomodoro { get; set; } + public string? RecordatorioHora { get; set; } + public bool? EstaActivo { get; set; } } } diff --git a/Controllers/Api/ApiIaController.cs b/Controllers/Api/ApiIaController.cs new file mode 100644 index 0000000..f4c3b55 --- /dev/null +++ b/Controllers/Api/ApiIaController.cs @@ -0,0 +1,94 @@ +using EpycusApp.Ayudantes; +using EpycusApp.DTOs; +using EpycusApp.Servicios.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace EpycusApp.Controllers.Api +{ + [ApiController] + [Route("api/v1/ia")] + [Authorize] + [EnableRateLimiting("Mobile")] + public class ApiIaController : BaseApiController + { + private readonly IServicioIA _servicioIA; + + public ApiIaController(IServicioIA servicioIA) + { + _servicioIA = servicioIA; + } + + [HttpPost("chat")] + public async Task Chat([FromBody] ChatRequest request) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var conversacionId = string.IsNullOrWhiteSpace(request.ConversacionId) + ? _servicioIA.NuevaConversacionId() + : request.ConversacionId; + var respuesta = await _servicioIA.ChatAsync(usuarioId, request.Mensaje, conversacionId); + return Ok(RespuestaApi.Exitosa(new IaChatResponseDto { Respuesta = respuesta, ConversacionId = conversacionId })); + } + + [HttpGet("historial")] + public async Task Historial([FromQuery] string conversacionId) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var historial = await _servicioIA.ObtenerHistorialAsync(usuarioId, conversacionId); + return Ok(RespuestaApi.Exitosa(new IaHistorialResponse { Historial = historial })); + } + + [HttpGet("conversaciones")] + public async Task Conversaciones() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var conversaciones = await _servicioIA.ObtenerConversacionesAsync(usuarioId); + return Ok(RespuestaApi.Exitosa(new IaConversacionesResponse { Conversaciones = conversaciones })); + } + + [HttpGet("sugerencias")] + public async Task Sugerencias() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var sugerencias = await _servicioIA.ObtenerSugerenciasPersonalizadasAsync(usuarioId); + return Ok(RespuestaApi.Exitosa(new IaSugerenciasResponse { Sugerencias = sugerencias })); + } + + [HttpGet("contexto-bienestar")] + public async Task ContextoBienestar() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var contexto = await _servicioIA.ObtenerBienestarContextoAsync(usuarioId); + return Ok(RespuestaApi.Exitosa(contexto)); + } + + [HttpPost("feedback")] + public async Task Feedback([FromBody] FeedbackRequest request) + { + var usuarioId = ObtenerUsuarioId()!.Value; + await _servicioIA.RegistrarFeedbackAsync(usuarioId, request.MensajeId, request.Util); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpGet("mensajes-hoy")] + public async Task MensajesHoy() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var cantidad = await _servicioIA.ObtenerMensajesHoyAsync(usuarioId); + return Ok(RespuestaApi.Exitosa(new IaMensajesHoyResponse { Cantidad = cantidad })); + } + } + + public class ChatRequest + { + public string Mensaje { get; set; } = string.Empty; + public string? ConversacionId { get; set; } + } + + public class FeedbackRequest + { + public int MensajeId { get; set; } + public bool Util { get; set; } + } +} diff --git a/Controllers/Api/ApiMisionesController.cs b/Controllers/Api/ApiMisionesController.cs index 1a296ba..c6f65b5 100644 --- a/Controllers/Api/ApiMisionesController.cs +++ b/Controllers/Api/ApiMisionesController.cs @@ -1,13 +1,17 @@ using EpycusApp.Ayudantes; +using EpycusApp.DTOs; using EpycusApp.Servicios.Interfaces; +using EpycusApp.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EpycusApp.Controllers.Api { [ApiController] - [Route("api/misiones")] + [Route("api/v1/misiones")] [Authorize] + [EnableRateLimiting("Mobile")] public class ApiMisionesController : BaseApiController { private readonly IServicioMisiones _servicioMisiones; @@ -17,6 +21,89 @@ public ApiMisionesController(IServicioMisiones servicioMisiones) _servicioMisiones = servicioMisiones; } + [HttpGet] + public async Task ObtenerMisiones() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var misiones = await _servicioMisiones.ObtenerMisionesDeUsuario(usuarioId); + var resultado = misiones.Select(m => new MisionListaItemResponse + { + Id = m.Id, + Nombre = m.Nombre, + Descripcion = m.Descripcion, + NombreCurso = m.NombreCurso, + Prioridad = m.Prioridad, + Estado = m.Estado, + FechaLimite = m.FechaLimite.ToString("yyyy-MM-dd"), + XpOtorgado = m.XpOtorgado, + FechaCreacion = m.FechaCreacion, + CategoriaId = m.CategoriaId, + SubTareasCount = m.SubTareas?.Count ?? 0, + SubTareasCompletadas = m.SubTareas?.Count(st => st.EstaCompletada) ?? 0, + TiempoEnfoqueSegundos = m.SubTareas?.Sum(st => st.TiempoEnfoqueSegundos) ?? 0 + }).ToList(); + return Ok(RespuestaApi>.Exitosa(resultado)); + } + + [HttpGet("{id}")] + public async Task ObtenerPorId(int id) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var mision = await _servicioMisiones.ObtenerPorId(id); + if (mision == null) + return NotFound(RespuestaApi.Fallida("Misión no encontrada")); + return Ok(RespuestaApi.Exitosa(mision)); + } + + [HttpPost] + public async Task Crear([FromBody] CrearMisionDto dto) + { + var usuarioId = ObtenerUsuarioId()!.Value; + + var modelo = new CrearMisionViewModel + { + Nombre = dto.Nombre, + Descripcion = dto.Descripcion, + NombreCurso = dto.NombreCurso, + FechaLimite = string.IsNullOrEmpty(dto.FechaLimite) ? default : DateTime.Parse(dto.FechaLimite), + Prioridad = dto.Prioridad, + ConPomodoro = dto.ConPomodoro ?? false, + CategoriaId = dto.CategoriaId ?? 0 + }; + + await _servicioMisiones.CrearMision(modelo, usuarioId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpPut("{id}")] + public async Task Editar(int id, [FromBody] EditarMisionDto dto) + { + var usuarioId = ObtenerUsuarioId()!.Value; + + var modelo = new EditarMisionViewModel + { + Id = id, + Nombre = dto.Nombre, + Descripcion = dto.Descripcion, + NombreCurso = dto.NombreCurso, + FechaLimite = DateTime.Parse(dto.FechaLimite), + Prioridad = dto.Prioridad, + ConPomodoro = dto.ConPomodoro ?? false, + CategoriaId = dto.CategoriaId + }; + + await _servicioMisiones.EditarMision(modelo, usuarioId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpDelete("{id}")] + public async Task Eliminar(int id) + { + var usuarioId = ObtenerUsuarioId()!.Value; + await _servicioMisiones.EliminarMision(id, usuarioId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + [HttpPost("{id}/completar")] public async Task Completar(int id) { @@ -25,10 +112,10 @@ public async Task Completar(int id) if (!resultado.Exito) { - return BadRequest(RespuestaApi.Fallida("No se pudo completar la misión")); + return BadRequest(RespuestaApi.Fallida("No se pudo completar la misión")); } - return Ok(RespuestaApi.Exitosa(new { xpGanado = resultado.XpGanado })); + return Ok(RespuestaApi.Exitosa(new MisionCompletarResponse { XpGanado = resultado.XpGanado })); } [HttpPost("{id}/estado")] @@ -36,7 +123,134 @@ public async Task CambiarEstado(int id, [FromBody] EstadoDto dto) { var usuarioId = ObtenerUsuarioId()!.Value; await _servicioMisiones.CambiarEstado(id, dto.Estado, usuarioId); - return Ok(RespuestaApi.Exitosa(new { success = true })); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpGet("{misionId}/sub-tareas")] + public async Task ObtenerSubTareas(int misionId) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var subTareas = await _servicioMisiones.ObtenerSubTareas(misionId, usuarioId); + var resultado = subTareas.Select(st => new SubTareaResponse + { + Id = st.Id, + Nombre = st.Nombre, + Descripcion = st.Descripcion, + EstaCompletada = st.EstaCompletada, + Orden = st.Orden, + TiempoEnfoqueSegundos = st.TiempoEnfoqueSegundos, + TiempoEnfoqueFormateado = FormateadorTiempo.FormatearSegundos(st.TiempoEnfoqueSegundos), + FechaCreacion = st.FechaCreacion, + FechaCompletado = st.FechaCompletado, + MisionId = st.MisionId + }).ToList(); + return Ok(RespuestaApi>.Exitosa(resultado)); + } + + [HttpGet("{misionId}/sub-tareas/{id}")] + public async Task ObtenerSubTarea(int misionId, int id) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var subTarea = await _servicioMisiones.ObtenerSubTareaPorId(id, usuarioId); + if (subTarea == null) + return NotFound(RespuestaApi.Fallida("Sub-tarea no encontrada")); + + var resultado = new SubTareaResponse + { + Id = subTarea.Id, + Nombre = subTarea.Nombre, + Descripcion = subTarea.Descripcion, + EstaCompletada = subTarea.EstaCompletada, + Orden = subTarea.Orden, + TiempoEnfoqueSegundos = subTarea.TiempoEnfoqueSegundos, + TiempoEnfoqueFormateado = FormateadorTiempo.FormatearSegundos(subTarea.TiempoEnfoqueSegundos), + FechaCreacion = subTarea.FechaCreacion, + FechaCompletado = subTarea.FechaCompletado, + MisionId = subTarea.MisionId + }; + return Ok(RespuestaApi.Exitosa(resultado)); + } + + [HttpPost("{misionId}/sub-tareas")] + public async Task CrearSubTarea(int misionId, [FromBody] CrearSubTareaDto dto) + { + var usuarioId = ObtenerUsuarioId()!.Value; + try + { + await _servicioMisiones.CrearSubTarea(dto.Nombre, dto.Descripcion, misionId, usuarioId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + catch (Exception ex) + { + return BadRequest(RespuestaApi.Fallida(ex.Message)); + } + } + + [HttpPut("{misionId}/sub-tareas/{id}")] + public async Task EditarSubTarea(int misionId, int id, [FromBody] EditarSubTareaDto dto) + { + var usuarioId = ObtenerUsuarioId()!.Value; + try + { + await _servicioMisiones.EditarSubTarea(id, dto.Nombre, dto.Descripcion, dto.Orden, usuarioId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + catch (Exception ex) + { + return BadRequest(RespuestaApi.Fallida(ex.Message)); + } + } + + [HttpPost("{misionId}/sub-tareas/{id}/completar")] + public async Task CompletarSubTarea(int misionId, int id) + { + var usuarioId = ObtenerUsuarioId()!.Value; + try + { + await _servicioMisiones.CompletarSubTarea(id, usuarioId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + catch (Exception ex) + { + return BadRequest(RespuestaApi.Fallida(ex.Message)); + } + } + + [HttpPost("{misionId}/sub-tareas/{id}/descompletar")] + public async Task DescompletarSubTarea(int misionId, int id) + { + var usuarioId = ObtenerUsuarioId()!.Value; + try + { + await _servicioMisiones.DescompletarSubTarea(id, usuarioId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + catch (Exception ex) + { + return BadRequest(RespuestaApi.Fallida(ex.Message)); + } + } + + [HttpDelete("{misionId}/sub-tareas/{id}")] + public async Task EliminarSubTarea(int misionId, int id) + { + var usuarioId = ObtenerUsuarioId()!.Value; + try + { + await _servicioMisiones.EliminarSubTarea(id, usuarioId); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + catch (Exception ex) + { + return BadRequest(RespuestaApi.Fallida(ex.Message)); + } + } + + [HttpGet("categorias")] + public async Task Categorias() + { + var categorias = await _servicioMisiones.ObtenerCategoriasMisionAsync(); + return Ok(RespuestaApi.Exitosa(categorias)); } public class EstadoDto @@ -44,4 +258,26 @@ public class EstadoDto public string Estado { get; set; } = string.Empty; } } + + public class CrearMisionDto + { + public string Nombre { get; set; } = string.Empty; + public string? Descripcion { get; set; } + public string? NombreCurso { get; set; } + public string FechaLimite { get; set; } = string.Empty; + public string Prioridad { get; set; } = "Media"; + public bool? ConPomodoro { get; set; } + public int? CategoriaId { get; set; } + } + + public class EditarMisionDto + { + public string Nombre { get; set; } = string.Empty; + public string? Descripcion { get; set; } + public string? NombreCurso { get; set; } + public string FechaLimite { get; set; } = string.Empty; + public string Prioridad { get; set; } = "Media"; + public bool? ConPomodoro { get; set; } + public int CategoriaId { get; set; } + } } diff --git a/Controllers/Api/ApiPerfilController.cs b/Controllers/Api/ApiPerfilController.cs new file mode 100644 index 0000000..2750d46 --- /dev/null +++ b/Controllers/Api/ApiPerfilController.cs @@ -0,0 +1,147 @@ +using EpycusApp.Ayudantes; +using EpycusApp.DTOs; +using EpycusApp.Models.Entidades; +using EpycusApp.Servicios.Interfaces; +using EpycusApp.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace EpycusApp.Controllers.Api +{ + [ApiController] + [Route("api/v1/perfil")] + [Authorize] + [EnableRateLimiting("Mobile")] + public class ApiPerfilController : BaseApiController + { + private readonly IServicioPerfil _servicioPerfil; + private readonly IServicioAutenticacion _servicioAutenticacion; + + public ApiPerfilController(IServicioPerfil servicioPerfil, IServicioAutenticacion servicioAutenticacion) + { + _servicioPerfil = servicioPerfil; + _servicioAutenticacion = servicioAutenticacion; + } + + [HttpGet] + public async Task Obtener() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var perfil = await _servicioPerfil.ObtenerPerfilCompletoAsync(usuarioId); + if (perfil == null) + { + return NotFound(RespuestaApi.Fallida("Perfil no encontrado")); + } + + var imagenPersonaje = ConvertirUrlAbsoluta(await _servicioPerfil.ObtenerImagenPersonajeActual(usuarioId)); + + return Ok(RespuestaApi.Exitosa(new + { + perfil, + imagenPersonaje + })); + } + + [HttpPut] + public async Task Actualizar([FromBody] ActualizarRequestDto request) + { + var usuarioId = ObtenerUsuarioId()!.Value; + + var modelo = new ActualizarPerfilViewModel + { + Nombre = request.Nombre, + CarreraId = request.CarreraId + }; + + var resultado = await _servicioPerfil.ActualizarPerfilAsync(usuarioId, modelo); + if (!resultado.EsExitoso) + { + return BadRequest(RespuestaApi.Fallida(resultado.Mensaje ?? "Error al actualizar perfil")); + } + + return Ok(RespuestaApi.Exitosa(new MensajeResponseDto { Mensaje = resultado.Mensaje ?? "Perfil actualizado" })); + } + + [HttpPut("cambiar-contrasena")] + public async Task CambiarContrasena([FromBody] CambiarContrasenaRequestDto request) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var perfil = await _servicioPerfil.ObtenerPerfil(usuarioId); + if (perfil == null) + { + return NotFound(RespuestaApi.Fallida("Usuario no encontrado")); + } + + var (exito, mensaje) = await _servicioAutenticacion.CambiarContrasenaAsync( + perfil.CorreoElectronico, request.ContrasenaActual, request.NuevaContrasena); + if (!exito) + { + return BadRequest(RespuestaApi.Fallida(mensaje ?? "Error al cambiar contraseña")); + } + + return Ok(RespuestaApi.Exitosa(new MensajeResponseDto { Mensaje = mensaje ?? "Contraseña cambiada exitosamente" })); + } + + [HttpPut("personaje")] + public async Task CambiarPersonaje([FromBody] PersonajeRequestDto request) + { + var usuarioId = ObtenerUsuarioId()!.Value; + await _servicioPerfil.CambiarPersonaje(request.PersonajeId, usuarioId); + var nuevaImagen = ConvertirUrlAbsoluta(await _servicioPerfil.ObtenerImagenPersonajeActual(usuarioId)); + + return Ok(RespuestaApi.Exitosa(new { imagenUrl = nuevaImagen })); + } + + [HttpPut("tema")] + public async Task CambiarTema([FromBody] TemaRequestDto request) + { + var usuarioId = ObtenerUsuarioId()!.Value; + var resultado = await _servicioPerfil.CambiarTemaAsync(usuarioId, request.TemaId); + if (!resultado.EsExitoso) + { + return BadRequest(RespuestaApi.Fallida(resultado.Mensaje ?? "Error al cambiar tema")); + } + + return Ok(RespuestaApi.Exitosa(new MensajeResponseDto { Mensaje = resultado.Mensaje ?? "Tema actualizado" })); + } + + [HttpGet("personajes")] + public async Task Personajes() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var personajes = await _servicioPerfil.ObtenerPersonajesDisponiblesAsync(usuarioId); + return Ok(RespuestaApi>.Exitosa(personajes)); + } + + [HttpGet("logros")] + public async Task Logros() + { + var usuarioId = ObtenerUsuarioId()!.Value; + var logros = await _servicioPerfil.ObtenerLogrosUsuarioConLogroAsync(usuarioId); + return Ok(RespuestaApi>.Exitosa(logros)); + } + + public class ActualizarRequestDto + { + public string Nombre { get; set; } = string.Empty; + public int? CarreraId { get; set; } + } + + public class CambiarContrasenaRequestDto + { + public string ContrasenaActual { get; set; } = string.Empty; + public string NuevaContrasena { get; set; } = string.Empty; + } + + public class PersonajeRequestDto + { + public int PersonajeId { get; set; } + } + + public class TemaRequestDto + { + public int TemaId { get; set; } + } + } +} diff --git a/Controllers/Api/ApiPomodoroController.cs b/Controllers/Api/ApiPomodoroController.cs index 3261128..ac3734f 100644 --- a/Controllers/Api/ApiPomodoroController.cs +++ b/Controllers/Api/ApiPomodoroController.cs @@ -1,14 +1,17 @@ using EpycusApp.Ayudantes; using EpycusApp.DTOs; using EpycusApp.Servicios.Interfaces; +using EpycusApp.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EpycusApp.Controllers.Api { [Authorize] [ApiController] - [Route("api/[controller]")] + [Route("api/v1/pomodoro")] + [EnableRateLimiting("Pomodoro")] public class ApiPomodoroController : BaseApiController { private readonly IServicioPomodoro _servicioPomodoro; @@ -18,8 +21,6 @@ public ApiPomodoroController(IServicioPomodoro servicioPomodoro) _servicioPomodoro = servicioPomodoro; } - public class IniciarRequest { public int? HabitoId { get; set; } public int? MisionId { get; set; } } - [HttpPost("iniciar")] public async Task Iniciar([FromBody] IniciarRequest req) { @@ -28,34 +29,77 @@ public async Task Iniciar([FromBody] IniciarRequest req) { return Unauthorized(RespuestaApi.Fallida("No autenticado")); } - var sesion = await _servicioPomodoro.IniciarSesion(usuarioId.Value, req?.HabitoId, req?.MisionId); - return Ok(RespuestaApi.Exitosa(new { sesionId = sesion.Id, fechaInicio = sesion.FechaInicio })); - } - public class CicloCompletadoRequest { public int CiclosCompletados { get; set; } } + var (exito, sesion, error) = await _servicioPomodoro.IniciarSesionSiNoActiva(usuarioId.Value, req?.HabitoId, req?.MisionId, req?.SubTareaId); + if (!exito) + { + return Conflict(RespuestaApi.Fallida(error ?? "No se pudo iniciar la sesión.")); + } + + return Ok(RespuestaApi.Exitosa(new PomodoroIniciarResponse { SesionId = sesion!.Id, FechaInicio = sesion.FechaInicio })); + } [HttpPost("{sesionId}/ciclo-completado")] public async Task CicloCompletado(int sesionId, [FromBody] CicloCompletadoRequest req) { - var resultado = await _servicioPomodoro.RegistrarCiclo(sesionId, req?.CiclosCompletados ?? 0); - return Ok(RespuestaApi.Exitosa(new { xpGanado = resultado.XpGanado, sugerirDescanso = resultado.SugerirDescanso, pausaActiva = resultado.PausaActiva })); + if (!ModelState.IsValid) + { + return BadRequest(RespuestaApi.Fallida("CiclosCompletados debe ser entre 1 y 100.")); + } + + var sesion = await _servicioPomodoro.ObtenerSesion(sesionId); + if (sesion == null) return NotFound(); + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null || sesion.UsuarioId != usuarioId.Value) + return Unauthorized(RespuestaApi.Fallida("No autorizado")); + + var resultado = await _servicioPomodoro.RegistrarCiclo(sesionId, req?.CiclosCompletados ?? 0, usuarioId.Value); + return Ok(RespuestaApi.Exitosa(new PomodoroCicloCompletadoResponse { XpGanado = resultado.XpGanado, SugerirDescanso = resultado.SugerirDescanso, PausaActiva = resultado.PausaActiva })); } [HttpPost("{sesionId}/finalizar")] public async Task Finalizar(int sesionId, [FromBody] CicloCompletadoRequest req) { - await _servicioPomodoro.FinalizarSesion(sesionId, req?.CiclosCompletados ?? 0); - - // Recalcular xpTotal de la sesión para devolverlo (o leerlo de base de datos) + if (!ModelState.IsValid) + { + return BadRequest(RespuestaApi.Fallida("CiclosCompletados debe ser entre 1 y 100.")); + } + var sesion = await _servicioPomodoro.ObtenerSesion(sesionId); - return Ok(RespuestaApi.Exitosa(new { xpTotal = sesion?.XpOtorgado ?? 0, sesionGuardada = true })); + if (sesion == null) return NotFound(); + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null || sesion.UsuarioId != usuarioId.Value) + return Unauthorized(RespuestaApi.Fallida("No autorizado")); + + var (xpTotal, xpBonus) = await _servicioPomodoro.FinalizarSesion(sesionId, req?.CiclosCompletados ?? 0, usuarioId.Value); + return Ok(RespuestaApi.Exitosa(new PomodoroFinalizarResponse { XpTotal = xpTotal, SesionGuardada = true, XpBonus = xpBonus })); } [HttpPost("{sesionId}/cancelar")] public async Task Cancelar(int sesionId) { - await _servicioPomodoro.CancelarSesion(sesionId); - return Ok(RespuestaApi.Exitosa(new { success = true })); + var sesion = await _servicioPomodoro.ObtenerSesion(sesionId); + if (sesion == null) return NotFound(); + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null || sesion.UsuarioId != usuarioId.Value) + return Unauthorized(RespuestaApi.Fallida("No autorizado")); + + await _servicioPomodoro.CancelarSesion(sesionId, usuarioId.Value); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); + } + + [HttpPost("descanso")] + public async Task RegistrarDescanso([FromBody] PomodoroDescansoRequest req) + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + if (req == null || string.IsNullOrEmpty(req.Tipo)) + return BadRequest(RespuestaApi.Fallida("Tipo de descanso requerido")); + + var sesion = await _servicioPomodoro.CrearSesionDescanso(usuarioId.Value, req.Tipo, req.Segundos); + return Ok(RespuestaApi.Exitosa(new PomodoroIniciarResponse { SesionId = sesion.Id, FechaInicio = sesion.FechaInicio })); } [HttpGet("configuracion")] @@ -67,12 +111,22 @@ public async Task ObtenerConfiguracion() return Unauthorized(RespuestaApi.Fallida("No autenticado")); } var config = await _servicioPomodoro.ObtenerConfiguracion(usuarioId.Value); - return Ok(RespuestaApi.Exitosa(new { - tiempoEstudio = config.TiempoEstudioMin, - tiempoDescanso = config.TiempoDescansoMin, - tiempoDescansoLargo = config.TiempoDescansoLargoMin, - ciclosAntesDescansoLargo = config.CiclosAntesDescansoLargo, - sonidoActivo = config.SonidoActivo + return Ok(RespuestaApi.Exitosa(new PomodoroConfiguracionResponse + { + TiempoEstudio = config.TiempoEstudioMin, + TiempoDescanso = config.TiempoDescansoMin, + TiempoDescansoLargo = config.TiempoDescansoLargoMin, + CiclosAntesDescansoLargo = config.CiclosAntesDescansoLargo, + SonidoActivo = config.SonidoActivo, + SonidoSeleccionado = config.SonidoSeleccionado, + Volumen = config.Volumen, + AutoIniciarDescanso = config.AutoIniciarDescanso, + AutoIniciarEnfoque = config.AutoIniciarEnfoque, + TicTacActivo = config.TicTacActivo, + MetaDiariaCiclos = config.MetaDiariaCiclos, + ModoPersonalizadoMin = config.ModoPersonalizadoMin, + VibracionActiva = config.VibracionActiva, + NotificacionDesktop = config.NotificacionDesktop })); } @@ -87,18 +141,134 @@ public async Task ActualizarConfiguracion([FromBody] ActualizarCo if (dto == null) { - return BadRequest(RespuestaApi.Fallida("Datos inválidos.")); + return BadRequest(RespuestaApi.Fallida("Datos inválidos.")); + } + + if (!ModelState.IsValid) + { + return BadRequest(RespuestaApi.Fallida("Uno o más valores no son válidos.")); } await _servicioPomodoro.ActualizarConfiguracion(usuarioId.Value, dto); - return Ok(RespuestaApi.Exitosa(new { success = true })); + return Ok(RespuestaApi.Exitosa(new SuccessResponseDto())); } [HttpGet("tip-aleatorio")] public async Task ObtenerTip() { var tip = await _servicioPomodoro.ObtenerTipAleatorio(); - return Ok(RespuestaApi.Exitosa(new { tip })); + return Ok(RespuestaApi.Exitosa(new PomodoroTipResponse { Consejo = tip })); + } + + [HttpGet("sesion-activa")] + public async Task ObtenerSesionActiva() + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var sesionesHoy = await _servicioPomodoro.ObtenerSesionesHoyAsync(usuarioId.Value); + var activa = sesionesHoy.FirstOrDefault(s => !s.FechaFin.HasValue); + if (activa == null) + return Ok(RespuestaApi.Exitosa(new PomodoroSesionActivaResponse { Activa = false })); + + return Ok(RespuestaApi.Exitosa(new PomodoroSesionActivaResponse + { + Activa = true, + SesionId = activa.Id, + FechaInicio = activa.FechaInicio, + CiclosCompletados = activa.CiclosCompletados + })); + } + + [HttpGet("historial")] + public async Task ObtenerHistorial([FromQuery] DateTime? desde, [FromQuery] DateTime? hasta, [FromQuery] int pagina = 1, [FromQuery] int tamano = 20, [FromQuery] bool? completada = null, [FromQuery] bool? conXp = null) + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var desdeDate = desde ?? DateTime.UtcNow.AddDays(-30); + var hastaDate = hasta ?? DateTime.UtcNow; + tamano = Math.Clamp(tamano, 1, 100); + + var historial = await _servicioPomodoro.ObtenerHistorialAsync(usuarioId.Value, desdeDate, hastaDate, pagina, tamano, completada, conXp); + return Ok(RespuestaApi.Exitosa(new PomodoroHistorialResponse { Historial = historial, Pagina = pagina, Tamano = tamano })); + } + + [HttpGet("racha")] + public async Task ObtenerRacha() + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var racha = await _servicioPomodoro.ObtenerRachaActualAsync(usuarioId.Value); + return Ok(RespuestaApi.Exitosa(new PomodoroRachaResponse { Racha = racha })); + } + + [HttpGet("estadisticas")] + public async Task ObtenerEstadisticas([FromQuery] DateTime? desde, [FromQuery] DateTime? hasta) + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var desdeDate = desde ?? DateTime.UtcNow.AddDays(-7); + var hastaDate = hasta ?? DateTime.UtcNow; + + var stats = await _servicioPomodoro.ObtenerEstadisticasPeriodoAsync(usuarioId.Value, desdeDate, hastaDate); + return Ok(RespuestaApi.Exitosa(stats)); + } + + [HttpGet("estadisticas-semanales")] + public async Task ObtenerEstadisticasSemanales() + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var stats = await _servicioPomodoro.ObtenerEstadisticasSemanalesAsync(usuarioId.Value); + return Ok(RespuestaApi>.Exitosa(stats)); + } + + [HttpGet("mision/{misionId}/sub-tareas")] + public async Task ObtenerSubTareasDeMision(int misionId) + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var subTareas = await _servicioPomodoro.ObtenerSubTareasDisponibles(usuarioId.Value, misionId); + var resultado = subTareas.Select(st => new SubTareaResponse + { + Id = st.Id, + Nombre = st.Nombre, + Descripcion = st.Descripcion, + EstaCompletada = st.EstaCompletada, + Orden = st.Orden, + TiempoEnfoqueSegundos = st.TiempoEnfoqueSegundos, + TiempoEnfoqueFormateado = FormateadorTiempo.FormatearSegundos(st.TiempoEnfoqueSegundos), + FechaCreacion = st.FechaCreacion, + FechaCompletado = st.FechaCompletado, + MisionId = st.MisionId + }).ToList(); + + return Ok(RespuestaApi>.Exitosa(resultado)); + } + + [HttpGet("estadisticas-avanzadas")] + public async Task ObtenerEstadisticasAvanzadas([FromQuery] DateTime? desde, [FromQuery] DateTime? hasta) + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == null) + return Unauthorized(RespuestaApi.Fallida("No autenticado")); + + var desdeDate = desde ?? DateTime.UtcNow.AddMonths(-1); + var hastaDate = hasta ?? DateTime.UtcNow; + + var stats = await _servicioPomodoro.ObtenerEstadisticasAvanzadasAsync(usuarioId.Value, desdeDate, hastaDate); + return Ok(RespuestaApi.Exitosa(stats)); } } } diff --git a/Controllers/Api/ApiProgresoController.cs b/Controllers/Api/ApiProgresoController.cs index a27bfd8..9cf15fc 100644 --- a/Controllers/Api/ApiProgresoController.cs +++ b/Controllers/Api/ApiProgresoController.cs @@ -1,13 +1,16 @@ using EpycusApp.Ayudantes; +using EpycusApp.DTOs; using EpycusApp.Servicios.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EpycusApp.Controllers.Api { [ApiController] - [Route("api/progreso")] + [Route("api/v1/progreso")] [Authorize] + [EnableRateLimiting("Mobile")] public class ApiProgresoController : BaseApiController { private readonly IServicioProgreso _servicioProgreso; @@ -26,12 +29,12 @@ public async Task Obtener() var xpParaSiguiente = CalculadorXP.XpParaSiguienteNivel(progreso.NivelActual.Numero); var porcentaje = CalculadorXP.PorcentajeProgreso(progreso.XpTotal, progreso.NivelActual.Numero); - return Ok(RespuestaApi.Exitosa(new + return Ok(RespuestaApi.Exitosa(new ProgresoResponseDto { - progreso, - nivelSiguiente, - xpParaSiguiente, - porcentaje + Progreso = progreso, + NivelSiguiente = nivelSiguiente, + XpParaSiguiente = xpParaSiguiente, + Porcentaje = (double)porcentaje })); } diff --git a/Controllers/Api/BaseApiController.cs b/Controllers/Api/BaseApiController.cs index 6c38da1..f116845 100644 --- a/Controllers/Api/BaseApiController.cs +++ b/Controllers/Api/BaseApiController.cs @@ -11,5 +11,13 @@ public abstract class BaseApiController : ControllerBase var claim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; return int.TryParse(claim, out var usuarioId) ? usuarioId : null; } + + protected string ConvertirUrlAbsoluta(string? ruta) + { + if (string.IsNullOrEmpty(ruta)) return string.Empty; + if (ruta.StartsWith("http://") || ruta.StartsWith("https://")) return ruta; + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + return $"{baseUrl}{ruta}"; + } } } diff --git a/Controllers/AutenticacionController.cs b/Controllers/AutenticacionController.cs index 8d906aa..cd66a82 100644 --- a/Controllers/AutenticacionController.cs +++ b/Controllers/AutenticacionController.cs @@ -1,5 +1,9 @@ +using System.Security.Claims; using EpycusApp.Servicios.Interfaces; +using EpycusApp.ViewModels; using EpycusApp.ViewModels.Autenticacion; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,8 +23,8 @@ private CookieOptions CrearOpcionesCookie(int expiracionDias = 7) return new CookieOptions { HttpOnly = true, - Secure = Request.IsHttps, - SameSite = SameSiteMode.Strict, + Secure = true, + SameSite = SameSiteMode.Lax, Expires = DateTime.UtcNow.AddDays(expiracionDias) }; } @@ -30,8 +34,8 @@ private CookieOptions CrearOpcionesCookie(int expiracionMinutos, bool recordarme return new CookieOptions { HttpOnly = true, - Secure = Request.IsHttps, - SameSite = SameSiteMode.Strict, + Secure = true, + SameSite = SameSiteMode.Lax, Expires = recordarme ? DateTime.UtcNow.AddDays(7) : DateTime.UtcNow.AddMinutes(expiracionMinutos) @@ -198,6 +202,111 @@ public async Task Login(LoginViewModel modelo) return View(modelo); } + [HttpGet] + [AllowAnonymous] + public IActionResult IniciarSesionGoogle() + { + var properties = new AuthenticationProperties + { + RedirectUri = Url.Action(nameof(CallbackGoogle)) + }; + return Challenge(properties, GoogleDefaults.AuthenticationScheme); + } + + [HttpGet] + [AllowAnonymous] + public async Task CallbackGoogle() + { + var result = await HttpContext.AuthenticateAsync("ExternalCookie"); + if (!result.Succeeded) + return RedirectToAction(nameof(Login)); + + var googleId = result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var email = result.Principal.FindFirst(ClaimTypes.Email)?.Value; + var name = result.Principal.FindFirst(ClaimTypes.Name)?.Value; + var photoUrl = result.Principal.FindFirst("picture")?.Value; + + await HttpContext.SignOutAsync("ExternalCookie"); + + if (string.IsNullOrWhiteSpace(googleId) || string.IsNullOrWhiteSpace(email)) + return RedirectToAction(nameof(Login)); + + var (exito, mensaje, token, refreshToken) = await _servicioAutenticacion + .ProcesarAutenticacionGoogleAsync(googleId, email, name ?? "Usuario", photoUrl); + + if (exito && !string.IsNullOrEmpty(token)) + { + var cookieOptions = CrearOpcionesCookie(7); + Response.Cookies.Append("jwt_token", token, cookieOptions); + if (!string.IsNullOrEmpty(refreshToken)) + Response.Cookies.Append("refresh_token", refreshToken, cookieOptions); + return RedirectToAction("Index", "Home"); + } + + if (mensaje == "completar_registro") + { + TempData["GoogleId"] = googleId; + TempData["GoogleEmail"] = email; + TempData["GoogleName"] = name ?? "Usuario"; + TempData["GooglePhoto"] = photoUrl; + return RedirectToAction(nameof(CompletarRegistroGoogle)); + } + + TempData["ErrorAutenticacion"] = mensaje; + return RedirectToAction(nameof(Login)); + } + + [HttpGet] + [AllowAnonymous] + public async Task CompletarRegistroGoogle() + { + var googleId = TempData["GoogleId"] as string; + var email = TempData["GoogleEmail"] as string; + var name = TempData["GoogleName"] as string; + + if (string.IsNullOrWhiteSpace(googleId) || string.IsNullOrWhiteSpace(email)) + return RedirectToAction(nameof(Registro)); + + ViewBag.Carreras = await _servicioAutenticacion.ObtenerCarrerasActivas(); + + return View(new CompletarRegistroGoogleViewModel + { + Nombre = name ?? "", + CorreoElectronico = email, + GoogleId = googleId, + FotoGoogleUrl = TempData["GooglePhoto"] as string + }); + } + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task CompletarRegistroGoogle(CompletarRegistroGoogleViewModel modelo) + { + if (!ModelState.IsValid) + { + ViewBag.Carreras = await _servicioAutenticacion.ObtenerCarrerasActivas(); + return View(modelo); + } + + var (exito, mensaje, token, refreshToken) = await _servicioAutenticacion + .CompletarRegistroGoogleAsync(modelo); + + if (!exito || string.IsNullOrWhiteSpace(token)) + { + ModelState.AddModelError(string.Empty, mensaje); + ViewBag.Carreras = await _servicioAutenticacion.ObtenerCarrerasActivas(); + return View(modelo); + } + + var cookieOptions = CrearOpcionesCookie(7); + Response.Cookies.Append("jwt_token", token, cookieOptions); + if (!string.IsNullOrEmpty(refreshToken)) + Response.Cookies.Append("refresh_token", refreshToken, cookieOptions); + + return RedirectToAction("Index", "Home"); + } + [HttpPost] [Authorize] [ValidateAntiForgeryToken] diff --git a/Controllers/BienestarController.cs b/Controllers/BienestarController.cs index 8dbe909..f4231a2 100644 --- a/Controllers/BienestarController.cs +++ b/Controllers/BienestarController.cs @@ -21,10 +21,13 @@ public async Task Index() var modelo = new BienestarViewModel { - EstadoHoy = await _servicioBienestar.ObtenerEstadoHoy(usuarioId), - Alertas = await _servicioBienestar.ObtenerAlertasActivas(usuarioId), - FraseMotivacional = await _servicioBienestar.ObtenerFraseMotivacionalAleatoria(), - HistorialAnimo = await _servicioBienestar.ObtenerHistorialAnimo(usuarioId, 14) + EstadoHoy = await _servicioBienestar.ObtenerEstadoHoy(usuarioId), + Alertas = await _servicioBienestar.ObtenerAlertasActivas(usuarioId), + FraseMotivacional = await _servicioBienestar.ObtenerFraseMotivacionalAleatoria(), + HistorialAnimo = await _servicioBienestar.ObtenerHistorialAnimo(usuarioId, 14), + HabitosPendientes = await _servicioBienestar.ObtenerHabitosPendientesAsync(usuarioId), + MisionesPendientes = await _servicioBienestar.ObtenerMisionesPendientesAsync(usuarioId), + RecomendacionActiva = _servicioBienestar.RecomendacionPausaActiva(0) }; return View(modelo); @@ -40,7 +43,7 @@ public async Task RegistrarAnimo(string estado, string? nota) var usuarioId = ObtenerUsuarioId(); await _servicioBienestar.RegistrarEstadoAnimo(usuarioId, estado, nota); - TempData["AnimoRegistrado"] = estado; + TempData["Exito"] = $"Estado de ánimo registrado: {estado}"; return RedirectToAction(nameof(Index)); } } diff --git a/Controllers/DiarioAnimoController.cs b/Controllers/DiarioAnimoController.cs new file mode 100644 index 0000000..7fdbca1 --- /dev/null +++ b/Controllers/DiarioAnimoController.cs @@ -0,0 +1,67 @@ +using EpycusApp.Servicios.Interfaces; +using EpycusApp.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace EpycusApp.Controllers +{ + [Authorize] + public class DiarioAnimoController : BaseController + { + private readonly IServicioDiarioAnimo _servicioDiario; + + public DiarioAnimoController(IServicioDiarioAnimo servicioDiario) + { + _servicioDiario = servicioDiario; + } + + public async Task Index(int? año, int? mes) + { + var usuarioId = ObtenerUsuarioId(); + var ahora = DateTime.UtcNow; + var añoActual = año ?? ahora.Year; + var mesActual = mes ?? ahora.Month; + + var modelo = new DiarioAnimoViewModel + { + EntradaHoy = await _servicioDiario.ObtenerEntradaHoy(usuarioId), + EntradasMes = await _servicioDiario.ObtenerEntradasMes(usuarioId, añoActual, mesActual), + Año = añoActual, + Mes = mesActual, + PreguntaGuiaHoy = _servicioDiario.ObtenerPreguntaGuia(), + DiasConsecutivos = await _servicioDiario.ObtenerDiasConsecutivos(usuarioId), + PromedioAnimoMes = await _servicioDiario.ObtenerPromedioAnimoMes(usuarioId, añoActual, mesActual), + TotalEntradasMes = await _servicioDiario.ObtenerEntradasMes(usuarioId, añoActual, mesActual).ContinueWith(t => t.Result.Count) + }; + + return View(modelo); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Registrar(RegistrarEntradaDiarioViewModel model) + { + if (!ModelState.IsValid) + { + TempData["Error"] = "Revisa los campos del formulario."; + return RedirectToAction(nameof(Index)); + } + + var usuarioId = ObtenerUsuarioId(); + var preguntaGuia = _servicioDiario.ObtenerPreguntaGuia(); + await _servicioDiario.RegistrarEntrada(usuarioId, model, preguntaGuia); + + TempData["Exito"] = "Entrada de diario guardada. Sigue cultivando tu bienestar."; + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult NavegarMes(int direccion) + { + var ahora = DateTime.UtcNow; + var fechaActual = new DateTime(ahora.Year, ahora.Month, 1).AddMonths(direccion); + return RedirectToAction(nameof(Index), new { año = fechaActual.Year, mes = fechaActual.Month }); + } + } +} diff --git a/Controllers/HabitosController.cs b/Controllers/HabitosController.cs index f98a89d..759f490 100644 --- a/Controllers/HabitosController.cs +++ b/Controllers/HabitosController.cs @@ -42,10 +42,20 @@ public async Task Crear() public async Task Crear(CrearHabitoViewModel modelo) { modelo ??= new CrearHabitoViewModel(); - CompletarModeloDesdeFormulario(modelo); + + if (modelo.DiasSemana?.Any() != true) + { + var diasRaw = Request.Form["DiasSemana"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(diasRaw)) + { + modelo.DiasSemana = diasRaw + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => int.Parse(s.Trim())) + .ToList(); + } + } ViewBag.Categorias = await _servicioHabitos.ObtenerCategoriasActivas(); - TryValidateModel(modelo); if (!ModelState.IsValid) { @@ -133,7 +143,18 @@ public async Task Editar(int id) [ValidateAntiForgeryToken] public async Task Editar(EditarHabitoViewModel modelo) { - CompletarDiasSemanaDesdeFormulario(modelo); + if (modelo.DiasSemana?.Any() != true) + { + var diasRaw = Request.Form["DiasSemana"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(diasRaw)) + { + modelo.DiasSemana = diasRaw + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => int.Parse(s.Trim())) + .ToList(); + } + } + ViewBag.Categorias = await _servicioHabitos.ObtenerCategoriasActivas(); if (!ModelState.IsValid) @@ -176,74 +197,6 @@ private Dictionary ObtenerErroresModelo() kvp => kvp.Key, kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()); - private void CompletarModeloDesdeFormulario(CrearHabitoViewModel modelo) - { - var nombreForm = Request.Form["Nombre"].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(nombreForm)) modelo.Nombre = nombreForm; - - var descForm = Request.Form["Descripcion"].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(descForm)) modelo.Descripcion = descForm; - - var catForm = Request.Form["CategoriaId"].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(catForm) && int.TryParse(catForm, out var catId)) modelo.CategoriaId = catId; - - var freqForm = Request.Form["Frecuencia"].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(freqForm)) modelo.Frecuencia = freqForm; - - CompletarDiasSemanaDesdeFormulario(modelo); - - modelo.ConPomodoro = EsCheckboxMarcado("ConPomodoro"); - modelo.EstaActivo = EsCheckboxMarcado("EstaActivo"); - - var horaForm = Request.Form["RecordatorioHora"].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(horaForm) && TimeSpan.TryParse(horaForm, out var ts)) - { - modelo.RecordatorioHora = ts; - } - } - - private void CompletarDiasSemanaDesdeFormulario(CrearHabitoViewModel modelo) - { - var diasRaw = Request.Form["DiasSemana"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(diasRaw) || modelo.DiasSemana?.Any() == true) - { - return; - } - - try - { - modelo.DiasSemana = diasRaw.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => int.Parse(s.Trim())) - .ToList(); - } - catch - { - ModelState.AddModelError("DiasSemana", "Formato de dias invalido. Use: 1,3,5"); - } - } - - private void CompletarDiasSemanaDesdeFormulario(EditarHabitoViewModel modelo) - { - var diasRaw = Request.Form["DiasSemana"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(diasRaw) || modelo.DiasSemana?.Any() == true) - { - return; - } - - try - { - modelo.DiasSemana = diasRaw.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => int.Parse(s.Trim())) - .ToList(); - } - catch - { - ModelState.AddModelError("DiasSemana", "Formato de dias invalido. Use: 1,3,5"); - } - } - private bool EsCheckboxMarcado(string nombre) - => Request.Form.ContainsKey(nombre) - && (Request.Form[nombre] == "on" || Request.Form[nombre] == "true"); } } diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs index b7d6c00..db6192c 100644 --- a/Controllers/HomeController.cs +++ b/Controllers/HomeController.cs @@ -35,12 +35,38 @@ public async Task Index() if (usuario != null) { modelo.NombreUsuario = usuario.Nombre; + if (usuario.Progreso != null) + { + modelo.RachaActual = usuario.Progreso.RachaActual; + modelo.RachaMaxima = usuario.Progreso.RachaMaxima; + modelo.XpTotal = usuario.Progreso.XpTotal; + if (usuario.Progreso.NivelActual != null) + { + modelo.NivelActual = usuario.Progreso.NivelActual.Numero; + modelo.XpRequeridoNivel = usuario.Progreso.NivelActual.XpRequerido; + } + } + var xpAnterior = modelo.XpRequeridoNivel > 0 + ? modelo.XpRequeridoNivel - 100 + : 0; + var xpEnNivel = modelo.XpTotal - xpAnterior; + var xpNecesario = modelo.XpRequeridoNivel - xpAnterior; + modelo.ProgresoNivel = xpNecesario > 0 + ? Math.Clamp((int)((double)xpEnNivel / xpNecesario * 100), 0, 100) + : 0; + + try + { + modelo.ImagenPersonajeUrl = await _servicioPerfil.ObtenerImagenPersonajeActual(usuarioId); + } + catch + { + modelo.ImagenPersonajeUrl = "/img/personajes/generico/masculino/placeholder.png"; + } } - // Obtener datos del dashboard modelo.Estadisticas = await _servicioHabitos.ObtenerDashboard(usuarioId); - // Obtener hábitos de hoy var todosLosHabitos = await _servicioHabitos.ObtenerHabitosViewModel(usuarioId); var hoy = (int)DateTime.Today.DayOfWeek; @@ -50,7 +76,6 @@ public async Task Index() (h.DiasSemana?.Contains(hoy) == true))) .ToList(); - // Obtener frase motivacional var frase = await _servicioBienestar.ObtenerFraseMotivacionalAleatoria(); if (frase != null) @@ -71,8 +96,9 @@ public IActionResult Privacy() } [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] - public IActionResult Error() + public IActionResult Error(int? statusCode = null) { + ViewBag.CodigoEstado = statusCode ?? HttpContext.Response.StatusCode; return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } } diff --git a/Controllers/IaController.cs b/Controllers/IaController.cs index d47205b..72ec176 100644 --- a/Controllers/IaController.cs +++ b/Controllers/IaController.cs @@ -1,53 +1,65 @@ -using EpycusApp.Servicios.Interfaces; +using EpycusApp.DTOs; +using EpycusApp.Models.Entidades; +using EpycusApp.Servicios.Interfaces; using EpycusApp.ViewModels.Ia; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EpycusApp.Controllers { [Authorize] + [EnableRateLimiting("DeepSeek")] public class IaController : BaseController { private readonly IServicioIA _servicioIA; + private readonly IServicioBienestar _servicioBienestar; private readonly ILogger _logger; - public IaController(IServicioIA servicioIA, ILogger logger) + public IaController( + IServicioIA servicioIA, + IServicioBienestar servicioBienestar, + ILogger logger) { _servicioIA = servicioIA; + _servicioBienestar = servicioBienestar; _logger = logger; } - // ── GET /ia o /ia?conv={guid} ────────────────────────────────────── [HttpGet] public async Task Index(string? conv) { - // Sin conversación → generar nueva y redirigir + var usuarioId = ObtenerUsuarioId(); + if (string.IsNullOrWhiteSpace(conv)) { var nuevaId = _servicioIA.NuevaConversacionId(); return RedirectToAction(nameof(Index), new { conv = nuevaId }); } - var usuarioId = ObtenerUsuarioId(); - var mensajes = await _servicioIA.ObtenerHistorialAsync(usuarioId, conv); + var mensajes = await _servicioIA.ObtenerHistorialAsync(usuarioId, conv); + var sugerencias = await _servicioIA.ObtenerSugerenciasPersonalizadasAsync(usuarioId); + var conversaciones = await _servicioIA.ObtenerConversacionesAsync(usuarioId); + var bienestarCtx = await _servicioIA.ObtenerBienestarContextoAsync(usuarioId); return View(new IaChatViewModel { ConversacionId = conv, - Mensajes = mensajes + Mensajes = mensajes, + Sugerencias = sugerencias, + Conversaciones = conversaciones, + BienestarCtx = bienestarCtx }); } - // ── POST /ia/nueva (botón "Nueva conversación") ───────────────────── - [HttpPost] - [ValidateAntiForgeryToken] + [HttpGet] public IActionResult Nueva() { return RedirectToAction(nameof(Index)); } - // ── POST /api/ia/chat (llamada AJAX) ───────────────────────────────── [HttpPost("/api/ia/chat")] + [ValidateAntiForgeryToken] public async Task Chat([FromBody] ChatMensajeDto? dto) { if (dto == null @@ -57,33 +69,60 @@ public async Task Chat([FromBody] ChatMensajeDto? dto) return BadRequest(new { exito = false, error = "Datos incompletos." }); } + dto.Mensaje = dto.Mensaje.Trim(); + if (dto.Mensaje.Length > 2000) + return BadRequest(new { exito = false, error = "El mensaje no puede superar los 2000 caracteres." }); + var usuarioId = ObtenerUsuarioId(); try { var respuesta = await _servicioIA.ChatAsync( usuarioId, - dto.Mensaje.Trim(), + dto.Mensaje, dto.ConversacionId); - return Ok(new { exito = true, respuesta }); + return Ok(new { exito = true, respuesta, mensajeId = 0 }); } catch (UnauthorizedAccessException) { return Forbid(); } + catch (InvalidOperationException ex) when (ex.Message.Contains("limite diario")) + { + return Ok(new { exito = false, error = ex.Message }); + } catch (Exception ex) { _logger.LogError(ex, "Error en chat IA para usuario {UsuarioId}", usuarioId); - return Ok(new { exito = false, error = "No pude conectarme con EDY. Inténtalo de nuevo." }); + return Ok(new { exito = false, error = "No pude conectarme con EDY AI. Intentelo de nuevo." }); } } - } + [HttpPost("/api/ia/feedback")] + [ValidateAntiForgeryToken] + public async Task Feedback([FromBody] FeedbackDto? dto) + { + if (dto == null || dto.MensajeId <= 0) + return BadRequest(new { exito = false, error = "Datos incompletos." }); + + var usuarioId = ObtenerUsuarioId(); + await _servicioIA.RegistrarFeedbackAsync(usuarioId, dto.MensajeId, dto.Util); + return Ok(new { exito = true }); + } + + [HttpPost("/api/ia/registrar-animo")] + [ValidateAntiForgeryToken] + public async Task RegistrarAnimo([FromBody] AnimoChatDto? dto) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.Estado)) + return BadRequest(new { exito = false }); + + var usuarioId = ObtenerUsuarioId(); + await _servicioBienestar.RegistrarEstadoAnimo(usuarioId, dto.Estado, dto.Nota); + return Ok(new { exito = true }); + } - public sealed class ChatMensajeDto - { - public string Mensaje { get; set; } = string.Empty; - public string ConversacionId { get; set; } = string.Empty; } + } diff --git a/Controllers/MisionesController.cs b/Controllers/MisionesController.cs index 73b7709..f020e53 100644 --- a/Controllers/MisionesController.cs +++ b/Controllers/MisionesController.cs @@ -48,12 +48,6 @@ public async Task Editar(int id) var mision = await _servicioMisiones.ObtenerPorId(id); if (mision == null || mision.UsuarioId != usuarioId) return NotFound(); - if (mision.Estado == "Completado" || mision.Estado == "Fallido") - { - TempData["Error"] = "No se puede editar una mision completada o fallida."; - return RedirectToAction(nameof(Index)); - } - var modelo = new EditarMisionViewModel { Id = mision.Id, @@ -63,7 +57,8 @@ public async Task Editar(int id) FechaLimite = mision.FechaLimite.ToDateTime(TimeOnly.MinValue), Prioridad = mision.Prioridad, ConPomodoro = mision.ConPomodoro, - CategoriaId = mision.CategoriaId + CategoriaId = mision.CategoriaId, + Estado = mision.Estado }; await CargarCategorias(); @@ -79,6 +74,7 @@ public async Task Editar(EditarMisionViewModel modelo) try { await _servicioMisiones.EditarMision(modelo, ObtenerUsuarioId()); + TempData["Exito"] = "Misi\u00f3n actualizada."; return RedirectToAction(nameof(Index)); } catch (Exception ex) @@ -106,16 +102,29 @@ public async Task Completar(int id) var resultado = await _servicioMisiones.CompletarMision(id, ObtenerUsuarioId()); if (resultado.Exito) { - TempData["Exito"] = $"Mision completada. Ganaste {resultado.XpGanado} XP."; + TempData["Exito"] = $"Misi\u00f3n completada. Ganaste {resultado.XpGanado} XP."; } else { - TempData["Error"] = "No se pudo completar la mision."; + TempData["Error"] = "No se pudo completar la misi\u00f3n."; } return RedirectToAction(nameof(Index)); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Revertir(int id) + { + var (exito, mensaje) = await _servicioMisiones.RevertirMision(id, ObtenerUsuarioId()); + if (exito) + TempData["Exito"] = mensaje; + else + TempData["Error"] = mensaje; + + return RedirectToAction(nameof(Index)); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task Eliminar(int id) diff --git a/Controllers/PerfilController.cs b/Controllers/PerfilController.cs index df599ef..764563a 100644 --- a/Controllers/PerfilController.cs +++ b/Controllers/PerfilController.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using EpycusApp.DTOs; using EpycusApp.Servicios.Interfaces; using EpycusApp.ViewModels; using Microsoft.AspNetCore.Authorization; @@ -77,7 +78,7 @@ public async Task CambiarContrasena(CambiarContrasenaViewModel mo correo, modelo.ContrasenaActual, modelo.NuevaContrasena); TempData[resultado.EsExitoso ? ClaveExitoContrasena : ClaveErrorContrasena] = - resultado.EsExitoso ? "Contraseña actualizada correctamente." : resultado.Mensaje; + resultado.EsExitoso ? "Contraseña actualizada correctamente." : resultado.Mensaje; return RedirectToAction(nameof(Index)); } @@ -107,9 +108,4 @@ public async Task CambiarTema([FromBody] CambiarTemaDto? dto) } } - - public sealed class CambiarTemaDto - { - public int TemaId { get; set; } - } } diff --git a/Controllers/PomodoroController.cs b/Controllers/PomodoroController.cs index 709ea1a..9c3aeac 100644 --- a/Controllers/PomodoroController.cs +++ b/Controllers/PomodoroController.cs @@ -1,8 +1,8 @@ -using Microsoft.AspNetCore.Authorization; +using EpycusApp.DTOs; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using EpycusApp.ViewModels; using EpycusApp.Servicios.Interfaces; -using System.Threading.Tasks; namespace EpycusApp.Controllers { @@ -10,12 +10,18 @@ namespace EpycusApp.Controllers public class PomodoroController : BaseController { private readonly IServicioPomodoro _servicioPomodoro; + private readonly IServicioMisiones _servicioMisiones; + private readonly ILogger _logger; - public PomodoroController(IServicioPomodoro servicioPomodoro) + public PomodoroController(IServicioPomodoro servicioPomodoro, IServicioMisiones servicioMisiones, ILogger logger) { _servicioPomodoro = servicioPomodoro; + _servicioMisiones = servicioMisiones; + _logger = logger; } + [HttpGet] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public async Task Index() { var modelo = new PomodoroIndexViewModel(); @@ -23,12 +29,10 @@ public async Task Index() var usuarioId = ObtenerUsuarioId(); if (usuarioId != 0) { - // Configuración modelo.Configuracion = await _servicioPomodoro.ObtenerConfiguracion(usuarioId); - // Estadísticas e Historial de Hoy var sesionesHoy = await _servicioPomodoro.ObtenerSesionesHoyAsync(usuarioId); - modelo.HistorialHoy = sesionesHoy; + modelo.HistorialHoy = sesionesHoy.Where(s => s.FueCompletada || s.CiclosCompletados > 0).ToList(); modelo.EstadisticasHoy.CiclosCompletados = 0; modelo.EstadisticasHoy.XpGanado = 0; @@ -37,14 +41,70 @@ public async Task Index() modelo.EstadisticasHoy.CiclosCompletados += s.CiclosCompletados; modelo.EstadisticasHoy.XpGanado += s.XpOtorgado; } - int tiempoEnfoque = modelo.Configuracion.TiempoEstudioMin; - modelo.EstadisticasHoy.MinutosEnfocados = modelo.EstadisticasHoy.CiclosCompletados * tiempoEnfoque; + modelo.EstadisticasHoy.MinutosEnfocados = (int)sesionesHoy + .Where(s => s.FechaFin.HasValue) + .Sum(s => (s.FechaFin!.Value - s.FechaInicio).TotalMinutes); - modelo.EstadisticasHoy.MisionesCompletadas = await _servicioPomodoro.ObtenerMisionesCompletadasHoyAsync(usuarioId); + modelo.EstadisticasHoy.MisionesCompletadas = await _servicioMisiones.ContarCompletadasHoyAsync(usuarioId); modelo.TareasEnfoque = await _servicioPomodoro.ObtenerTareasEnfoqueAsync(usuarioId); + modelo.RachaActual = await _servicioPomodoro.ObtenerRachaActualAsync(usuarioId); + + modelo.EstadisticasSemanales = await _servicioPomodoro.ObtenerEstadisticasSemanalesAsync(usuarioId); } return View(modelo); } + + [HttpGet] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task Configuracion() + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == 0) return Challenge(); + var config = await _servicioPomodoro.ObtenerConfiguracion(usuarioId); + var dto = new ActualizarConfiguracionPomodoroDto + { + TiempoEstudioMin = config.TiempoEstudioMin, + TiempoDescansoMin = config.TiempoDescansoMin, + TiempoDescansoLargoMin = config.TiempoDescansoLargoMin, + CiclosAntesDescansoLargo = config.CiclosAntesDescansoLargo, + SonidoActivo = config.SonidoActivo, + SonidoSeleccionado = config.SonidoSeleccionado, + Volumen = config.Volumen, + AutoIniciarDescanso = config.AutoIniciarDescanso, + AutoIniciarEnfoque = config.AutoIniciarEnfoque, + TicTacActivo = config.TicTacActivo, + MetaDiariaCiclos = config.MetaDiariaCiclos, + ModoPersonalizadoMin = config.ModoPersonalizadoMin, + VibracionActiva = config.VibracionActiva, + NotificacionDesktop = config.NotificacionDesktop + }; + return View(dto); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Configuracion(ActualizarConfiguracionPomodoroDto dto) + { + var usuarioId = ObtenerUsuarioId(); + if (usuarioId == 0) return Challenge(); + if (!ModelState.IsValid) + { + TempData["Error"] = "Corrige los errores antes de guardar."; + return View(dto); + } + try + { + await _servicioPomodoro.ActualizarConfiguracion(usuarioId, dto); + TempData["Exito"] = "Configuración guardada correctamente."; + return RedirectToAction("Index"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al guardar configuración Pomodoro para usuario {UsuarioId}", usuarioId); + TempData["Error"] = "Ocurrió un error al guardar la configuración. Intenta de nuevo."; + return View(dto); + } + } } } diff --git a/Controllers/ProgresoController.cs b/Controllers/ProgresoController.cs index afbd0f7..2ac1e4c 100644 --- a/Controllers/ProgresoController.cs +++ b/Controllers/ProgresoController.cs @@ -24,7 +24,7 @@ public async Task Index() var usuarioId = ObtenerUsuarioId(); if (usuarioId == 0) { - // Usuario anónimo o sin sesión + // Usuario anónimo o sin sesión var nivelInicial = await _servicioProgreso.ObtenerNivelInicialAsync(); var progresoDefecto = new ProgresoUsuario { diff --git a/DTOs/ActualizarConfiguracionPomodoroDto.cs b/DTOs/ActualizarConfiguracionPomodoroDto.cs index 2117edb..7ab8260 100644 --- a/DTOs/ActualizarConfiguracionPomodoroDto.cs +++ b/DTOs/ActualizarConfiguracionPomodoroDto.cs @@ -1,11 +1,68 @@ -namespace EpycusApp.DTOs +using System.ComponentModel.DataAnnotations; + +namespace EpycusApp.DTOs { - public class ActualizarConfiguracionPomodoroDto + public class ActualizarConfiguracionPomodoroDto : IValidatableObject { + private static readonly string[] SonidosPermitidos = ["campana", "digital", "naturaleza", "silencio"]; + + [Range(1, 180, ErrorMessage = "El tiempo de estudio debe ser entre 1 y 180 minutos.")] public int TiempoEstudioMin { get; set; } + + [Range(1, 60, ErrorMessage = "El tiempo de descanso debe ser entre 1 y 60 minutos.")] public int TiempoDescansoMin { get; set; } + + [Range(1, 120, ErrorMessage = "El tiempo de descanso largo debe ser entre 1 y 120 minutos.")] public int TiempoDescansoLargoMin { get; set; } + + [Range(1, 20, ErrorMessage = "Los ciclos antes del descanso largo deben ser entre 1 y 20.")] public int CiclosAntesDescansoLargo { get; set; } + public bool SonidoActivo { get; set; } + + [Required] + public string SonidoSeleccionado { get; set; } = "campana"; + + [Range(0, 100, ErrorMessage = "El volumen debe ser entre 0 y 100.")] + public int Volumen { get; set; } = 100; + + public bool AutoIniciarDescanso { get; set; } + public bool AutoIniciarEnfoque { get; set; } + public bool TicTacActivo { get; set; } + + [Range(0, 50, ErrorMessage = "La meta diaria debe ser entre 0 y 50 ciclos.")] + public int MetaDiariaCiclos { get; set; } + + [Range(1, 180, ErrorMessage = "El modo personalizado debe ser entre 1 y 180 minutos.")] + public int ModoPersonalizadoMin { get; set; } = 25; + + public bool VibracionActiva { get; set; } = true; + public bool NotificacionDesktop { get; set; } = true; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (!SonidosPermitidos.Contains(SonidoSeleccionado)) + { + yield return new ValidationResult( + $"El sonido seleccionado no es válido. Valores permitidos: {string.Join(", ", SonidosPermitidos)}.", + [nameof(SonidoSeleccionado)]); + } + + if (TiempoDescansoMin >= TiempoEstudioMin) + { + yield return new ValidationResult( + "El tiempo de descanso debe ser menor que el tiempo de estudio.", + [nameof(TiempoDescansoMin)]); + } + + if (TiempoDescansoLargoMin <= TiempoDescansoMin) + { + yield return new ValidationResult( + "El tiempo de descanso largo debe ser mayor que el tiempo de descanso corto.", + [nameof(TiempoDescansoLargoMin)]); + } + + + } } } diff --git a/DTOs/AnimoChatDto.cs b/DTOs/AnimoChatDto.cs new file mode 100644 index 0000000..2a9b9ee --- /dev/null +++ b/DTOs/AnimoChatDto.cs @@ -0,0 +1,8 @@ +namespace EpycusApp.DTOs +{ + public sealed class AnimoChatDto + { + public string Estado { get; set; } = string.Empty; + public string? Nota { get; set; } + } +} diff --git a/DTOs/CambiarTemaDto.cs b/DTOs/CambiarTemaDto.cs new file mode 100644 index 0000000..c306d54 --- /dev/null +++ b/DTOs/CambiarTemaDto.cs @@ -0,0 +1,7 @@ +namespace EpycusApp.DTOs +{ + public sealed class CambiarTemaDto + { + public int TemaId { get; set; } + } +} diff --git a/DTOs/ChatMensajeDto.cs b/DTOs/ChatMensajeDto.cs new file mode 100644 index 0000000..2b8ceaf --- /dev/null +++ b/DTOs/ChatMensajeDto.cs @@ -0,0 +1,8 @@ +namespace EpycusApp.DTOs +{ + public sealed class ChatMensajeDto + { + public string Mensaje { get; set; } = string.Empty; + public string ConversacionId { get; set; } = string.Empty; + } +} diff --git a/DTOs/CicloCompletadoRequest.cs b/DTOs/CicloCompletadoRequest.cs new file mode 100644 index 0000000..8ac6115 --- /dev/null +++ b/DTOs/CicloCompletadoRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace EpycusApp.DTOs +{ + public class CicloCompletadoRequest + { + [Range(1, 100, ErrorMessage = "CiclosCompletados debe ser entre 1 y 100.")] + public int CiclosCompletados { get; set; } + } +} diff --git a/DTOs/CompletarHabitoRespuestaDto.cs b/DTOs/CompletarHabitoRespuestaDto.cs index 96057b7..b43e8b4 100644 --- a/DTOs/CompletarHabitoRespuestaDto.cs +++ b/DTOs/CompletarHabitoRespuestaDto.cs @@ -1,7 +1,7 @@ namespace EpycusApp.DTOs { /// - /// DTO para la respuesta al completar un hábito + /// DTO para la respuesta al completar un hábito /// public class CompletarHabitoRespuestaDto { diff --git a/DTOs/FallarHabitoRespuestaDto.cs b/DTOs/FallarHabitoRespuestaDto.cs index bd1a7e6..74ac201 100644 --- a/DTOs/FallarHabitoRespuestaDto.cs +++ b/DTOs/FallarHabitoRespuestaDto.cs @@ -1,7 +1,7 @@ namespace EpycusApp.DTOs { /// - /// DTO para la respuesta al fallar un hábito + /// DTO para la respuesta al fallar un hábito /// public class FallarHabitoRespuestaDto { diff --git a/DTOs/FeedbackDto.cs b/DTOs/FeedbackDto.cs new file mode 100644 index 0000000..11ac2d5 --- /dev/null +++ b/DTOs/FeedbackDto.cs @@ -0,0 +1,8 @@ +namespace EpycusApp.DTOs +{ + public sealed class FeedbackDto + { + public int MensajeId { get; set; } + public bool Util { get; set; } + } +} diff --git a/DTOs/HabitoHoyRespuestaDto.cs b/DTOs/HabitoHoyRespuestaDto.cs index f537fbf..376040a 100644 --- a/DTOs/HabitoHoyRespuestaDto.cs +++ b/DTOs/HabitoHoyRespuestaDto.cs @@ -1,13 +1,11 @@ namespace EpycusApp.DTOs { - /// - /// DTO para la respuesta de hábitos activos con estado de hoy - /// public class HabitoHoyRespuestaDto { public int Id { get; set; } public string Nombre { get; set; } = string.Empty; public string EstadoHoy { get; set; } = string.Empty; public int XpPotencial { get; set; } + public string Categoria { get; set; } = string.Empty; } } diff --git a/DTOs/HabitoRespuestaDto.cs b/DTOs/HabitoRespuestaDto.cs index 15329cd..9defcfe 100644 --- a/DTOs/HabitoRespuestaDto.cs +++ b/DTOs/HabitoRespuestaDto.cs @@ -1,7 +1,7 @@ namespace EpycusApp.DTOs { /// - /// DTO para la respuesta de un hábito con su estado + /// DTO para la respuesta de un hábito con su estado /// public class HabitoRespuestaDto { diff --git a/DTOs/IniciarRequest.cs b/DTOs/IniciarRequest.cs new file mode 100644 index 0000000..937c228 --- /dev/null +++ b/DTOs/IniciarRequest.cs @@ -0,0 +1,9 @@ +namespace EpycusApp.DTOs +{ + public class IniciarRequest + { + public int? HabitoId { get; set; } + public int? MisionId { get; set; } + public int? SubTareaId { get; set; } + } +} diff --git a/DTOs/RecomendacionPausaDto.cs b/DTOs/RecomendacionPausaDto.cs new file mode 100644 index 0000000..9b14951 --- /dev/null +++ b/DTOs/RecomendacionPausaDto.cs @@ -0,0 +1,10 @@ +namespace EpycusApp.DTOs +{ + public class RecomendacionPausaDto + { + public string Tipo { get; set; } = string.Empty; + public int DuracionSegundos { get; set; } + public string Descripcion { get; set; } = string.Empty; + public string Icono { get; set; } = "bi-arrow-repeat"; + } +} diff --git a/DTOs/RegistroSemanaDto.cs b/DTOs/RegistroSemanaDto.cs index eacb3d6..83f8887 100644 --- a/DTOs/RegistroSemanaDto.cs +++ b/DTOs/RegistroSemanaDto.cs @@ -1,7 +1,7 @@ namespace EpycusApp.DTOs { /// - /// DTO para el registro de un hábito en la semana + /// DTO para el registro de un hábito en la semana /// public class RegistroSemanaDto { diff --git a/Models/DTOs/RespuestaOperacion.cs b/DTOs/RespuestaOperacion.cs similarity index 95% rename from Models/DTOs/RespuestaOperacion.cs rename to DTOs/RespuestaOperacion.cs index f2d3375..2344c38 100644 --- a/Models/DTOs/RespuestaOperacion.cs +++ b/DTOs/RespuestaOperacion.cs @@ -1,4 +1,4 @@ -namespace EpycusApp.Models.DTOs +namespace EpycusApp.DTOs { public class RespuestaOperacion { diff --git a/DTOs/RespuestasApi.cs b/DTOs/RespuestasApi.cs new file mode 100644 index 0000000..d1ec5c5 --- /dev/null +++ b/DTOs/RespuestasApi.cs @@ -0,0 +1,276 @@ +using EpycusApp.Models.Entidades; +using EpycusApp.ViewModels; + +namespace EpycusApp.DTOs; + +// ── Auth ── +public class AuthResponseDto +{ + public string Token { get; set; } = string.Empty; + public string RefreshToken { get; set; } = string.Empty; +} + +public class MensajeResponseDto +{ + public string Mensaje { get; set; } = string.Empty; +} + +public class SuccessResponseDto +{ + public bool Success { get; set; } = true; +} + +// ── Dashboard ── +public class DashboardResumenResponse +{ + public DashboardKpis Kpis { get; set; } = new(); + public int HabitosPendientes { get; set; } + public int MisionesPendientes { get; set; } + public FraseResponseDto? Frase { get; set; } +} + +public class DashboardKpis +{ + public int HabitosPendientes { get; set; } + public int MisionesPendientes { get; set; } +} + +public class FraseResponseDto +{ + public string Frase { get; set; } = string.Empty; + public string Autor { get; set; } = string.Empty; +} + +// ── Gamificacion ── +public class GamificacionProgresoResponse +{ + public int XpTotal { get; set; } + public int Nivel { get; set; } + public string Titulo { get; set; } = string.Empty; + public int RachaActual { get; set; } + public int XpParaSiguienteNivel { get; set; } + public double PorcentajeProgreso { get; set; } + public string? ImagenPersonaje { get; set; } +} + +public class LogroConProgresoResponse +{ + public object? Logro { get; set; } + public bool Desbloqueado { get; set; } + public int Progreso { get; set; } + public int Meta { get; set; } +} + +// ── Bienestar ── +public class BienestarResumenResponse +{ + public object? Alertas { get; set; } + public object? Frase { get; set; } + public object? EstadoHoy { get; set; } + public int HabitosPendientes { get; set; } + public int MisionesPendientes { get; set; } +} + +public class AlertasResponse +{ + public object? Alertas { get; set; } +} + +public class EstadoHoyResponse +{ + public object? Estado { get; set; } +} + +public class HistorialAnimoResponse +{ + public object? Historial { get; set; } +} + +public class CantidadResponse +{ + public int Cantidad { get; set; } +} + +public class PausaActivaResponse +{ + public RecomendacionPausaDto? Recomendacion { get; set; } +} + +// ── Diario ── +public class DiarioEntradaResponse +{ + public object? Entrada { get; set; } +} + +public class DiarioEntradasResponse +{ + public object? Entradas { get; set; } +} + +public class DiarioRachaResponse +{ + public int DiasConsecutivos { get; set; } +} + +public class DiarioPromedioMesResponse +{ + public double? Promedio { get; set; } +} + +public class PreguntaGuiaResponse +{ + public string Pregunta { get; set; } = string.Empty; +} + +// ── IA ── +public class IaChatResponseDto +{ + public string Respuesta { get; set; } = string.Empty; + public string? ConversacionId { get; set; } +} + +public class IaHistorialResponse +{ + public object? Historial { get; set; } +} + +public class IaConversacionesResponse +{ + public object? Conversaciones { get; set; } +} + +public class IaSugerenciasResponse +{ + public object? Sugerencias { get; set; } +} + +public class IaMensajesHoyResponse +{ + public int Cantidad { get; set; } +} + +// ── Pomodoro ── +public class PomodoroIniciarResponse +{ + public int SesionId { get; set; } + public DateTime FechaInicio { get; set; } +} + +public class PomodoroCicloCompletadoResponse +{ + public int XpGanado { get; set; } + public bool SugerirDescanso { get; set; } + public string? PausaActiva { get; set; } +} + +public class PomodoroFinalizarResponse +{ + public int XpTotal { get; set; } + public int XpBonus { get; set; } + public bool SesionGuardada { get; set; } +} + +public class PomodoroConfiguracionResponse +{ + public int TiempoEstudio { get; set; } + public int TiempoDescanso { get; set; } + public int TiempoDescansoLargo { get; set; } + public int CiclosAntesDescansoLargo { get; set; } + public bool SonidoActivo { get; set; } + public string? SonidoSeleccionado { get; set; } + public int Volumen { get; set; } + public bool AutoIniciarDescanso { get; set; } + public bool AutoIniciarEnfoque { get; set; } + public bool TicTacActivo { get; set; } + public int MetaDiariaCiclos { get; set; } + public int ModoPersonalizadoMin { get; set; } + public bool VibracionActiva { get; set; } + public bool NotificacionDesktop { get; set; } +} + +public class PomodoroTipResponse +{ + public string? Consejo { get; set; } +} + +public class PomodoroSesionActivaResponse +{ + public bool Activa { get; set; } + public int? SesionId { get; set; } + public DateTime? FechaInicio { get; set; } + public int? CiclosCompletados { get; set; } +} + +public class PomodoroHistorialResponse +{ + public List? Historial { get; set; } + public int Pagina { get; set; } + public int Tamano { get; set; } +} + +public class PomodoroDescansoRequest +{ + public string Tipo { get; set; } = "DescansoCorto"; + public int Segundos { get; set; } +} + +public class PomodoroRachaResponse +{ + public int Racha { get; set; } +} + +public class PomodoroEstadisticasAvanzadasResponse +{ + public double PromedioCiclosPorDia { get; set; } + public int TotalCiclos { get; set; } + public int TotalMinutos { get; set; } + public int TotalXp { get; set; } + public List PorMes { get; set; } = new(); + public List HeatmapHoras { get; set; } = new(); +} + +public class HeatmapPorHora +{ + public int Hora { get; set; } + public int Ciclos { get; set; } +} + +// ── Misiones ── +public class MisionListaItemResponse +{ + public int Id { get; set; } + public string Nombre { get; set; } = string.Empty; + public string? Descripcion { get; set; } + public string? NombreCurso { get; set; } + public string Prioridad { get; set; } = string.Empty; + public string Estado { get; set; } = string.Empty; + public string FechaLimite { get; set; } = string.Empty; + public int XpOtorgado { get; set; } + public DateTime FechaCreacion { get; set; } + public int? CategoriaId { get; set; } + public int SubTareasCount { get; set; } + public int SubTareasCompletadas { get; set; } + public int TiempoEnfoqueSegundos { get; set; } +} + +public class MisionCompletarResponse +{ + public int XpGanado { get; set; } +} + +// ── Progreso ── +public class ProgresoResponseDto +{ + public object? Progreso { get; set; } + public object? NivelSiguiente { get; set; } + public int XpParaSiguiente { get; set; } + public double Porcentaje { get; set; } +} + +// ── Admin ── +public class AdminLoginResponseDto +{ + public string Token { get; set; } = string.Empty; + public string RefreshToken { get; set; } = string.Empty; + public string Mensaje { get; set; } = string.Empty; +} diff --git a/DTOs/SubTareaDto.cs b/DTOs/SubTareaDto.cs new file mode 100644 index 0000000..4a89248 --- /dev/null +++ b/DTOs/SubTareaDto.cs @@ -0,0 +1,31 @@ +using EpycusApp.Ayudantes; + +namespace EpycusApp.DTOs +{ + public class SubTareaResponse + { + public int Id { get; set; } + public string Nombre { get; set; } = string.Empty; + public string? Descripcion { get; set; } + public bool EstaCompletada { get; set; } + public int Orden { get; set; } + public int TiempoEnfoqueSegundos { get; set; } + public string TiempoEnfoqueFormateado { get; set; } = string.Empty; + public DateTime FechaCreacion { get; set; } + public DateTime? FechaCompletado { get; set; } + public int MisionId { get; set; } + } + + public class CrearSubTareaDto + { + public string Nombre { get; set; } = string.Empty; + public string? Descripcion { get; set; } + } + + public class EditarSubTareaDto + { + public string Nombre { get; set; } = string.Empty; + public string? Descripcion { get; set; } + public int? Orden { get; set; } + } +} diff --git a/Datos/ContextoAplicacion.cs b/Datos/ContextoAplicacion.cs index 6639b6a..3f060bf 100644 --- a/Datos/ContextoAplicacion.cs +++ b/Datos/ContextoAplicacion.cs @@ -38,6 +38,9 @@ public ContextoAplicacion(DbContextOptions options) public DbSet DiasSemanaHabito { get; set; } = null!; public DbSet MensajesIA { get; set; } = null!; + public DbSet EntradasDiario { get; set; } = null!; + public DbSet Logs { get; set; } = null!; + public DbSet SubTareas { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -183,6 +186,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(e => e.UsuarioId); + modelBuilder.Entity() + .HasIndex(e => new { e.UsuarioId, e.Fecha }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(e => e.UsuarioId); + + modelBuilder.Entity() + .HasOne(e => e.Usuario) + .WithMany() + .HasForeignKey(e => e.UsuarioId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() .HasIndex(t => t.UsuarioId); @@ -192,6 +208,25 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(r => r.UsuarioId); + modelBuilder.Entity() + .HasOne(st => st.Mision) + .WithMany(m => m.SubTareas) + .HasForeignKey(st => st.MisionId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(s => s.SubTarea) + .WithMany(st => st.SesionesPomodoro) + .HasForeignKey(s => s.SubTareaId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + + modelBuilder.Entity() + .HasIndex(st => st.MisionId); + + modelBuilder.Entity() + .HasIndex(s => s.SubTareaId); + modelBuilder.Entity() .HasIndex(s => s.UsuarioId); diff --git a/Datos/Semilla/DatosSemilla.cs b/Datos/Semilla/DatosSemilla.cs index 902a6d2..84921c7 100644 --- a/Datos/Semilla/DatosSemilla.cs +++ b/Datos/Semilla/DatosSemilla.cs @@ -161,23 +161,45 @@ public static async Task InicializarAsync(ContextoAplicacion contexto) new Logro { Nombre = "Asistente", Descripcion = "Alcanza el nivel 5.", IconoUrl = "/img/logros/nivel_5.png", CondicionTipo = "NivelAlcanzado", CondicionValor = 5, XpRecompensa = 150, EstaActivo = true }, new Logro { Nombre = "Profesional", Descripcion = "Alcanza el nivel 10.", IconoUrl = "/img/logros/nivel_10.png", CondicionTipo = "NivelAlcanzado", CondicionValor = 10, XpRecompensa = 300, EstaActivo = true }, new Logro { Nombre = "Gran Maestro", Descripcion = "Alcanza el nivel 18.", IconoUrl = "/img/logros/nivel_18.png", CondicionTipo = "NivelAlcanzado", CondicionValor = 18, XpRecompensa = 700, EstaActivo = true }, - new Logro { Nombre = "Leyenda Viviente", Descripcion = "Alcanza el nivel máximo: nivel 20.", IconoUrl = "/img/logros/leyenda.png", CondicionTipo = "NivelAlcanzado", CondicionValor = 20, XpRecompensa = 1000, EstaActivo = true }); + new Logro { Nombre = "Leyenda Viviente", Descripcion = "Alcanza el nivel máximo: nivel 20.", IconoUrl = "/img/logros/leyenda.png", CondicionTipo = "NivelAlcanzado", CondicionValor = 20, XpRecompensa = 1000, EstaActivo = true }, + // Logros ODS-3 — Bienestar + new Logro { Nombre = "Ánimo Estable", Descripcion = "Mantén 7 días consecutivos sin registrar ánimo negativo.", IconoUrl = "/img/logros/placeholder.png", CondicionTipo = "RachaAnimoPositivo", CondicionValor = 7, XpRecompensa = 30, EstaActivo = true }, + new Logro { Nombre = "Autoconsciente", Descripcion = "Registra tu estado de ánimo 30 veces.", IconoUrl = "/img/logros/placeholder.png", CondicionTipo = "TotalRegistrosAnimo", CondicionValor = 30, XpRecompensa = 50, EstaActivo = true }, + new Logro { Nombre = "Alerta Superada", Descripcion = "Sigue una recomendación de alerta de bienestar.", IconoUrl = "/img/logros/placeholder.png", CondicionTipo = "AlertasAtendidas", CondicionValor = 1, XpRecompensa = 20, EstaActivo = true }, + new Logro { Nombre = "Bienestar Constante", Descripcion = "Registra tu estado de ánimo por 14 días consecutivos.", IconoUrl = "/img/logros/placeholder.png", CondicionTipo = "RachaRegistroAnimo", CondicionValor = 14, XpRecompensa = 80, EstaActivo = true }, + new Logro { Nombre = "Maestro del Equilibrio", Descripcion = "Completa 5 pausas activas recomendadas.", IconoUrl = "/img/logros/placeholder.png", CondicionTipo = "PausasActivasCompletadas", CondicionValor = 5, XpRecompensa = 40, EstaActivo = true }); await contexto.SaveChangesAsync(); } if (!contexto.FrasesMotivacionales.Any()) { contexto.FrasesMotivacionales.AddRange( - new FraseMotivacional { Frase = "El secreto para avanzar es comenzar.", Autor = "Mark Twain", EstaActiva = true }, - new FraseMotivacional { Frase = "No cuentes los días, haz que los días cuenten.", Autor = "Muhammad Ali", EstaActiva = true }, - new FraseMotivacional { Frase = "Sé el cambio que quieres ver en el mundo.", Autor = "Mahatma Gandhi", EstaActiva = true }, - new FraseMotivacional { Frase = "El éxito es la suma de pequeños esfuerzos repetidos día tras día.", Autor = "Robert Collier", EstaActiva = true }, - new FraseMotivacional { Frase = "No importa lo lento que vayas, siempre y cuando no te detengas.", Autor = "Confucio", EstaActiva = true }, - new FraseMotivacional { Frase = "La disciplina es el puente entre metas y logros.", Autor = "Jim Rohn", EstaActiva = true }, - new FraseMotivacional { Frase = "Cree que puedes y ya estarás a mitad de camino.", Autor = "Theodore Roosevelt", EstaActiva = true }, - new FraseMotivacional { Frase = "Cada día es una nueva oportunidad para mejorar.", Autor = "Anónimo", EstaActiva = true }, - new FraseMotivacional { Frase = "La constancia es la madre del éxito.", Autor = "Anónimo", EstaActiva = true }, - new FraseMotivacional { Frase = "Haz de cada día tu obra maestra.", Autor = "John Wooden", EstaActiva = true }); + new FraseMotivacional { Frase = "El secreto para avanzar es comenzar.", Autor = "Mark Twain", Categoria = "Motivación", EstaActiva = true }, + new FraseMotivacional { Frase = "No cuentes los días, haz que los días cuenten.", Autor = "Muhammad Ali", Categoria = "Motivación", EstaActiva = true }, + new FraseMotivacional { Frase = "Sé el cambio que quieres ver en el mundo.", Autor = "Mahatma Gandhi", Categoria = "Reflexión", EstaActiva = true }, + new FraseMotivacional { Frase = "El éxito es la suma de pequeños esfuerzos repetidos día tras día.", Autor = "Robert Collier", Categoria = "Constancia", EstaActiva = true }, + new FraseMotivacional { Frase = "No importa lo lento que vayas, siempre y cuando no te detengas.", Autor = "Confucio", Categoria = "Persistencia", EstaActiva = true }, + new FraseMotivacional { Frase = "La disciplina es el puente entre metas y logros.", Autor = "Jim Rohn", Categoria = "Disciplina", EstaActiva = true }, + new FraseMotivacional { Frase = "Cree que puedes y ya estarás a mitad de camino.", Autor = "Theodore Roosevelt", Categoria = "Confianza", EstaActiva = true }, + new FraseMotivacional { Frase = "Cada día es una nueva oportunidad para mejorar.", Autor = "Anónimo", Categoria = "Motivación", EstaActiva = true }, + new FraseMotivacional { Frase = "La constancia es la madre del éxito.", Autor = "Anónimo", Categoria = "Constancia", EstaActiva = true }, + new FraseMotivacional { Frase = "Haz de cada día tu obra maestra.", Autor = "John Wooden", Categoria = "Motivación", EstaActiva = true }, + // Frases adicionales de bienestar universitario (B-INC-04) + new FraseMotivacional { Frase = "Tu salud mental es tan importante como tus notas. Priorízate.", Autor = "Anónimo", Categoria = "Salud Mental", EstaActiva = true }, + new FraseMotivacional { Frase = "Descansar no es perder el tiempo. Es invertir en tu rendimiento.", Autor = "Anónimo", Categoria = "Descanso", EstaActiva = true }, + new FraseMotivacional { Frase = "Está bien no estar bien. Lo importante es buscar ayuda cuando la necesites.", Autor = "Anónimo", Categoria = "Salud Mental", EstaActiva = true }, + new FraseMotivacional { Frase = "Duerme bien, come bien, muévete. Tu cerebro te lo agradecerá en los exámenes.", Autor = "Anónimo", Categoria = "Bienestar", EstaActiva = true }, + new FraseMotivacional { Frase = "Una mente descansada es una mente creativa. No olvides tus pausas.", Autor = "Anónimo", Categoria = "Descanso", EstaActiva = true }, + new FraseMotivacional { Frase = "La universidad no es una carrera de velocidad, es una maratón. Cuida tu paso.", Autor = "Anónimo", Categoria = "Reflexión", EstaActiva = true }, + new FraseMotivacional { Frase = "Hidrátate, estira las piernas y respira. Tu cuerpo es tu herramienta más valiosa.", Autor = "Anónimo", Categoria = "Bienestar", EstaActiva = true }, + new FraseMotivacional { Frase = "Compararte con otros es robarle la alegría a tu propio progreso.", Autor = "Theodore Roosevelt", Categoria = "Confianza", EstaActiva = true }, + new FraseMotivacional { Frase = "El descanso no es un lujo, es una necesidad biológica.", Autor = "Matthew Walker", Categoria = "Descanso", EstaActiva = true }, + new FraseMotivacional { Frase = "Hablar de lo que sientes no te hace débil. Te hace humano.", Autor = "Anónimo", Categoria = "Salud Mental", EstaActiva = true }, + new FraseMotivacional { Frase = "No necesitas hacer más. A veces necesitas hacer menos, pero mejor.", Autor = "Greg McKeown", Categoria = "Reflexión", EstaActiva = true }, + new FraseMotivacional { Frase = "Tomar agua, dormir bien y moverte: tres hábitos que cambian tu vida.", Autor = "Anónimo", Categoria = "Bienestar", EstaActiva = true }, + new FraseMotivacional { Frase = "Un paso a la vez. No tienes que tener todo resuelto hoy.", Autor = "Anónimo", Categoria = "Motivación", EstaActiva = true }, + new FraseMotivacional { Frase = "El estrés es temporal. Tu capacidad para superarlo también.", Autor = "Anónimo", Categoria = "Salud Mental", EstaActiva = true }, + new FraseMotivacional { Frase = "Respira. Todo lo que estás atravesando te está preparando para algo mejor.", Autor = "Anónimo", Categoria = "Reflexión", EstaActiva = true }); await contexto.SaveChangesAsync(); } diff --git a/Documentacion.md b/Documentacion.md new file mode 100644 index 0000000..0c68197 --- /dev/null +++ b/Documentacion.md @@ -0,0 +1,887 @@ +# Auditoría Completa de EpycusApp + +**Fecha:** 2026-06-21 +**Proyecto:** EpycusApp - Plataforma de gamificación para productividad y bienestar universitario +**Repositorio:** GitHub con CI/CD vía GitHub Actions +**Entorno:** Windows (desarrollo local) → Debian 13 VPS (producción) + +--- + +## Índice + +1. [Estructura General](#1-estructura-general) +2. [Tecnologías Usadas](#2-tecnologías-usadas) +3. [Funcionalidades Principales](#3-funcionalidades-principales) +4. [Rutas y Endpoints](#4-rutas-y-endpoints) +5. [Base de Datos](#5-base-de-datos) +6. [Autenticación y Seguridad](#6-autenticación-y-seguridad) +7. [Tests](#7-tests) +8. [Archivos de Configuración](#8-archivos-de-configuración) +9. [Dependencias](#9-dependencias) +10. [Debilidades y Pendientes](#10-debilidades-y-pendientes) +11. [Roadmap](#11-roadmap) + +--- + +## 1. Estructura General + +**Tipo de proyecto:** Monolito Web (ASP.NET Core MVC 9) +**Arquitectura:** Monolítica con capas bien definidas (Controladores → Servicios → Datos/ORM) +**Idioma del código:** Español (nombres de clases, métodos, variables, comentarios en español) + +### Árbol de directorios + +``` +C:\Users\marco\Pictures\EpycusApp\ +├── Ayudantes/ # Clases de utilidad (helpers) +├── bin/ # Build output +├── Controllers/ # Controladores MVC +│ └── Api/ # Controladores API REST +├── Datos/ # DbContext, migraciones, seed data +│ └── Semilla/ +├── deploy/ # Scripts de deploy, nginx, systemd +├── diagrams/ # Diagramas PlantUML +├── DTOs/ # Data Transfer Objects +├── EpycusApp.AcceptanceTests/ # Tests de aceptación (Playwright) +├── EpycusApp.Tests/ # Tests unitarios (xUnit) +├── Middleware/ # Filtros y middleware personalizado +├── Migrations/ # Migraciones EF Core (5 migraciones) +├── Models/ # Entidades, Enums, DTOs +│ ├── Entidades/ # 29 entidades de dominio +│ ├── Enums/ # 5 enumeraciones +│ └── DTOs/ # RespuestaOperacion +├── obj/ # Build output +├── Properties/ # launchSettings.json +├── Servicios/ # Lógica de negocio +│ ├── Interfaces/ # 13 interfaces +│ └── Implementaciones/ # 15 implementaciones +├── ViewModels/ # 21 ViewModels (más subdirectorios) +│ ├── Admin/ +│ ├── Autenticacion/ +│ └── Ia/ +├── Views/ # Vistas Razor (15 directorios) +│ ├── Admin/ +│ ├── Ajustes/ +│ ├── Autenticacion/ +│ ├── Bienestar/ +│ ├── DiarioAnimo/ +│ ├── Habitos/ +│ ├── Home/ +│ ├── Ia/ +│ ├── Misiones/ +│ ├── Perfil/ +│ ├── Pomodoro/ +│ ├── Progreso/ +│ └── Shared/ +├── wwwroot/ # Archivos estáticos +│ ├── css/ (12 archivos) +│ ├── js/ (4 archivos) +│ ├── lib/ (jQuery, Bootstrap, Chart.js, etc.) +│ └── img/ +└── Archivos raíz: + ├── Program.cs + ├── appsettings.json + ├── appsettings.Development.json + ├── appsettings.Example.json + ├── .gitignore + ├── .gitleaks.toml + ├── EpycusApp.csproj + └── EpycusApp.sln +``` + +--- + +## 2. Tecnologías Usadas + +| Capa | Tecnología | Versión | +|------|-----------|---------| +| **Runtime** | .NET | 9.0 | +| **Framework** | ASP.NET Core MVC | 9.0 | +| **ORM** | Entity Framework Core | 9.0 | +| **Conector MySQL** | Pomelo.EntityFrameworkCore.MySql | 9.0 | +| **Base de datos** | MariaDB | 11.8.6 | +| **Autenticación** | JWT Bearer + Cookies | 9.0 | +| **Autenticación Google** | Microsoft.AspNetCore.Authentication.Google | 9.0 | +| **Hashing** | BCrypt.Net-Next | 4.2.0 | +| **Swagger/OpenAPI** | Swashbuckle.AspNetCore | 10.2.1 | +| **Health Checks** | AspNetCore.HealthChecks.MySql | 9.0 | +| **Frontend** | Bootstrap 5 + Chart.js + jQuery | — | +| **Iconos** | Bootstrap Icons | — | +| **Testing unitario** | xUnit + Moq + FluentAssertions | — | +| **Testing aceptación** | xUnit + Microsoft.Playwright | 1.51.0 | +| **CI/CD** | GitHub Actions | — | +| **Proxy inverso** | Nginx | — | +| **Servicio** | systemd (Linux) | — | +| **Monitoreo** | Health Checks personalizados | — | + +### APIs externas integradas + +- **Google Gemini API** (modelo: gemini-2.0-flash) +- **DeepSeek API** (modelo: deepseek-v4-flash) — alternativa configurable +- **Google OAuth 2.0** +- **SMTP Gmail** (envío de correos) + +--- + +## 3. Funcionalidades Principales + +### Módulo 1: Autenticación y Usuarios +**Controladores:** `AutenticacionController.cs`, `ApiAuthController.cs` + +- Registro de usuarios con nombre, correo, contraseña (BCrypt), fecha nacimiento, género, carrera +- Login con JWT (token en cookie HttpOnly `jwt_token`) +- Login/Registro con Google OAuth 2.0 +- Completar registro Google (si el usuario ya existe en Google pero no en la BD) +- Verificación de correo electrónico (token por email) +- Recuperación y restablecimiento de contraseña +- Refresh token rotation (SHA256 hasheado en BD) +- Logout (revocación de tokens refresh) +- Roles: `Usuario`, `Administrador` + +### Módulo 2: Hábitos +**Controladores:** `HabitosController.cs`, `ApiHabitosController.cs` +**Servicio:** `ServicioHabitos.cs` + +- CRUD completo de hábitos +- Frecuencias: Diaria, Semanal, Personalizada (días de semana) +- Categorización de hábitos (Salud, Estudio, Ejercicio, Sueño, etc.) +- Registro diario de estado (Completado/Parcial/Omitido) +- Rachas (streaks) con día de gracia +- Dashboard de hábitos (completados hoy, esta semana, distribución por categoría, mejores rachas) +- Vinculación con Pomodoro +- Recordatorio por hora + +### Módulo 3: Misiones +**Controladores:** `MisionesController.cs`, `ApiMisionesController.cs` +**Servicio:** `ServicioMisiones.cs` + +- CRUD completo de misiones con fecha límite +- Estados: Pendiente, EnProgreso, Completado, Fallido +- Prioridades: Baja, Media, Alta +- Asignación de XP según prioridad (30/50/80 XP) +- Vinculación con Pomodoro +- Categorías específicas para misiones + +### Módulo 4: Pomodoro +**Controladores:** `PomodoroController.cs`, `ApiPomodoroController.cs` +**Servicio:** `ServicioPomodoro.cs` + +- Temporizador con tiempos configurables: estudio, descanso, descanso largo +- Configuración: sonido, volumen, auto-iniciar, tic-tac, vibración, notificaciones desktop +- Sesiones asociables a hábitos o misiones +- Ciclos completados con ganancia de XP +- Meta diaria de ciclos +- Modo personalizado +- Tips de productividad +- Rachas de Pomodoro +- Estadísticas semanales +- Alertas de bienestar por uso excesivo (>8 ciclos/día) + +### Módulo 5: Progreso y Gamificación +**Controlador:** `ProgresoController.cs` +**Servicio:** `ServicioGamificacion.cs` + +- Sistema de niveles (20 niveles: Novato → Leyenda Viviente) +- Cálculo de XP: fórmula `100 + (nivel * 50)` por nivel +- XP por: hábitos (20), misiones (30-80), pomodoro (15), login diario (10), mensajes IA (1) +- Rachas con día de gracia +- Productividad diaria (% de hábitos completados) +- Barra de progreso de nivel animada +- Página de progreso visible para usuarios anónimos + +### Módulo 6: Logros +**Servicio:** `ServicioGamificacion.cs` + +- 15 logros definidos (semilla) +- Condiciones: HábitosCompletados, MisionesCompletadas, RachaDias, SesionesPomodoro, XpTotal, NivelAlcanzado +- Logros ODS-3 adicionales: Ánimo Estable, Autoconsciente, Alerta Superada, Bienestar Constante, Maestro del Equilibrio +- XP de recompensa por logro +- Verificación automática al ganar XP + +### Módulo 7: Perfil +**Controladores:** `PerfilController.cs`, `AjustesController.cs`, `ApiPerfilController.cs` + +- Visualización y edición de perfil (nombre, carrera) +- Cambio de contraseña +- Selección de personaje (Kai, Luna, Ares, Nova) +- Cambio de tema visual (Noche Épica / Sakura) +- Visualización de logros obtenidos +- Personajes disponibles + +### Módulo 8: Personajes y Niveles + +- 4 personajes base (Kai - Sistemas masculino, Luna - Sistemas femenino, Ares - genérico masculino, Nova - genérico femenino) +- Imágenes por nivel para cada personaje/carrera +- Personajes vinculados a carreras universitarias +- Sistema de evolución visual del personaje + +### Módulo 9: Bienestar (ODS 3) +**Controlador:** `BienestarController.cs` +**Servicio:** `ServicioBienestar.cs` + +- Registro de estado de ánimo (Genial, Bien, Normal, Cansado, Estresado) +- Alertas de bienestar automáticas: + - Uso excesivo de Pomodoro (>8 ciclos/día) + - Ánimo negativo consecutivo (>3 días) + - Hábito de sueño descuidado + - Sobrecarga de misiones (>8 pendientes) +- Frases motivacionales (25 frases precargadas) +- Recomendación de pausa activa +- Integración con el asistente IA + +### Módulo 10: Diario de Ánimo +**Controladores:** `DiarioAnimoController.cs`, `ApiDiarioController.cs` + +- Entrada diaria con: estado de ánimo (1-5), nivel de energía, horas de sueño, nivel de estrés, actividad física +- Pregunta guía diaria para reflexión +- Diario textual libre +- Visualización por mes con promedio de ánimo +- Rachas de días consecutivos registrando +- Preguntas guía rotativas + +### Módulo 11: Asistente IA - EDY +**Controlador:** `IaController.cs` +**Servicio:** `ServicioIA.cs` + +- Chat con IA usando Gemini API (gemini-2.0-flash) o DeepSeek API +- System prompt contextual con datos del usuario (nombre, nivel, XP, racha, hábitos, misiones, estado de ánimo) +- Historial de conversaciones por usuario +- Sugerencias personalizadas basadas en el estado del usuario +- Contexto de bienestar para alertas +- Feedback del usuario (útil/no útil) en respuestas +- Límite de 50 mensajes/día +- Resumen automático de conversaciones largas +- XP por mensaje al asistente + +### Módulo 12: Administración +**Controladores:** `AdminController.cs`, `ApiAdminController.cs` + +- Dashboard admin: total usuarios, activos, suscripciones activas, total frases +- Listado y detalle de usuarios +- Activación/desactivación de suscripciones +- CRUD de frases motivacionales +- Panel de administración con layout separado + +### Módulo 13: Suscripciones + +- Planes de suscripción (Premium) +- Activación manual por admin +- Fechas de inicio y fin +- Precio en soles (PEN) + +### Módulo 14: Temas Visuales + +- 2 temas: "Noche Épica" (oscuro) y "Sakura" (claro) +- Temas premium (planificados) +- Persistencia de selección por usuario +- Archivos CSS separados por tema + +### Módulo 15: Home / Dashboard +**Controlador:** `HomeController.cs` + +- Dashboard principal con resumen: nivel, XP, rachas, barra de progreso +- Hábitos del día +- Frase motivacional aleatoria +- Estadísticas rápidas +- Imagen del personaje del usuario + +--- + +## 4. Rutas y Endpoints + +### Rutas MVC (Vistas) + +| Ruta | Controlador | Acción | Auth | +|------|-------------|--------|------| +| `/` | Home | Index | Requerido | +| `/Home/Index` | Home | Index | Requerido | +| `/Home/Privacy` | Home | Privacy | No | +| `/Home/Error` | Home | Error | No | +| `/Autenticacion/Registro` | Autenticacion | Registro (GET/POST) | No | +| `/Autenticacion/Login` | Autenticacion | Login (GET/POST) | No | +| `/Autenticacion/Logout` | Autenticacion | Logout | Sí | +| `/Autenticacion/RecuperarContrasena` | Autenticacion | RecuperarContrasena (GET/POST) | No | +| `/Autenticacion/RestablecerContrasena` | Autenticacion | RestablecerContrasena (GET/POST) | No | +| `/Autenticacion/VerificarCorreo` | Autenticacion | VerificarCorreo | No | +| `/Autenticacion/IniciarSesionGoogle` | Autenticacion | IniciarSesionGoogle | No | +| `/Autenticacion/CallbackGoogle` | Autenticacion | CallbackGoogle | No | +| `/Autenticacion/CompletarRegistroGoogle` | Autenticacion | CompletarRegistroGoogle (GET/POST) | No | +| `/Habitos` | Habitos | Index | Sí | +| `/Habitos/Crear` | Habitos | Crear (GET/POST) | Sí | +| `/Habitos/Editar/{id}` | Habitos | Editar (GET/POST) | Sí | +| `/Habitos/Eliminar/{id}` | Habitos | Eliminar (POST) | Sí | +| `/Misiones` | Misiones | Index | Sí | +| `/Misiones/Crear` | Misiones | Crear (GET/POST) | Sí | +| `/Misiones/Editar/{id}` | Misiones | Editar (GET/POST) | Sí | +| `/Misiones/CambiarEstado` | Misiones | CambiarEstado | Sí | +| `/Misiones/Completar/{id}` | Misiones | Completar | Sí | +| `/Misiones/Eliminar/{id}` | Misiones | Eliminar | Sí | +| `/Pomodoro` | Pomodoro | Index | Sí | +| `/Pomodoro/Configuracion` | Pomodoro | Configuracion (GET/POST) | Sí | +| `/Progreso` | Progreso | Index | No (público) | +| `/Perfil` | Perfil | Index | Sí | +| `/Perfil/ActualizarPerfil` | Perfil | ActualizarPerfil | Sí | +| `/Perfil/CambiarContrasena` | Perfil | CambiarContrasena | Sí | +| `/Perfil/CambiarPersonaje` | Perfil | CambiarPersonaje | Sí | +| `/api/perfil/tema` | Perfil | CambiarTema (POST AJAX) | Sí | +| `/Ajustes` | Ajustes | Index | Sí | +| `/Ajustes/ActualizarPerfil` | Ajustes | ActualizarPerfil | Sí | +| `/Ajustes/CambiarContrasena` | Ajustes | CambiarContrasena | Sí | +| `/Ajustes/CambiarPersonaje` | Ajustes | CambiarPersonaje | Sí | +| `/Ajustes/CambiarTema` | Ajustes | CambiarTema | Sí | +| `/Bienestar` | Bienestar | Index | Sí | +| `/Bienestar/RegistrarAnimo` | Bienestar | RegistrarAnimo | Sí | +| `/DiarioAnimo` | DiarioAnimo | Index | Sí | +| `/DiarioAnimo/Registrar` | DiarioAnimo | Registrar | Sí | +| `/DiarioAnimo/NavegarMes` | DiarioAnimo | NavegarMes | Sí | +| `/Ia` | Ia | Index | Sí | +| `/Ia/Nueva` | Ia | Nueva | Sí | +| `/api/ia/chat` | Ia | Chat (POST) | Sí | +| `/api/ia/feedback` | Ia | Feedback (POST) | Sí | +| `/api/ia/registrar-animo` | Ia | RegistrarAnimo (POST) | Sí | +| `/admin/login` | Admin | Login (GET/POST) | No | +| `/admin/logout` | Admin | Logout | Admin | +| `/admin` | Admin | Index | Admin | +| `/admin/Usuarios` | Admin | Usuarios | Admin | +| `/admin/DetalleUsuario/{id}` | Admin | DetalleUsuario | Admin | +| `/admin/ActivarSuscripcion` | Admin | ActivarSuscripcion | Admin | +| `/admin/DesactivarSuscripcion` | Admin | DesactivarSuscripcion | Admin | +| `/admin/Frases` | Admin | Frases | Admin | +| `/admin/CrearFrase` | Admin | CrearFrase | Admin | +| `/admin/EliminarFrase` | Admin | EliminarFrase | Admin | +| `/health` | — | Health Checks | No | + +### Rutas API REST (`/api/*`) + +#### Autenticación + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/auth/login` | POST | Login | Auth (20/min) | +| `api/auth/refresh` | POST | Renovar token | Auth | +| `api/auth/logout` | POST | Logout | Auth | +| `api/auth/registro` | POST | Registro | Auth | +| `api/auth/verificar-correo` | GET | Verificar correo | Auth | +| `api/auth/recuperar-contrasena` | POST | Recuperar contraseña | Auth | +| `api/auth/restablecer-contrasena` | POST | Restablecer contraseña | Auth | +| `api/auth/google` | POST | Login Google | Auth | +| `api/auth/completar-registro-google` | POST | Completar registro Google | Auth | +| `api/auth/carreras` | GET | Listar carreras | Auth | + +#### Hábitos + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/habitos` | GET | Listar hábitos | Mobile (400/min) | +| `api/habitos/hoy` | GET | Hábitos de hoy | Mobile | +| `api/habitos/{id}` | GET | Hábito por ID | Mobile | +| `api/habitos` | POST | Crear hábito | Mobile | +| `api/habitos/{id}` | PUT | Editar hábito | Mobile | +| `api/habitos/{id}` | DELETE | Eliminar hábito | Mobile | +| `api/habitos/{id}/completar` | POST | Completar hábito | Mobile | +| `api/habitos/{id}/fallar` | POST | Fallar hábito | Mobile | +| `api/habitos/{id}/semana` | GET | Registros semana | Mobile | +| `api/habitos/dashboard` | GET | Dashboard hábitos | Mobile | +| `api/habitos/categorias` | GET | Categorías | Mobile | + +#### Misiones + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/misiones` | GET | Listar misiones | Mobile | +| `api/misiones/{id}` | GET | Misión por ID | Mobile | +| `api/misiones` | POST | Crear misión | Mobile | +| `api/misiones/{id}` | PUT | Editar misión | Mobile | +| `api/misiones/{id}` | DELETE | Eliminar misión | Mobile | +| `api/misiones/{id}/completar` | POST | Completar misión | Mobile | +| `api/misiones/{id}/estado` | POST | Cambiar estado | Mobile | +| `api/misiones/categorias` | GET | Categorías misión | Mobile | + +#### Pomodoro + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/pomodoro/iniciar` | POST | Iniciar sesión | Mobile | +| `api/pomodoro/{id}/ciclo-completado` | POST | Ciclo completado | Mobile | +| `api/pomodoro/{id}/finalizar` | POST | Finalizar sesión | Mobile | +| `api/pomodoro/{id}/cancelar` | POST | Cancelar sesión | Mobile | +| `api/pomodoro/configuracion` | GET | Obtener configuración | Mobile | +| `api/pomodoro/configuracion` | PUT | Actualizar configuración | Mobile | +| `api/pomodoro/tip-aleatorio` | GET | Tip aleatorio | Mobile | +| `api/pomodoro/historial` | GET | Historial sesiones | Mobile | +| `api/pomodoro/racha` | GET | Racha actual | Mobile | +| `api/pomodoro/estadisticas` | GET | Estadísticas | Mobile | + +#### Perfil + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/perfil` | GET | Obtener perfil | Mobile | +| `api/perfil` | PUT | Actualizar perfil | Mobile | +| `api/perfil/cambiar-contrasena` | PUT | Cambiar contraseña | Mobile | +| `api/perfil/personaje` | PUT | Cambiar personaje | Mobile | +| `api/perfil/tema` | PUT | Cambiar tema | Mobile | +| `api/perfil/personajes` | GET | Personajes disponibles | Mobile | +| `api/perfil/logros` | GET | Logros del usuario | Mobile | + +#### Progreso y Gamificación + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/progreso` | GET | Obtener progreso | Mobile | +| `api/progreso/logros` | GET | Logros desbloqueados | Mobile | +| `api/progreso/historial-animo` | GET | Historial ánimo | Mobile | +| `api/gamificacion/mi-progreso` | GET | Progreso completo | Mobile | +| `api/gamificacion/logros` | GET | Todos los logros | Mobile | + +#### Bienestar + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/bienestar/resumen` | GET | Resumen bienestar | Mobile | +| `api/bienestar/alertas` | GET | Alertas activas | Mobile | +| `api/bienestar/frase` | GET | Frase del día | Mobile | +| `api/bienestar/estado-hoy` | GET | Estado hoy | Mobile | +| `api/bienestar/historial-animo` | GET | Historial ánimo | Mobile | +| `api/bienestar/habitos-pendientes` | GET | Hábitos pendientes | Mobile | +| `api/bienestar/misiones-pendientes` | GET | Misiones pendientes | Mobile | +| `api/bienestar/pausa-activa` | POST | Recomendar pausa | Mobile | + +#### Diario de Ánimo + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/diario/hoy` | GET | Entrada de hoy | Mobile | +| `api/diario/fecha` | GET | Entrada por fecha | Mobile | +| `api/diario/mes` | GET | Entradas del mes | Mobile | +| `api/diario` | POST | Registrar entrada | Mobile | +| `api/diario/{fecha}` | PUT | Actualizar entrada | Mobile | +| `api/diario/racha` | GET | Racha diario | Mobile | +| `api/diario/promedio-mes` | GET | Promedio ánimo | Mobile | +| `api/diario/pregunta-guia` | GET | Pregunta guía | Mobile | + +#### Asistente IA + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/ia/chat` | POST | Chat IA | Mobile | +| `api/ia/historial` | GET | Historial chat | Mobile | +| `api/ia/conversaciones` | GET | Conversaciones | Mobile | +| `api/ia/sugerencias` | GET | Sugerencias | Mobile | +| `api/ia/contexto-bienestar` | GET | Contexto bienestar | Mobile | +| `api/ia/feedback` | POST | Feedback | Mobile | +| `api/ia/mensajes-hoy` | GET | Mensajes hoy | Mobile | + +#### Dashboard + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/dashboard/resumen` | GET | Resumen dashboard | Mobile | +| `api/dashboard/frase-del-dia` | GET | Frase del día | Mobile | + +#### Administración + +| Endpoint | Método | Descripción | Rate Limit | +|----------|--------|-------------|------------| +| `api/admin/login` | POST | Login admin | Api (300/min) | +| `api/admin/usuarios` | GET | Listar usuarios | Api | +| `api/admin/usuarios/{id}` | GET | Usuario por ID | Api | +| `api/admin/usuarios/{id}/suscripcion/activar` | POST | Activar suscripción | Api | +| `api/admin/usuarios/{id}/suscripcion/desactivar` | POST | Desactivar suscripción | Api | +| `api/admin/frases` | GET | Listar frases | Api | +| `api/admin/frases` | POST | Crear frase | Api | +| `api/admin/frases/{id}` | DELETE | Eliminar frase | Api | + +--- + +## 5. Base de Datos + +**Motor:** MariaDB 11.8.6 (vía Pomelo.EntityFrameworkCore.MySql 9.0) +Soporta también modo InMemory para desarrollo/testing. + +### Esquema completo (29 entidades) + +#### Usuarios y Seguridad + +| Entidad | Campos clave | +|---------|--------------| +| `Usuario` | Id, CodigoUnico, Nombre, CorreoElectronico, ContrasenaHash, FechaNacimiento, Genero, CorreoVerificado, AceptoTerminos, EstaActivo, FechaRegistro, UltimoAcceso, GoogleId, FotoGoogleUrl, RolId, CarreraId, TemaActualId | +| `Rol` | Id, Nombre — "Usuario" o "Administrador" | +| `TokenRefresh` | Id, Token, ExpiraEn, Revocado, FechaCreacion, UsuarioId | +| `RecuperacionContrasena` | Id, Token, ExpiraEn, Usado, FechaCreacion, UsuarioId | +| `VerificacionCorreo` | Id, Token, ExpiraEn, Usado, FechaCreacion, UsuarioId | + +#### Hábitos y Misiones + +| Entidad | Campos clave | +|---------|--------------| +| `Habito` | Id, Nombre, Descripcion, Frecuencia, ConPomodoro, RecordatorioHora, RachaActual, RachaMaxima, EstaActivo, FechaCreacion, UsuarioId, CategoriaId | +| `DiasSemanaHabito` | Id, HabitoId, DiaSemana | +| `RegistroHabito` | Id, Fecha, Estado, XpOtorgado, FechaRegistro, HabitoId | +| `Categoria` | Id, Nombre, Icono, Tipo, EstaActiva — "Habito", "Mision" o "Ambos" | +| `Mision` | Id, Nombre, Descripcion, NombreCurso, FechaLimite, Prioridad, Estado, ConPomodoro, XpOtorgado, FechaCreacion, FechaCompletado, UsuarioId, CategoriaId | + +#### Pomodoro + +| Entidad | Campos clave | +|---------|--------------| +| `ConfiguracionPomodoro` | Id, TiempoEstudioMin, TiempoDescansoMin, TiempoDescansoLargoMin, CiclosAntesDescansoLargo, SonidoActivo, SonidoSeleccionado, Volumen, AutoIniciarDescanso, AutoIniciarEnfoque, TicTacActivo, MetaDiariaCiclos, ModoPersonalizadoMinutos, VibracionActiva, NotificacionDesktop, FechaActualizacion, UsuarioId | +| `SesionPomodoro` | Id, FechaInicio, FechaFin, CiclosCompletados, XpOtorgado, FueCompletada, UsuarioId, HabitoId, MisionId | +| `TipPomodoro` | Id, Tip, EstaActivo | + +#### Gamificación + +| Entidad | Campos clave | +|---------|--------------| +| `Nivel` | Id, Numero, Titulo, XpRequerido, Descripcion — 20 niveles | +| `ProgresoUsuario` | Id, XpTotal, RachaActual, RachaMaxima, FechaUltimaActividad, FechaInicioRacha, DiaDeGraciaUsado, ProductividadDiaria, UsuarioId, NivelActualId — 1:1 con Usuario | +| `Logro` | Id, Nombre, Descripcion, IconoUrl, CondicionTipo, CondicionValor, XpRecompensa, EstaActivo | +| `LogroUsuario` | Id, FechaObtenido, UsuarioId, LogroId | + +#### Personajes + +| Entidad | Campos clave | +|---------|--------------| +| `Personaje` | Id, Nombre, Genero, EstaActivo, CarreraId | +| `ImagenNivelPersonaje` | Id, NivelNumero, ImagenUrl, EsPlaceholder, PersonajeId | +| `PersonajeUsuario` | Id, EstaSeleccionado, FechaObtenido, UsuarioId, PersonajeId | + +#### Carreras Universitarias + +| Entidad | Campos clave | +|---------|--------------| +| `Carrera` | Id, Nombre, Area, Codigo, EstaActiva — 12 carreras precargadas | + +#### Bienestar + +| Entidad | Campos clave | +|---------|--------------| +| `EstadoAnimo` | Id, Fecha, Estado, Nota, FechaRegistro, UsuarioId | +| `EntradaDiario` | Id, Fecha, EstadoAnimo, NivelEnergia, HorasSueno, NivelEstres, ActividadFisica, DiarioTexto, PreguntaGuia, RespuestaGuia, FechaRegistro, UsuarioId | +| `FraseMotivacional` | Id, Frase, Autor, Categoria, EstaActiva | + +#### IA + +| Entidad | Campos clave | +|---------|--------------| +| `MensajeIA` | Id, ConversacionId, UsuarioId, Rol, Contenido, FechaHora, FeedbackRecibido, FeedbackUtil | + +#### Otros + +| Entidad | Campos clave | +|---------|--------------| +| `Suscripcion` | Id, Plan, PrecioSoles, FechaInicio, FechaFin, EstaActiva, ActivadaPorAdminId, FechaActivacion, UsuarioId | +| `Tema` | Id, Nombre, Descripcion, Modo, ArchivoCss, ImagenPreviewUrl, EsPremium, Precio, EstaActivo | +| `TemaUsuario` | Id, FechaObtenido, UsuarioId, TemaId | +| `Log` | Id, Accion, Detalle, DireccionIp, FechaRegistro, UsuarioId | + +### Migraciones (5 migraciones) + +1. `20260615234414_Initial` — Creación inicial de todas las tablas +2. `20260618182406_AddCategoriaToFraseMotivacional` — Columna Categoria en FraseMotivacional +3. `20260618223131_AddFeedbackToMensajesIA` — Feedback en MensajesIA +4. `20260619000237_AddEntradaDiario` — Nueva entidad EntradaDiario +5. `20260621163455_AddPomodoroConfigExtras` — Configuraciones extras de Pomodoro + +### Índices clave + +- `Usuario.CorreoElectronico` (único) +- `Usuario.CodigoUnico` (único) +- `Usuario.GoogleId` (único) +- `ProgresoUsuario.UsuarioId` (único, 1:1) +- `EntradaDiario.UsuarioId + Fecha` (único compuesto) +- `MensajeIA.ConversacionId` +- `MensajeIA.UsuarioId` +- `TokenRefresh.UsuarioId`, `TokenRefresh.Token` +- Índices en `UsuarioId` de varias tablas + +--- + +## 6. Autenticación y Seguridad + +### Sistema de Autenticación + +- **JWT (Json Web Tokens)** con cookies HttpOnly +- Cookie principal: `jwt_token` (usuarios normales) +- Cookie admin: `admin_jwt_token` (rutas `/admin/*`) +- Cookie de autenticación externa: `.AspNetCore.ExternalAuth` (Google OAuth temporal) + +### Configuración JWT + +| Parámetro | Valor | +|-----------|-------| +| Algoritmo | HMAC-SHA256 | +| Claims | NameIdentifier, Email, Name, Role, CodigoUnico, CarreraId | +| Expiración | 60 minutos (configurable) | +| Refresh tokens | Rotación con hash SHA256, expiran en 7 días | +| Emisor | `EpycusWeb` | +| Audiencia | `EpycusUsuarios` | + +### Google OAuth 2.0 + +- Login con Google account +- Esquema de autenticación externa: `ExternalCookie` (5 min de expiración) +- Claims: NameIdentifier (Google ID), Email, Name, picture +- Flujo: Challenge → Callback → ProcesarAutenticacionGoogleAsync + +### Roles + +- `Usuario` (rol por defecto al registrarse) +- `Administrador` (acceso a panel `/admin/*` con autorización `[Authorize(Roles = "Administrador")]`) + +### Rate Limiting + +| Policy | Límite | Ventana | Cola | +|--------|--------|---------|------| +| Global | 600 | 1 min | 0 | +| Api | 300 | 1 min | 10 | +| Auth | 20 | 1 min | 0 | +| Mobile | 400 | 1 min | 10 | +| Gemini | 20 | 1 min | 0 | +| DeepSeek | 2500 | 1 min | 0 | + +### Protecciones de seguridad implementadas + +- ✅ `[ValidateAntiForgeryToken]` en formularios MVC, header `X-CSRF-TOKEN` +- ✅ Content-Security-Policy en producción +- ✅ `Strict-Transport-Security` (en producción vía Nginx) +- ✅ X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy +- ✅ Cookies: HttpOnly, Secure, SameSite=Lax/Strict +- ✅ HTTPS redirect configurable (`DisableHttpsRedirect`) +- ✅ BCrypt con work factor 12 + +### Pendientes de seguridad + +- ❌ Sin CAPTCHA en login/registro +- ❌ Sin bloqueo de cuenta por intentos fallidos +- ❌ Sin política de contraseñas (longitud mínima, complejidad) +- ❌ API endpoints sin CSRF +- ❌ Refresh tokens no se rotan adecuadamente +- ❌ Sin auditoría de operaciones sensibles +- ❌ Cambio de contraseña no invalida JWTs existentes + +### Middleware de seguridad + +- Middleware de telemetría (logs de requests lentos y errores 500+) +- Health checks de BD, disco, Gemini y DeepSeek + +--- + +## 7. Tests + +### Tests Unitarios (`EpycusApp.Tests`) + +**Framework:** xUnit 2.9.3 + Moq 4.20.72 + FluentAssertions 8.2.0 +**SDK:** Microsoft.NET.Test.Sdk 17.13.0 +**Cobertura:** coverlet.collector 6.0.4 +**Base de datos:** InMemory para tests + +#### Archivos de test (13 archivos) + +**Servicios (8):** +- `ServicioGamificacionTests.cs` +- `ServicioBienestarTests.cs` +- `ServicioAdminTests.cs` +- `ServicioMisionesTests.cs` +- `ServicioHabitosTests.cs` +- `ServicioPerfilTests.cs` +- `ServicioPomodoroTests.cs` +- `ServicioProgresoTests.cs` + +**Controladores (4):** +- `ProgresoControllerTests.cs` +- `HomeControllerTests.cs` +- `HabitosControllerTests.cs` +- `AutenticacionControllerTests.cs` + +**Ayudantes (1):** +- `CalculadorXPTests.cs` (teoría con múltiples casos) + +**Helper:** +- `DbContextFactory.cs` (factory para InMemory DbContext) + +### Tests de Aceptación (`EpycusApp.AcceptanceTests`) + +**Framework:** xUnit + Microsoft.Playwright 1.51.0 + Microsoft.AspNetCore.Mvc.Testing 9.0 +**Tipo:** Pruebas end-to-end con navegador real + +#### Archivos (4) + +- `RegistroYLoginTests.cs` — Flujo completo registro, login, login inválido, redirección +- `GestionHabitosTests.cs` — Crear hábito, listar hábitos +- `GestionMisionesTests.cs` — Gestión de misiones +- `ProgresoYPerfilTests.cs` — Progreso y perfil + +**Screenshots:** Se guardan capturas automáticas en `Screenshots/` durante los tests. + +### Observaciones sobre tests + +- `EpycusApp.Tests/Integracion/` está vacío +- Los tests de aceptación requieren la app corriendo (usa `AcceptanceFixture` con CustomWebApplicationFactory) +- No hay tests de integración implementados + +--- + +## 8. Archivos de Configuración + +### CI/CD (GitHub Actions) + +**`.github/workflows/ci-cd.yml`** — Pipeline principal: + +1. **Code Quality**: Restore → Format check → Build (warnings as errors) +2. **Build & Publish**: Restore → Build → Publish → Upload artifact +3. **Deploy to VPS**: Backup actual → SCP al VPS → Restart systemd service +4. **Security Scan**: Gitleaks (detección de secretos en código) + +**`.github/workflows/deploy.yml`** — Deploy manual (workflow_dispatch): +- Similar al deploy automático pero activado manualmente + +**`.github/dependabot.yml`**: Actualizaciones automáticas semanales de NuGet y GitHub Actions. + +### Variables de entorno (producción vía systemd) + +``` +ASPNETCORE_ENVIRONMENT=Production +ASPNETCORE_URLS=http://localhost:5000 +ConnectionStrings__ConexionPrincipal= +Jwt__Clave= +Google__ClientId / Google__ClientSecret +Correo__Servidor / Correo__Puerto / Correo__Usuario / Correo__Contrasena +Gemini__ApiKey / Gemini__Modelo +Cors__OrigenesPermitidos__0 / __1 +``` + +### Archivos de configuración principales + +| Archivo | Propósito | +|---------|-----------| +| `appsettings.json` | Config principal (BD, JWT, Google, Correo, IA, CORS, RateLimiting) | +| `appsettings.Development.json` | Override desarrollo | +| `appsettings.Example.json` | Template con placeholders | +| `Properties/launchSettings.json` | Perfiles de ejecución (http:5053, https:7254) | +| `.gitignore` | 444 líneas, ignora bin, obj, secrets, etc. | +| `.gitleaks.toml` | Whitelist para evitar falsos positivos de secretos | +| `deploy/epycus-web.service.example` | Template systemd service | +| `deploy/nginx-epycus.conf` | Config Nginx (HTTP→HTTPS, proxy, HSTS, caching) | +| `deploy/setup-vps.sh.example` | Script automatizado de aprovisionamiento VPS | +| `deploy/maintenance.sh` | Activar/desactivar modo mantenimiento | +| `deploy/monitoreo-uptime.sh` | Health check con notificaciones Discord/Telegram | +| `deploy/journald-log-rotation.conf` | Rotación de logs (500MB máx, 7 días retención) | +| `deploy/maintenance.html` | Página 503 de mantenimiento | + +--- + +## 9. Dependencias + +### NuGet Packages (producción) + +| Paquete | Versión | Propósito | +|---------|---------|-----------| +| `AspNetCore.HealthChecks.MySql` | 9.0.0 | Health check de BD | +| `BCrypt.Net-Next` | 4.2.0 | Hashing de contraseñas | +| `Microsoft.AspNetCore.Authentication.Google` | 9.0.0 | Google OAuth | +| `Microsoft.AspNetCore.Authentication.JwtBearer` | 9.0.0 | JWT authentication | +| `Microsoft.EntityFrameworkCore` | 9.0.0 | ORM principal | +| `Microsoft.EntityFrameworkCore.InMemory` | 9.0.0 | BD InMemory para tests | +| `Microsoft.EntityFrameworkCore.Design` | 9.0.0 | Migraciones (solo diseño) | +| `Microsoft.EntityFrameworkCore.Tools` | 9.0.0 | Herramientas CLI (solo diseño) | +| `Pomelo.EntityFrameworkCore.MySql` | 9.0.0 | Provider MySQL/MariaDB | +| `Swashbuckle.AspNetCore` | 10.2.1 | Swagger/OpenAPI | + +### NuGet Packages (testing) + +| Paquete | Versión | +|---------|---------| +| `Microsoft.NET.Test.Sdk` | 17.13.0 | +| `xunit` | 2.9.3 | +| `xunit.runner.visualstudio` | 3.0.2 | +| `Moq` | 4.20.72 | +| `FluentAssertions` | 8.2.0 | +| `coverlet.collector` | 6.0.4 | +| `Microsoft.Playwright` | 1.51.0 | +| `Microsoft.AspNetCore.Mvc.Testing` | 9.0.0 | + +### Frontend Libraries (`wwwroot/lib/`) + +- Bootstrap 5 (CSS + JS bundle) +- Bootstrap Icons +- Chart.js +- jQuery + jQuery Validation + jQuery Validation Unobtrusive + +--- + +## 10. Debilidades y Pendientes + +### Seguridad + +| # | Pendiente | Impacto | +|---|-----------|---------| +| 1 | Sin CAPTCHA en login/registro | Ataques de fuerza bruta automatizados | +| 2 | Sin bloqueo de cuenta por intentos fallidos | Cuentas vulnerables a brute force | +| 3 | Sin política de contraseñas | Contraseñas débiles permitidas | +| 4 | API endpoints sin CSRF | Vulnerabilidad en APIs | +| 5 | Refresh tokens no se rotan adecuadamente | Riesgo de reuso de tokens | +| 6 | Sin auditoría de operaciones sensibles | Sin trazabilidad de acciones críticas | +| 7 | Cambio de contraseña no invalida JWTs existentes | Sesiones antiguas siguen activas | + +### Técnicos + +| # | Pendiente | Detalle | +|---|-----------|---------| +| 1 | Rate limiter de Gemini no aplicado correctamente | No protege efectivamente el controlador | +| 2 | Sin migraciones automáticas en CI/CD | Las migraciones se aplican manualmente | +| 3 | Sin caché para datos frecuentes | Carga innecesaria en BD | +| 4 | Sin graceful shutdown | Pérdida de datos en sesiones activas | +| 5 | API no versionada | Breaking changes afectan a clients | +| 6 | Sin logging/auditoría de operaciones sensibles | No se rastrean acciones admin | +| 7 | Sin análisis de sentimiento en el chat IA | No se detectan estados de ánimo del usuario | +| 8 | Personajes y logros con imágenes placeholder | Mayoría de carreras sin imágenes reales | + +### Testing + +| # | Pendiente | Detalle | +|---|-----------|---------| +| 1 | Sin tests de integración | No se prueba la interacción entre capas | +| 2 | `EpycusApp.Tests/Integracion/` vacío | Directorio preparado pero sin contenido | +| 3 | Cobertura limitada | Solo 13 archivos de test | + +### Infraestructura + +| # | Pendiente | Detalle | +|---|-----------|---------| +| 1 | Sin PWA | Sin service worker, manifest, offline support | +| 2 | Sin SignalR | Sin notificaciones en tiempo real | +| 3 | Sin caché distribuido | Sesiones en memoria del servidor | + +--- + +## 11. Roadmap + +Según `NUEVOS_MODULOS.md`, el proyecto tiene una hoja de ruta con 15 módulos nuevos planificados: + +### Fase 1: Inmediata +- PWA (service worker, manifest, offline) +- API Móvil definitiva +- Notificaciones SignalR en tiempo real + +### Fase 2: Corto plazo +- Amigos / Red social +- Misiones automáticas +- Retos de bienestar ODS-3+ + +### Fase 3: Medio plazo +- Tienda / Recompensas +- Analytics +- Auditoría + +### Fase 4: Largo plazo +- Coleccionables +- Desafíos +- Memoria IA +- Internacionalización (i18n) +- Animaciones avanzadas + +--- + +*Auditoría generada el 2026-06-21 basada en el código fuente completo de EpycusApp.* +*Total de archivos C# analizados: ~90+ (controladores, servicios, modelos, DTOs, ViewModels, middleware, helpers, tests)* diff --git a/EpycusApp.Tests/ContratoJsonTests.cs b/EpycusApp.Tests/ContratoJsonTests.cs new file mode 100644 index 0000000..c8d066b --- /dev/null +++ b/EpycusApp.Tests/ContratoJsonTests.cs @@ -0,0 +1,400 @@ +using System.Text.Json; +using EpycusApp.Ayudantes; +using EpycusApp.DTOs; +using EpycusApp.Models.Entidades; +using FluentAssertions; + +namespace EpycusApp.Tests; + +public class ContratoJsonTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + [Fact] + public void AuthResponseDto_Serializa_Y_Deserializa_Correctamente() + { + var original = new AuthResponseDto { Token = "jwt.123", RefreshToken = "refresh.456" }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("token", out _).Should().BeTrue(); + jsonDoc.RootElement.TryGetProperty("refreshToken", out _).Should().BeTrue(); + + var deserializado = JsonSerializer.Deserialize(json, JsonOptions); + deserializado.Should().NotBeNull(); + deserializado!.Token.Should().Be("jwt.123"); + deserializado.RefreshToken.Should().Be("refresh.456"); + } + + [Fact] + public void MensajeResponseDto_Serializa_Correctamente() + { + var original = new MensajeResponseDto { Mensaje = "Operacion exitosa" }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("mensaje", out var prop).Should().BeTrue(); + prop.GetString().Should().Be("Operacion exitosa"); + } + + [Fact] + public void SuccessResponseDto_Serializa_Correctamente() + { + var original = new SuccessResponseDto(); + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("success", out var prop).Should().BeTrue(); + prop.GetBoolean().Should().BeTrue(); + } + + [Fact] + public void DashboardResumenResponse_Serializa_Todas_Las_Propiedades() + { + var original = new DashboardResumenResponse + { + Kpis = new DashboardKpis { HabitosPendientes = 3, MisionesPendientes = 2 }, + HabitosPendientes = 3, + MisionesPendientes = 2, + Frase = new FraseResponseDto { Frase = "Persiste", Autor = "Anonimo" } + }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + var propiedadesEsperadas = new[] { "kpis", "habitosPendientes", "misionesPendientes", "frase" }; + foreach (var prop in propiedadesEsperadas) + jsonDoc.RootElement.TryGetProperty(prop, out _).Should().BeTrue($"'{prop}' deberia existir"); + } + + [Fact] + public void GamificacionProgresoResponse_Serializa_Todas_Las_Propiedades() + { + var original = new GamificacionProgresoResponse + { + XpTotal = 1500, + Nivel = 5, + Titulo = "Guerrero", + RachaActual = 7, + XpParaSiguienteNivel = 500, + PorcentajeProgreso = 75.0, + ImagenPersonaje = "https://ejemplo.com/avatar.png" + }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + var propiedades = new[] { "xpTotal", "nivel", "titulo", "rachaActual", "xpParaSiguienteNivel", "porcentajeProgreso", "imagenPersonaje" }; + foreach (var prop in propiedades) + jsonDoc.RootElement.TryGetProperty(prop, out _).Should().BeTrue($"'{prop}' deberia existir"); + } + + [Fact] + public void PomodoroIniciarResponse_Serializa_Correctamente() + { + var original = new PomodoroIniciarResponse { SesionId = 42, FechaInicio = new DateTime(2025, 3, 15, 10, 30, 0) }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("sesionId", out var idProp).Should().BeTrue(); + idProp.GetInt32().Should().Be(42); + jsonDoc.RootElement.TryGetProperty("fechaInicio", out _).Should().BeTrue(); + } + + [Fact] + public void PomodoroCicloCompletadoResponse_Serializa_Correctamente() + { + var original = new PomodoroCicloCompletadoResponse + { + XpGanado = 50, + SugerirDescanso = true, + PausaActiva = "Estiramientos" + }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("xpGanado", out _).Should().BeTrue(); + jsonDoc.RootElement.TryGetProperty("sugerirDescanso", out _).Should().BeTrue(); + jsonDoc.RootElement.TryGetProperty("pausaActiva", out _).Should().BeTrue(); + } + + [Fact] + public void PomodoroConfiguracionResponse_Serializa_Todas_Las_Propiedades() + { + var original = new PomodoroConfiguracionResponse + { + TiempoEstudio = 25, + TiempoDescanso = 5, + TiempoDescansoLargo = 15, + CiclosAntesDescansoLargo = 4, + SonidoActivo = true, + SonidoSeleccionado = "lluvia", + Volumen = 80, + AutoIniciarDescanso = false, + AutoIniciarEnfoque = true, + TicTacActivo = false, + MetaDiariaCiclos = 8, + ModoPersonalizadoMin = 25, + VibracionActiva = true, + NotificacionDesktop = false + }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + var propiedades = new[] + { + "tiempoEstudio", "tiempoDescanso", "tiempoDescansoLargo", "ciclosAntesDescansoLargo", + "sonidoActivo", "sonidoSeleccionado", "volumen", "autoIniciarDescanso", + "autoIniciarEnfoque", "ticTacActivo", "metaDiariaCiclos", "modoPersonalizadoMin", + "vibracionActiva", "notificacionDesktop" + }; + foreach (var prop in propiedades) + jsonDoc.RootElement.TryGetProperty(prop, out _).Should().BeTrue($"'{prop}' deberia existir"); + } + + [Fact] + public void MisionListaItemResponse_Serializa_Todas_Las_Propiedades() + { + var original = new MisionListaItemResponse + { + Id = 1, + Nombre = "Estudiar matematicas", + Descripcion = "Capitulo 5", + NombreCurso = "Matematicas", + Prioridad = "Alta", + Estado = "Pendiente", + FechaLimite = "2025-04-01", + XpOtorgado = 100, + FechaCreacion = DateTime.Now, + CategoriaId = 2 + }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + var propiedades = new[] + { + "id", "nombre", "descripcion", "nombreCurso", "prioridad", + "estado", "fechaLimite", "xpOtorgado", "fechaCreacion", "categoriaId" + }; + foreach (var prop in propiedades) + jsonDoc.RootElement.TryGetProperty(prop, out _).Should().BeTrue($"'{prop}' deberia existir"); + } + + [Fact] + public void MisionCompletarResponse_Serializa_Correctamente() + { + var original = new MisionCompletarResponse { XpGanado = 75 }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("xpGanado", out var prop).Should().BeTrue(); + prop.GetInt32().Should().Be(75); + } + + [Fact] + public void DiarioRachaResponse_Serializa_Correctamente() + { + var original = new DiarioRachaResponse { DiasConsecutivos = 5 }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("diasConsecutivos", out var prop).Should().BeTrue(); + prop.GetInt32().Should().Be(5); + } + + [Fact] + public void PreguntaGuiaResponse_Serializa_Correctamente() + { + var original = new PreguntaGuiaResponse { Pregunta = "Que aprendiste hoy?" }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("pregunta", out var prop).Should().BeTrue(); + prop.GetString().Should().Be("Que aprendiste hoy?"); + } + + [Fact] + public void PomodoroTipResponse_Serializa_Correctamente() + { + var original = new PomodoroTipResponse { Consejo = "Toma descansos frecuentes" }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("consejo", out var prop).Should().BeTrue(); + prop.GetString().Should().Be("Toma descansos frecuentes"); + } + + [Fact] + public void PomodoroSesionActivaResponse_Serializa_Correctamente() + { + var original = new PomodoroSesionActivaResponse + { + Activa = true, + SesionId = 10, + FechaInicio = DateTime.Now, + CiclosCompletados = 2 + }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + var propiedades = new[] { "activa", "sesionId", "fechaInicio", "ciclosCompletados" }; + foreach (var prop in propiedades) + jsonDoc.RootElement.TryGetProperty(prop, out _).Should().BeTrue($"'{prop}' deberia existir"); + } + + [Fact] + public void IaChatResponseDto_Serializa_Correctamente() + { + var original = new IaChatResponseDto { Respuesta = "Hola!", ConversacionId = "conv-1" }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("respuesta", out _).Should().BeTrue(); + jsonDoc.RootElement.TryGetProperty("conversacionId", out _).Should().BeTrue(); + } + + [Fact] + public void FraseResponseDto_Serializa_Correctamente() + { + var original = new FraseResponseDto { Frase = "El exito es la suma de pequenos esfuerzos", Autor = "R. Collard" }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("frase", out var fraseProp).Should().BeTrue(); + fraseProp.GetString().Should().Be("El exito es la suma de pequenos esfuerzos"); + jsonDoc.RootElement.TryGetProperty("autor", out var autorProp).Should().BeTrue(); + autorProp.GetString().Should().Be("R. Collard"); + } + + [Fact] + public void RespuestaApi_Exitosa_Serializa_Estructura_Correcta() + { + var datos = new AuthResponseDto { Token = "test", RefreshToken = "test" }; + var respuesta = RespuestaApi.Exitosa(datos, "Login exitoso"); + + var json = JsonSerializer.Serialize(respuesta, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("exito", out var exitoProp).Should().BeTrue(); + exitoProp.GetBoolean().Should().BeTrue(); + + jsonDoc.RootElement.TryGetProperty("mensaje", out var msgProp).Should().BeTrue(); + msgProp.GetString().Should().Be("Login exitoso"); + + jsonDoc.RootElement.TryGetProperty("datos", out var datosProp).Should().BeTrue(); + datosProp.TryGetProperty("token", out _).Should().BeTrue(); + } + + [Fact] + public void RespuestaApi_Fallida_Serializa_Estructura_Correcta() + { + var respuesta = RespuestaApi.Fallida("Error de autenticacion", + new List { "Credenciales invalidas" }); + + var json = JsonSerializer.Serialize(respuesta, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("exito", out var exitoProp).Should().BeTrue(); + exitoProp.GetBoolean().Should().BeFalse(); + + jsonDoc.RootElement.TryGetProperty("mensaje", out var msgProp).Should().BeTrue(); + msgProp.GetString().Should().Be("Error de autenticacion"); + + jsonDoc.RootElement.TryGetProperty("errores", out var errProp).Should().BeTrue(); + errProp.GetArrayLength().Should().Be(1); + } + + [Fact] + public void ProgresoResponseDto_Serializa_Correctamente() + { + var original = new ProgresoResponseDto + { + Progreso = new { xp = 1000 }, + NivelSiguiente = new { nivel = 6 }, + XpParaSiguiente = 500, + Porcentaje = 66.7 + }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + var propiedades = new[] { "progreso", "nivelSiguiente", "xpParaSiguiente", "porcentaje" }; + foreach (var prop in propiedades) + jsonDoc.RootElement.TryGetProperty(prop, out _).Should().BeTrue($"'{prop}' deberia existir"); + } + + [Fact] + public void PomodoroRachaResponse_Serializa_Correctamente() + { + var original = new PomodoroRachaResponse { Racha = 10 }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("racha", out var prop).Should().BeTrue(); + prop.GetInt32().Should().Be(10); + } + + [Fact] + public void PomodoroFinalizarResponse_Serializa_Correctamente() + { + var original = new PomodoroFinalizarResponse { XpTotal = 300, SesionGuardada = true }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("xpTotal", out _).Should().BeTrue(); + jsonDoc.RootElement.TryGetProperty("sesionGuardada", out _).Should().BeTrue(); + } + + [Fact] + public void DashboardKpis_Serializa_Correctamente() + { + var original = new DashboardKpis { HabitosPendientes = 5, MisionesPendientes = 3 }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("habitosPendientes", out var hProp).Should().BeTrue(); + hProp.GetInt32().Should().Be(5); + jsonDoc.RootElement.TryGetProperty("misionesPendientes", out var mProp).Should().BeTrue(); + mProp.GetInt32().Should().Be(3); + } + + [Fact] + public void BienestarResumenResponse_Serializa_Correctamente() + { + var original = new BienestarResumenResponse + { + Alertas = new { hayAlertas = false }, + Frase = new { frase = "Respira" }, + EstadoHoy = new { estado = "Feliz" }, + HabitosPendientes = 2, + MisionesPendientes = 1 + }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + var propiedades = new[] { "alertas", "frase", "estadoHoy", "habitosPendientes", "misionesPendientes" }; + foreach (var prop in propiedades) + jsonDoc.RootElement.TryGetProperty(prop, out _).Should().BeTrue($"'{prop}' deberia existir"); + } + + [Fact] + public void PomodoroHistorialResponse_Serializa_Correctamente() + { + var original = new PomodoroHistorialResponse + { + Historial = new List + { + new SesionPomodoro { Id = 1, FechaInicio = new DateTime(2025, 1, 1) } + }, + Pagina = 1, + Tamano = 20 + }; + var json = JsonSerializer.Serialize(original, JsonOptions); + var jsonDoc = JsonDocument.Parse(json); + + jsonDoc.RootElement.TryGetProperty("historial", out _).Should().BeTrue(); + jsonDoc.RootElement.TryGetProperty("pagina", out _).Should().BeTrue(); + jsonDoc.RootElement.TryGetProperty("tamano", out _).Should().BeTrue(); + } +} diff --git a/EpycusApp.Tests/Integracion/ApiPomodoroTests.cs b/EpycusApp.Tests/Integracion/ApiPomodoroTests.cs new file mode 100644 index 0000000..a60abe5 --- /dev/null +++ b/EpycusApp.Tests/Integracion/ApiPomodoroTests.cs @@ -0,0 +1,505 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using EpycusApp.Ayudantes; +using EpycusApp.Datos; +using EpycusApp.DTOs; +using EpycusApp.Controllers.Api; +using EpycusApp.Models.Entidades; +using EpycusApp.Servicios.Implementaciones; +using EpycusApp.Servicios.Interfaces; +using EpycusApp.Tests.AyudantesTests; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; + +namespace EpycusApp.Tests.Integracion; + +public class ApiPomodoroTests : IDisposable +{ + private readonly ContextoAplicacion _contexto; + private readonly ServicioPomodoro _servicio; + private readonly ApiPomodoroController _controller; + private readonly Mock _gamificacionMock; + private readonly Mock _bienestarMock; + private readonly Mock _habitosMock; + private readonly Mock _misionesMock; + private readonly Mock> _loggerMock; + private int _usuarioId; + + public ApiPomodoroTests() + { + _contexto = DbContextFactory.CrearContexto("PomodoroIntegracion"); + _gamificacionMock = new Mock(); + _bienestarMock = new Mock(); + _habitosMock = new Mock(); + _misionesMock = new Mock(); + _loggerMock = new Mock>(); + + _servicio = new ServicioPomodoro( + _contexto, _gamificacionMock.Object, _bienestarMock.Object, + _habitosMock.Object, _misionesMock.Object, _loggerMock.Object); + + _controller = new ApiPomodoroController(_servicio); + + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "0") }; + var identity = new ClaimsIdentity(claims, "TestAuth"); + var principal = new ClaimsPrincipal(identity); + var httpContext = new DefaultHttpContext { User = principal }; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + } + + public void Dispose() + { + _contexto.Dispose(); + } + + private async Task SeedUsuarioAsync() + { + _contexto.Roles.Add(new Rol { Id = 1, Nombre = "Usuario" }); + _contexto.Carreras.Add(new Carrera { Id = 1, Nombre = "Test", Area = "Test", Codigo = "TST" }); + await _contexto.SaveChangesAsync(); + + var usuario = new Usuario + { + CodigoUnico = "API_POM_" + Guid.NewGuid().ToString()[..6], + Nombre = "Api Test", + CorreoElectronico = "api_pom@test.com", + ContrasenaHash = "hash", + FechaNacimiento = new DateOnly(2000, 1, 1), + Genero = "Masculino", + RolId = 1, + CarreraId = 1 + }; + _contexto.Usuarios.Add(usuario); + await _contexto.SaveChangesAsync(); + _usuarioId = usuario.Id; + + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, _usuarioId.ToString()) }; + var identity = new ClaimsIdentity(claims, "TestAuth"); + var principal = new ClaimsPrincipal(identity); + _controller.ControllerContext.HttpContext.User = principal; + + _gamificacionMock.Setup(g => g.SumarXP(It.IsAny(), It.IsAny())) + .ReturnsAsync((15, false, 1)); + _gamificacionMock.Setup(g => g.VerificarYOtorgarLogros(It.IsAny())) + .Returns(Task.CompletedTask); + + return usuario.Id; + } + + // T8: Validación DTO ActualizarConfiguracionPomodoroDto + [Fact] + public void T8_ActualizarConfiguracionDto_ValoresValidos_PasaValidacion() + { + var dto = new ActualizarConfiguracionPomodoroDto + { + TiempoEstudioMin = 25, + TiempoDescansoMin = 5, + TiempoDescansoLargoMin = 15, + CiclosAntesDescansoLargo = 4, + SonidoSeleccionado = "campana", + Volumen = 80, + MetaDiariaCiclos = 8, + ModoPersonalizadoMin = 30 + }; + + var ctx = new ValidationContext(dto); + var resultados = new List(); + var esValido = Validator.TryValidateObject(dto, ctx, resultados, true); + + esValido.Should().BeTrue(); + } + + [Fact] + public void T8_ActualizarConfiguracionDto_DescansoMayorAEstudio_FallaValidacion() + { + var dto = new ActualizarConfiguracionPomodoroDto + { + TiempoEstudioMin = 10, + TiempoDescansoMin = 15, + TiempoDescansoLargoMin = 20, + CiclosAntesDescansoLargo = 4, + SonidoSeleccionado = "campana", + }; + + var ctx = new ValidationContext(dto); + var resultados = new List(); + var esValido = Validator.TryValidateObject(dto, ctx, resultados, true); + + esValido.Should().BeFalse(); + resultados.Should().Contain(r => r.MemberNames.Contains("TiempoDescansoMin")); + } + + [Fact] + public void T8_ActualizarConfiguracionDto_SonidoInvalido_FallaValidacion() + { + var dto = new ActualizarConfiguracionPomodoroDto + { + TiempoEstudioMin = 25, + TiempoDescansoMin = 5, + TiempoDescansoLargoMin = 15, + CiclosAntesDescansoLargo = 4, + SonidoSeleccionado = "inexistente", + }; + + var ctx = new ValidationContext(dto); + var resultados = new List(); + var esValido = Validator.TryValidateObject(dto, ctx, resultados, true); + + esValido.Should().BeFalse(); + resultados.Should().Contain(r => r.MemberNames.Contains("SonidoSeleccionado")); + } + + [Fact] + public void T8_ActualizarConfiguracionDto_TiempoEstudioFueraDeRango_FallaValidacion() + { + var dto = new ActualizarConfiguracionPomodoroDto + { + TiempoEstudioMin = 200, + TiempoDescansoMin = 5, + TiempoDescansoLargoMin = 15, + CiclosAntesDescansoLargo = 4, + SonidoSeleccionado = "campana", + }; + + var ctx = new ValidationContext(dto); + var resultados = new List(); + var esValido = Validator.TryValidateObject(dto, ctx, resultados, true); + + esValido.Should().BeFalse(); + } + + [Fact] + public void T8_ActualizarConfiguracionDto_DescansoLargoNoMayorQueCorto_FallaValidacion() + { + var dto = new ActualizarConfiguracionPomodoroDto + { + TiempoEstudioMin = 25, + TiempoDescansoMin = 15, + TiempoDescansoLargoMin = 10, + CiclosAntesDescansoLargo = 4, + SonidoSeleccionado = "campana", + }; + + var ctx = new ValidationContext(dto); + var resultados = new List(); + var esValido = Validator.TryValidateObject(dto, ctx, resultados, true); + + esValido.Should().BeFalse(); + resultados.Should().Contain(r => r.MemberNames.Contains("TiempoDescansoLargoMin")); + } + + // T9: Validación DTO CicloCompletadoRequest + [Fact] + public void T9_CicloCompletadoRequest_RangoValido_PasaValidacion() + { + var req = new CicloCompletadoRequest { CiclosCompletados = 5 }; + var ctx = new ValidationContext(req); + var resultados = new List(); + + var esValido = Validator.TryValidateObject(req, ctx, resultados, true); + + esValido.Should().BeTrue(); + } + + [Fact] + public void T9_CicloCompletadoRequest_Cero_FallaValidacion() + { + var req = new CicloCompletadoRequest { CiclosCompletados = 0 }; + var ctx = new ValidationContext(req); + var resultados = new List(); + + var esValido = Validator.TryValidateObject(req, ctx, resultados, true); + + esValido.Should().BeFalse(); + } + + [Fact] + public void T9_CicloCompletadoRequest_MayorQue100_FallaValidacion() + { + var req = new CicloCompletadoRequest { CiclosCompletados = 101 }; + var ctx = new ValidationContext(req); + var resultados = new List(); + + var esValido = Validator.TryValidateObject(req, ctx, resultados, true); + + esValido.Should().BeFalse(); + } + + // T10: POST /api/pomodoro/iniciar + [Fact] + public async Task T10_Iniciar_SinSesionActiva_CreaSesion() + { + await SeedUsuarioAsync(); + + var resultado = await _controller.Iniciar(new IniciarRequest()); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Exito.Should().BeTrue(); + respuesta.Datos.Should().NotBeNull(); + respuesta.Datos!.SesionId.Should().BeGreaterThan(0); + } + + [Fact] + public async Task T10_Iniciar_ConSesionActiva_RetornaConflict() + { + await SeedUsuarioAsync(); + await _servicio.IniciarSesion(_usuarioId, null, null); + + var resultado = await _controller.Iniciar(new IniciarRequest()); + + resultado.Should().BeOfType(); + } + + // T11: POST /api/pomodoro/{id}/ciclo-completado + [Fact] + public async Task T11_CicloCompletado_SesionValida_OtorgaXP() + { + await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(_usuarioId, null, null); + + var resultado = await _controller.CicloCompletado(sesion.Id, new CicloCompletadoRequest { CiclosCompletados = 2 }); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Datos.Should().NotBeNull(); + respuesta.Datos!.XpGanado.Should().Be(ConstantesGamificacion.XpBasePomodoro * 2); + } + + [Fact] + public async Task T11_CicloCompletado_SesionInexistente_RetornaNotFound() + { + await SeedUsuarioAsync(); + + var resultado = await _controller.CicloCompletado(999, new CicloCompletadoRequest { CiclosCompletados = 1 }); + + resultado.Should().BeOfType(); + } + + [Fact] + public async Task T11_CicloCompletado_SinAutenticacion_RetornaUnauthorized() + { + await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(_usuarioId, null, null); + var otroUsuarioId = _usuarioId + 1; + + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, otroUsuarioId.ToString()) }; + var identity = new ClaimsIdentity(claims, "TestAuth"); + var principal = new ClaimsPrincipal(identity); + _controller.ControllerContext.HttpContext.User = principal; + + var resultado = await _controller.CicloCompletado(sesion.Id, new CicloCompletadoRequest { CiclosCompletados = 1 }); + + resultado.Should().BeOfType(); + } + + // T12: POST /api/pomodoro/{id}/finalizar + [Fact] + public async Task T12_Finalizar_SesionValida_OtorgaBonusXP() + { + await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(_usuarioId, null, null); + + var resultado = await _controller.Finalizar(sesion.Id, new CicloCompletadoRequest { CiclosCompletados = 3 }); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Datos.Should().NotBeNull(); + respuesta.Datos!.XpBonus.Should().Be(3 * 5 + 10); + respuesta.Datos.SesionGuardada.Should().BeTrue(); + } + + [Fact] + public async Task T12_Finalizar_SesionInexistente_RetornaNotFound() + { + await SeedUsuarioAsync(); + + var resultado = await _controller.Finalizar(999, new CicloCompletadoRequest { CiclosCompletados = 1 }); + + resultado.Should().BeOfType(); + } + + // T13: POST /api/pomodoro/{id}/cancelar + [Fact] + public async Task T13_Cancelar_SesionValida_MarcaCancelada() + { + await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(_usuarioId, null, null); + + var resultado = await _controller.Cancelar(sesion.Id); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Exito.Should().BeTrue(); + + var sesionDb = await _contexto.SesionesPomodoro.FindAsync(sesion.Id); + sesionDb.Should().NotBeNull(); + sesionDb!.FueCompletada.Should().BeFalse(); + sesionDb.FechaFin.Should().NotBeNull(); + } + + [Fact] + public async Task T13_Cancelar_SesionInexistente_RetornaNotFound() + { + await SeedUsuarioAsync(); + + var resultado = await _controller.Cancelar(999); + + resultado.Should().BeOfType(); + } + + // T14: GET/PUT /api/pomodoro/configuracion + [Fact] + public async Task T14_ObtenerConfiguracion_SinConfig_RetornaDefault() + { + await SeedUsuarioAsync(); + + var resultado = await _controller.ObtenerConfiguracion(); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Datos.Should().NotBeNull(); + respuesta.Datos!.TiempoEstudio.Should().Be(25); + } + + [Fact] + public async Task T14_ObtenerConfiguracion_SinConfig_NoPersisteEnBD() + { + await SeedUsuarioAsync(); + var countBefore = await _contexto.ConfiguracionesPomodoro.CountAsync(); + + await _controller.ObtenerConfiguracion(); + + var countAfter = await _contexto.ConfiguracionesPomodoro.CountAsync(); + countAfter.Should().Be(countBefore); + } + + [Fact] + public async Task T14_ActualizarConfiguracion_ValoresValidos_GuardaCambios() + { + await SeedUsuarioAsync(); + var dto = new ActualizarConfiguracionPomodoroDto + { + TiempoEstudioMin = 50, + TiempoDescansoMin = 10, + TiempoDescansoLargoMin = 20, + CiclosAntesDescansoLargo = 4, + SonidoSeleccionado = "digital", + Volumen = 80, + AutoIniciarDescanso = true, + MetaDiariaCiclos = 6, + ModoPersonalizadoMin = 30, + VibracionActiva = true, + NotificacionDesktop = true + }; + + var resultado = await _controller.ActualizarConfiguracion(dto); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Exito.Should().BeTrue(); + + var config = await _contexto.ConfiguracionesPomodoro.FirstAsync(c => c.UsuarioId == _usuarioId); + config.TiempoEstudioMin.Should().Be(50); + config.SonidoSeleccionado.Should().Be("digital"); + } + + [Fact] + public async Task T14_ActualizarConfiguracion_DatosInvalidos_RetornaBadRequest() + { + await SeedUsuarioAsync(); + _controller.ModelState.AddModelError("TiempoEstudioMin", "Fuera de rango"); + + var dto = new ActualizarConfiguracionPomodoroDto(); + var resultado = await _controller.ActualizarConfiguracion(dto); + + resultado.Should().BeOfType(); + } + + // T15: GET /api/pomodoro/tip-aleatorio + [Fact] + public async Task T15_TipAleatorio_SinTips_RetornaVacio() + { + await SeedUsuarioAsync(); + + var resultado = await _controller.ObtenerTip(); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Datos.Should().NotBeNull(); + respuesta.Datos!.Consejo.Should().BeEmpty(); + } + + [Fact] + public async Task T15_TipAleatorio_ConTips_RetornaTip() + { + await SeedUsuarioAsync(); + _contexto.TipsPomodoro.Add(new TipPomodoro { Tip = "Test tip", EstaActivo = true }); + await _contexto.SaveChangesAsync(); + + var resultado = await _controller.ObtenerTip(); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Datos.Should().NotBeNull(); + respuesta.Datos!.Consejo.Should().Be("Test tip"); + } + + // NF-22: Tests de filtros en GET /api/pomodoro/historial + [Fact] + public async Task T16_Historial_FiltroCompletadaTrue_RetornaSoloCompletadas() + { + await SeedUsuarioAsync(); + var s1 = new SesionPomodoro { UsuarioId = _usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-2), CiclosCompletados = 2, XpOtorgado = 30, FueCompletada = true }; + var s2 = new SesionPomodoro { UsuarioId = _usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-1), CiclosCompletados = 0, XpOtorgado = 0, FueCompletada = false }; + _contexto.SesionesPomodoro.AddRange(s1, s2); + await _contexto.SaveChangesAsync(); + + var resultado = await _controller.ObtenerHistorial(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1, 20, true, null); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Datos.Should().NotBeNull(); + respuesta.Datos!.Historial.Should().HaveCount(1); + respuesta.Datos.Historial![0].FueCompletada.Should().BeTrue(); + } + + [Fact] + public async Task T16_Historial_FiltroConXpTrue_RetornaSoloConXp() + { + await SeedUsuarioAsync(); + var s1 = new SesionPomodoro { UsuarioId = _usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-2), CiclosCompletados = 2, XpOtorgado = 30, FueCompletada = true }; + var s2 = new SesionPomodoro { UsuarioId = _usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-1), CiclosCompletados = 1, XpOtorgado = 0, FueCompletada = true }; + _contexto.SesionesPomodoro.AddRange(s1, s2); + await _contexto.SaveChangesAsync(); + + var resultado = await _controller.ObtenerHistorial(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1, 20, null, true); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Datos.Should().NotBeNull(); + respuesta.Datos!.Historial.Should().HaveCount(1); + respuesta.Datos.Historial![0].XpOtorgado.Should().BeGreaterThan(0); + } + + [Fact] + public async Task T16_Historial_FiltrosCombinados_RetornaCoincidentes() + { + await SeedUsuarioAsync(); + var s1 = new SesionPomodoro { UsuarioId = _usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-3), CiclosCompletados = 2, XpOtorgado = 30, FueCompletada = true }; + var s2 = new SesionPomodoro { UsuarioId = _usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-2), CiclosCompletados = 0, XpOtorgado = 0, FueCompletada = false }; + var s3 = new SesionPomodoro { UsuarioId = _usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-1), CiclosCompletados = 1, XpOtorgado = 15, FueCompletada = true }; + _contexto.SesionesPomodoro.AddRange(s1, s2, s3); + await _contexto.SaveChangesAsync(); + + var resultado = await _controller.ObtenerHistorial(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1, 20, true, true); + + var okResult = resultado.Should().BeOfType().Subject; + var respuesta = okResult.Value.Should().BeOfType>().Subject; + respuesta.Datos.Should().NotBeNull(); + respuesta.Datos!.Historial.Should().HaveCount(2); + } +} diff --git a/EpycusApp.Tests/Unitarios/Servicios/ServicioPomodoroTests.cs b/EpycusApp.Tests/Unitarios/Servicios/ServicioPomodoroTests.cs index 7716e77..38d92fa 100644 --- a/EpycusApp.Tests/Unitarios/Servicios/ServicioPomodoroTests.cs +++ b/EpycusApp.Tests/Unitarios/Servicios/ServicioPomodoroTests.cs @@ -18,6 +18,8 @@ public class ServicioPomodoroTests private readonly ServicioPomodoro _servicio; private readonly Mock _gamificacionMock; private readonly Mock _bienestarMock; + private readonly Mock _habitosMock; + private readonly Mock _misionesMock; private readonly Mock> _loggerMock; public ServicioPomodoroTests() @@ -25,8 +27,12 @@ public ServicioPomodoroTests() _contexto = DbContextFactory.CrearContexto("PomodoroTest"); _gamificacionMock = new Mock(); _bienestarMock = new Mock(); + _habitosMock = new Mock(); + _misionesMock = new Mock(); _loggerMock = new Mock>(); - _servicio = new ServicioPomodoro(_contexto, _gamificacionMock.Object, _bienestarMock.Object, _loggerMock.Object); + _servicio = new ServicioPomodoro(_contexto, _gamificacionMock.Object, _bienestarMock.Object, _habitosMock.Object, _misionesMock.Object, _loggerMock.Object); + + _gamificacionMock.Setup(g => g.VerificarYOtorgarLogros(It.IsAny())).Returns(Task.CompletedTask); } private async Task SeedUsuarioAsync() @@ -51,6 +57,74 @@ private async Task SeedUsuarioAsync() return usuario.Id; } + // T1: ObtenerRachaActualAsync — racha 0 sin sesiones + [Fact] + public async Task Racha_SinSesiones_RetornaCero() + { + var racha = await _servicio.ObtenerRachaActualAsync(999); + racha.Should().Be(0); + } + + // T2: ObtenerRachaActualAsync — racha 1 con sesión hoy + [Fact] + public async Task Racha_UnaSesionHoy_RetornaUno() + { + var usuarioId = await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(usuarioId, null, null); + await _servicio.FinalizarSesion(sesion.Id, 1, usuarioId); + + var racha = await _servicio.ObtenerRachaActualAsync(usuarioId); + racha.Should().Be(1); + } + + // T3: ObtenerRachaActualAsync — racha 3 con 3 días consecutivos + [Fact] + public async Task Racha_TresDiasConsecutivos_RetornaTres() + { + var usuarioId = await SeedUsuarioAsync(); + for (int i = 2; i >= 0; i--) + { + var sesion = new SesionPomodoro + { + UsuarioId = usuarioId, + FechaInicio = DateTime.UtcNow.Date.AddDays(-i).AddHours(10), + FechaFin = DateTime.UtcNow.Date.AddDays(-i).AddHours(11), + CiclosCompletados = 1, + XpOtorgado = 15, + FueCompletada = true + }; + _contexto.SesionesPomodoro.Add(sesion); + } + await _contexto.SaveChangesAsync(); + + var racha = await _servicio.ObtenerRachaActualAsync(usuarioId); + racha.Should().Be(3); + } + + // T4: ObtenerRachaActualAsync — racha 1 (gap de 2 días rompe racha, pero ayer hay sesión) + [Fact] + public async Task Racha_GapDeDosDias_RetornaUno() + { + var usuarioId = await SeedUsuarioAsync(); + for (int i = 3; i >= 0; i -= 2) + { + var sesion = new SesionPomodoro + { + UsuarioId = usuarioId, + FechaInicio = DateTime.UtcNow.Date.AddDays(-i).AddHours(10), + FechaFin = DateTime.UtcNow.Date.AddDays(-i).AddHours(11), + CiclosCompletados = 1, + XpOtorgado = 15, + FueCompletada = true + }; + _contexto.SesionesPomodoro.Add(sesion); + } + await _contexto.SaveChangesAsync(); + + var racha = await _servicio.ObtenerRachaActualAsync(usuarioId); + racha.Should().Be(1); + } + [Fact] public async Task IniciarSesion_CreaSesionCorrectamente() { @@ -64,6 +138,31 @@ public async Task IniciarSesion_CreaSesionCorrectamente() sesion.CiclosCompletados.Should().Be(0); } + [Fact] + public async Task IniciarSesionSiNoActiva_SinActiva_CreaSesion() + { + var usuarioId = await SeedUsuarioAsync(); + + var (exito, sesion, error) = await _servicio.IniciarSesionSiNoActiva(usuarioId, null, null); + + exito.Should().BeTrue(); + sesion.Should().NotBeNull(); + error.Should().BeNull(); + } + + [Fact] + public async Task IniciarSesionSiNoActiva_ConActiva_RetornaError() + { + var usuarioId = await SeedUsuarioAsync(); + await _servicio.IniciarSesion(usuarioId, null, null); + + var (exito, sesion, error) = await _servicio.IniciarSesionSiNoActiva(usuarioId, null, null); + + exito.Should().BeFalse(); + sesion.Should().BeNull(); + error.Should().NotBeNull(); + } + [Fact] public async Task RegistrarCiclo_ActualizaCiclosYOtorgaXP() { @@ -72,16 +171,40 @@ public async Task RegistrarCiclo_ActualizaCiclosYOtorgaXP() _contexto.ConfiguracionesPomodoro.Add(new ConfiguracionPomodoro { UsuarioId = usuarioId, CiclosAntesDescansoLargo = 4 }); await _contexto.SaveChangesAsync(); - _gamificacionMock.Setup(g => g.SumarXP(usuarioId, ConstantesGamificacion.XP_BASE_POMODORO)) - .ReturnsAsync((ConstantesGamificacion.XP_BASE_POMODORO, false, 1)); + _gamificacionMock.Setup(g => g.SumarXP(usuarioId, ConstantesGamificacion.XpBasePomodoro * 2)) + .ReturnsAsync((ConstantesGamificacion.XpBasePomodoro * 2, false, 1)); - var (xp, sugerirDescanso, _) = await _servicio.RegistrarCiclo(sesion.Id, 2); + var (xp, sugerirDescanso, _) = await _servicio.RegistrarCiclo(sesion.Id, 2, usuarioId); - xp.Should().Be(ConstantesGamificacion.XP_BASE_POMODORO * 2); + xp.Should().Be(ConstantesGamificacion.XpBasePomodoro * 2); sugerirDescanso.Should().BeFalse(); var sesionDb = await _contexto.SesionesPomodoro.FirstAsync(s => s.Id == sesion.Id); sesionDb.CiclosCompletados.Should().Be(2); + sesionDb.XpOtorgado.Should().Be(ConstantesGamificacion.XpBasePomodoro * 2); + + _gamificacionMock.Verify(g => g.SumarXP(usuarioId, ConstantesGamificacion.XpBasePomodoro * 2), Times.Once); + } + + [Fact] + public async Task RegistrarCiclo_XpPorDeltaDeCiclos_CuandoMultipleLlamadas() + { + var usuarioId = await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(usuarioId, null, null); + _contexto.ConfiguracionesPomodoro.Add(new ConfiguracionPomodoro { UsuarioId = usuarioId, CiclosAntesDescansoLargo = 4 }); + await _contexto.SaveChangesAsync(); + + _gamificacionMock.Setup(g => g.SumarXP(It.IsAny(), It.IsAny())) + .ReturnsAsync((15, false, 1)); + + await _servicio.RegistrarCiclo(sesion.Id, 2, usuarioId); + var (xp, _, _) = await _servicio.RegistrarCiclo(sesion.Id, 4, usuarioId); + + xp.Should().Be(ConstantesGamificacion.XpBasePomodoro * 2); + + var sesionDb = await _contexto.SesionesPomodoro.FirstAsync(s => s.Id == sesion.Id); + sesionDb.CiclosCompletados.Should().Be(4); + sesionDb.XpOtorgado.Should().Be(ConstantesGamificacion.XpBasePomodoro * 4); } [Fact] @@ -95,23 +218,49 @@ public async Task RegistrarCiclo_SugiereDescansoLargo_CuandoMultiplo() _gamificacionMock.Setup(g => g.SumarXP(It.IsAny(), It.IsAny())) .ReturnsAsync((15, false, 1)); - var (_, sugerirDescanso, pausaActiva) = await _servicio.RegistrarCiclo(sesion.Id, 4); + var (_, sugerirDescanso, pausaActiva) = await _servicio.RegistrarCiclo(sesion.Id, 4, usuarioId); sugerirDescanso.Should().BeTrue(); } [Fact] - public async Task FinalizarSesion_MarcaComoCompletada() + public async Task FinalizarSesion_MarcaComoCompletadaYOtorgaBonus() { var usuarioId = await SeedUsuarioAsync(); var sesion = await _servicio.IniciarSesion(usuarioId, null, null); - await _servicio.FinalizarSesion(sesion.Id, 3); + _gamificacionMock.Setup(g => g.SumarXP(It.IsAny(), It.IsAny())) + .ReturnsAsync((25, false, 1)); + + var (xpTotal, xpBonus) = await _servicio.FinalizarSesion(sesion.Id, 3, usuarioId); var sesionDb = await _contexto.SesionesPomodoro.FirstAsync(s => s.Id == sesion.Id); sesionDb.FueCompletada.Should().BeTrue(); sesionDb.CiclosCompletados.Should().Be(3); sesionDb.FechaFin.Should().NotBeNull(); + xpBonus.Should().Be(3 * 5 + 10); + + _gamificacionMock.Verify(g => g.SumarXP(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task FinalizarSesion_SinCiclos_NoOtorgaBonus() + { + var usuarioId = await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(usuarioId, null, null); + + var (xpTotal, xpBonus) = await _servicio.FinalizarSesion(sesion.Id, 0, usuarioId); + + var sesionDb = await _contexto.SesionesPomodoro.FirstAsync(s => s.Id == sesion.Id); + sesionDb.FueCompletada.Should().BeTrue(); + sesionDb.CiclosCompletados.Should().Be(0); + sesionDb.FechaFin.Should().NotBeNull(); + sesionDb.XpOtorgado.Should().Be(0); + xpBonus.Should().Be(0); + xpTotal.Should().Be(0); + + _gamificacionMock.Verify(g => g.SumarXP(It.IsAny(), It.IsAny()), Times.Never); + _gamificacionMock.Verify(g => g.VerificarYOtorgarLogros(It.IsAny()), Times.Never); } [Fact] @@ -120,7 +269,7 @@ public async Task CancelarSesion_MarcaComoNoCompletada() var usuarioId = await SeedUsuarioAsync(); var sesion = await _servicio.IniciarSesion(usuarioId, null, null); - await _servicio.CancelarSesion(sesion.Id); + await _servicio.CancelarSesion(sesion.Id, usuarioId); var sesionDb = await _contexto.SesionesPomodoro.FirstAsync(s => s.Id == sesion.Id); sesionDb.FueCompletada.Should().BeFalse(); @@ -128,7 +277,7 @@ public async Task CancelarSesion_MarcaComoNoCompletada() } [Fact] - public async Task ObtenerConfiguracion_SinConfig_CreaDefault() + public async Task ObtenerConfiguracion_SinConfig_RetornaDefaultSinGuardar() { var usuarioId = await SeedUsuarioAsync(); @@ -137,6 +286,9 @@ public async Task ObtenerConfiguracion_SinConfig_CreaDefault() config.Should().NotBeNull(); config.TiempoEstudioMin.Should().Be(25); config.TiempoDescansoMin.Should().Be(5); + + var countEnDb = await _contexto.ConfiguracionesPomodoro.CountAsync(); + countEnDb.Should().Be(0); } [Fact] @@ -149,7 +301,16 @@ public async Task ActualizarConfiguracion_ActualizaValores() TiempoDescansoMin = 10, TiempoDescansoLargoMin = 20, CiclosAntesDescansoLargo = 6, - SonidoActivo = false + SonidoActivo = false, + SonidoSeleccionado = "digital", + Volumen = 80, + AutoIniciarDescanso = true, + AutoIniciarEnfoque = false, + TicTacActivo = true, + MetaDiariaCiclos = 8, + ModoPersonalizadoMin = 30, + VibracionActiva = false, + NotificacionDesktop = true }; await _servicio.ActualizarConfiguracion(usuarioId, dto); @@ -158,6 +319,28 @@ public async Task ActualizarConfiguracion_ActualizaValores() config.TiempoEstudioMin.Should().Be(50); config.TiempoDescansoMin.Should().Be(10); config.SonidoActivo.Should().BeFalse(); + config.SonidoSeleccionado.Should().Be("digital"); + config.Volumen.Should().Be(80); + config.AutoIniciarDescanso.Should().BeTrue(); + config.TicTacActivo.Should().BeTrue(); + config.MetaDiariaCiclos.Should().Be(8); + config.ModoPersonalizadoMin.Should().Be(30); + } + + [Fact] + public async Task RegistrarCiclo_CiclosNoDecrecientes_NoOtorgaXP() + { + var usuarioId = await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(usuarioId, null, null); + _contexto.ConfiguracionesPomodoro.Add(new ConfiguracionPomodoro { UsuarioId = usuarioId, CiclosAntesDescansoLargo = 4 }); + await _contexto.SaveChangesAsync(); + + await _servicio.RegistrarCiclo(sesion.Id, 3, usuarioId); + + var (xp, _, _) = await _servicio.RegistrarCiclo(sesion.Id, 2, usuarioId); + + xp.Should().Be(0); + _gamificacionMock.Verify(g => g.SumarXP(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -189,4 +372,334 @@ public async Task ObtenerTipAleatorio_ConTip_RetornaTip() tip.Should().Be("Toma agua frecuentemente"); } + + // T5: ObtenerEstadisticasPeriodoAsync + [Fact] + public async Task EstadisticasPeriodo_CalculaCiclosMinutosXP() + { + var usuarioId = await SeedUsuarioAsync(); + _contexto.ConfiguracionesPomodoro.Add(new ConfiguracionPomodoro { UsuarioId = usuarioId, TiempoEstudioMin = 25 }); + var sesion = await _servicio.IniciarSesion(usuarioId, null, null); + sesion.CiclosCompletados = 3; + sesion.XpOtorgado = 45; + await _contexto.SaveChangesAsync(); + + var desde = DateTime.UtcNow.Date; + var hasta = DateTime.UtcNow.Date.AddDays(1); + var stats = await _servicio.ObtenerEstadisticasPeriodoAsync(usuarioId, desde, hasta); + + stats.Ciclos.Should().Be(3); + stats.Minutos.Should().Be(0); + stats.Xp.Should().Be(45); + } + + // T6-T7: ObtenerTareasEnfoqueAsync + [Fact] + public async Task TareasEnfoque_ConHabitosActivos_RetornaTareas() + { + var usuarioId = await SeedUsuarioAsync(); + _contexto.Categorias.Add(new Categoria { Nombre = "Test", Icono = "bi-test", Tipo = "Habito", EstaActiva = true }); + await _contexto.SaveChangesAsync(); + var catId = _contexto.Categorias.First().Id; + + _contexto.Habitos.Add(new Habito + { + UsuarioId = usuarioId, Nombre = "Leer", EstaActivo = true, ConPomodoro = true, + CategoriaId = catId, Frecuencia = "Diario" + }); + await _contexto.SaveChangesAsync(); + + var tareas = await _servicio.ObtenerTareasEnfoqueAsync(usuarioId); + tareas.Should().Contain(t => t.Nombre == "Leer" && t.Tipo == "Habito"); + } + + [Fact] + public async Task TareasEnfoque_ConMisionesActivas_RetornaTareas() + { + var usuarioId = await SeedUsuarioAsync(); + _contexto.Categorias.Add(new Categoria { Nombre = "Test", Icono = "bi-test", Tipo = "Mision", EstaActiva = true }); + await _contexto.SaveChangesAsync(); + var catId = _contexto.Categorias.First().Id; + + _contexto.Misiones.Add(new Mision + { + UsuarioId = usuarioId, Nombre = "Proyecto Final", Estado = "En progreso", ConPomodoro = true, + CategoriaId = catId, Prioridad = "Alta" + }); + await _contexto.SaveChangesAsync(); + + var tareas = await _servicio.ObtenerTareasEnfoqueAsync(usuarioId); + tareas.Should().Contain(t => t.Nombre == "Proyecto Final" && t.Tipo == "Mision"); + } + + [Fact] + public async Task ObtenerEstadisticasSemanalesAsync_Retorna7Dias() + { + var usuarioId = await SeedUsuarioAsync(); + var stats = await _servicio.ObtenerEstadisticasSemanalesAsync(usuarioId); + stats.Should().HaveCount(7); + } + + [Fact] + public async Task IniciarSesion_ConHabitoInvalido_LanzaError() + { + var usuarioId = await SeedUsuarioAsync(); + await Assert.ThrowsAsync(() => + _servicio.IniciarSesion(usuarioId, 999, null)); + } + + [Fact] + public async Task IniciarSesion_ConMisionInvalida_LanzaError() + { + var usuarioId = await SeedUsuarioAsync(); + await Assert.ThrowsAsync(() => + _servicio.IniciarSesion(usuarioId, null, 999)); + } + + [Fact] + public async Task ObtenerHistorialAsync_SinSesiones_RetornaVacio() + { + var usuarioId = await SeedUsuarioAsync(); + + var historial = await _servicio.ObtenerHistorialAsync(usuarioId, DateTime.UtcNow.AddDays(-30), DateTime.UtcNow); + + historial.Should().BeEmpty(); + } + + [Fact] + public async Task ObtenerHistorialAsync_ConSesiones_RetornaPagina() + { + var usuarioId = await SeedUsuarioAsync(); + for (int i = 0; i < 5; i++) + { + var s = new SesionPomodoro + { + UsuarioId = usuarioId, + FechaInicio = DateTime.UtcNow.AddHours(-i), + CiclosCompletados = 1, + XpOtorgado = 15, + FueCompletada = true + }; + _contexto.SesionesPomodoro.Add(s); + } + await _contexto.SaveChangesAsync(); + + var pagina1 = await _servicio.ObtenerHistorialAsync(usuarioId, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1, 2); + var pagina2 = await _servicio.ObtenerHistorialAsync(usuarioId, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 2, 2); + + pagina1.Should().HaveCount(2); + pagina2.Should().HaveCount(2); + } + + [Fact] + public async Task ObtenerHistorialAsync_FueraDeRango_RetornaVacio() + { + var usuarioId = await SeedUsuarioAsync(); + var s = new SesionPomodoro + { + UsuarioId = usuarioId, + FechaInicio = DateTime.UtcNow.AddDays(-10), + CiclosCompletados = 1, + XpOtorgado = 15, + FueCompletada = true + }; + _contexto.SesionesPomodoro.Add(s); + await _contexto.SaveChangesAsync(); + + var historial = await _servicio.ObtenerHistorialAsync(usuarioId, DateTime.UtcNow.AddDays(-5), DateTime.UtcNow); + + historial.Should().BeEmpty(); + } + + [Fact] + public async Task EstadisticasPeriodo_SinSesiones_RetornaCeros() + { + var usuarioId = await SeedUsuarioAsync(); + + var stats = await _servicio.ObtenerEstadisticasPeriodoAsync(usuarioId, DateTime.UtcNow.AddDays(-30), DateTime.UtcNow); + + stats.Ciclos.Should().Be(0); + stats.Minutos.Should().Be(0); + stats.Xp.Should().Be(0); + } + + [Fact] + public async Task RegistrarCiclo_SesionInexistente_RetornaCeros() + { + var (xp, sugerir, pausa) = await _servicio.RegistrarCiclo(999, 1, 1); + + xp.Should().Be(0); + sugerir.Should().BeFalse(); + pausa.Should().BeNull(); + } + + [Fact] + public async Task FinalizarSesion_SesionInexistente_RetornaCeros() + { + var (xpTotal, xpBonus) = await _servicio.FinalizarSesion(999, 1, 1); + + xpTotal.Should().Be(0); + xpBonus.Should().Be(0); + } + + [Fact] + public async Task CancelarSesion_SesionInexistente_NoLanzaExcepcion() + { + await _servicio.CancelarSesion(999, 1); + } + + [Fact] + public async Task RegistrarCiclo_DeOtroUsuario_NoOtorgaXP() + { + var usuarioId = await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(usuarioId, null, null); + + var (xp, _, _) = await _servicio.RegistrarCiclo(sesion.Id, 1, 999); + + xp.Should().Be(0); + } + + [Fact] + public async Task FinalizarSesion_DeOtroUsuario_RetornaCeros() + { + var usuarioId = await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(usuarioId, null, null); + + var (xpTotal, xpBonus) = await _servicio.FinalizarSesion(sesion.Id, 1, 999); + + xpTotal.Should().Be(0); + xpBonus.Should().Be(0); + } + + [Fact] + public async Task CancelarSesion_DeOtroUsuario_NoCancela() + { + var usuarioId = await SeedUsuarioAsync(); + var sesion = await _servicio.IniciarSesion(usuarioId, null, null); + + await _servicio.CancelarSesion(sesion.Id, 999); + + var sesionDb = await _contexto.SesionesPomodoro.FirstAsync(s => s.Id == sesion.Id); + sesionDb.FechaFin.Should().BeNull(); + sesionDb.FueCompletada.Should().BeFalse(); + } + + // NF-22: Tests de filtros en ObtenerHistorialAsync + [Fact] + public async Task ObtenerHistorialAsync_FiltroCompletada_True_RetornaSoloCompletadas() + { + var usuarioId = await SeedUsuarioAsync(); + var s1 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-2), CiclosCompletados = 2, XpOtorgado = 30, FueCompletada = true }; + var s2 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-1), CiclosCompletados = 0, XpOtorgado = 0, FueCompletada = false }; + _contexto.SesionesPomodoro.AddRange(s1, s2); + await _contexto.SaveChangesAsync(); + + var historial = await _servicio.ObtenerHistorialAsync(usuarioId, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1, 20, completada: true); + + historial.Should().HaveCount(1); + historial[0].FueCompletada.Should().BeTrue(); + } + + [Fact] + public async Task ObtenerHistorialAsync_FiltroCompletada_False_RetornaSoloNoCompletadas() + { + var usuarioId = await SeedUsuarioAsync(); + var s1 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-2), CiclosCompletados = 2, XpOtorgado = 30, FueCompletada = true }; + var s2 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-1), CiclosCompletados = 0, XpOtorgado = 0, FueCompletada = false }; + _contexto.SesionesPomodoro.AddRange(s1, s2); + await _contexto.SaveChangesAsync(); + + var historial = await _servicio.ObtenerHistorialAsync(usuarioId, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1, 20, completada: false); + + historial.Should().HaveCount(1); + historial[0].FueCompletada.Should().BeFalse(); + } + + [Fact] + public async Task ObtenerHistorialAsync_FiltroConXp_True_RetornaSoloConXp() + { + var usuarioId = await SeedUsuarioAsync(); + var s1 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-2), CiclosCompletados = 2, XpOtorgado = 30, FueCompletada = true }; + var s2 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-1), CiclosCompletados = 1, XpOtorgado = 0, FueCompletada = true }; + _contexto.SesionesPomodoro.AddRange(s1, s2); + await _contexto.SaveChangesAsync(); + + var historial = await _servicio.ObtenerHistorialAsync(usuarioId, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1, 20, conXp: true); + + historial.Should().HaveCount(1); + historial[0].XpOtorgado.Should().BeGreaterThan(0); + } + + [Fact] + public async Task ObtenerHistorialAsync_FiltroConXp_False_RetornaSoloSinXp() + { + var usuarioId = await SeedUsuarioAsync(); + var s1 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-2), CiclosCompletados = 2, XpOtorgado = 30, FueCompletada = true }; + var s2 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-1), CiclosCompletados = 1, XpOtorgado = 0, FueCompletada = true }; + _contexto.SesionesPomodoro.AddRange(s1, s2); + await _contexto.SaveChangesAsync(); + + var historial = await _servicio.ObtenerHistorialAsync(usuarioId, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1, 20, conXp: false); + + historial.Should().HaveCount(1); + historial[0].XpOtorgado.Should().Be(0); + } + + // NF-6: Tests de estadísticas avanzadas + [Fact] + public async Task EstadisticasAvanzadas_SinSesiones_RetornaCeros() + { + var usuarioId = await SeedUsuarioAsync(); + + var stats = await _servicio.ObtenerEstadisticasAvanzadasAsync(usuarioId, DateTime.UtcNow.AddMonths(-1), DateTime.UtcNow); + + stats.Should().NotBeNull(); + stats.TotalCiclos.Should().Be(0); + stats.TotalMinutos.Should().Be(0); + stats.TotalXp.Should().Be(0); + stats.PromedioCiclosPorDia.Should().Be(0); + stats.PorMes.Should().BeEmpty(); + stats.HeatmapHoras.Should().HaveCount(24); + stats.HeatmapHoras.Should().AllSatisfy(h => h.Ciclos.Should().Be(0)); + } + + [Fact] + public async Task EstadisticasAvanzadas_ConSesiones_CalculaCorrectamente() + { + var usuarioId = await SeedUsuarioAsync(); + _contexto.ConfiguracionesPomodoro.Add(new ConfiguracionPomodoro { UsuarioId = usuarioId, TiempoEstudioMin = 25 }); + await _contexto.SaveChangesAsync(); + + var s1 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.Date.AddHours(10), FechaFin = DateTime.UtcNow.Date.AddHours(11), CiclosCompletados = 2, XpOtorgado = 30, FueCompletada = true }; + var s2 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.Date.AddDays(-1).AddHours(15), FechaFin = DateTime.UtcNow.Date.AddDays(-1).AddHours(16), CiclosCompletados = 1, XpOtorgado = 15, FueCompletada = true }; + _contexto.SesionesPomodoro.AddRange(s1, s2); + await _contexto.SaveChangesAsync(); + + var stats = await _servicio.ObtenerEstadisticasAvanzadasAsync(usuarioId, DateTime.UtcNow.AddDays(-2), DateTime.UtcNow); + + stats.TotalCiclos.Should().Be(3); + stats.TotalMinutos.Should().Be(120); + stats.TotalXp.Should().Be(45); + stats.PromedioCiclosPorDia.Should().Be(1.0); + stats.PorMes.Should().NotBeEmpty(); + stats.HeatmapHoras.Should().HaveCount(24); + stats.HeatmapHoras.First(h => h.Hora == 10).Ciclos.Should().Be(2); + stats.HeatmapHoras.First(h => h.Hora == 15).Ciclos.Should().Be(1); + } + + [Fact] + public async Task ObtenerHistorialAsync_FiltrosCombinados_RetornaCoincidentes() + { + var usuarioId = await SeedUsuarioAsync(); + var s1 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-3), CiclosCompletados = 2, XpOtorgado = 30, FueCompletada = true }; + var s2 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-2), CiclosCompletados = 0, XpOtorgado = 0, FueCompletada = false }; + var s3 = new SesionPomodoro { UsuarioId = usuarioId, FechaInicio = DateTime.UtcNow.AddHours(-1), CiclosCompletados = 1, XpOtorgado = 15, FueCompletada = true }; + _contexto.SesionesPomodoro.AddRange(s1, s2, s3); + await _contexto.SaveChangesAsync(); + + var historial = await _servicio.ObtenerHistorialAsync(usuarioId, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1, 20, completada: true, conXp: true); + + historial.Should().HaveCount(2); + historial.Should().AllSatisfy(s => { s.FueCompletada.Should().BeTrue(); s.XpOtorgado.Should().BeGreaterThan(0); }); + } } diff --git a/EpycusApp.Tests/Unitarios/Servicios/ServicioSubTareasTests.cs b/EpycusApp.Tests/Unitarios/Servicios/ServicioSubTareasTests.cs new file mode 100644 index 0000000..38e4db7 --- /dev/null +++ b/EpycusApp.Tests/Unitarios/Servicios/ServicioSubTareasTests.cs @@ -0,0 +1,258 @@ +using EpycusApp.Ayudantes; +using EpycusApp.Datos; +using EpycusApp.Models.Entidades; +using EpycusApp.Servicios.Implementaciones; +using EpycusApp.Servicios.Interfaces; +using EpycusApp.Tests.AyudantesTests; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; + +namespace EpycusApp.Tests.Unitarios.Servicios; + +public class ServicioSubTareasTests +{ + private readonly ContextoAplicacion _contexto; + private readonly ServicioMisiones _servicio; + private readonly Mock _gamificacionMock; + private readonly Mock> _loggerMock; + + public ServicioSubTareasTests() + { + _contexto = DbContextFactory.CrearContexto("SubTareasTest"); + _gamificacionMock = new Mock(); + _loggerMock = new Mock>(); + _servicio = new ServicioMisiones(_contexto, _gamificacionMock.Object, _loggerMock.Object); + } + + private async Task SeedMisionPendienteAsync() + { + _contexto.Roles.Add(new Rol { Id = 1, Nombre = "Usuario" }); + _contexto.Carreras.Add(new Carrera { Id = 1, Nombre = "Test", Area = "Test", Codigo = "TST", EstaActiva = true }); + _contexto.Categorias.Add(new Categoria { Id = 1, Nombre = "Academico", Tipo = "Mision", EstaActiva = true }); + await _contexto.SaveChangesAsync(); + + var usuario = new Usuario + { + CodigoUnico = "SUB001", + Nombre = "SubTareas Test", + CorreoElectronico = "subtareas@test.com", + ContrasenaHash = "hash", + FechaNacimiento = new DateOnly(2000, 1, 1), + Genero = "Masculino", + RolId = 1, + CarreraId = 1 + }; + _contexto.Usuarios.Add(usuario); + await _contexto.SaveChangesAsync(); + + var mision = new Mision + { + Nombre = "Mision Test", + Estado = "Pendiente", + Prioridad = "Media", + UsuarioId = usuario.Id, + CategoriaId = 1, + FechaLimite = new DateOnly(2025, 12, 31), + FechaCreacion = DateTime.UtcNow + }; + _contexto.Misiones.Add(mision); + await _contexto.SaveChangesAsync(); + return usuario.Id; + } + + [Fact] + public async Task CrearSubTarea_EnMisionValida_CreaCorrectamente() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + + await _servicio.CrearSubTarea("Estudiar capitulo 1", null, mision.Id, usuarioId); + + var subTareas = await _contexto.SubTareas.ToListAsync(); + subTareas.Should().ContainSingle(); + subTareas[0].Nombre.Should().Be("Estudiar capitulo 1"); + subTareas[0].Orden.Should().Be(0); + subTareas[0].MisionId.Should().Be(mision.Id); + } + + [Fact] + public async Task CrearSubTarea_EnMisionCompletada_LanzaError() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + mision.Estado = "Completado"; + await _contexto.SaveChangesAsync(); + + await _servicio.Invoking(s => s.CrearSubTarea("Test", null, mision.Id, usuarioId)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task CrearSubTarea_EnMisionFallida_LanzaError() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + mision.Estado = "Fallido"; + await _contexto.SaveChangesAsync(); + + await _servicio.Invoking(s => s.CrearSubTarea("Test", null, mision.Id, usuarioId)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task CompletarSubTarea_UnicaPendiente_CompletaMision() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + _contexto.SubTareas.Add(new SubTarea + { + Nombre = "Unica tarea", + MisionId = mision.Id, + Orden = 0, + FechaCreacion = DateTime.UtcNow + }); + await _contexto.SaveChangesAsync(); + + _gamificacionMock.Setup(g => g.SumarXP(usuarioId, ConstantesGamificacion.XP_MISION_MEDIA)) + .ReturnsAsync((ConstantesGamificacion.XP_MISION_MEDIA, false, 1)); + + var subTarea = await _contexto.SubTareas.FirstAsync(); + await _servicio.CompletarSubTarea(subTarea.Id, usuarioId); + + var misionActualizada = await _contexto.Misiones.FirstAsync(); + misionActualizada.Estado.Should().Be("Completado"); + misionActualizada.FechaCompletado.Should().NotBeNull(); + } + + [Fact] + public async Task CompletarSubTarea_NoEsUltima_NoCompletaMision() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + _contexto.SubTareas.AddRange( + new SubTarea { Nombre = "Tarea 1", MisionId = mision.Id, Orden = 0, FechaCreacion = DateTime.UtcNow }, + new SubTarea { Nombre = "Tarea 2", MisionId = mision.Id, Orden = 1, FechaCreacion = DateTime.UtcNow } + ); + await _contexto.SaveChangesAsync(); + + var subTarea = await _contexto.SubTareas.FirstAsync(); + await _servicio.CompletarSubTarea(subTarea.Id, usuarioId); + + var misionActualizada = await _contexto.Misiones.FirstAsync(); + misionActualizada.Estado.Should().Be("Pendiente"); + + var subTareas = await _contexto.SubTareas.ToListAsync(); + subTareas.Count(st => st.EstaCompletada).Should().Be(1); + } + + [Fact] + public async Task EliminarSubTarea_EnMisionPendiente_Elimina() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + _contexto.SubTareas.Add(new SubTarea + { + Nombre = "Tarea a eliminar", + MisionId = mision.Id, + Orden = 0, + FechaCreacion = DateTime.UtcNow + }); + await _contexto.SaveChangesAsync(); + + var subTarea = await _contexto.SubTareas.FirstAsync(); + await _servicio.EliminarSubTarea(subTarea.Id, usuarioId); + + var subTareas = await _contexto.SubTareas.ToListAsync(); + subTareas.Should().BeEmpty(); + } + + [Fact] + public async Task EliminarSubTarea_EnMisionCompletada_LanzaError() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + mision.Estado = "Completado"; + await _contexto.SaveChangesAsync(); + _contexto.SubTareas.Add(new SubTarea + { + Nombre = "Tarea", + MisionId = mision.Id, + Orden = 0, + FechaCreacion = DateTime.UtcNow + }); + await _contexto.SaveChangesAsync(); + + var subTarea = await _contexto.SubTareas.FirstAsync(); + await _servicio.Invoking(s => s.EliminarSubTarea(subTarea.Id, usuarioId)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task ObtenerSubTareas_OrdenCorrecto() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + _contexto.SubTareas.AddRange( + new SubTarea { Nombre = "B", MisionId = mision.Id, Orden = 2, FechaCreacion = DateTime.UtcNow }, + new SubTarea { Nombre = "A", MisionId = mision.Id, Orden = 1, FechaCreacion = DateTime.UtcNow }, + new SubTarea { Nombre = "C", MisionId = mision.Id, Orden = 1, FechaCreacion = DateTime.UtcNow.AddMinutes(1) } + ); + await _contexto.SaveChangesAsync(); + + var subTareas = await _servicio.ObtenerSubTareas(mision.Id, usuarioId); + + subTareas.Should().HaveCount(3); + subTareas[0].Nombre.Should().Be("A"); + subTareas[1].Nombre.Should().Be("C"); + subTareas[2].Nombre.Should().Be("B"); + } + + [Fact] + public async Task DescompletarSubTarea_RevertirCompletado() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + _contexto.SubTareas.Add(new SubTarea + { + Nombre = "Tarea", + MisionId = mision.Id, + EstaCompletada = true, + FechaCompletado = DateTime.UtcNow, + Orden = 0, + FechaCreacion = DateTime.UtcNow + }); + await _contexto.SaveChangesAsync(); + + var subTarea = await _contexto.SubTareas.FirstAsync(); + await _servicio.DescompletarSubTarea(subTarea.Id, usuarioId); + + var actualizada = await _contexto.SubTareas.FirstAsync(); + actualizada.EstaCompletada.Should().BeFalse(); + actualizada.FechaCompletado.Should().BeNull(); + } + + [Fact] + public async Task EditarSubTarea_ActualizaCampos() + { + var usuarioId = await SeedMisionPendienteAsync(); + var mision = await _contexto.Misiones.FirstAsync(); + _contexto.SubTareas.Add(new SubTarea + { + Nombre = "Original", + MisionId = mision.Id, + Orden = 0, + FechaCreacion = DateTime.UtcNow + }); + await _contexto.SaveChangesAsync(); + + var subTarea = await _contexto.SubTareas.FirstAsync(); + await _servicio.EditarSubTarea(subTarea.Id, "Actualizado", "Nueva desc", 5, usuarioId); + + var actualizada = await _contexto.SubTareas.FirstAsync(); + actualizada.Nombre.Should().Be("Actualizado"); + actualizada.Descripcion.Should().Be("Nueva desc"); + actualizada.Orden.Should().Be(5); + } +} diff --git a/EpycusApp.csproj b/EpycusApp.csproj index 2f1f255..56141e2 100644 --- a/EpycusApp.csproj +++ b/EpycusApp.csproj @@ -25,10 +25,11 @@ + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Migrations/20260618182406_AddCategoriaToFraseMotivacional.Designer.cs b/Migrations/20260618182406_AddCategoriaToFraseMotivacional.Designer.cs new file mode 100644 index 0000000..900a24b --- /dev/null +++ b/Migrations/20260618182406_AddCategoriaToFraseMotivacional.Designer.cs @@ -0,0 +1,1352 @@ +// +using System; +using EpycusApp.Datos; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EpycusApp.Migrations +{ + [DbContext(typeof(ContextoAplicacion))] + [Migration("20260618182406_AddCategoriaToFraseMotivacional")] + partial class AddCategoriaToFraseMotivacional + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Codigo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Carreras"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Icono") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categorias"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CiclosAntesDescansoLargo") + .HasColumnType("int"); + + b.Property("FechaActualizacion") + .HasColumnType("datetime(6)"); + + b.Property("SonidoActivo") + .HasColumnType("tinyint(1)"); + + b.Property("TiempoDescansoLargoMin") + .HasColumnType("int"); + + b.Property("TiempoDescansoMin") + .HasColumnType("int"); + + b.Property("TiempoEstudioMin") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ConfiguracionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaSemana") + .HasColumnType("int"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("DiasSemanaHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("Nota") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("EstadosAnimo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.FraseMotivacional", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Autor") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Frase") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("FrasesMotivacionales"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Frecuencia") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("RecordatorioHora") + .HasColumnType("time(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Habitos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EsPlaceholder") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NivelNumero") + .HasColumnType("int"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.ToTable("ImagenesNivelPersonaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Accion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Detalle") + .HasColumnType("longtext"); + + b.Property("DireccionIp") + .HasColumnType("longtext"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Log"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Logro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CondicionTipo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CondicionValor") + .HasColumnType("int"); + + b.Property("Descripcion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("IconoUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRecompensa") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Logros"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("LogroId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LogroId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("LogrosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Contenido") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ConversacionId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("FechaHora") + .HasColumnType("datetime(6)"); + + b.Property("Rol") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ConversacionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("MensajesIA"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FechaCompletado") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaLimite") + .HasColumnType("date"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NombreCurso") + .HasColumnType("longtext"); + + b.Property("Prioridad") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Numero") + .HasColumnType("int"); + + b.Property("Titulo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRequerido") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Niveles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.ToTable("Personajes"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaSeleccionado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaDeGraciaUsado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaInicioRacha") + .HasColumnType("datetime(6)"); + + b.Property("FechaUltimaActividad") + .HasColumnType("datetime(6)"); + + b.Property("NivelActualId") + .HasColumnType("int"); + + b.Property("ProductividadDiaria") + .HasColumnType("decimal(65,30)"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpTotal") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NivelActualId"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ProgresosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("RecuperacionesContrasena"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("RegistrosHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Rol", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CiclosCompletados") + .HasColumnType("int"); + + b.Property("FechaFin") + .HasColumnType("datetime(6)"); + + b.Property("FechaInicio") + .HasColumnType("datetime(6)"); + + b.Property("FueCompletada") + .HasColumnType("tinyint(1)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("MisionId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.HasIndex("MisionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivadaPorAdminId") + .HasColumnType("int"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("FechaActivacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaFin") + .HasColumnType("date"); + + b.Property("FechaInicio") + .HasColumnType("date"); + + b.Property("Plan") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PrecioSoles") + .HasColumnType("decimal(65,30)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActivadaPorAdminId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Suscripciones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Tema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ArchivoCss") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EsPremium") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenPreviewUrl") + .HasColumnType("longtext"); + + b.Property("Modo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Precio") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Temas"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("TemaId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TemasUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TipPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Tip") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("TipsPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Revocado") + .HasColumnType("tinyint(1)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TokensRefresh"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AceptoTerminos") + .HasColumnType("tinyint(1)"); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("CodigoUnico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ContrasenaHash") + .HasColumnType("longtext"); + + b.Property("CorreoElectronico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("CorreoVerificado") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaNacimiento") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("FotoGoogleUrl") + .HasColumnType("longtext"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("GoogleId") + .HasColumnType("varchar(255)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RolId") + .HasColumnType("int"); + + b.Property("TemaActualId") + .HasColumnType("int"); + + b.Property("UltimoAcceso") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.HasIndex("CodigoUnico") + .IsUnique(); + + b.HasIndex("CorreoElectronico") + .IsUnique(); + + b.HasIndex("GoogleId") + .IsUnique(); + + b.HasIndex("RolId"); + + b.HasIndex("TemaActualId"); + + b.ToTable("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("VerificacionesCorreo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("ConfiguracionPomodoro") + .HasForeignKey("EpycusApp.Models.Entidades.ConfiguracionPomodoro", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("DiasSemana") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("EstadosAnimo") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Habitos") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Habitos") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("Imagenes") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Logro", "Logro") + .WithMany() + .HasForeignKey("LogroId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("LogrosUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Logro"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Misiones") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Misiones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany() + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("PersonajesUsuario") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("PersonajesUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Nivel", "NivelActual") + .WithMany("Progresos") + .HasForeignKey("NivelActualId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("Progreso") + .HasForeignKey("EpycusApp.Models.Entidades.ProgresoUsuario", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NivelActual"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("Registros") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("SesionesPomodoro") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Mision", "Mision") + .WithMany("SesionesPomodoro") + .HasForeignKey("MisionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("SesionesPomodoro") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + + b.Navigation("Mision"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", null) + .WithMany() + .HasForeignKey("ActivadaPorAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Suscripciones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Tema", "Tema") + .WithMany() + .HasForeignKey("TemaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TemasUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tema"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TokensRefresh") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany("Usuarios") + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Rol", "Rol") + .WithMany() + .HasForeignKey("RolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Tema", "TemaActual") + .WithMany() + .HasForeignKey("TemaActualId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + + b.Navigation("Rol"); + + b.Navigation("TemaActual"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Navigation("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Navigation("Habitos"); + + b.Navigation("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Navigation("DiasSemana"); + + b.Navigation("Registros"); + + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Navigation("Progresos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Navigation("Imagenes"); + + b.Navigation("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Navigation("ConfiguracionPomodoro") + .IsRequired(); + + b.Navigation("EstadosAnimo"); + + b.Navigation("Habitos"); + + b.Navigation("LogrosUsuario"); + + b.Navigation("Misiones"); + + b.Navigation("PersonajesUsuario"); + + b.Navigation("Progreso") + .IsRequired(); + + b.Navigation("SesionesPomodoro"); + + b.Navigation("Suscripciones"); + + b.Navigation("TemasUsuario"); + + b.Navigation("TokensRefresh"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260618182406_AddCategoriaToFraseMotivacional.cs b/Migrations/20260618182406_AddCategoriaToFraseMotivacional.cs new file mode 100644 index 0000000..23b8a1d --- /dev/null +++ b/Migrations/20260618182406_AddCategoriaToFraseMotivacional.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EpycusApp.Migrations +{ + /// + public partial class AddCategoriaToFraseMotivacional : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/Migrations/20260618223131_AddFeedbackToMensajesIA.Designer.cs b/Migrations/20260618223131_AddFeedbackToMensajesIA.Designer.cs new file mode 100644 index 0000000..2f2334e --- /dev/null +++ b/Migrations/20260618223131_AddFeedbackToMensajesIA.Designer.cs @@ -0,0 +1,1362 @@ +// +using System; +using EpycusApp.Datos; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EpycusApp.Migrations +{ + [DbContext(typeof(ContextoAplicacion))] + [Migration("20260618223131_AddFeedbackToMensajesIA")] + partial class AddFeedbackToMensajesIA + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Codigo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Carreras"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Icono") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categorias"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CiclosAntesDescansoLargo") + .HasColumnType("int"); + + b.Property("FechaActualizacion") + .HasColumnType("datetime(6)"); + + b.Property("SonidoActivo") + .HasColumnType("tinyint(1)"); + + b.Property("TiempoDescansoLargoMin") + .HasColumnType("int"); + + b.Property("TiempoDescansoMin") + .HasColumnType("int"); + + b.Property("TiempoEstudioMin") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ConfiguracionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaSemana") + .HasColumnType("int"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("DiasSemanaHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("Nota") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("EstadosAnimo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.FraseMotivacional", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Autor") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Categoria") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Frase") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("FrasesMotivacionales"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Frecuencia") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("RecordatorioHora") + .HasColumnType("time(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Habitos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EsPlaceholder") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NivelNumero") + .HasColumnType("int"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.ToTable("ImagenesNivelPersonaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Accion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Detalle") + .HasColumnType("longtext"); + + b.Property("DireccionIp") + .HasColumnType("longtext"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Log"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Logro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CondicionTipo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CondicionValor") + .HasColumnType("int"); + + b.Property("Descripcion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("IconoUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRecompensa") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Logros"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("LogroId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LogroId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("LogrosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Contenido") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ConversacionId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("FechaHora") + .HasColumnType("datetime(6)"); + + b.Property("FeedbackRecibido") + .HasColumnType("tinyint(1)"); + + b.Property("FeedbackUtil") + .HasColumnType("tinyint(1)"); + + b.Property("Rol") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ConversacionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("MensajesIA"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FechaCompletado") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaLimite") + .HasColumnType("date"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NombreCurso") + .HasColumnType("longtext"); + + b.Property("Prioridad") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Numero") + .HasColumnType("int"); + + b.Property("Titulo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRequerido") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Niveles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.ToTable("Personajes"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaSeleccionado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaDeGraciaUsado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaInicioRacha") + .HasColumnType("datetime(6)"); + + b.Property("FechaUltimaActividad") + .HasColumnType("datetime(6)"); + + b.Property("NivelActualId") + .HasColumnType("int"); + + b.Property("ProductividadDiaria") + .HasColumnType("decimal(65,30)"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpTotal") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NivelActualId"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ProgresosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("RecuperacionesContrasena"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("RegistrosHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Rol", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CiclosCompletados") + .HasColumnType("int"); + + b.Property("FechaFin") + .HasColumnType("datetime(6)"); + + b.Property("FechaInicio") + .HasColumnType("datetime(6)"); + + b.Property("FueCompletada") + .HasColumnType("tinyint(1)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("MisionId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.HasIndex("MisionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivadaPorAdminId") + .HasColumnType("int"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("FechaActivacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaFin") + .HasColumnType("date"); + + b.Property("FechaInicio") + .HasColumnType("date"); + + b.Property("Plan") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PrecioSoles") + .HasColumnType("decimal(65,30)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActivadaPorAdminId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Suscripciones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Tema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ArchivoCss") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EsPremium") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenPreviewUrl") + .HasColumnType("longtext"); + + b.Property("Modo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Precio") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Temas"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("TemaId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TemasUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TipPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Tip") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("TipsPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Revocado") + .HasColumnType("tinyint(1)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TokensRefresh"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AceptoTerminos") + .HasColumnType("tinyint(1)"); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("CodigoUnico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ContrasenaHash") + .HasColumnType("longtext"); + + b.Property("CorreoElectronico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("CorreoVerificado") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaNacimiento") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("FotoGoogleUrl") + .HasColumnType("longtext"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("GoogleId") + .HasColumnType("varchar(255)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RolId") + .HasColumnType("int"); + + b.Property("TemaActualId") + .HasColumnType("int"); + + b.Property("UltimoAcceso") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.HasIndex("CodigoUnico") + .IsUnique(); + + b.HasIndex("CorreoElectronico") + .IsUnique(); + + b.HasIndex("GoogleId") + .IsUnique(); + + b.HasIndex("RolId"); + + b.HasIndex("TemaActualId"); + + b.ToTable("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("VerificacionesCorreo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("ConfiguracionPomodoro") + .HasForeignKey("EpycusApp.Models.Entidades.ConfiguracionPomodoro", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("DiasSemana") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("EstadosAnimo") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Habitos") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Habitos") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("Imagenes") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Logro", "Logro") + .WithMany() + .HasForeignKey("LogroId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("LogrosUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Logro"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Misiones") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Misiones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany() + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("PersonajesUsuario") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("PersonajesUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Nivel", "NivelActual") + .WithMany("Progresos") + .HasForeignKey("NivelActualId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("Progreso") + .HasForeignKey("EpycusApp.Models.Entidades.ProgresoUsuario", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NivelActual"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("Registros") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("SesionesPomodoro") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Mision", "Mision") + .WithMany("SesionesPomodoro") + .HasForeignKey("MisionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("SesionesPomodoro") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + + b.Navigation("Mision"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", null) + .WithMany() + .HasForeignKey("ActivadaPorAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Suscripciones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Tema", "Tema") + .WithMany() + .HasForeignKey("TemaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TemasUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tema"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TokensRefresh") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany("Usuarios") + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Rol", "Rol") + .WithMany() + .HasForeignKey("RolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Tema", "TemaActual") + .WithMany() + .HasForeignKey("TemaActualId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + + b.Navigation("Rol"); + + b.Navigation("TemaActual"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Navigation("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Navigation("Habitos"); + + b.Navigation("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Navigation("DiasSemana"); + + b.Navigation("Registros"); + + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Navigation("Progresos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Navigation("Imagenes"); + + b.Navigation("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Navigation("ConfiguracionPomodoro") + .IsRequired(); + + b.Navigation("EstadosAnimo"); + + b.Navigation("Habitos"); + + b.Navigation("LogrosUsuario"); + + b.Navigation("Misiones"); + + b.Navigation("PersonajesUsuario"); + + b.Navigation("Progreso") + .IsRequired(); + + b.Navigation("SesionesPomodoro"); + + b.Navigation("Suscripciones"); + + b.Navigation("TemasUsuario"); + + b.Navigation("TokensRefresh"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260618223131_AddFeedbackToMensajesIA.cs b/Migrations/20260618223131_AddFeedbackToMensajesIA.cs new file mode 100644 index 0000000..e1f1ffe --- /dev/null +++ b/Migrations/20260618223131_AddFeedbackToMensajesIA.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EpycusApp.Migrations +{ + /// + public partial class AddFeedbackToMensajesIA : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/Migrations/20260619000237_AddEntradaDiario.Designer.cs b/Migrations/20260619000237_AddEntradaDiario.Designer.cs new file mode 100644 index 0000000..4716357 --- /dev/null +++ b/Migrations/20260619000237_AddEntradaDiario.Designer.cs @@ -0,0 +1,1424 @@ +// +using System; +using EpycusApp.Datos; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EpycusApp.Migrations +{ + [DbContext(typeof(ContextoAplicacion))] + [Migration("20260619000237_AddEntradaDiario")] + partial class AddEntradaDiario + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Codigo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Carreras"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Icono") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categorias"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CiclosAntesDescansoLargo") + .HasColumnType("int"); + + b.Property("FechaActualizacion") + .HasColumnType("datetime(6)"); + + b.Property("SonidoActivo") + .HasColumnType("tinyint(1)"); + + b.Property("TiempoDescansoLargoMin") + .HasColumnType("int"); + + b.Property("TiempoDescansoMin") + .HasColumnType("int"); + + b.Property("TiempoEstudioMin") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ConfiguracionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaSemana") + .HasColumnType("int"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("DiasSemanaHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActividadFisica") + .HasColumnType("tinyint(1)"); + + b.Property("DiarioTexto") + .HasColumnType("longtext"); + + b.Property("EstadoAnimo") + .HasColumnType("int"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HorasSueno") + .HasColumnType("decimal(65,30)"); + + b.Property("NivelEnergia") + .HasColumnType("int"); + + b.Property("NivelEstres") + .HasColumnType("int"); + + b.Property("PreguntaGuia") + .HasColumnType("longtext"); + + b.Property("RespuestaGuia") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.HasIndex("UsuarioId", "Fecha") + .IsUnique(); + + b.ToTable("EntradasDiario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("Nota") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("EstadosAnimo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.FraseMotivacional", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Autor") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Categoria") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Frase") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("FrasesMotivacionales"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Frecuencia") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("RecordatorioHora") + .HasColumnType("time(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Habitos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EsPlaceholder") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NivelNumero") + .HasColumnType("int"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.ToTable("ImagenesNivelPersonaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Accion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Detalle") + .HasColumnType("longtext"); + + b.Property("DireccionIp") + .HasColumnType("longtext"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Log"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Logro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CondicionTipo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CondicionValor") + .HasColumnType("int"); + + b.Property("Descripcion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("IconoUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRecompensa") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Logros"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("LogroId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LogroId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("LogrosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Contenido") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ConversacionId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("FechaHora") + .HasColumnType("datetime(6)"); + + b.Property("FeedbackRecibido") + .HasColumnType("tinyint(1)"); + + b.Property("FeedbackUtil") + .HasColumnType("tinyint(1)"); + + b.Property("Rol") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ConversacionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("MensajesIA"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FechaCompletado") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaLimite") + .HasColumnType("date"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NombreCurso") + .HasColumnType("longtext"); + + b.Property("Prioridad") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Numero") + .HasColumnType("int"); + + b.Property("Titulo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRequerido") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Niveles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.ToTable("Personajes"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaSeleccionado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaDeGraciaUsado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaInicioRacha") + .HasColumnType("datetime(6)"); + + b.Property("FechaUltimaActividad") + .HasColumnType("datetime(6)"); + + b.Property("NivelActualId") + .HasColumnType("int"); + + b.Property("ProductividadDiaria") + .HasColumnType("decimal(65,30)"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpTotal") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NivelActualId"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ProgresosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("RecuperacionesContrasena"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("RegistrosHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Rol", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CiclosCompletados") + .HasColumnType("int"); + + b.Property("FechaFin") + .HasColumnType("datetime(6)"); + + b.Property("FechaInicio") + .HasColumnType("datetime(6)"); + + b.Property("FueCompletada") + .HasColumnType("tinyint(1)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("MisionId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.HasIndex("MisionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivadaPorAdminId") + .HasColumnType("int"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("FechaActivacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaFin") + .HasColumnType("date"); + + b.Property("FechaInicio") + .HasColumnType("date"); + + b.Property("Plan") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PrecioSoles") + .HasColumnType("decimal(65,30)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActivadaPorAdminId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Suscripciones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Tema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ArchivoCss") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EsPremium") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenPreviewUrl") + .HasColumnType("longtext"); + + b.Property("Modo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Precio") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Temas"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("TemaId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TemasUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TipPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Tip") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("TipsPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Revocado") + .HasColumnType("tinyint(1)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TokensRefresh"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AceptoTerminos") + .HasColumnType("tinyint(1)"); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("CodigoUnico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ContrasenaHash") + .HasColumnType("longtext"); + + b.Property("CorreoElectronico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("CorreoVerificado") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaNacimiento") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("FotoGoogleUrl") + .HasColumnType("longtext"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("GoogleId") + .HasColumnType("varchar(255)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RolId") + .HasColumnType("int"); + + b.Property("TemaActualId") + .HasColumnType("int"); + + b.Property("UltimoAcceso") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.HasIndex("CodigoUnico") + .IsUnique(); + + b.HasIndex("CorreoElectronico") + .IsUnique(); + + b.HasIndex("GoogleId") + .IsUnique(); + + b.HasIndex("RolId"); + + b.HasIndex("TemaActualId"); + + b.ToTable("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("VerificacionesCorreo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("ConfiguracionPomodoro") + .HasForeignKey("EpycusApp.Models.Entidades.ConfiguracionPomodoro", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("DiasSemana") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("EstadosAnimo") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Habitos") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Habitos") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("Imagenes") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Logro", "Logro") + .WithMany() + .HasForeignKey("LogroId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("LogrosUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Logro"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Misiones") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Misiones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany() + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("PersonajesUsuario") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("PersonajesUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Nivel", "NivelActual") + .WithMany("Progresos") + .HasForeignKey("NivelActualId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("Progreso") + .HasForeignKey("EpycusApp.Models.Entidades.ProgresoUsuario", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NivelActual"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("Registros") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("SesionesPomodoro") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Mision", "Mision") + .WithMany("SesionesPomodoro") + .HasForeignKey("MisionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("SesionesPomodoro") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + + b.Navigation("Mision"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", null) + .WithMany() + .HasForeignKey("ActivadaPorAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Suscripciones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Tema", "Tema") + .WithMany() + .HasForeignKey("TemaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TemasUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tema"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TokensRefresh") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany("Usuarios") + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Rol", "Rol") + .WithMany() + .HasForeignKey("RolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Tema", "TemaActual") + .WithMany() + .HasForeignKey("TemaActualId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + + b.Navigation("Rol"); + + b.Navigation("TemaActual"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Navigation("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Navigation("Habitos"); + + b.Navigation("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Navigation("DiasSemana"); + + b.Navigation("Registros"); + + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Navigation("Progresos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Navigation("Imagenes"); + + b.Navigation("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Navigation("ConfiguracionPomodoro") + .IsRequired(); + + b.Navigation("EstadosAnimo"); + + b.Navigation("Habitos"); + + b.Navigation("LogrosUsuario"); + + b.Navigation("Misiones"); + + b.Navigation("PersonajesUsuario"); + + b.Navigation("Progreso") + .IsRequired(); + + b.Navigation("SesionesPomodoro"); + + b.Navigation("Suscripciones"); + + b.Navigation("TemasUsuario"); + + b.Navigation("TokensRefresh"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260619000237_AddEntradaDiario.cs b/Migrations/20260619000237_AddEntradaDiario.cs new file mode 100644 index 0000000..15c4281 --- /dev/null +++ b/Migrations/20260619000237_AddEntradaDiario.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EpycusApp.Migrations +{ + /// + public partial class AddEntradaDiario : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EntradasDiario", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Fecha = table.Column(type: "date", nullable: false), + EstadoAnimo = table.Column(type: "int", nullable: false), + NivelEnergia = table.Column(type: "int", nullable: false), + HorasSueno = table.Column(type: "decimal(65,30)", nullable: true), + NivelEstres = table.Column(type: "int", nullable: true), + ActividadFisica = table.Column(type: "tinyint(1)", nullable: true), + DiarioTexto = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + PreguntaGuia = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + RespuestaGuia = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + FechaRegistro = table.Column(type: "datetime(6)", nullable: false), + UsuarioId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EntradasDiario", x => x.Id); + table.ForeignKey( + name: "FK_EntradasDiario_Usuarios_UsuarioId", + column: x => x.UsuarioId, + principalTable: "Usuarios", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_EntradasDiario_UsuarioId", + table: "EntradasDiario", + column: "UsuarioId"); + + migrationBuilder.CreateIndex( + name: "IX_EntradasDiario_UsuarioId_Fecha", + table: "EntradasDiario", + columns: new[] { "UsuarioId", "Fecha" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EntradasDiario"); + } + } +} diff --git a/Migrations/20260621163455_AddPomodoroConfigExtras.Designer.cs b/Migrations/20260621163455_AddPomodoroConfigExtras.Designer.cs new file mode 100644 index 0000000..7e9704b --- /dev/null +++ b/Migrations/20260621163455_AddPomodoroConfigExtras.Designer.cs @@ -0,0 +1,1452 @@ +// +using System; +using EpycusApp.Datos; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EpycusApp.Migrations +{ + [DbContext(typeof(ContextoAplicacion))] + [Migration("20260621163455_AddPomodoroConfigExtras")] + partial class AddPomodoroConfigExtras + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Codigo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Carreras"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Icono") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categorias"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AutoIniciarDescanso") + .HasColumnType("tinyint(1)"); + + b.Property("AutoIniciarEnfoque") + .HasColumnType("tinyint(1)"); + + b.Property("CiclosAntesDescansoLargo") + .HasColumnType("int"); + + b.Property("FechaActualizacion") + .HasColumnType("datetime(6)"); + + b.Property("MetaDiariaCiclos") + .HasColumnType("int"); + + b.Property("ModoPersonalizadoMinutos") + .HasColumnType("int"); + + b.Property("NotificacionDesktop") + .HasColumnType("tinyint(1)"); + + b.Property("SonidoActivo") + .HasColumnType("tinyint(1)"); + + b.Property("SonidoSeleccionado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("TicTacActivo") + .HasColumnType("tinyint(1)"); + + b.Property("TiempoDescansoLargoMin") + .HasColumnType("int"); + + b.Property("TiempoDescansoMin") + .HasColumnType("int"); + + b.Property("TiempoEstudioMin") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("VibracionActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Volumen") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ConfiguracionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaSemana") + .HasColumnType("int"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("DiasSemanaHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActividadFisica") + .HasColumnType("tinyint(1)"); + + b.Property("DiarioTexto") + .HasColumnType("longtext"); + + b.Property("EstadoAnimo") + .HasColumnType("int"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HorasSueno") + .HasColumnType("decimal(65,30)"); + + b.Property("NivelEnergia") + .HasColumnType("int"); + + b.Property("NivelEstres") + .HasColumnType("int"); + + b.Property("PreguntaGuia") + .HasColumnType("longtext"); + + b.Property("RespuestaGuia") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.HasIndex("UsuarioId", "Fecha") + .IsUnique(); + + b.ToTable("EntradasDiario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("Nota") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("EstadosAnimo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.FraseMotivacional", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Autor") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Categoria") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Frase") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("FrasesMotivacionales"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Frecuencia") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("RecordatorioHora") + .HasColumnType("time(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Habitos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EsPlaceholder") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NivelNumero") + .HasColumnType("int"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.ToTable("ImagenesNivelPersonaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Accion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Detalle") + .HasColumnType("longtext"); + + b.Property("DireccionIp") + .HasColumnType("longtext"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Log"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Logro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CondicionTipo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CondicionValor") + .HasColumnType("int"); + + b.Property("Descripcion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("IconoUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRecompensa") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Logros"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("LogroId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LogroId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("LogrosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Contenido") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ConversacionId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("FechaHora") + .HasColumnType("datetime(6)"); + + b.Property("FeedbackRecibido") + .HasColumnType("tinyint(1)"); + + b.Property("FeedbackUtil") + .HasColumnType("tinyint(1)"); + + b.Property("Rol") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ConversacionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("MensajesIA"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FechaCompletado") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaLimite") + .HasColumnType("date"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NombreCurso") + .HasColumnType("longtext"); + + b.Property("Prioridad") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Numero") + .HasColumnType("int"); + + b.Property("Titulo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRequerido") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Niveles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.ToTable("Personajes"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaSeleccionado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaDeGraciaUsado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaInicioRacha") + .HasColumnType("datetime(6)"); + + b.Property("FechaUltimaActividad") + .HasColumnType("datetime(6)"); + + b.Property("NivelActualId") + .HasColumnType("int"); + + b.Property("ProductividadDiaria") + .HasColumnType("decimal(65,30)"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpTotal") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NivelActualId"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ProgresosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("RecuperacionesContrasena"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("RegistrosHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Rol", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CiclosCompletados") + .HasColumnType("int"); + + b.Property("FechaFin") + .HasColumnType("datetime(6)"); + + b.Property("FechaInicio") + .HasColumnType("datetime(6)"); + + b.Property("FueCompletada") + .HasColumnType("tinyint(1)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("MisionId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.HasIndex("MisionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivadaPorAdminId") + .HasColumnType("int"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("FechaActivacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaFin") + .HasColumnType("date"); + + b.Property("FechaInicio") + .HasColumnType("date"); + + b.Property("Plan") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PrecioSoles") + .HasColumnType("decimal(65,30)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActivadaPorAdminId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Suscripciones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Tema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ArchivoCss") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EsPremium") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenPreviewUrl") + .HasColumnType("longtext"); + + b.Property("Modo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Precio") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Temas"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("TemaId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TemasUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TipPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Tip") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("TipsPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Revocado") + .HasColumnType("tinyint(1)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TokensRefresh"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AceptoTerminos") + .HasColumnType("tinyint(1)"); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("CodigoUnico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ContrasenaHash") + .HasColumnType("longtext"); + + b.Property("CorreoElectronico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("CorreoVerificado") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaNacimiento") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("FotoGoogleUrl") + .HasColumnType("longtext"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("GoogleId") + .HasColumnType("varchar(255)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RolId") + .HasColumnType("int"); + + b.Property("TemaActualId") + .HasColumnType("int"); + + b.Property("UltimoAcceso") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.HasIndex("CodigoUnico") + .IsUnique(); + + b.HasIndex("CorreoElectronico") + .IsUnique(); + + b.HasIndex("GoogleId") + .IsUnique(); + + b.HasIndex("RolId"); + + b.HasIndex("TemaActualId"); + + b.ToTable("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("VerificacionesCorreo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("ConfiguracionPomodoro") + .HasForeignKey("EpycusApp.Models.Entidades.ConfiguracionPomodoro", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("DiasSemana") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("EstadosAnimo") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Habitos") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Habitos") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("Imagenes") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Logro", "Logro") + .WithMany() + .HasForeignKey("LogroId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("LogrosUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Logro"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Misiones") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Misiones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany() + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("PersonajesUsuario") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("PersonajesUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Nivel", "NivelActual") + .WithMany("Progresos") + .HasForeignKey("NivelActualId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("Progreso") + .HasForeignKey("EpycusApp.Models.Entidades.ProgresoUsuario", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NivelActual"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("Registros") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("SesionesPomodoro") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Mision", "Mision") + .WithMany("SesionesPomodoro") + .HasForeignKey("MisionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("SesionesPomodoro") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + + b.Navigation("Mision"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", null) + .WithMany() + .HasForeignKey("ActivadaPorAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Suscripciones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Tema", "Tema") + .WithMany() + .HasForeignKey("TemaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TemasUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tema"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TokensRefresh") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany("Usuarios") + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Rol", "Rol") + .WithMany() + .HasForeignKey("RolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Tema", "TemaActual") + .WithMany() + .HasForeignKey("TemaActualId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + + b.Navigation("Rol"); + + b.Navigation("TemaActual"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Navigation("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Navigation("Habitos"); + + b.Navigation("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Navigation("DiasSemana"); + + b.Navigation("Registros"); + + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Navigation("Progresos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Navigation("Imagenes"); + + b.Navigation("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Navigation("ConfiguracionPomodoro") + .IsRequired(); + + b.Navigation("EstadosAnimo"); + + b.Navigation("Habitos"); + + b.Navigation("LogrosUsuario"); + + b.Navigation("Misiones"); + + b.Navigation("PersonajesUsuario"); + + b.Navigation("Progreso") + .IsRequired(); + + b.Navigation("SesionesPomodoro"); + + b.Navigation("Suscripciones"); + + b.Navigation("TemasUsuario"); + + b.Navigation("TokensRefresh"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260621163455_AddPomodoroConfigExtras.cs b/Migrations/20260621163455_AddPomodoroConfigExtras.cs new file mode 100644 index 0000000..eb90679 --- /dev/null +++ b/Migrations/20260621163455_AddPomodoroConfigExtras.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EpycusApp.Migrations +{ + /// + public partial class AddPomodoroConfigExtras : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AutoIniciarDescanso", + table: "ConfiguracionesPomodoro", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "AutoIniciarEnfoque", + table: "ConfiguracionesPomodoro", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MetaDiariaCiclos", + table: "ConfiguracionesPomodoro", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ModoPersonalizadoMinutos", + table: "ConfiguracionesPomodoro", + type: "int", + nullable: false, + defaultValue: 25); + + migrationBuilder.AddColumn( + name: "NotificacionDesktop", + table: "ConfiguracionesPomodoro", + type: "tinyint(1)", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "SonidoSeleccionado", + table: "ConfiguracionesPomodoro", + type: "longtext", + nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "TicTacActivo", + table: "ConfiguracionesPomodoro", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "VibracionActiva", + table: "ConfiguracionesPomodoro", + type: "tinyint(1)", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "Volumen", + table: "ConfiguracionesPomodoro", + type: "int", + nullable: false, + defaultValue: 100); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AutoIniciarDescanso", + table: "ConfiguracionesPomodoro"); + + migrationBuilder.DropColumn( + name: "AutoIniciarEnfoque", + table: "ConfiguracionesPomodoro"); + + migrationBuilder.DropColumn( + name: "MetaDiariaCiclos", + table: "ConfiguracionesPomodoro"); + + migrationBuilder.DropColumn( + name: "ModoPersonalizadoMinutos", + table: "ConfiguracionesPomodoro"); + + migrationBuilder.DropColumn( + name: "NotificacionDesktop", + table: "ConfiguracionesPomodoro"); + + migrationBuilder.DropColumn( + name: "SonidoSeleccionado", + table: "ConfiguracionesPomodoro"); + + migrationBuilder.DropColumn( + name: "TicTacActivo", + table: "ConfiguracionesPomodoro"); + + migrationBuilder.DropColumn( + name: "VibracionActiva", + table: "ConfiguracionesPomodoro"); + + migrationBuilder.DropColumn( + name: "Volumen", + table: "ConfiguracionesPomodoro"); + } + } +} diff --git a/Migrations/20260622205730_AddTipoToSesionPomodoro.Designer.cs b/Migrations/20260622205730_AddTipoToSesionPomodoro.Designer.cs new file mode 100644 index 0000000..a15302e --- /dev/null +++ b/Migrations/20260622205730_AddTipoToSesionPomodoro.Designer.cs @@ -0,0 +1,1457 @@ +// +using System; +using EpycusApp.Datos; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EpycusApp.Migrations +{ + [DbContext(typeof(ContextoAplicacion))] + [Migration("20260622205730_AddTipoToSesionPomodoro")] + partial class AddTipoToSesionPomodoro + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Codigo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Carreras"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Icono") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categorias"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AutoIniciarDescanso") + .HasColumnType("tinyint(1)"); + + b.Property("AutoIniciarEnfoque") + .HasColumnType("tinyint(1)"); + + b.Property("CiclosAntesDescansoLargo") + .HasColumnType("int"); + + b.Property("FechaActualizacion") + .HasColumnType("datetime(6)"); + + b.Property("MetaDiariaCiclos") + .HasColumnType("int"); + + b.Property("ModoPersonalizadoMin") + .HasColumnType("int") + .HasColumnName("ModoPersonalizadoMinutos"); + + b.Property("NotificacionDesktop") + .HasColumnType("tinyint(1)"); + + b.Property("SonidoActivo") + .HasColumnType("tinyint(1)"); + + b.Property("SonidoSeleccionado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("TicTacActivo") + .HasColumnType("tinyint(1)"); + + b.Property("TiempoDescansoLargoMin") + .HasColumnType("int"); + + b.Property("TiempoDescansoMin") + .HasColumnType("int"); + + b.Property("TiempoEstudioMin") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("VibracionActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Volumen") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ConfiguracionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaSemana") + .HasColumnType("int"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("DiasSemanaHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActividadFisica") + .HasColumnType("tinyint(1)"); + + b.Property("DiarioTexto") + .HasColumnType("longtext"); + + b.Property("EstadoAnimo") + .HasColumnType("int"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HorasSueno") + .HasColumnType("decimal(65,30)"); + + b.Property("NivelEnergia") + .HasColumnType("int"); + + b.Property("NivelEstres") + .HasColumnType("int"); + + b.Property("PreguntaGuia") + .HasColumnType("longtext"); + + b.Property("RespuestaGuia") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.HasIndex("UsuarioId", "Fecha") + .IsUnique(); + + b.ToTable("EntradasDiario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("Nota") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("EstadosAnimo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.FraseMotivacional", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Autor") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Categoria") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Frase") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("FrasesMotivacionales"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Frecuencia") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("RecordatorioHora") + .HasColumnType("time(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Habitos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EsPlaceholder") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NivelNumero") + .HasColumnType("int"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.ToTable("ImagenesNivelPersonaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Accion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Detalle") + .HasColumnType("longtext"); + + b.Property("DireccionIp") + .HasColumnType("longtext"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Log"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Logro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CondicionTipo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CondicionValor") + .HasColumnType("int"); + + b.Property("Descripcion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("IconoUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRecompensa") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Logros"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("LogroId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LogroId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("LogrosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Contenido") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ConversacionId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("FechaHora") + .HasColumnType("datetime(6)"); + + b.Property("FeedbackRecibido") + .HasColumnType("tinyint(1)"); + + b.Property("FeedbackUtil") + .HasColumnType("tinyint(1)"); + + b.Property("Rol") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ConversacionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("MensajesIA"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FechaCompletado") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaLimite") + .HasColumnType("date"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NombreCurso") + .HasColumnType("longtext"); + + b.Property("Prioridad") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Numero") + .HasColumnType("int"); + + b.Property("Titulo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRequerido") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Niveles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.ToTable("Personajes"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaSeleccionado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaDeGraciaUsado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaInicioRacha") + .HasColumnType("datetime(6)"); + + b.Property("FechaUltimaActividad") + .HasColumnType("datetime(6)"); + + b.Property("NivelActualId") + .HasColumnType("int"); + + b.Property("ProductividadDiaria") + .HasColumnType("decimal(65,30)"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpTotal") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NivelActualId"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ProgresosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("RecuperacionesContrasena"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("RegistrosHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Rol", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CiclosCompletados") + .HasColumnType("int"); + + b.Property("FechaFin") + .HasColumnType("datetime(6)"); + + b.Property("FechaInicio") + .HasColumnType("datetime(6)"); + + b.Property("FueCompletada") + .HasColumnType("tinyint(1)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("MisionId") + .HasColumnType("int"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.HasIndex("MisionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivadaPorAdminId") + .HasColumnType("int"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("FechaActivacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaFin") + .HasColumnType("date"); + + b.Property("FechaInicio") + .HasColumnType("date"); + + b.Property("Plan") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PrecioSoles") + .HasColumnType("decimal(65,30)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActivadaPorAdminId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Suscripciones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Tema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ArchivoCss") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EsPremium") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenPreviewUrl") + .HasColumnType("longtext"); + + b.Property("Modo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Precio") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Temas"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("TemaId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TemasUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TipPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Tip") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("TipsPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Revocado") + .HasColumnType("tinyint(1)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TokensRefresh"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AceptoTerminos") + .HasColumnType("tinyint(1)"); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("CodigoUnico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ContrasenaHash") + .HasColumnType("longtext"); + + b.Property("CorreoElectronico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("CorreoVerificado") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaNacimiento") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("FotoGoogleUrl") + .HasColumnType("longtext"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("GoogleId") + .HasColumnType("varchar(255)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RolId") + .HasColumnType("int"); + + b.Property("TemaActualId") + .HasColumnType("int"); + + b.Property("UltimoAcceso") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.HasIndex("CodigoUnico") + .IsUnique(); + + b.HasIndex("CorreoElectronico") + .IsUnique(); + + b.HasIndex("GoogleId") + .IsUnique(); + + b.HasIndex("RolId"); + + b.HasIndex("TemaActualId"); + + b.ToTable("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("VerificacionesCorreo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("ConfiguracionPomodoro") + .HasForeignKey("EpycusApp.Models.Entidades.ConfiguracionPomodoro", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("DiasSemana") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("EstadosAnimo") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Habitos") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Habitos") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("Imagenes") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Logro", "Logro") + .WithMany() + .HasForeignKey("LogroId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("LogrosUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Logro"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Misiones") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Misiones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany() + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("PersonajesUsuario") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("PersonajesUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Nivel", "NivelActual") + .WithMany("Progresos") + .HasForeignKey("NivelActualId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("Progreso") + .HasForeignKey("EpycusApp.Models.Entidades.ProgresoUsuario", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NivelActual"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("Registros") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("SesionesPomodoro") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Mision", "Mision") + .WithMany("SesionesPomodoro") + .HasForeignKey("MisionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("SesionesPomodoro") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + + b.Navigation("Mision"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", null) + .WithMany() + .HasForeignKey("ActivadaPorAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Suscripciones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Tema", "Tema") + .WithMany() + .HasForeignKey("TemaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TemasUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tema"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TokensRefresh") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany("Usuarios") + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Rol", "Rol") + .WithMany() + .HasForeignKey("RolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Tema", "TemaActual") + .WithMany() + .HasForeignKey("TemaActualId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + + b.Navigation("Rol"); + + b.Navigation("TemaActual"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Navigation("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Navigation("Habitos"); + + b.Navigation("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Navigation("DiasSemana"); + + b.Navigation("Registros"); + + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Navigation("Progresos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Navigation("Imagenes"); + + b.Navigation("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Navigation("ConfiguracionPomodoro") + .IsRequired(); + + b.Navigation("EstadosAnimo"); + + b.Navigation("Habitos"); + + b.Navigation("LogrosUsuario"); + + b.Navigation("Misiones"); + + b.Navigation("PersonajesUsuario"); + + b.Navigation("Progreso") + .IsRequired(); + + b.Navigation("SesionesPomodoro"); + + b.Navigation("Suscripciones"); + + b.Navigation("TemasUsuario"); + + b.Navigation("TokensRefresh"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260622205730_AddTipoToSesionPomodoro.cs b/Migrations/20260622205730_AddTipoToSesionPomodoro.cs new file mode 100644 index 0000000..580bfa1 --- /dev/null +++ b/Migrations/20260622205730_AddTipoToSesionPomodoro.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EpycusApp.Migrations +{ + /// + public partial class AddTipoToSesionPomodoro : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Tipo", + table: "SesionesPomodoro", + type: "longtext", + nullable: false, + defaultValue: "Enfoque") + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Tipo", + table: "SesionesPomodoro"); + } + } +} diff --git a/Migrations/20260622214253_AddSubTareas.Designer.cs b/Migrations/20260622214253_AddSubTareas.Designer.cs new file mode 100644 index 0000000..14989d2 --- /dev/null +++ b/Migrations/20260622214253_AddSubTareas.Designer.cs @@ -0,0 +1,1527 @@ +// +using System; +using EpycusApp.Datos; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EpycusApp.Migrations +{ + [DbContext(typeof(ContextoAplicacion))] + [Migration("20260622214253_AddSubTareas")] + partial class AddSubTareas + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Codigo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Carreras"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Icono") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categorias"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AutoIniciarDescanso") + .HasColumnType("tinyint(1)"); + + b.Property("AutoIniciarEnfoque") + .HasColumnType("tinyint(1)"); + + b.Property("CiclosAntesDescansoLargo") + .HasColumnType("int"); + + b.Property("FechaActualizacion") + .HasColumnType("datetime(6)"); + + b.Property("MetaDiariaCiclos") + .HasColumnType("int"); + + b.Property("ModoPersonalizadoMin") + .HasColumnType("int") + .HasColumnName("ModoPersonalizadoMinutos"); + + b.Property("NotificacionDesktop") + .HasColumnType("tinyint(1)"); + + b.Property("SonidoActivo") + .HasColumnType("tinyint(1)"); + + b.Property("SonidoSeleccionado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("TicTacActivo") + .HasColumnType("tinyint(1)"); + + b.Property("TiempoDescansoLargoMin") + .HasColumnType("int"); + + b.Property("TiempoDescansoMin") + .HasColumnType("int"); + + b.Property("TiempoEstudioMin") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("VibracionActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Volumen") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ConfiguracionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaSemana") + .HasColumnType("int"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("DiasSemanaHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActividadFisica") + .HasColumnType("tinyint(1)"); + + b.Property("DiarioTexto") + .HasColumnType("longtext"); + + b.Property("EstadoAnimo") + .HasColumnType("int"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HorasSueno") + .HasColumnType("decimal(65,30)"); + + b.Property("NivelEnergia") + .HasColumnType("int"); + + b.Property("NivelEstres") + .HasColumnType("int"); + + b.Property("PreguntaGuia") + .HasColumnType("longtext"); + + b.Property("RespuestaGuia") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.HasIndex("UsuarioId", "Fecha") + .IsUnique(); + + b.ToTable("EntradasDiario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("Nota") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("EstadosAnimo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.FraseMotivacional", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Autor") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Categoria") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Frase") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("FrasesMotivacionales"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Frecuencia") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("RecordatorioHora") + .HasColumnType("time(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Habitos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EsPlaceholder") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NivelNumero") + .HasColumnType("int"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.ToTable("ImagenesNivelPersonaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Accion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Detalle") + .HasColumnType("longtext"); + + b.Property("DireccionIp") + .HasColumnType("longtext"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Log"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Logro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CondicionTipo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CondicionValor") + .HasColumnType("int"); + + b.Property("Descripcion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("IconoUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRecompensa") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Logros"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("LogroId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LogroId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("LogrosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Contenido") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ConversacionId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("FechaHora") + .HasColumnType("datetime(6)"); + + b.Property("FeedbackRecibido") + .HasColumnType("tinyint(1)"); + + b.Property("FeedbackUtil") + .HasColumnType("tinyint(1)"); + + b.Property("Rol") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ConversacionId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("MensajesIA"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CategoriaId") + .HasColumnType("int"); + + b.Property("ConPomodoro") + .HasColumnType("tinyint(1)"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FechaCompletado") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaLimite") + .HasColumnType("date"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NombreCurso") + .HasColumnType("longtext"); + + b.Property("Prioridad") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoriaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("Numero") + .HasColumnType("int"); + + b.Property("Titulo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("XpRequerido") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Niveles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.ToTable("Personajes"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaSeleccionado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("PersonajeId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PersonajeId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DiaDeGraciaUsado") + .HasColumnType("tinyint(1)"); + + b.Property("FechaInicioRacha") + .HasColumnType("datetime(6)"); + + b.Property("FechaUltimaActividad") + .HasColumnType("datetime(6)"); + + b.Property("NivelActualId") + .HasColumnType("int"); + + b.Property("ProductividadDiaria") + .HasColumnType("decimal(65,30)"); + + b.Property("RachaActual") + .HasColumnType("int"); + + b.Property("RachaMaxima") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpTotal") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NivelActualId"); + + b.HasIndex("UsuarioId") + .IsUnique(); + + b.ToTable("ProgresosUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("RecuperacionesContrasena"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Estado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.ToTable("RegistrosHabito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Rol", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CiclosCompletados") + .HasColumnType("int"); + + b.Property("FechaFin") + .HasColumnType("datetime(6)"); + + b.Property("FechaInicio") + .HasColumnType("datetime(6)"); + + b.Property("FueCompletada") + .HasColumnType("tinyint(1)"); + + b.Property("HabitoId") + .HasColumnType("int"); + + b.Property("MisionId") + .HasColumnType("int"); + + b.Property("SubTareaId") + .HasColumnType("int"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.Property("XpOtorgado") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("HabitoId"); + + b.HasIndex("MisionId"); + + b.HasIndex("SubTareaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SubTarea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EstaCompletada") + .HasColumnType("tinyint(1)"); + + b.Property("FechaCompletado") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("MisionId") + .HasColumnType("int"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Orden") + .HasColumnType("int"); + + b.Property("TiempoEnfoqueSegundos") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MisionId"); + + b.ToTable("SubTareas"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivadaPorAdminId") + .HasColumnType("int"); + + b.Property("EstaActiva") + .HasColumnType("tinyint(1)"); + + b.Property("FechaActivacion") + .HasColumnType("datetime(6)"); + + b.Property("FechaFin") + .HasColumnType("date"); + + b.Property("FechaInicio") + .HasColumnType("date"); + + b.Property("Plan") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PrecioSoles") + .HasColumnType("decimal(65,30)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActivadaPorAdminId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("Suscripciones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Tema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ArchivoCss") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EsPremium") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("ImagenPreviewUrl") + .HasColumnType("longtext"); + + b.Property("Modo") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Precio") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Temas"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FechaObtenido") + .HasColumnType("datetime(6)"); + + b.Property("TemaId") + .HasColumnType("int"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemaId"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TemasUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TipPomodoro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("Tip") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("TipsPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Revocado") + .HasColumnType("tinyint(1)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("UsuarioId"); + + b.ToTable("TokensRefresh"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AceptoTerminos") + .HasColumnType("tinyint(1)"); + + b.Property("CarreraId") + .HasColumnType("int"); + + b.Property("CodigoUnico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ContrasenaHash") + .HasColumnType("longtext"); + + b.Property("CorreoElectronico") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("CorreoVerificado") + .HasColumnType("tinyint(1)"); + + b.Property("EstaActivo") + .HasColumnType("tinyint(1)"); + + b.Property("FechaNacimiento") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("FotoGoogleUrl") + .HasColumnType("longtext"); + + b.Property("Genero") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("GoogleId") + .HasColumnType("varchar(255)"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RolId") + .HasColumnType("int"); + + b.Property("TemaActualId") + .HasColumnType("int"); + + b.Property("UltimoAcceso") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CarreraId"); + + b.HasIndex("CodigoUnico") + .IsUnique(); + + b.HasIndex("CorreoElectronico") + .IsUnique(); + + b.HasIndex("GoogleId") + .IsUnique(); + + b.HasIndex("RolId"); + + b.HasIndex("TemaActualId"); + + b.ToTable("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExpiraEn") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Usado") + .HasColumnType("tinyint(1)"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.ToTable("VerificacionesCorreo"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ConfiguracionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("ConfiguracionPomodoro") + .HasForeignKey("EpycusApp.Models.Entidades.ConfiguracionPomodoro", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.DiasSemanaHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("DiasSemana") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("EstadosAnimo") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Habitos") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Habitos") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ImagenNivelPersonaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("Imagenes") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Log", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.LogroUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Logro", "Logro") + .WithMany() + .HasForeignKey("LogroId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("LogrosUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Logro"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.MensajeIA", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.HasOne("EpycusApp.Models.Entidades.Categoria", "Categoria") + .WithMany("Misiones") + .HasForeignKey("CategoriaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Misiones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Categoria"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany() + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.PersonajeUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Personaje", "Personaje") + .WithMany("PersonajesUsuario") + .HasForeignKey("PersonajeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("PersonajesUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Personaje"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.ProgresoUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Nivel", "NivelActual") + .WithMany("Progresos") + .HasForeignKey("NivelActualId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithOne("Progreso") + .HasForeignKey("EpycusApp.Models.Entidades.ProgresoUsuario", "UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NivelActual"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RecuperacionContrasena", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.RegistroHabito", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("Registros") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SesionPomodoro", b => + { + b.HasOne("EpycusApp.Models.Entidades.Habito", "Habito") + .WithMany("SesionesPomodoro") + .HasForeignKey("HabitoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Mision", "Mision") + .WithMany("SesionesPomodoro") + .HasForeignKey("MisionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.SubTarea", "SubTarea") + .WithMany("SesionesPomodoro") + .HasForeignKey("SubTareaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("SesionesPomodoro") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habito"); + + b.Navigation("Mision"); + + b.Navigation("SubTarea"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SubTarea", b => + { + b.HasOne("EpycusApp.Models.Entidades.Mision", "Mision") + .WithMany("SubTareas") + .HasForeignKey("MisionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mision"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", null) + .WithMany() + .HasForeignKey("ActivadaPorAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("Suscripciones") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TemaUsuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Tema", "Tema") + .WithMany() + .HasForeignKey("TemaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TemasUsuario") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tema"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.TokenRefresh", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany("TokensRefresh") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Carrera", "Carrera") + .WithMany("Usuarios") + .HasForeignKey("CarreraId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Rol", "Rol") + .WithMany() + .HasForeignKey("RolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EpycusApp.Models.Entidades.Tema", "TemaActual") + .WithMany() + .HasForeignKey("TemaActualId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Carrera"); + + b.Navigation("Rol"); + + b.Navigation("TemaActual"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.VerificacionCorreo", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Carrera", b => + { + b.Navigation("Usuarios"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Categoria", b => + { + b.Navigation("Habitos"); + + b.Navigation("Misiones"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Habito", b => + { + b.Navigation("DiasSemana"); + + b.Navigation("Registros"); + + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => + { + b.Navigation("SesionesPomodoro"); + + b.Navigation("SubTareas"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => + { + b.Navigation("Progresos"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Personaje", b => + { + b.Navigation("Imagenes"); + + b.Navigation("PersonajesUsuario"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.SubTarea", b => + { + b.Navigation("SesionesPomodoro"); + }); + + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => + { + b.Navigation("ConfiguracionPomodoro") + .IsRequired(); + + b.Navigation("EstadosAnimo"); + + b.Navigation("Habitos"); + + b.Navigation("LogrosUsuario"); + + b.Navigation("Misiones"); + + b.Navigation("PersonajesUsuario"); + + b.Navigation("Progreso") + .IsRequired(); + + b.Navigation("SesionesPomodoro"); + + b.Navigation("Suscripciones"); + + b.Navigation("TemasUsuario"); + + b.Navigation("TokensRefresh"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260622214253_AddSubTareas.cs b/Migrations/20260622214253_AddSubTareas.cs new file mode 100644 index 0000000..8bfb75d --- /dev/null +++ b/Migrations/20260622214253_AddSubTareas.cs @@ -0,0 +1,88 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EpycusApp.Migrations +{ + /// + public partial class AddSubTareas : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SubTareaId", + table: "SesionesPomodoro", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "SubTareas", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Nombre = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Descripcion = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + EstaCompletada = table.Column(type: "tinyint(1)", nullable: false), + Orden = table.Column(type: "int", nullable: false), + TiempoEnfoqueSegundos = table.Column(type: "int", nullable: false), + FechaCreacion = table.Column(type: "datetime(6)", nullable: false), + FechaCompletado = table.Column(type: "datetime(6)", nullable: true), + MisionId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SubTareas", x => x.Id); + table.ForeignKey( + name: "FK_SubTareas_Misiones_MisionId", + column: x => x.MisionId, + principalTable: "Misiones", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_SesionesPomodoro_SubTareaId", + table: "SesionesPomodoro", + column: "SubTareaId"); + + migrationBuilder.CreateIndex( + name: "IX_SubTareas_MisionId", + table: "SubTareas", + column: "MisionId"); + + migrationBuilder.AddForeignKey( + name: "FK_SesionesPomodoro_SubTareas_SubTareaId", + table: "SesionesPomodoro", + column: "SubTareaId", + principalTable: "SubTareas", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_SesionesPomodoro_SubTareas_SubTareaId", + table: "SesionesPomodoro"); + + migrationBuilder.DropTable( + name: "SubTareas"); + + migrationBuilder.DropIndex( + name: "IX_SesionesPomodoro_SubTareaId", + table: "SesionesPomodoro"); + + migrationBuilder.DropColumn( + name: "SubTareaId", + table: "SesionesPomodoro"); + } + } +} diff --git a/Migrations/ContextoAplicacionModelSnapshot.cs b/Migrations/ContextoAplicacionModelSnapshot.cs index cd833d2..58bc938 100644 --- a/Migrations/ContextoAplicacionModelSnapshot.cs +++ b/Migrations/ContextoAplicacionModelSnapshot.cs @@ -86,15 +86,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + b.Property("AutoIniciarDescanso") + .HasColumnType("tinyint(1)"); + + b.Property("AutoIniciarEnfoque") + .HasColumnType("tinyint(1)"); + b.Property("CiclosAntesDescansoLargo") .HasColumnType("int"); b.Property("FechaActualizacion") .HasColumnType("datetime(6)"); + b.Property("MetaDiariaCiclos") + .HasColumnType("int"); + + b.Property("ModoPersonalizadoMin") + .HasColumnType("int") + .HasColumnName("ModoPersonalizadoMinutos"); + + b.Property("NotificacionDesktop") + .HasColumnType("tinyint(1)"); + b.Property("SonidoActivo") .HasColumnType("tinyint(1)"); + b.Property("SonidoSeleccionado") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("TicTacActivo") + .HasColumnType("tinyint(1)"); + b.Property("TiempoDescansoLargoMin") .HasColumnType("int"); @@ -107,6 +130,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UsuarioId") .HasColumnType("int"); + b.Property("VibracionActiva") + .HasColumnType("tinyint(1)"); + + b.Property("Volumen") + .HasColumnType("int"); + b.HasKey("Id"); b.HasIndex("UsuarioId") @@ -136,6 +165,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("DiasSemanaHabito"); }); + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActividadFisica") + .HasColumnType("tinyint(1)"); + + b.Property("DiarioTexto") + .HasColumnType("longtext"); + + b.Property("EstadoAnimo") + .HasColumnType("int"); + + b.Property("Fecha") + .HasColumnType("date"); + + b.Property("FechaRegistro") + .HasColumnType("datetime(6)"); + + b.Property("HorasSueno") + .HasColumnType("decimal(65,30)"); + + b.Property("NivelEnergia") + .HasColumnType("int"); + + b.Property("NivelEstres") + .HasColumnType("int"); + + b.Property("PreguntaGuia") + .HasColumnType("longtext"); + + b.Property("RespuestaGuia") + .HasColumnType("longtext"); + + b.Property("UsuarioId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UsuarioId"); + + b.HasIndex("UsuarioId", "Fecha") + .IsUnique(); + + b.ToTable("EntradasDiario"); + }); + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => { b.Property("Id") @@ -179,6 +259,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("longtext"); + b.Property("Categoria") + .IsRequired() + .HasColumnType("longtext"); + b.Property("EstaActiva") .HasColumnType("tinyint(1)"); @@ -385,6 +469,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FechaHora") .HasColumnType("datetime(6)"); + b.Property("FeedbackRecibido") + .HasColumnType("tinyint(1)"); + + b.Property("FeedbackUtil") + .HasColumnType("tinyint(1)"); + b.Property("Rol") .IsRequired() .HasColumnType("longtext"); @@ -691,6 +781,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MisionId") .HasColumnType("int"); + b.Property("SubTareaId") + .HasColumnType("int"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("longtext"); + b.Property("UsuarioId") .HasColumnType("int"); @@ -703,11 +800,53 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("MisionId"); + b.HasIndex("SubTareaId"); + b.HasIndex("UsuarioId"); b.ToTable("SesionesPomodoro"); }); + modelBuilder.Entity("EpycusApp.Models.Entidades.SubTarea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Descripcion") + .HasColumnType("longtext"); + + b.Property("EstaCompletada") + .HasColumnType("tinyint(1)"); + + b.Property("FechaCompletado") + .HasColumnType("datetime(6)"); + + b.Property("FechaCreacion") + .HasColumnType("datetime(6)"); + + b.Property("MisionId") + .HasColumnType("int"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Orden") + .HasColumnType("int"); + + b.Property("TiempoEnfoqueSegundos") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MisionId"); + + b.ToTable("SubTareas"); + }); + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => { b.Property("Id") @@ -1002,6 +1141,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Habito"); }); + modelBuilder.Entity("EpycusApp.Models.Entidades.EntradaDiario", b => + { + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Usuario"); + }); + modelBuilder.Entity("EpycusApp.Models.Entidades.EstadoAnimo", b => { b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") @@ -1183,6 +1333,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("MisionId") .OnDelete(DeleteBehavior.SetNull); + b.HasOne("EpycusApp.Models.Entidades.SubTarea", "SubTarea") + .WithMany("SesionesPomodoro") + .HasForeignKey("SubTareaId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("EpycusApp.Models.Entidades.Usuario", "Usuario") .WithMany("SesionesPomodoro") .HasForeignKey("UsuarioId") @@ -1193,9 +1348,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Mision"); + b.Navigation("SubTarea"); + b.Navigation("Usuario"); }); + modelBuilder.Entity("EpycusApp.Models.Entidades.SubTarea", b => + { + b.HasOne("EpycusApp.Models.Entidades.Mision", "Mision") + .WithMany("SubTareas") + .HasForeignKey("MisionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mision"); + }); + modelBuilder.Entity("EpycusApp.Models.Entidades.Suscripcion", b => { b.HasOne("EpycusApp.Models.Entidades.Usuario", null) @@ -1303,6 +1471,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("EpycusApp.Models.Entidades.Mision", b => { b.Navigation("SesionesPomodoro"); + + b.Navigation("SubTareas"); }); modelBuilder.Entity("EpycusApp.Models.Entidades.Nivel", b => @@ -1317,6 +1487,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("PersonajesUsuario"); }); + modelBuilder.Entity("EpycusApp.Models.Entidades.SubTarea", b => + { + b.Navigation("SesionesPomodoro"); + }); + modelBuilder.Entity("EpycusApp.Models.Entidades.Usuario", b => { b.Navigation("ConfiguracionPomodoro") diff --git a/Models/Entidades/ConfiguracionPomodoro.cs b/Models/Entidades/ConfiguracionPomodoro.cs index 134d8c5..6046068 100644 --- a/Models/Entidades/ConfiguracionPomodoro.cs +++ b/Models/Entidades/ConfiguracionPomodoro.cs @@ -1,4 +1,6 @@ -namespace EpycusApp.Models.Entidades +using System.ComponentModel.DataAnnotations.Schema; + +namespace EpycusApp.Models.Entidades { public class ConfiguracionPomodoro { @@ -8,6 +10,16 @@ public class ConfiguracionPomodoro public int TiempoDescansoLargoMin { get; set; } = 15; public int CiclosAntesDescansoLargo { get; set; } = 4; public bool SonidoActivo { get; set; } = true; + public string SonidoSeleccionado { get; set; } = "campana"; + public int Volumen { get; set; } = 100; + public bool AutoIniciarDescanso { get; set; } = false; + public bool AutoIniciarEnfoque { get; set; } = false; + public bool TicTacActivo { get; set; } = false; + public int MetaDiariaCiclos { get; set; } = 0; + [Column("ModoPersonalizadoMinutos")] + public int ModoPersonalizadoMin { get; set; } = 25; + public bool VibracionActiva { get; set; } = true; + public bool NotificacionDesktop { get; set; } = true; public DateTime FechaActualizacion { get; set; } = DateTime.UtcNow; public int UsuarioId { get; set; } public Usuario Usuario { get; set; } = null!; diff --git a/Models/Entidades/EntradaDiario.cs b/Models/Entidades/EntradaDiario.cs new file mode 100644 index 0000000..7bc0831 --- /dev/null +++ b/Models/Entidades/EntradaDiario.cs @@ -0,0 +1,19 @@ +namespace EpycusApp.Models.Entidades +{ + public class EntradaDiario + { + public int Id { get; set; } + public DateOnly Fecha { get; set; } + public int EstadoAnimo { get; set; } + public int NivelEnergia { get; set; } + public decimal? HorasSueno { get; set; } + public int? NivelEstres { get; set; } + public bool? ActividadFisica { get; set; } + public string? DiarioTexto { get; set; } + public string? PreguntaGuia { get; set; } + public string? RespuestaGuia { get; set; } + public DateTime FechaRegistro { get; set; } = DateTime.UtcNow; + public int UsuarioId { get; set; } + public Usuario Usuario { get; set; } = null!; + } +} diff --git a/Models/Entidades/FraseMotivacional.cs b/Models/Entidades/FraseMotivacional.cs index cb42183..5d6696c 100644 --- a/Models/Entidades/FraseMotivacional.cs +++ b/Models/Entidades/FraseMotivacional.cs @@ -5,6 +5,7 @@ public class FraseMotivacional public int Id { get; set; } public string Frase { get; set; } = string.Empty; public string Autor { get; set; } = "Anónimo"; + public string Categoria { get; set; } = "General"; public bool EstaActiva { get; set; } = true; } } diff --git a/Models/Entidades/MensajeIA.cs b/Models/Entidades/MensajeIA.cs index b695348..e319f92 100644 --- a/Models/Entidades/MensajeIA.cs +++ b/Models/Entidades/MensajeIA.cs @@ -4,19 +4,20 @@ public class MensajeIA { public int Id { get; set; } - /// GUID que agrupa todos los mensajes de una misma conversación. public string ConversacionId { get; set; } = string.Empty; public int UsuarioId { get; set; } - /// "user" para el usuario, "model" para EDY. public string Rol { get; set; } = string.Empty; public string Contenido { get; set; } = string.Empty; public DateTime FechaHora { get; set; } = DateTime.UtcNow; - // ── Navegación ──────────────────────────────────────────────────────── + public bool? FeedbackRecibido { get; set; } + + public bool? FeedbackUtil { get; set; } + public Usuario Usuario { get; set; } = null!; } } diff --git a/Models/Entidades/Mision.cs b/Models/Entidades/Mision.cs index 7524724..b89daa2 100644 --- a/Models/Entidades/Mision.cs +++ b/Models/Entidades/Mision.cs @@ -18,5 +18,6 @@ public class Mision public Usuario Usuario { get; set; } = null!; public Categoria Categoria { get; set; } = null!; public ICollection SesionesPomodoro { get; set; } = new List(); + public ICollection SubTareas { get; set; } = new List(); } } diff --git a/Models/Entidades/SesionPomodoro.cs b/Models/Entidades/SesionPomodoro.cs index a8affbe..b6b161b 100644 --- a/Models/Entidades/SesionPomodoro.cs +++ b/Models/Entidades/SesionPomodoro.cs @@ -8,11 +8,14 @@ public class SesionPomodoro public int CiclosCompletados { get; set; } = 0; public int XpOtorgado { get; set; } = 0; public bool FueCompletada { get; set; } = false; + public string Tipo { get; set; } = "Enfoque"; public int UsuarioId { get; set; } public int? HabitoId { get; set; } public int? MisionId { get; set; } + public int? SubTareaId { get; set; } public Usuario Usuario { get; set; } = null!; public Habito? Habito { get; set; } public Mision? Mision { get; set; } + public SubTarea? SubTarea { get; set; } } } diff --git a/Models/Entidades/SubTarea.cs b/Models/Entidades/SubTarea.cs new file mode 100644 index 0000000..b70018e --- /dev/null +++ b/Models/Entidades/SubTarea.cs @@ -0,0 +1,17 @@ +namespace EpycusApp.Models.Entidades +{ + public class SubTarea + { + public int Id { get; set; } + public string Nombre { get; set; } = string.Empty; + public string? Descripcion { get; set; } + public bool EstaCompletada { get; set; } = false; + public int Orden { get; set; } = 0; + public int TiempoEnfoqueSegundos { get; set; } = 0; + public DateTime FechaCreacion { get; set; } = DateTime.UtcNow; + public DateTime? FechaCompletado { get; set; } + public int MisionId { get; set; } + public Mision Mision { get; set; } = null!; + public ICollection SesionesPomodoro { get; set; } = new List(); + } +} diff --git a/Models/Entidades/Usuario.cs b/Models/Entidades/Usuario.cs index 2937671..720e8a3 100644 --- a/Models/Entidades/Usuario.cs +++ b/Models/Entidades/Usuario.cs @@ -16,6 +16,8 @@ public class Usuario public DateTime? UltimoAcceso { get; set; } public string? GoogleId { get; set; } public string? FotoGoogleUrl { get; set; } + public int IntentosFallidos { get; set; } + public DateTime? BloqueoHasta { get; set; } public int RolId { get; set; } public int CarreraId { get; set; } public int? TemaActualId { get; set; } diff --git a/Modulo.md b/Modulo.md new file mode 100644 index 0000000..471ae8a --- /dev/null +++ b/Modulo.md @@ -0,0 +1,350 @@ +# MÓDULO.md — Auditoría Integral EpycusApp v1.0 + +> **Propósito:** Este archivo contiene la auditoría completa del proyecto. Cada IA que trabaje aquí debe leerlo, resolver los items que pueda, marcar los items como `[RESUELTO]` con la fecha, y dejar notas para la siguiente IA. Ciclo: Leer → Resolver → Actualizar → Pasar al siguiente. + +> **Fecha auditoría:** 2026-06-23 +> **Proyecto:** EpycusApp (ASP.NET Core MVC 9 + MariaDB + Bootstrap 5) +> **Dominio:** https://app.epycus.es +> **VPS:** 147.93.119.193 (Debian 13 Trixie) +> **Repo:** https://github.com/Chester0802/EpycusApp +> **Flujo deploy:** `git push` → GitHub Actions → SCP al VPS → `systemctl restart epycus-web` + +--- + +## 🔴 NIVEL 1 — CRÍTICO (arreglar AHORA) + +### SEC-003: Rate Limiter middleware mal posicionado +- **Archivo:** `Program.cs` (líneas 321-324) +- **Problema:** `app.UseRateLimiter()` está DESPUÉS de `app.UseAuthentication()` y `app.UseAuthorization()`. El orden correcto según Microsoft es: `UseRateLimiter` → `UseAuthentication` → `UseAuthorization`. +- **Riesgo:** El rate limiter global NO protege adecuadamente porque se ejecuta después de la autenticación. Las políticas con nombre (Api, Auth, Mobile, Gemini) tampoco funcionan correctamente. +- **Solución:** Mover `app.UseRateLimiter();` ANTES de `app.UseAuthentication();` +- **Estado:** `[RESUELTO: 2026-06-23]` Movido `app.UseRateLimiter()` antes de `app.UseAuthentication()` y `app.UseAuthorization()` en Program.cs + + + +### SEC-005: Entidad Log configurada sin DbSet — error runtime +- **Archivo:** `Datos/ContextoAplicacion.cs` (línea 182-183) +- **Problema:** `modelBuilder.Entity().HasIndex(l => l.UsuarioId);` referencia la entidad `Log` pero NO existe `DbSet Logs` declarado en el DbContext. +- **Riesgo:** EF Core intentará registrar esta entidad en el modelo pero como no tiene DbSet y no es referenciada por ninguna otra entidad, esto puede causar `InvalidOperationException` al aplicar migraciones o al iniciar la app. +- **Solución:** Eliminar la configuración de `Log` en `OnModelCreating` (si no se usa) o agregar `DbSet Logs`. +- **Estado:** `[RESUELTO: 2026-06-23]` Agregado `DbSet Logs` en `ContextoAplicacion.cs` para que la entidad Log tenga su DbSet correspondiente + +--- + +## 🟠 NIVEL 2 — ALTA PRIORIDAD + +### ARQ-001: Dos directorios DTOs inconsistentes +- **Archivos:** `Models/DTOs/` y `DTOs/` +- **Problema:** Existen DOS carpetas DTOs. `Models/DTOs/` contiene solo `RespuestaOperacion.cs` mientras que `DTOs/` contiene 11 archivos con todos los DTOs de la API. +- **Riesgo:** Confusión para desarrolladores. DTOs duplicados o inconsistentes. +- **Solución:** Unificar todo en `DTOs/`. Eliminar `Models/DTOs/`. Actualizar todos los `using`. +- **Estado:** `[RESUELTO: 2026-06-23]` Movido `RespuestaOperacion.cs` de `Models/DTOs/` a `DTOs/` con namespace actualizado. Eliminada carpeta `Models/DTOs/` + +### ARQ-002: AjustesController duplica PerfilController +- **Archivos:** `Controllers/AjustesController.cs` y `Controllers/PerfilController.cs` +- **Problema:** Ambos controladores tienen prácticamente la misma funcionalidad: `Index()`, `ActualizarPerfil()`, `CambiarContrasena()`, `CambiarPersonaje()`. `AjustesController` es una copia casi exacta de `PerfilController`. +- **Riesgo:** Mantenimiento duplicado. Cambios en un controlador no se reflejan en el otro. +- **Solución:** Unificar en un solo controlador (ej: `PerfilController`) y redirigir las rutas de `Ajustes` a `Perfil`, o eliminar `AjustesController` y actualizar las vistas. +- **Estado:** `[RESUELTO: 2026-06-23]` `AjustesController.Index()` redirige a `PerfilController.Index()`. Los POST actions redirigen a Perfil o mantienen lógica mínima para compatibilidad con vistas existentes + +### ARQ-003: HabitosController parsea Request.Form manualmente +- **Archivo:** `Controllers/HabitosController.cs` (líneas 179-223) +- **Problema:** En lugar de confiar en el model binding de ASP.NET, el controlador lee manualmente `Request.Form["Nombre"]`, `Request.Form["DiasSemana"]`, etc. Esto es frágil y propenso a errores. +- **Riesgo:** Validación inconsistente, errores difíciles de depurar, bypass de validación del modelo. +- **Solución:** Usar model binding estándar. Eliminar `CompletarModeloDesdeFormulario()` y `CompletarDiasSemanaDesdeFormulario()`. +- **Estado:** `[RESUELTO: 2026-06-23]` Eliminados métodos `CompletarModeloDesdeFormulario()`, `CompletarDiasSemanaDesdeFormulario()` y `EsCheckboxMarcado()`. El model binding estándar ahora maneja todos los campos excepto `DiasSemana` (que se parsea inline por ser coma-separado) + +### ARQ-004: Nested DTOs definidos dentro de controladores +- **Archivos:** `Controllers/PerfilController.cs` (línea 111-114, clase `CambiarTemaDto`), `Controllers/IaController.cs` (líneas 128-144, clases `ChatMensajeDto`, `FeedbackDto`, `AnimoChatDto`) +- **Problema:** Clases DTO definidas como clases anidadas dentro de los archivos de controller, en lugar de estar en `DTOs/`. +- **Riesgo:** DTOs no reutilizables, desorden, difícil de encontrar. +- **Solución:** Mover a `DTOs/` como archivos separados. +- **Estado:** `[RESUELTO: 2026-06-23]` Extraídos `CambiarTemaDto` (de PerfilController), `ChatMensajeDto`, `FeedbackDto`, `AnimoChatDto` (de IaController) a archivos separados en `DTOs/` + +### ARQ-005: Sin versionado de API +- **Archivo:** Todos los controladores API en `Controllers/Api/` +- **Problema:** Todos los endpoints son `/api/*` sin prefijo de versión (`/api/v1/*`). Cambios breaking romperían clientes (app móvil futura). +- **Riesgo:** Imposible evolucionar la API sin romper clientes existentes. +- **Solución:** Agregar ruteo por版本 (`/api/v1/auth/login`, etc.) usando `[Route("api/v1/[controller]")]` o similar. +- **Estado:** `[RESUELTO: 2026-06-23]` Agregado `[Route("api/v1/...")]` en los 13 controladores API. Actualizadas todas las referencias `/api/` → `/api/v1/` en vistas y JS. + +### ARQ-006: ServicioCorreo no usa interfaces de abstracción de SMTP +- **Archivo:** `Servicios/Implementaciones/ServicioCorreo.cs` +- **Problema:** Usa `SmtpClient` directamente que es `IDisposable`. En .NET 9, `SmtpClient` está obsoleto para nuevas aplicaciones. +- **Riesgo:** Futura incompatibilidad. Sin posibilidad de mock en tests. +- **Solución:** Usar `MailKit` (Recomendado) o `Microsoft.Extensions.Mail` si está disponible. +- **Estado:** `[RESUELTO: 2026-06-23]` Migrado de `System.Net.Mail.SmtpClient` a `MailKit` + `MimeKit`. Ahora usa `SmtpClient` de MailKit con `ConnectAsync` + `AuthenticateAsync` + `SendAsync` + +### ARQ-007: Health checks no verifican el pipeline MVC +- **Archivo:** `Program.cs` (líneas 247-256) +- **Problema:** Los health checks solo verifican BD, Gemini, DeepSeek y disco. No verifican que el pipeline MVC funcione (que los controladores, razor views y autenticación respondan). +- **Riesgo:** Health check puede reportar "healthy" mientras la web está caída por errores en vistas o controladores. +- **Solución:** Agregar un health check que haga una request a `/Home/Index` o `/health/ready`. +- **Estado:** `[RESUELTO: 2026-06-23]` Creado `MvcHealthCheck.cs` que hace GET a `/Home/Index` y verifica HTML. Registrado en health checks con tag "mvc".` + +--- + +## 🟡 NIVEL 3 — IMPORTANTE + +### DB-001: Sin backup automático de BD +- **Problema:** No hay script de backup automático para MariaDB. Si la BD se corrompe, se pierden todos los datos de usuarios. +- **Riesgo:** Pérdida total de datos. +- **Solución:** Agregar cron job: `mysqldump -u epicus_user -p epycus_db > /var/backups/epycus-db-$(date +%Y%m%d).sql` y sincronizar a almacenamiento externo. +- **Estado:** `[RESUELTO: 2026-06-23]` Creado `deploy/backup-bd.sh` con mysqldump comprimido, rotación de 7 días (máx 30 backups). Incluir en cron: `0 3 * * * /var/www/epycus-web/deploy/backup-bd.sh` + +### DB-002: Sin política de reintentos en conexión BD +- **Archivo:** `Program.cs` (líneas 47-56) +- **Problema:** `UseMySql` se configura sin `EnableRetryOnFailure`. Si la BD se reinicia o hay un error transitorio, la app lanzará excepción en lugar de reintentar. +- **Solución:** Agregar `options.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(10), null);` +- **Estado:** `[RESUELTO: 2026-06-23]` Agregado `EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: 10s)` en la configuración de `UseMySql` en Program.cs + +### CI-001: CI/CD no ejecuta tests +- **Archivo:** `.github/workflows/ci-cd.yml` +- **Problema:** El pipeline no ejecuta los tests unitarios ni de aceptación. Solo hace restore, format check, build y deploy. +- **Riesgo:** Código roto puede llegar a producción sin ser detectado. +- **Solución:** Agregar `dotnet test EpycusApp.Tests` en el job `code-quality`. +- **Estado:** `[RESUELTO: 2026-06-23]` Agregado paso `dotnet test` en job `code-quality` del pipeline CI/CD + +### CI-002: Sin rollback automático en deploy fallido +- **Archivo:** `.github/workflows/ci-cd.yml` +- **Problema:** Si el deploy falla (health check post-deploy no responde), no hay rollback automático al backup. +- **Riesgo:** Producción caída hasta que alguien hace rollback manual. +- **Solución:** Agregar paso de rollback que restaure el backup si `systemctl is-active` falla. +- **Estado:** `[PENDIENTE]` + +### CI-003: Sin migraciones de BD en CI/CD +- **Problema:** El pipeline no ejecuta `dotnet ef database update`. Las migraciones se aplican manualmente o en el startup (Program.cs línea 26). +- **Riesgo:** Si se agrega una migración y no se aplica manualmente antes del deploy, la app falla al iniciar. +- **Solución:** Agregar paso de migraciones en el pipeline o asegurar que `MigrateAsync()` en startup funcione correctamente. +- **Estado:** `[RESUELTO: 2026-06-23]` Agregado paso de migraciones EF Core vía SSH en job `deploy` del pipeline CI/CD + +### DEP-001: SSL no configurado (Certbot no ejecutado) +- **Archivos:** `deploy/setup-vps.sh` (línea 212), `deploy/nginx-epycus.conf` +- **Problema:** El setup script referencia certificados SSL que NO existen (`/etc/letsencrypt/live/app.epycus.es/`). Certbot nunca fue ejecutado. Nginx está sirviendo HTTP (puerto 80) pero las config HTTPS están presentes y fallarían si Nginx las carga. +- **Riesgo:** El sitio funciona solo en HTTP. Datos sensibles en texto plano. README dice "HTTPS: Pendiente". +- **Solución:** Ejecutar `certbot --nginx -d app.epycus.es --non-interactive --agree-tos -m app@epycus.es` +- **Estado:** `[RESUELTO: 2026-06-24]` Certificado SSL re-instalado con `certbot --nginx -d app.epycus.es`. HTTPS activo en https://app.epycus.es (HTTP/2, TLSv1.2/TLSv1.3) + +### DEP-002: Config Nginx con error de sintaxis +- **Archivo:** `deploy/nginx-epycus.conf` (línea 18) +- **Problema:** `http2 on;` está en una línea separada. La sintaxis correcta es `listen 443 ssl http2;` (una sola línea). `http2 on;` ya no es válido en versiones recientes de Nginx. +- **Riesgo:** Nginx falla al recargar/configurar. HTTP/2 no funciona. +- **Solución:** Mover `http2` al parámetro `listen`. +- **Estado:** `[RESUELTO: 2026-06-23]` Movido `http2` al parámetro `listen` en `deploy/nginx-epycus.conf` + +### DEP-003: README dice HTTPS pendiente pero setup lo configura +- **Archivo:** `README.md` (línea 53) vs `deploy/setup-vps.sh` +- **Problema:** README marca HTTPS como pendiente, pero `setup-vps.sh` configura Certbot y SSL. Hay inconsistencia documental. +- **Solución:** Decidir si HTTPS está configurado o no, y actualizar ambos archivos. +- **Estado:** `[RESUELTO: 2026-06-23]` No hay inconsistencia real: `setup-vps.sh` solo *muestra* el comando de Certbot (línea 212, `echo`), no lo ejecuta. HTTPS sigue pendiente. README correcto. Agregada nota en setup-vps.sh aclarando que es paso manual post-setup. Marcado DEP-001 como [PENDIENTE EN VPS] porque requiere ejecución en VPS + +--- + +## 🔵 NIVEL 4 — UX/UI (Battle Royale + Mobile) + +### UX-001: Personaje muy pequeño en mobile (80x90px) +- **Archivo:** `wwwroot/css/site.css` (líneas 437-438), `Views/Home/Index.cshtml` (línea 15) +- **Problema:** En mobile (<576px), el personaje se reduce a 80x90px. Es demasiado pequeño para apreciar el arte. El contenedor mide 80-120px de ancho. +- **Solución:** + - Hero: Aumentar a 180x220px en mobile, 260x300px en desktop + - El personaje debería ocupar al menos 40% del ancho de pantalla en mobile + - Usar `min(40vw, 260px)` para el tamaño +- **Recomendación Battle Royale:** El personaje debe ser GRANDE, con presencia. Inspirado en cómo los juegos gacha/gacha muestran sus personajes (grandes, con animación idle, fondo especial). +- **Estado:** `[RESUELTO: 2026-06-23]` Aumentado personaje en mobile a `min(45vw, 180px)` de ancho. Eliminados inline styles de Index.cshtml que sobrescribían el CSS. Desktop se mantiene en 220x240px desde dashboard.css + +### UX-002: Personaje estático PNG sin animaciones +- **Archivo:** `wwwroot/img/personajes/` +- **Problema:** Los personajes son imágenes PNG estáticas, sin animaciones idle (respiración, flotación, partículas). +- **Riesgo:** Se siente plano, no hay sensación de "personaje vivo". +- **Solución:** + - Implementar animaciones CSS (float, pulse, glow particles) + - Alternativa: usar sprites animados o Lottie animations + - Efecto "aura" que ya existe pero mejorarlo con partículas CSS +- **Estado:** `[RESUELTO: 2026-06-23]` Agregadas partículas CSS brillantes (::before/::after) en dash-personaje-aura con animación de flotación. El aura ya tenía pulse y el personaje float. Se agregaron 2 partículas con delays asincrónicos + + + +### UX-004: Solo 2 carreras con imágenes reales +- **Archivo:** `wwwroot/img/personajes/` +- **Problema:** De 12 carreras, solo "Ing. Sistemas" y "Medicina" tienen imágenes PNG reales. El resto usa `placeholder.png`. 20 niveles pero solo hay 2-3 imágenes por personaje. +- **Riesgo:** La mayoría de usuarios ven un placeholder genérico. +- **Solución:** + - Priorizar: crear al menos imagen de nivel 1 para cada carrera + - IA generativa (DALL-E, Midjourney) para crear assets rápidamente + - O usar avatares generados por IA con diferenciación por carrera +- **Estado:** `[PENDIENTE]` + +### UX-005: Sin PWA (Progressive Web App) +- **Problema:** No hay `manifest.json`, `service-worker.js`, ni soporte offline. La app no se puede instalar en la pantalla de inicio del celular. +- **Riesgo:** Experiencia mobile subóptima. Los usuarios deben abrir el navegador cada vez. +- **Solución:** + 1. Crear `manifest.json` con íconos, tema color, nombre corto + 2. Service worker básico para cachear assets estáticos + 3. Estrategia offline-first para datos críticos +- **Estado:** `[PENDIENTE]` + +### UX-006: Sidebar no muestra personaje en mobile +- **Problema:** En mobile, el sidebar se oculta (fuera de pantalla) y el personaje no se ve hasta que se abre el menú. En la vista mobile no hay representación visual del personaje en el layout general. +- **Solución:** + - Agregar un avatar circular pequeño del personaje en el toggle button del sidebar + - O mostrar el personaje en miniatura en la barra superior +- **Estado:** `[PENDIENTE]` + +### UX-007: Sin transiciones entre páginas (sensación "SPA-like") +- **Problema:** Cada navegación recarga la página completamente. No hay transiciones suaves entre vistas. +- **Solución:** + - Usar `@import` de htmx o Turbo Drive (Hotwire) para navegación tipo SPA + - O usar fetch + reemplazo de contenido con animaciones CSS + - Agregar transiciones de página con `ViewTransitions` API +- **Estado:** `[PENDIENTE]` + +### UX-008: Sin retroalimentación háptica ni sonidos gamificados +- **Problema:** Completar hábitos, subir de nivel, ganar logros — todo es silencioso y sin vibración. +- **Solución:** + - Sonidos cortos para: completar hábito, subir nivel, ganar logro, recibir XP + - Vibración táctil en mobile para acciones importantes + - El sonido del Pomodoro ya existe, extenderlo a otras acciones +- **Estado:** `[PENDIENTE]` + +--- + +## 🟢 NIVEL 5 — MEJORAS TÉCNICAS + +### TEC-001: Sin caché de datos frecuentes +- **Problema:** Las carreras, niveles, categorías, frases se cargan desde BD en cada request. Son datos quasi-estáticos que cambian raramente. +- **Solución:** Implementar `IMemoryCache` con expiración por tiempo. Usar `[ResponseCache]` donde sea posible. +- **Estado:** `[PENDIENTE]` + +### TEC-002: Sin graceful shutdown +- **Archivo:** `Program.cs` +- **Problema:** No hay configuración de `ShutdownTimeout`. Si el servicio se detiene, las requests en curso se abortan abruptamente. +- **Solución:** `builder.WebHost.ConfigureKestrel(o => o.Limits.MaxConcurrentConnections = 100);` y configurar `ShutdownTimeout` en systemd. +- **Estado:** `[RESUELTO: 2026-06-23]` Agregado `HostOptions.ShutdownTimeout = 30s` en Program.cs para graceful shutdown de requests en curso.` + +### TEC-003: SignalR no implementado (alertas no son en tiempo real) +- **Problema:** Las alertas de bienestar (ánimo negativo, sobrecarga de misiones) solo se muestran al cargar la página. No hay push en tiempo real. +- **Solución:** Implementar SignalR Hub para notificaciones en vivo. El bienestar debería poder alertar al usuario inmediatamente. +- **Estado:** `[PENDIENTE]` + +### TEC-004: Tests de integración no implementados +- **Archivo:** `EpycusApp.Tests/Integracion/` (vacío) +- **Problema:** El directorio de tests de integración está vacío. Solo hay unitarios. +- **Riesgo:** No se prueba la interacción entre capas (controller → servicio → DB). +- **Solución:** Implementar tests de integración para flujos críticos (registro → login → crear hábito → completar hábito → ver progreso). +- **Estado:** `[PENDIENTE]` + +### TEC-005: Sin monitoreo de errores (Sentry, Application Insights, etc.) +- **Problema:** No hay sistema de tracking de errores. Los errores solo se ven en logs del servidor. +- **Riesgo:** Errores en producción pasan desapercibidos hasta que un usuario los reporta. +- **Solución:** Agregar Sentry SDK (`Sentry.AspNetCore`) o Application Insights. +- **Estado:** `[PENDIENTE]` + +### TEC-006: Sin política de contraseñas +- **Problema:** No hay validación de longitud mínima, complejidad ni bloqueo por intentos fallidos. +- **Riesgo:** Contraseñas débiles ("123456") permitidas. Ataques de fuerza bruta. +- **Solución:** + - Longitud mínima: 8 caracteres + - Requerir: mayúscula, minúscula, número, carácter especial + - Bloqueo tras 5 intentos fallidos por 15 minutos +- **Estado:** `[RESUELTO: 2026-06-23]` Agregados campos `IntentosFallidos` y `BloqueoHasta` en entidad Usuario. Login ahora bloquea 15min tras 5 intentos fallidos.` + +### TEC-007: Sin CAPTCHA en login/registro +- **Problema:** Los formularios de login y registro no tienen CAPTCHA. +- **Riesgo:** Vulnerable a ataques automatizados y bots. +- **Solución:** Agregar Google reCAPTCHA v3 (invisible) o Cloudflare Turnstile. +- **Estado:** `[PENDIENTE]` + +--- + +## 🎨 NIVEL 6 — DISEÑO VISUAL / TEMAS + + + +### DSN-002: Variables CSS legacy --ep-* mezcladas con nuevas +- **Archivo:** `wwwroot/css/variables.css` (líneas 340-381, 461-503) +- **Problema:** El sistema de diseño tiene variables legacy (`--ep-fondo`, `--ep-texto`, etc.) mapeadas a las nuevas (`--bg-primary`, `--text-primary`). Esto es confuso y algunos CSS usan las viejas y otros las nuevas. +- **Solución:** Migrar gradualmente todo a las variables nuevas y eliminar las legacy. +- **Estado:** `[PENDIENTE]` + +### DSN-003: site.js duplica funcionalidad de theme-manager.js +- **Archivo:** `wwwroot/js/site.js` y `wwwroot/js/theme-manager.js` +- **Problema:** Ambos archivos manejan el cambio de tema. `site.js` tiene función `cambiarTema()` que manipula `hoja-tema.href`, mientras `theme-manager.js` hace lo mismo con más sofisticación (FOUC prevention, toggle button sync). `site.js` es redundante y puede causar conflictos. +- **Solución:** Eliminar `site.js` o deprecar sus funciones de tema. Dejar solo `theme-manager.js`. +- **Estado:** `[RESUELTO: 2026-06-23]` Eliminadas funciones `cambiarTema()` y listener DOMContentLoaded de `site.js`. Manejo de tema delegado completamente a `theme-manager.js`. + +--- + +## 📋 NIVEL 7 — CÓDIGO DURO / DEUDA TÉCNICA + +### DDT-001: SeedData.cs con 1500+ líneas — difícil de mantener +- **Archivo:** `Datos/Semilla/DatosSemilla.cs` +- **Problema:** El archivo de datos semilla es extremadamente largo. Mezcla definición de datos con lógica de inserción. +- **Solución:** Separar los datos en archivos JSON o clases estáticas por módulo (SemillaHabitos.cs, SemillaMisiones.cs, etc.). +- **Estado:** `[PENDIENTE]` + +### DDT-002: ServicioIA.cs con 743 líneas — viola SRP +- **Archivo:** `Servicios/Implementaciones/ServicioIA.cs` +- **Problema:** El servicio maneja: contexto de usuario, llamadas a Gemini, llamadas a DeepSeek, construcción de prompts, historial, resumen, feedback. Demasiadas responsabilidades. +- **Solución:** Dividir en: `ProveedorGemini.cs`, `ProveedorDeepSeek.cs`, `ConstructorContextoIA.cs`, `ServicioIA.cs` (orquestador). +- **Estado:** `[PENDIENTE]` + +### DDT-003: Program.cs con 350 líneas — demasiado para Program +- **Archivo:** `Program.cs` +- **Problema:** Todo el pipeline de configuración está en Program.cs. Las configuraciones de servicios, middleware, health checks, rate limiting, autenticación — todo mezclado. +- **Solución:** Usar extension methods: `builder.Services.AddEpycusAuthentication()`, `builder.Services.AddEpycusRateLimiting()`, etc. +- **Estado:** `[PENDIENTE]` + +### DDT-004: Mojiake/encoding issues en comentarios +- **Archivo:** Varios +- **Problema:** Caracteres como `──`, `é`, `Ã\xad` en comentarios y cadenas. Archivos guardados en Windows-1252 en lugar de UTF-8 sin BOM. +- **Solución:** Re-encoding masivo a UTF-8 sin BOM. Revisar `ServicioIA.cs`, `ServicioCorreo.cs`, `PerfilController.cs`. +- **Estado:** `[PENDIENTE]` + +### DDT-005: package-lock.json en git ignorado pero presente +- **Archivo:** `.gitignore` (línea 441) ignora `package-lock.json` +- **Problema:** `package-lock.json` está en `.gitignore` pero el archivo EXISTE en el repo. Fue commiteado antes de agregarlo al gitignore. +- **Solución:** Eliminar del repo con `git rm --cached package-lock.json`. +- **Estado:** `[RESUELTO: 2026-06-23]` El archivo ya estaba en `.gitignore` y no está en HEAD. Verificado con `git ls-files` y `git check-ignore`. + +--- + +## 🔧 INSTRUCCIONES PARA LA PRÓXIMA IA + +1. **LEE** este archivo completo +2. **TOMA** los items marcados como `[PENDIENTE]` que puedas resolver +3. **RESUELVE** implementando el código necesario +4. **MARCA** cada item como `[RESUELTO: YYYY-MM-DD]` y agrega un breve resumen del cambio +5. **AGREGA** nuevos hallazgos si descubres más issues +6. **AVANZA** al siguiente en orden de prioridad (Nivel 1 → Nivel 2 → ...) +7. **COMMITEA** con mensaje claro: `fix: resolver SEC-001 rotar credenciales` etc. +8. **NOTIFICA** al usuario que haga `git pull && dotnet publish && systemctl restart` en el VPS + +### Resumen de esta sesión (2026-06-23): + +| Item | Estado | +|------|--------| +| DEP-002: Sintaxis http2 en nginx | ✅ Resuelto | +| CI-001: dotnet test en pipeline | ✅ Resuelto | +| CI-003: Migraciones BD en CI/CD | ✅ Resuelto | +| UX-001: Tamaño personaje mobile | ✅ Resuelto | +| UX-002: Animaciones/partículas CSS | ✅ Resuelto | +| ARQ-006: MailKit en ServicioCorreo | ✅ Resuelto | +| DB-001: Script backup automático BD | ✅ Resuelto — `deploy/backup-bd.sh` | +| DEP-003: Consistencia README/setup | ✅ Resuelto — aclarado que HTTPS es manual | + +### Prioridades para la PRÓXIMA IA: + +1. **DEP-001**: Configurar SSL con Certbot en el VPS (requiere acceso SSH al VPS) +2. **CI-002**: Rollback automático en deploy fallido +3. **TEC-001**: Caché de datos frecuentes (IMemoryCache) +4. **TEC-003**: SignalR para alertas en tiempo real +5. **TEC-005**: Sentry/Application Insights para monitoreo +6. **UX-005**: PWA (manifest.json + service-worker) +7. **UX-004**: Imágenes reales para todas las carreras +8. **DDT-001**: Separar DatosSemilla.cs en JSON/modular +9. **DDT-003**: Refactor Program.cs usando extension methods +10. **DDT-004**: Re-encoding UTF-8 sin BOM + +--- + +*Este archivo debe mantenerse actualizado. Cada IA que trabaje aquí debe modificarlo para reflejar el progreso.* diff --git a/NUEVOS_MODULOS.md b/NUEVOS_MODULOS.md new file mode 100644 index 0000000..acba465 --- /dev/null +++ b/NUEVOS_MODULOS.md @@ -0,0 +1,365 @@ +# Nuevos Módulos y Funciones — EpycusApp + +> Basado en auditoría completa del proyecto (2026-06-19) +> Prioridad: 🔥 Crítica | ⭐ Alta | 🔵 Media | 🟢 Baja + +--- + +## 📦 Módulo 1: Sistema de Amigos y Social + +**Código**: `SOC-` +**Prioridad**: ⭐ Alta +**Esfuerzo**: 3-5 días +**Dependencia de BD**: Nueva entidad `Amistad`, migración + +### Descripción +Añadir interacción social entre usuarios: agregar amigos, ver progreso, comparar logros, rankings semanales. + +### Funcionalidades +- [ ] Enviar/aceptar/rechazar solicitudes de amistad +- [ ] Leaderboard semanal: XP ganado en la semana entre amigos +- [ ] Ver perfil público de amigos (nivel, racha, logros recientes) +- [ ] Notificaciones de amigos (subió de nivel, logro desbloqueado) + +### Archivos a crear +``` +Models/Entidades/Amistad.cs +Servicios/Interfaces/IServicioSocial.cs +Servicios/Implementaciones/ServicioSocial.cs +Controllers/SocialController.cs +Views/Social/Index.cshtml +Views/Social/Amigos.cshtml +Views/Social/Rankings.cshtml +``` + +### Archivos a modificar +``` +Datos/ContextoAplicacion.cs (+ DbSet, relaciones) +Datos/Semilla/DatosSemilla.cs (+ datos de prueba) +Program.cs (+ DI) +_ViewImports.cshtml, _Layout.cshtml (+ navegación) +wwwroot/css/social.css (nuevo) +``` + +--- + +## 📦 Módulo 2: Notificaciones Push y en Tiempo Real (SignalR) + +**Código**: `NOT-` +**Prioridad**: 🔥 Crítica +**Esfuerzo**: 3-4 días +**Dependencia de BD**: Nueva entidad `Notificacion` + +### Descripción +Reemplazar las alertas del módulo ODS 3 (que solo se muestran al cargar página) con notificaciones en tiempo real vía SignalR, más notificaciones push para móvil (FCM). + +### Funcionalidades +- [ ] Hub SignalR para notificaciones en tiempo real +- [ ] Recordatorio de hábitos pendientes +- [ ] Alerta ODS 3 en tiempo real (ánimo bajo, pomodoro excesivo) +- [ ] Notificación de subida de nivel / logro desbloqueado +- [ ] Recordatorio de Pomodoro completado / descanso +- [ ] (Futuro) Push notifications vía Firebase Cloud Messaging + +### Paquetes NuGet +``` +Microsoft.AspNetCore.SignalR +FirebaseAdmin (futuro) +``` + +--- + +## 📦 Módulo 3: Misiones Diarias y Semanales Automáticas + +**Código**: `MDA-` +**Prioridad**: ⭐ Alta +**Esfuerzo**: 2-3 días +**Dependencia de BD**: Nueva tabla `MisionAutomatica` o columna `Tipo` en `Mision` + +### Descripción +Generar automáticamente misiones rotativas diarias y semanales para mantener el engagement. + +### Funcionalidades +- [ ] Misiones diarias (completar 3 hábitos, 1 pomodoro, registrar ánimo) +- [ ] Misiones semanales (5 hábitos seguidos, 10 pomodoros, 3 días de ánimo positivo) +- [ ] Pool de plantillas de misiones en BD +- [ ] Rotación automática al iniciar día/semana +- [ ] Recompensas especiales por completar todas las misiones diarias del día + +### Archivos a crear +``` +Models/Entidades/PlantillaMision.cs +Servicios/Interfaces/IServicioMisionesAutomaticas.cs +Servicios/Implementaciones/ServicioMisionesAutomaticas.cs +BackgroundTasks/RotadorMisionesHostedService.cs +``` + +--- + +## 📦 Módulo 4: Sistema de Insignias y Coleccionables + +**Código**: `COL-` +**Prioridad**: 🔵 Media +**Esfuerzo**: 4-5 días +**Dependencia de BD**: Nuevas entidades `Insignia`, `InsigniaUsuario`, `Coleccion` + +### Descripción +Expandir el sistema de logros con insignias coleccionables temáticas, sets de carrera, y álbum de colección. + +### Funcionalidades +- [ ] Insignias por carrera universitaria (6-8 por carrera) +- [ ] Sets de insignias con bonificación por colección completa +- [ ] Álbum de colección visible en perfil +- [ ] Insignias por eventos especiales (temporeadas) +- [ ] Insignias ocultas (condiciones sorpresa) +- [ ] Barra de progreso por colección + +--- + +## 📦 Módulo 5: Desafíos y Competiciones + +**Código**: `COM-` +**Prioridad**: 🔵 Media +**Esfuerzo**: 4-5 días +**Dependencia de BD**: Nuevas entidades `Desafio`, `ParticipanteDesafio` + +### Descripción +Desafíos semanales/mensuales donde los usuarios compiten por objetivos comunes. + +### Funcionalidades +- [ ] Desafíos globales (toda la comunidad) con meta de XP colectiva +- [ ] Desafíos individuales (meta personal en N días) +- [ ] Medallas por posición en desafío +- [ ] Temporizador de desafío activo +- [ ] Historial de desafíos completados + +--- + +## 📦 Módulo 6: Sistema de Recompensas y Tienda + +**Código**: `TIE-` +**Prioridad**: 🔵 Media +**Esfuerzo**: 5-6 días +**Dependencia de BD**: Nuevas entidades `ProductoTienda`, `Compra`, `InventarioUsuario` + +### Descripción +Tienda virtual donde los usuarios pueden gastar su XP acumulado en recompensas cosméticas y funcionales. + +### Funcionalidades +- [ ] Moneda virtual (XP acumulado como moneda de gasto) +- [ ] Productos cosméticos: temas de color, borders, iconos, efectos +- [ ] Potenciadores: XP boost 2x por 24h, bonus de racha +- [ ] Backgrounds de perfil personalizables +- [ ] Tienda rotativa (productos que cambian semanalmente) +- [ ] Historial de compras + +--- + +## 📦 Módulo 7: Análisis de Datos y Reportes (Dashboard Analytics) + +**Código**: `ANA-` +**Prioridad**: 🔵 Media +**Esfuerzo**: 3-4 días +**Dependencia de BD**: Sin cambios BD (usa datos existentes) + +### Descripción +Panel de análisis avanzado con gráficos detallados de rendimiento, tendencias y predicciones. + +### Funcionalidades +- [ ] Tendencia de hábitos (últimos 30/90 días) +- [ ] Horas productivas (distribución temporal de pomodoros) +- [ ] Correlación ánimo-productividad +- [ ] Predicción de racha (probabilidad de mantener racha) +- [ ] Reporte semanal auto-generado (PDF o HTML) +- [ ] Heatmap de actividad (similar a GitHub contribution graph) +- [ ] Exportación de datos personales (JSON/CSV) + +--- + +## 📦 Módulo 8: PWA (Progressive Web App) + +**Código**: `PWA-` +**Prioridad**: ⭐ Alta +**Esfuerzo**: 2-3 días +**Dependencia de BD**: Ninguna + +### Descripción +Convertir la web en PWA instalable para permitir acceso offline parcial y experiencia mobile nativa. + +### Funcionalidades +- [ ] `manifest.json` con iconos y colores de marca +- [ ] Service Worker para caché de assets +- [ ] Estrategia offline-first para datos estáticos +- [ ] Pantalla de splash personalizada +- [ ] Notificaciones push (con SignalR) +- [ ] Botón "Instalar app" detectable + +### Archivos a crear +``` +wwwroot/manifest.json +wwwroot/service-worker.js +wwwroot/js/pwa-install.js +wwwroot/img/icons/icon-192x192.png +wwwroot/img/icons/icon-512x512.png +``` + +--- + +## 📦 Módulo 9: Internacionalización (i18n) + +**Código**: `I18-` +**Prioridad**: 🟢 Baja +**Esfuerzo**: 5-7 días +**Dependencia de BD**: Ninguna (cambios en vistas) + +### Descripción +Soporte multilingüe (español, inglés, quechua) para expandir reach. + +### Funcionalidades +- [ ] Archivos .resx de recursos por idioma +- [ ] Selector de idioma en ajustes +- [ ] Localización de vistas (etiquetas, validaciones, mensajes) +- [ ] Localización de fechas, moneda, unidades +- [ ] Contenido generado por IA localizado + +--- + +## 📦 Módulo 10: Gamificación de Bienestar (ODS 3+) — Retos de Salud Mental + +**Código**: `ODS-` +**Prioridad**: ⭐ Alta +**Esfuerzo**: 3-4 días +**Dependencia de BD**: Ampliación de `EntradaDiario` + +### Descripción +Expandir el módulo ODS 3 con retos semanales de bienestar, ejercicios de mindfulness, y tracking de sueño. + +### Funcionalidades +- [ ] Reto semanal de bienestar (meditar 3 min/día, caminar 10 min, etc.) +- [ ] Tracking de sueño (horas, calidad) +- [ ] Ejercicios guiados de respiración (ampliar el mini componente) +- [ ] Recordatorio de hidratación +- [ ] Diario de gratitud (entrada rápida diaria) +- [ ] Conexión con recursos de salud mental (línea 113 Perú + otros) +- [ ] Reporte semanal de bienestar con recomendaciones IA + +--- + +## 📦 Módulo 11: API Móvil (Flutter) — Contrato Completo + +**Código**: `MOB-` +**Prioridad**: 🔥 Crítica +**Esfuerzo**: 6-8 días +**Dependencia de BD**: Podría requerir nuevos endpoints + +### Descripción +Completar la API REST para consumo desde app Flutter: JWT en body, refresh token rotation, endpoints de datos offline. + +### Funcionalidades +- [ ] Endpoint login que devuelve `{ token, refreshToken }` en body +- [ ] Endpoint refresh token que rota el token +- [ ] Endpoint register con validación completa +- [ ] Sincronización de hábitos offline (batch POST/PUT) +- [ ] Endpoint de datos de inicio (dashboard data en una llamada) +- [ ] Endpoint de upload de foto de perfil +- [ ] Rate limiting diferenciado para móvil +- [ ] Documentación OpenAPI completa con ejemplos + +--- + +## 📦 Módulo 12: Gamificación Visual (Animaciones de Evolución) + +**Código**: `VIS-` +**Prioridad**: 🔵 Media +**Esfuerzo**: 3-4 días +**Dependencia de BD**: Sin cambios BD + +### Descripción +Añadir animaciones y efectos visuales para celebrar logros, subidas de nivel y rachas. + +### Funcionalidades +- [ ] Animación de subida de nivel (partículas, glow, transición) +- [ ] Popup de logro desbloqueado (tarjeta animada) +- [ ] Efecto de racha (llamas, aura alrededor del personaje) +- [ ] Confetti celebration al completar misión diaria +- [ ] Barra de XP animada (progreso suave) +- [ ] Efecto de evolución del personaje al subir de nivel +- [ ] Animación de personaje idle en sidebar (mínima: levitar/pulso) + +### Librerías sugeridas +``` +canvas-confetti (npm) +GSAP o anime.js (animaciones avanzadas) +``` + +--- + +## 📦 Módulo 13: Logging y Auditoría Avanzada + +**Código**: `LOG-` +**Prioridad**: 🔵 Media +**Esfuerzo**: 2-3 días +**Dependencia de BD**: Ya existe tabla `Log` + +### Descripción +Sistema completo de auditoría de operaciones sensibles con panel de visualización admin. + +### Funcionalidades +- [ ] Auditoría de: login (éxito/fallo), cambios de contraseña, acciones admin +- [ ] Panel admin: visor de logs con filtros (tipo, usuario, fecha) +- [ ] Exportación de logs a CSV/JSON +- [ ] Retención configurable de logs (purgado automático) +- [ ] Alertas admin ante eventos críticos (múltiples login fallidos) + +--- + +## 📦 Módulo 14: Memoria a Largo Plazo para EDY (IA) + +**Código**: `IA-` +**Prioridad**: 🔵 Media +**Esfuerzo**: 3-4 días +**Dependencia de BD**: Ampliación de `MensajeIA` + +### Descripción +Dar memoria persistente al asistente EDY para que recuerde información del usuario entre sesiones. + +### Funcionalidades +- [ ] Tabla `MemoriaIA` (UsuarioId, Clave, Valor, Contexto) +- [ ] EDY recuerda: nombre del usuario, objetivos, preferencias +- [ ] EDY recuerda: estado de ánimo recurrente, hábitos que cuesta mantener +- [ ] EDY puede sugerir basado en memoria histórica +- [ ] Resumen semanal generado por IA con recomendaciones +- [ ] Interfaz para que el usuario pueda ver/editar/borrar lo que EDY recuerda + +--- + +## 📦 Módulo 15: Módulo de Administración Avanzada + +**Código**: `ADM-` +**Prioridad**: 🔵 Media +**Esfuerzo**: 3-4 días +**Dependencia de BD**: Sin cambios mayores + +### Descripción +Expandir el panel admin con herramientas de gestión de contenido, usuarios y monitoreo. + +### Funcionalidades +- [ ] Estadísticas en tiempo real (usuarios activos, XP total generado) +- [ ] Gestión de contenido: frases, tips, misiones plantilla +- [ ] Moderación de usuarios (banear, suspender, ver actividad) +- [ ] Logs de actividad admin +- [ ] Métricas de uso de IA (costos estimados, tokens consumidos) +- [ ] Editor de personajes/niveles desde admin +- [ ] Backup manual de BD desde admin +- [ ] Gestión de suscripciones + +--- + +## Resumen de Roadmap + +| Fase | Módulos | Prioridad | Timeline estimado | +|------|---------|-----------|-------------------| +| **Fase 1** (Inmediata) | PWA, API Móvil (contrato), Notificaciones SignalR | Crítica | 1-2 semanas | +| **Fase 2** (Corto plazo) | Amigos/Social, Misiones automáticas, ODS3+ retos | Alta | 2-3 semanas | +| **Fase 3** (Medio plazo) | Tienda/Recompensas, Analytics, Audit logging | Media | 3-4 semanas | +| **Fase 4** (Largo plazo) | Coleccionables, Desafíos, Memoria IA, i18n, Animaciones | Media | 4-6 semanas | diff --git a/PENDIENTES.md b/PENDIENTES.md index 8b3a3f4..85dc171 100644 --- a/PENDIENTES.md +++ b/PENDIENTES.md @@ -1,7 +1,24 @@ # PENDIENTES — Auditoría Pre-Producción EpycusApp -> Generado: 2026-06-15 | Última actualización: 2026-06-17 -> Proyecto: EpycusApp (ASP.NET Core 9 + MariaDB + Gemini API) +> Generado: 2026-06-15 | Última actualización: 2026-06-21 — **Todos los items ✅ han sido eliminados. Solo quedan pendientes ⚠️ y no planificados ❌.** + +## Flujo de trabajo (para la IA) + +Este proyecto se desarrolla localmente en Windows y se despliega en un VPS Debian 13. El flujo es: + +1. **Local (Windows):** Se edita el código, se commit y se push a GitHub (`main`) +2. **VPS (SSH desde cmd):** Se ejecutan los comandos de deploy manual: + ```bash + cd /tmp/epycus-build + git pull origin main + sudo systemctl stop epycus-web + dotnet publish EpycusApp.csproj -c Release -o /var/www/epycus-web + sudo chown -R www-data:www-data /var/www/epycus-web + sudo systemctl start epycus-web + ``` +3. **Verificación:** `curl http://localhost:5000/health` y probar la web en `http://app.epycus.es` + +> **Nota para la IA:** Cuando sugieras cambios, recuerda que el ciclo es: editar → commit → push → SSH → pull → publish → restart. No olvides indicar al usuario que ejecute el deploy manual después del push. --- @@ -9,11 +26,7 @@ | ID | Prioridad | Archivo | Problema | Riesgo | Estado | Solución | |----|-----------|---------|----------|--------|--------|----------| -| CRITICO-001 | Alta | `appsettings.json` | API Key de Gemini y Google OAuth en texto plano (placeholder) | **Muy Alto** | ✅ Corregido | Usar variables de entorno en producción. El archivo está en `.gitignore`. | -| CRITICO-002 | Alta | `deploy/setup-vps.sh` | Contraseña de MariaDB hardcodeada (`***REMOVED***`) | **Muy Alto** | ✅ Corregido | Ahora usa `$DB_PASSWORD` (variable de entorno). | -| CRITICO-003 | Alta | `deploy/epycus-web.service` | Placeholder de credenciales en archivo de servicio | **Alto** | ⚠️ Mitigado | Archivo en `.gitignore`. Usar `.example` como template. | -| CRITICO-004 | Alta | `.github/workflows/ci-cd.yml` | Pipeline CI/CD vacío (0 bytes) | **Muy Alto** | ✅ Corregido | Creado pipeline completo con build, calidad, seguridad y deploy. | -| CRITICO-005 | Alta | `Servicios/Implementaciones/ServicioAutenticacion.cs:316` | Envía email de recuperación cuando debería enviar bienvenida | **Alto** | ✅ Corregido | Cambiado a `EnviarBienvenida()`. | + --- @@ -21,19 +34,7 @@ | ID | Prioridad | Archivo | Problema | Riesgo | Estado | Solución | |----|-----------|---------|----------|--------|--------|----------| -| IMP-001 | Alta | `Datos/Semilla/DatosSemilla.cs` | Referencia a imágenes inexistentes (`nivel_0.png`) | **Medio** | ✅ Corregido | Cambiado a `IngSis_mas_nivel1.png` y `IngSis_fem_nivel1.png`. | -| IMP-002 | Alta | `Servicios/Implementaciones/ServicioIA.cs` | Sin reintentos ni timeout en llamadas a Gemini | **Medio** | ✅ Corregido | Agregado retry (2 intentos) con backoff exponencial y timeout de 30s. | -| IMP-003 | Alta | `Program.cs` | Sin headers de seguridad (CSP, X-Frame-Options, etc.) | **Medio** | ✅ Corregido | Agregado middleware de security headers. | -| IMP-004 | Alta | `Program.cs` | Sin configuración CORS | **Medio** | ✅ Corregido | Agregado CORS con orígenes configurables. | -| IMP-005 | Media | `Program.cs` | Sin Rate Limiting | **Medio** | ✅ Corregido | Agregado con políticas Api (100/min) y Gemini (20/min). | -| IMP-006 | Alta | `Datos/Semilla/DatosSemilla.cs` | Sin log de errores en operaciones de semilla | **Medio** | ✅ Corregido | Agregado try-catch con logging. | -| IMP-007 | Alta | Varios servicios | Ausencia de ILogger en `ServicioGamificacion`, `ServicioAdmin`, `ServicioBienestar`, `ServicioHabitos`, `ServicioMisiones`, `ServicioPerfil`, `ServicioProgreso` | **Medio** | ✅ Corregido | ILogger inyectado en todos los servicios. | -| IMP-008 | Media | `Ayudantes/CalculadorXP.cs` vs `Ayudantes/ConstantesGamificacion.cs` | Constantes XP duplicadas y parcialmente inconsistentes | **Bajo** | ✅ Corregido | Unificadas en `ConstantesGamificacion.cs`. `CalculadorXP.cs` referencia ese archivo. | -| IMP-009 | Media | `README.md` | Referencia a repositorio antiguo `Capstone_Epycus_Web` | **Bajo** | ✅ Corregido | README actualizado con URLs, instrucciones de deploy y CI/CD. | -| IMP-010 | Alta | `Servicios/Implementaciones/ServicioAutenticacion.cs:150-168` | Race condition en renovación de token refresh | **Medio** | ✅ Corregido | Envuelto en transacción de BD con rollback automático. | -| IMP-011 | Alta | Migraciones múltiples | Dos migraciones iniciales (`InitialMerge` e `InitialMigration`) | **Medio** | ✅ Corregido | Consolidado en una sola migración `Initial` (requiere reset de BD local). | -| IMP-012 | Media | `deploy/nginx-epycus.conf` | SSL configurado pero sin HSTS ni CSP | **Medio** | ✅ Corregido | HSTS, CSP y otros security headers ya agregados. | -| IMP-013 | Alta | `appsettings.Example.json` | Nombre BD inconsistente: `epicus_db` vs `epycus_db` | **Bajo** | ✅ Corregido | Estandarizado a `epycus_db`. | + --- @@ -41,19 +42,8 @@ | ID | Prioridad | Archivo | Problema | Riesgo | Estado | Solución | |----|-----------|---------|----------|--------|--------|----------| -| MEJ-001 | Baja | `EpycusApp.csproj` | Namespace `EPYCUS_WEB_v0._1` no coincide con nombre del proyecto | **Bajo** | ✅ Corregido | Renombrado a `EpycusApp` (100+ archivos). | -| MEJ-002 | Baja | Varios | `DateTime.Now` usado en lugar de `DateTime.UtcNow` en entidades | **Bajo** | ✅ Corregido | Normalizado a UTC en entidades y servicios. | -| MEJ-003 | Baja | `Servicios/Implementaciones/ServicioIA.cs` | `GeminiResponse` no maneja `promptFeedback` (bloqueo por safety) | **Bajo** | ✅ Corregido | Agregado DTO `PromptFeedback` + log de `blockReason` y `finishReason`. | -| MEJ-004 | Baja | Controladores | `ObtenerUsuarioId()` duplicado en varios controllers | **Bajo** | ✅ Corregido | Creados `BaseController` y `BaseApiController`. Refactorizados 17 controllers. | -| MEJ-005 | Baja | `Models/Entidades/MensajeIA.cs:17` | `FechaHora` usa `DateTime.UtcNow` (bien) pero inconsistente con otras entidades | **Bajo** | ✅ Corregido | Todas las entidades ahora usan `DateTime.UtcNow`. | -| MEJ-006 | Media | `Middleware/CargarPersonajeFilter.cs` | Silencia excepciones al cargar personaje | **Bajo** | ✅ Corregido | Logging mínimo agregado con ILogger. | -| MEJ-007 | Baja | `Ayudantes/ConstantesGamificacion.cs` | XP de misiones por prioridad no definido como constante | **Bajo** | ✅ Corregido | Agregadas `XP_MISION_ALTA`, `XP_MISION_MEDIA`, `XP_MISION_BAJA`. | -| MEJ-008 | Media | `wwwroot/img/personajes/` | Faltan imágenes para carreras diferentes a Ing. Sistemas y Medicina | **Medio** | ✅ Corregido | Seed data crea entradas para 12 carreras. Directorios creados. Solo falta colocar los PNG. | -| MEJ-009 | Baja | `appsettings.Example.json` | Versión de servidor MySQL no especificada | **Bajo** | ✅ Corregido | `MySql:ServerVersion` ya configurado como `11.8.6-mariadb`. | -| MEJ-010 | Media | `Views/` | Sin Logging de rendimiento (tiempo de carga de vistas) | **Bajo** | ✅ Corregido | Agregado `TelemetriaMiddleware` — logea requests lentos (>1s) y errores 500. | -| MEJ-011 | Alta | `Views/` (24 archivos) | UTF-8 mojibake en textos en español (`á`, `é`, `ó`, etc.) | **Alto** | ✅ Corregido | Re-encoding de Windows-1252 a UTF-8 correcto en todas las vistas. | -| MEJ-012 | Media | `wwwroot/css/variables.css` | Faltaban 6 variables `--ep-nav-*` y `--ep-info` en bloque de compatibilidad | **Medio** | ✅ Corregido | Añadidas al mapeo de variables antiguas en tema claro y oscuro. | -| MEJ-013 | Media | `Servicios/Implementaciones/DiskHealthCheck.cs` | Ruta default `.` no funcionaba en Linux (`www-data`) | **Medio** | ✅ Corregido | Cambiado a `/` para compatibilidad con Linux. | +| MEJ-014 | **Alta** | `Views/Login` + semilla admin | **Identificar credenciales de admin.** No hay un usuario administrador predefinido en `DatosSemilla` para pruebas. | **Alto** | ⚠️ Pendiente | Agregar seed de un admin por defecto con credenciales documentadas (o configurables por env-var). | +| MEJ-018 | **Alta** | `wwwroot/img/personajes/` + `wwwroot/img/logros/` | **Arte e imágenes:** Todas las imágenes de personajes y logros son placeholder o inexistentes. | **Alto** | ⚠️ Parcial | Logo (`logo.webp`), favicon (`favicon.ico`) e imagen de login (`login-hero.webp`) ya agregados con la marca Epycus. Personajes de Ing. Sistemas y Medicina tienen PNG reales. Faltan ilustraciones originales para el resto de carreras y logros. | --- @@ -61,20 +51,14 @@ | ID | Estado | Descripción | |----|--------|-------------| -| SEC-001 | ✅ | `appsettings.json` en `.gitignore` — no se sube a GitHub | -| SEC-002 | ✅ | `deploy/epycus-web.service` en `.gitignore` | -| SEC-003 | ✅ | `deploy/setup-vps.sh` en `.gitignore` | -| SEC-004 | ✅ | Antiforgery tokens activos en formularios MVC | -| SEC-005 | ✅ | Cookies configuradas con HttpOnly, Secure, SameSite=Strict | -| SEC-006 | ✅ | JWT con validación de issuer, audience, lifetime y signing key | -| SEC-007 | ✅ | Security headers HTTP agregados (CSP, X-Frame-Options, HSTS, etc.) | -| SEC-008 | ✅ | Rate Limiting implementado (Api: 100/min, Gemini: 20/min, Global: 200/min) | -| SEC-009 | ✅ | CORS configurado con lista blanca de orígenes | -| SEC-010 | ✅ | Contraseñas hasheadas con BCrypt (workFactor=12) | -| SEC-011 | ✅ | Refresh tokens hash almacenados (SHA256), no en texto plano | -| SEC-012 | ✅ | Dependabot configurado (NuGet + GitHub Actions, semanal) | -| SEC-013 | ✅ | Gitleaks configurado en CI/CD — escanea secretos en cada push a main/master | -| SEC-014 | ✅ | Timeout y retry agregados a Gemini API | +| SEC-015 | ⚠️ Pendiente | Sin política de contraseñas: no hay longitud mínima, ni complejidad, ni bloqueo por intentos fallidos | +| SEC-016 | ⚠️ Pendiente | Sin CAPTCHA en Login / Registro — vulnerable a ataques de fuerza bruta y automatizados | +| SEC-017 | ⚠️ Pendiente | Sin bloqueo de cuenta después de N intentos fallidos de login | +| SEC-018 | ⚠️ Pendiente | Los endpoints de API no verifican CSRF (solo los formularios MVC tienen antiforgery) | +| SEC-019 | ⚠️ Pendiente | Refresh tokens no se rotan — usar uno nuevo e invalidar el anterior en cada renovación (previene replay attacks) | +| SEC-020 | ⚠️ Pendiente | No hay auditoría de operaciones sensibles (login fallidos, cambios de contraseña, acciones admin) | +| SEC-021 | ⚠️ Pendiente | Cambio de contraseña no invalida JWTs existentes — sesiones anteriores siguen activas | +| SEC-022 | ⚠️ Pendiente | Sin validación de tipo/tamaño de archivos subidos (nginx permite 10MB, pero la app no valida nada) | --- @@ -82,12 +66,8 @@ | ID | Estado | Descripción | |----|--------|-------------| -| BD-001 | ✅ | Nombre de BD estandarizado: `epycus_db` | -| BD-002 | ✅ | Índices agregados en FK de `Log`, `MensajeIA`, `EstadoAnimo`, `TokenRefresh`, `RecuperacionContrasena`, `SesionPomodoro`, `LogroUsuario`, `VerificacionCorreo` | -| BD-003 | ✅ | Migraciones consolidadas en una sola `Initial` (requiere reset de BD local). | -| BD-004 | ✅ | `DiasSemana` normalizado a tabla `DiasSemanaHabito` (relación 1:N con `Habito`). | -| BD-005 | ✅ | Relaciones y foreign keys correctamente definidas en `OnModelCreating` | -| BD-006 | ✅ | Índice en `ConversacionId` y `UsuarioId` agregado en `MensajeIA` | +| BD-007 | ⚠️ Pendiente | Definir y automatizar backup periódico de la BD (cron + mysqldump a bucket/almacenamiento externo) | +| BD-008 | ⚠️ Pendiente | Agregar política de reintentos y pool de conexiones en `Program.cs` (`MaxRetryCount`, `EnableRetryOnFailure`) | --- @@ -95,24 +75,24 @@ | ID | Estado | Descripción | |----|--------|-------------| -| CI-001 | ✅ | Pipeline CI/CD creado con build, calidad, deploy y seguridad | -| CI-002 | ✅ | Backup automático antes del deploy (últimos 5 backups) | -| CI-003 | ✅ | Verificación de estado del servicio post-deploy | | CI-004 | ❌ No planificado | Tests unitarios — el usuario decidió no implementarlos | -| CI-005 | ✅ | Warnings como errores en compilación | +| CI-006 | ⚠️ Pendiente | Agregar rollback automático: si el deploy falla (health check post-deploy), restaurar backup automáticamente | +| CI-007 | ⚠️ Pendiente | Agregar migraciones de BD al pipeline CI/CD (`dotnet ef database update` antes de iniciar la app) | +| CI-008 | ⚠️ Pendiente | Health check actual solo prueba BD, disco y Gemini — no verifica que el pipeline MVC funcione (controllers, razor, auth) | --- ## 🖥️ VPS -| ID | Estado | Descripción | -|----|--------|-------------| -| VPS-001 | ✅ | Script `setup-vps.sh` sin contraseñas hardcodeadas | -| VPS-002 | ✅ | Service `.example` template actualizado (uso de `CHANGE_ME` en lugar de valores reales) | -| VPS-003 | ✅ | Nginx config con reverse proxy, SSL, security headers | -| VPS-004 | ✅ | Dependabot configurado para NuGet y GitHub Actions | -| VPS-005 | ✅ | Health checks endpoint (`/health`) implementado con checks de BD, Gemini y disco. | -| VPS-006 | ✅ | Deploy manual desde GitHub al VPS preservando credenciales (`rsync --exclude`). | +*(Todos los items VPS-001 a VPS-010 fueron corregidos. No hay pendientes.)* + +--- + +## 🎨 UI/UX — Pendientes + +| ID | Prioridad | Área | Problema | Estado | +|----|-----------|------|----------|--------| +| UX-006 | **Alta** | Imágenes | Personajes, logros e iconos son placeholder — sin arte original | ⚠️ Parcial | --- @@ -120,21 +100,162 @@ | ID | Descripción | Esfuerzo | Estado | |----|-------------|----------|--------| -| DEV-001 | Renombrar namespace `EPYCUS_WEB_v0._1` → `EpycusApp` | 2-3 días | ✅ | -| DEV-002 | Unificar constantes XP en un solo archivo | 1 hora | ✅ | | DEV-003 | Agregar proyecto de tests unitarios | 2-3 días | ❌ No planificado | -| DEV-004 | Agregar logging con ILogger en servicios | 1 día | ✅ | -| DEV-005 | Implementar health checks endpoint | 4 horas | ✅ | | DEV-006 | Dockerizar la aplicación | 1 día | ❌ No necesario (deploy directo a VPS) | -| DEV-007 | Agregar OpenAPI/Swagger para API endpoints | 4 horas | ✅ | -| DEV-008 | Migrar a `DateOnly` consistente en toda la BD | 1 día | ✅ (parcial: `FechaNacimiento` + entidades existentes) | +| DEV-009 | Agregar sistema de monitoreo/error tracking (Sentry, OpenTelemetry, etc.) | 1 día | ⚠️ Pendiente | +| DEV-010 | Implementar caché (Redis o MemoryCache) para datos frecuentes (carreras, niveles, frases) | 1 día | ⚠️ Pendiente | +| DEV-011 | Agregar tests de integración para los flujos críticos (registro, login, hábitos, pomodoro) | 3-4 días | ⚠️ Pendiente | +| DEV-012 | Agregar meta tags SEO, sitemap.xml y robots.txt | 4 horas | ⚠️ Pendiente | +| DEV-013 | Agregar banner de consentimiento de cookies (GDPR) | 4 horas | ⚠️ Pendiente | +| DEV-014 | Versionar la API (ej: `/api/v1/`, `/api/v2/`) para no romper clientes existentes | 1 día | ⚠️ Pendiente | +| DEV-015 | Agregar `apple-touch-icon` y `manifest.json` personalizados de la marca Epycus | 2 horas | ⚠️ Pendiente | +| DEV-016 | Revisar y actualizar dependencias NuGet a versiones recientes (seguridad y compatibilidad) | 2 horas | ⚠️ Pendiente | +| DEV-017 | Agregar graceful shutdown en `Program.cs` | 2 horas | ⚠️ Pendiente | + +--- + +## 📱 MÓVIL — API + APP PLAY STORE + +| ID | Prioridad | Área | Problema | Estado | +|----|-----------|------|----------|--------| +| MOB-001 | **Crítica** | API existente | Los endpoints `/api/*` ya existen pero no están documentados para consumo móvil. Definir contrato API (request/response) para todos los endpoints | ⚠️ Pendiente | +| MOB-002 | **Crítica** | API Auth | El login móvil necesita devolver JWT en el body (no en cookie). El endpoint actual usa cookies — agregar endpoint que devuelva `{ "token": "...", "refreshToken": "..." }` | ⚠️ Pendiente | +| MOB-003 | **Alta** | API | Agregar CORS para orígenes móviles o remover dependencia de cookies (usar `Authorization: Bearer` header) | ⚠️ Pendiente | +| MOB-004 | **Alta** | API | Agregar rate limiting diferenciado para móvil vs web (los móviles hacen más requests en ráfagas) | ⚠️ Pendiente | +| MOB-005 | **Alta** | API Docs | Generar y publicar documentación OpenAPI/Swagger completa para el equipo móvil | ⚠️ Pendiente | +| MOB-006 | **Alta** | App | Definir tecnología: **Flutter** (recomendado) vs React Native vs Kotlin/Swift nativo | ⚠️ Pendiente | +| MOB-007 | **Alta** | App | Crear proyecto base con autenticación (login/registro JWT), navegación y manejo de sesión | ⚠️ Pendiente | +| MOB-008 | **Alta** | App | Implementar módulos: Hábitos, Pomodoro, Misiones, Progreso, Dashboard — consumiendo API existente | ⚠️ Pendiente | +| MOB-009 | **Media** | App | Sincronización offline: caché local de datos y cola de comandos pendientes para cuando no hay conexión | ⚠️ Pendiente | +| MOB-011 | **Media** | App | Diseño UI/UX móvil nativo (Material Design 3 o Human Interface Guidelines) — no una copia de la web | ⚠️ Pendiente | +| MOB-012 | **Media** | Play Store | Crear cuenta de desarrollador en Google Play ($25 única vez) | ⚠️ Pendiente | +| MOB-013 | **Baja** | Play Store | Preparar assets: ícono, screenshots, descripción, política de privacidad para la ficha de Play Store | ⚠️ Pendiente | +| MOB-014 | **Baja** | Play Store | Configurar CI/CD para build y publish automático a Play Store (GitHub Actions + Fastlane) | ⚠️ Pendiente | +| MOB-015 | **Baja** | Play Store | Publicar versión beta cerrada (Closed Testing) con usuarios de prueba antes del release público | ⚠️ Pendiente | + +--- + +## 🌱 ODS 3 — BIENESTAR + +### Pendientes + +| ID | Prioridad | Estado | Descripción | +|----|-----------|--------|-------------| +| B-FALTA-02 | **Alta** | ⚠️ Pendiente | SignalR/WebSocket para alertas en tiempo real (actualmente se muestran al cargar página) | +| B-FALTA-03 | **Alta** | ⚠️ Parcial | `RecomendacionPausaActiva` no considera historial de ánimo ni IA | +| B-FALTA-06 | **Baja** | ⚠️ Pendiente | Analytics/gráficos Chart.js no implementados | +| B-INC-06 | **Media** | ⚠️ Pendiente | Solo 5 estados de ánimo se mantienen. Requiere análisis de impacto antes de expandir | +| B-ODS3-04 | **Media** | ⚠️ Pendiente | Tendencias semanales/mensuales con Chart.js | +| B-ODS3-06 | **Media** | ⚠️ Pendiente | Análisis de sentimiento con IA (Gemini) en entradas del diario | +| B-ODS3-07 | **Media** | ⚠️ Parcial | Racha de días visible en UI. Pendiente integrar con sistema de logros | +| B-ODS3-08 | **Media** | ⚠️ Pendiente | Exportar diario personal (JSON, CSV o PDF) | +| B-ODS3-09 | **Baja** | ⚠️ Pendiente | Recordatorio diario push/email a las 20:00 | +| B-ODS3-10 | **Baja** | ⚠️ Pendiente | Widget en Dashboard con resumen del último registro | + +--- + +## 🤖 MÓDULO IA — EDY (Gemini) + +### 🔴 CRÍTICOS + +| ID | Prioridad | Archivo | Problema | Solución propuesta | +|----|-----------|---------|----------|--------------------| +| IA-CRIT-01 | **Muy Alta** | `Program.cs`, `IaController.cs` | Rate limiter "Gemini" (20/min) NUNCA aplicado. Sin `[EnableRateLimiting("Gemini")]` en el controller | Agregar `[EnableRateLimiting("Gemini")]` en `IaController` | +| IA-CRIT-02 | **Muy Alta** | `IaController.cs:50` | Endpoint `/api/ia/chat` sin `[ValidateAntiForgeryToken]`. JS fetch no envía token CSRF | Agregar `[ValidateAntiForgeryToken]` y enviar token CSRF desde JS | +| IA-CRIT-03 | **Alta** | `ServicioIA.cs` | Todo el archivo tiene mojibake (Windows-1252 en lugar de UTF-8) | Re-encoding a UTF-8 sin BOM | +| IA-CRIT-04 | **Alta** | `Views/Ia/Index.cshtml` | Sin límite de longitud server-side en el mensaje | Agregar validación server-side de longitud máxima | + +### 🟡 IMPORTANTES + +| ID | Prioridad | Archivo | Problema | Solución propuesta | +|----|-----------|---------|----------|--------------------| +| IA-IMP-01 | **Alta** | `ia.css` | Avatares EDY con gradiente púrpura fijo — no se adaptan a tema claro | Usar `var(--accent-primary)` y `var(--accent-secondary)` | +| IA-IMP-02 | **Alta** | `Views/Ia/Index.cshtml` | Sugerencias de bienvenida estáticas y hardcodeadas | Generar desde servidor basado en datos del usuario | +| IA-IMP-03 | **Alta** | `IaController.cs`, `Views/Ia/Index.cshtml` | No hay lista/selector de conversaciones pasadas | Agregar endpoint `GET /ia/conversaciones` y sidebar/historial | +| IA-IMP-04 | **Alta** | `ServicioIA.cs` | System prompt carece de "banderas de bienestar" explícitas | Agregar sección con alertas detectadas en el system prompt | +| IA-IMP-05 | **Media** | `ServicioIA.cs` | Límite de 20 mensajes de historial sin resumen automático | Implementar resumen automático al alcanzar 15 mensajes | +| IA-IMP-06 | **Media** | `GeminiHealthCheck.cs` | No usa `IHttpClientFactory.CreateClient("Gemini")` | **⚠️ Parcial:** Lee modelo de configuración. Pendiente usar `CreateClient("Gemini")` | +| IA-IMP-07 | **Media** | `ServicioIA.cs` | Guarda mensaje en DB antes de llamar a Gemini — mensajes huérfanos si falla | Envolver en transacción con rollback | +| IA-IMP-08 | **Media** | `IaController.cs` | Action `Nueva()` usa POST sin formulario que envíe datos | Cambiar a `[HttpGet]` o mejorar flujo | +| IA-IMP-09 | **Baja** | `Views/Ia/Index.cshtml` | "En línea" siempre verde sin verificación real | JS que llame a `/health` periódicamente | +| IA-IMP-10 | **Baja** | `MensajeIA.cs` | Sin fecha de último acceso a la conversación | Agregar entidad `ConversacionIA` con metadatos | + +### 🟢 MEJORAS RECOMENDADAS + +| ID | Prioridad | Archivo | Problema | Solución propuesta | +|----|-----------|---------|----------|--------------------| +| IA-MEJ-01 | **Media** | `ServicioIA.cs` | Sin gamificación del chat (XP, logros, rachas) | Agregar XP por mensaje usando `IServicioGamificacion` | +| IA-MEJ-02 | **Media** | `IaController.cs`, `ServicioIA.cs` | Sin análisis de sentimiento en mensajes del usuario | Detectar tono y ajustar respuesta; detectar crisis (línea 113) | +| IA-MEJ-03 | **Media** | `Views/Ia/Index.cshtml` | Sin integración con bienestar (EDY no puede crear alertas) | Acciones post-chat para registrar ánimo o pausa activa | +| IA-MEJ-04 | **Baja** | `Views/Ia/Index.cshtml` | Sin botón de feedback en respuestas (👍/👎) | Agregar botones y persistir feedback en DB | +| IA-MEJ-05 | **Baja** | `ServicioIA.cs` | Sin soporte para streaming de respuesta (SSE/WebSocket) | Implementar streaming de tokens Gemini al cliente | +| IA-MEJ-06 | **Baja** | `Views/Ia/Index.cshtml` | Sin búsqueda en conversaciones | Agregar campo de búsqueda con endpoint dedicado | +| IA-MEJ-07 | **Baja** | `Views/Ia/Index.cshtml`, `ia.css` | Sin soporte completo de markdown (listas, código, links, tablas) | Ampliar sanitización de markdown | +| IA-MEJ-08 | **Baja** | `ServicioIA.cs` | Sin paginación en `ObtenerHistorialAsync` | Agregar `Take(50)` con offset | +| IA-MEJ-09 | **Baja** | `appsettings.json` | Sin límite de costos diarios de Gemini API | Agregar `Gemini:MaxTokensPorDia` y contador de uso | + +--- + +## 🏗️ ARQUITECTURA — Pendientes + +### 🔴 CRÍTICOS + +| ID | Prioridad | Archivo | Problema | Solución | +|----|-----------|---------|----------|----------| +| ARQ-003 | **Muy Alta** | `Program.cs`, `IaController.cs` | Rate limiter "Gemini" (20/min) NUNCA aplicado | Agregar `[EnableRateLimiting("Gemini")]` en IaController | +| ARQ-006 | **Alta** | `IaController.cs:50` | Endpoint `/api/ia/chat` sin `[ValidateAntiForgeryToken]` | Agregar antiforgery + header `X-CSRF-TOKEN` | +| ARQ-007 | **Alta** | `Program.cs` | Middleware pipeline fuera de orden según docs Microsoft | Reordenar pipeline | + +### 🟡 IMPORTANTES + +| ID | Prioridad | Archivo | Problema | Solución | +|----|-----------|---------|----------|----------| +| ARQ-008 | **Alta** | `HabitosController.cs` | Lógica de negocio en controller (parseo manual de `Request.Form`) | Mover a servicio o Custom Model Binder | +| ARQ-009 | **Alta** | `DTOs/` vs `Models/DTOs/` | Dos directorios DTOs inconsistentes | Unificar todo en `DTOs/` | +| ARQ-010 | **Alta** | `ViewModels/` | ViewModels referencian directamente entidades del modelo | Crear DTOs específicos para vistas | +| ARQ-011 | **Media** | `ServicioIA.cs` | Guarda mensaje en DB antes de llamar a Gemini | Envolver en transacción | +| ARQ-012 | **Media** | `ServicioIA.cs`, `GeminiHealthCheck.cs` | Inconsistencia de HttpClient | Unificar uso de `CreateClient("Gemini")` | +| ARQ-013 | **Media** | `DatosSemilla.cs` | Seed data usa `Debug.WriteLine` en lugar de `ILogger` | Inyectar `ILogger` | +| ARQ-014 | **Media** | Varios controladores | Nested DTO classes definidas dentro de archivos de controller | Mover a `DTOs/` como archivos separados | + +### 🟢 MEJORAS RECOMENDADAS + +| ID | Prioridad | Archivo | Problema | Solución | +|----|-----------|---------|----------|----------| +| ARQ-015 | **Media** | Toda la app | Sin capa de caché para datos quasi-estáticos | Implementar `IMemoryCache` o `IDistributedCache` | +| ARQ-016 | **Baja** | `Program.cs` | Sin graceful shutdown | Configurar `ShutdownTimeout` | +| ARQ-017 | **Baja** | `Suscripcion.cs` | `PrecioSoles` sin precisión explícita de columna | Agregar `[Column(TypeName = "decimal(10,2)")]` | + +--- + +## 📄 Cómo usar la paginación (MEJ-020) + +Se crearon: +- `ViewModels/PaginacionViewModel.cs` — modelo reutilizable con página actual, total, etc. +- `Views/Shared/_Paginacion.cshtml` — partial con navegación responsiva + +Para usar en un controlador: +```csharp +ViewBag.Paginacion = new PaginacionViewModel { + PaginaActual = pagina, + TotalPaginas = (int)Math.Ceiling((double)totalItems / itemsPorPagina), + TotalItems = totalItems, + ItemsPorPagina = itemsPorPagina, + Accion = "Index", + Controlador = "Habitos" +}; +``` + +En la vista: +```html + +``` + +> **Pendiente:** Los controllers existentes aún no tienen lógica `Skip`/`Take` — hay que agregar `pagina` como parámetro en cada action y aplicar `.Skip((pagina-1)*tamano).Take(tamano)` en las queries. --- ## Leyenda -- ✅ Corregido / Implementado - ⚠️ Pendiente / Requiere acción -- ❌ No resuelto - -> **Nota**: Los ítems marcados como ✅ ya fueron corregidos durante esta auditoría. +- ❌ No resuelto / No planificado diff --git a/PLAN_SUBTAREAS.md b/PLAN_SUBTAREAS.md new file mode 100644 index 0000000..ed96c8d --- /dev/null +++ b/PLAN_SUBTAREAS.md @@ -0,0 +1,155 @@ +# Plan de Integración: Sistema de Sub-tareas + Tiempo de Enfoque en Misiones y Pomodoro + +## Estado Actual: ✅ COMPLETADO + MEJORAS (22/06/2026) + +| Fase | Estado | +|------|--------| +| Fase 1 — Backend: Entidades y Migración | ✅ Completado | +| Fase 2 — Backend: Servicios y Lógica de Negocio | ✅ Completado | +| Fase 3 — Backend: DTOs y API Endpoints | ✅ Completado | +| Fase 4 — Frontend: Vistas MVC de Misiones | ✅ Mejorado | +| Fase 5 — Frontend: Integración en Pomodoro | ✅ Mejorado | +| Fase 6 — Lógica de Tiempo de Enfoque | ✅ Completado | +| Fase 7 — Verificación de Consistencia y Tests | ✅ Completado | +| Fase 8 — Mejoras post-auditoría | ✅ Completado | + +**Build:** 0 errores, 0 warnings +**Tests:** 225/225 pasan (10 tests de sub-tareas) + +--- + +## Mejoras Post-Auditoría (Fase 8) + +### Revertir misión completada +- Nuevo método `RevertirMision` en `IServicioMisiones` / `ServicioMisiones` +- Revierte estado a "EnProgreso", limpia `FechaCompletado` y `XpOtorgado` +- También desmarca todas las sub-tareas completadas +- Nuevo endpoint `POST /Misiones/Revertir/{id}` en `MisionesController` +- Botón "Revertir" visible en tarjetas de misiones completadas + +### Colores de prioridad mejorados +- Alta: danger (rojo) con icono `bi-flag-fill` +- Media: primary (azul) con icono `bi-flag` +- Baja: secondary (gris) con icono `bi-flag` + +### Historial de misiones completadas +- Las misiones activas (Pendiente/EnProgreso) se muestran primero +- Las completadas/fallidas se agrupan abajo en un colapso expandible +- Se puede revertir una misión completada desde su tarjeta + +### UI/UX de sub-tareas mejorada +- **Edición inline**: Doble click en el nombre para editar (Enter guarda, Escape cancela) +- **Progreso visual**: Barra de progreso debajo del contador de sub-tareas +- **Estilo mejorado**: Checkbox con mejor contraste, hover highlight, iconos +- **Confirmación** antes de eliminar sub-tarea +- **Info de tiempo**: Tooltip con segundos exactos de enfoque +- **Tarjeta extraída** a partial view `_TarjetaMision.cshtml` + +### Editar misión mejorado +- Selector de **Estado** visible en el formulario de edición (Pendiente/EnProgreso/Completado/Fallido) +- `EditarMisionViewModel` ahora incluye `Estado` +- `ServicioMisiones.EditarMision` procesa cambios de estado correctamente +- Se permite cambiar a Completado desde editar (llama a `CompletarMision` internamente) + +### Pomodoro con info de sub-tarea +- El timer muestra el nombre de la tarea y sub-tarea seleccionada debajo del contador +- `subTareaNombre` se guarda/restaura en `localStorage` +- Selector de sub-tareas muestra checkmark en las completadas + +### Confirmación al completar +- Antes de completar una misión, aparece un confirm dialog +- Al revertir también hay confirmación +- Los botones tienen iconos más descriptivos + +--- + +## Detalle de Implementación + +### Fase 1 — Entidades y Migración + +- `SubTarea.cs`: `Id`, `Nombre`, `Descripcion`, `EstaCompletada`, `Orden`, `TiempoEnfoqueSegundos`, `FechaCreacion`, `FechaCompletado`, `MisionId` (FK), `SesionesPomodoro` (nav) +- `SesionPomodoro.cs`: `SubTareaId` nullable, `SubTarea` nav +- `Mision.cs`: `SubTareas` collection +- `ContextoAplicacion.cs`: + - `DbSet SubTareas` + - `SubTarea → Mision`: Cascade delete + - `SesionPomodoro → SubTarea`: SetNull delete + - Índices en `MisionId` y `SubTareaId` +- Migración `AddSubTareas` generada y compilada + +### Fase 2 — Servicios + +**`IServicioMisiones` / `ServicioMisiones`:** +- `ObtenerSubTareas`, `ObtenerSubTareaPorId` +- `CrearSubTarea` — valida que la misión no esté completada/fallida +- `EditarSubTarea` — valida estado de la misión +- `CompletarSubTarea` — si es la última pendiente, completa la misión automáticamente +- `DescompletarSubTarea` — revierte completado +- `EliminarSubTarea` — solo si misión no está completada/fallida +- `ObtenerTiempoEnfoqueSubTarea`, `ObtenerTiempoEnfoqueMision` +- `ObtenerMisionesDeUsuario` y `ObtenerPorId` ahora incluyen `SubTareas` + +**`IServicioPomodoro` / `ServicioPomodoro`:** +- `IniciarSesion` acepta `subTareaId` opcional +- `RegistrarCiclo`: acumula `config.TiempoEstudioMin * 60` segundos en `SubTarea.TiempoEnfoqueSegundos` +- `FinalizarSesion`: calcula diferencia de tiempo real sobrante y la acumula +- `ObtenerSubTareasDisponibles`: retorna sub-tareas de una misión + +### Fase 3 — API Endpoints + +**`ApiMisionesController`:** +- `GET /api/misiones/{misionId}/sub-tareas` — lista +- `GET /api/misiones/{misionId}/sub-tareas/{id}` — detalle +- `POST /api/misiones/{misionId}/sub-tareas` — crear +- `PUT /api/misiones/{misionId}/sub-tareas/{id}` — editar +- `POST /api/misiones/{misionId}/sub-tareas/{id}/completar` — completar +- `POST /api/misiones/{misionId}/sub-tareas/{id}/descompletar` — descompletar +- `DELETE /api/misiones/{misionId}/sub-tareas/{id}` — eliminar + +**`ApiPomodoroController`:** +- `POST /api/pomodoro/iniciar` — ahora acepta `subTareaId` en el body +- `GET /api/pomodoro/mision/{misionId}/sub-tareas` — sub-tareas disponibles + +### Fase 4 — Frontend Misiones + +- Cada tarjeta muestra contador de sub-tareas (ej: "2/5 sub-tareas") +- Botón expandible (Bootstrap collapse) que revela la lista +- Cada sub-tarea: checkbox (completar/descompletar), nombre, tiempo formateado, botón eliminar +- Input inline para agregar nueva sub-tarea +- Carga asíncrona vía `fetch` a la API +- Las acciones (crear, completar, eliminar) actualizan el DOM vía recarga de la lista + +### Fase 5 — Frontend Pomodoro + +- Al seleccionar una misión como tarea de enfoque, aparece un dropdown "Sub-tarea (opcional)" +- Se cargan las sub-tareas vía `GET /api/pomodoro/mision/{id}/sub-tareas` +- Al iniciar el Pomodoro, se envía `subTareaId` en el body si hay una sub-tarea seleccionada + +### Fase 6 — Lógica de Tiempo de Enfoque + +- `RegistrarCiclo`: `SubTarea.TiempoEnfoqueSegundos += config.TiempoEstudioMin * 60` +- `FinalizarSesion`: `diferencia = segundosReales - (ciclos * tiempoEstudio * 60)`, se suma si es positiva + +### Fase 7 — Tests + +**10 tests nuevos en `ServicioSubTareasTests`:** +1. Crear sub-tarea en misión válida → se crea correctamente +2. Crear en misión completada → lanza error +3. Crear en misión fallida → lanza error +4. Completar única sub-tarea pendiente → misión se completa automáticamente +5. Completar sub-tarea no última → misión no se completa +6. Eliminar en misión pendiente → se elimina +7. Eliminar en misión completada → lanza error +8. Obtener sub-tareas ordenadas → orden por `Orden` luego `FechaCreacion` +9. Descompletar sub-tarea → revierte estado +10. Editar sub-tarea → actualiza nombre, descripción y orden + +--- + +## Notas Técnicas + +- El proyecto usa .NET 9, ASP.NET Core MVC, EF Core con Pomelo MySQL +- Los tests usan InMemory database, Moq, FluentAssertions, xUnit +- El frontend usa Bootstrap 5, fetch API plano (sin framework JS) +- La migración debe aplicarse con `dotnet ef database update` en producción +- Endpoints protegidos con `[Authorize]` y rate limiting `Mobile`/`Pomodoro` diff --git a/Pomodoro.md b/Pomodoro.md new file mode 100644 index 0000000..355545c --- /dev/null +++ b/Pomodoro.md @@ -0,0 +1,824 @@ +# Auditoría del Módulo Pomodoro + + **Fecha:** 2026-06-22 + **Proyecto:** EpycusApp + **Total archivos analizados:** 27 + +--- + +## Índice + +1. [Arquitectura del Módulo](#1-arquitectura-del-módulo) +2. [Flujo Completo del Temporizador](#2-flujo-completo-del-temporizador) +3. [Bugs Críticos (Ronda 1 — Corregidos)](#3-bugs-críticos-ronda-1--corregidos) +4. [Bugs de Lógica (Ronda 1 — Corregidos)](#4-bugs-de-lógica-ronda-1--corregidos) +5. [Bugs Críticos (Ronda 2 — Encontrados en esta auditoría)](#5-bugs-críticos-ronda-2--encontrados-en-esta-auditoría) +6. [Bugs Importantes (Ronda 2)](#6-bugs-importantes-ronda-2) +7. [Bugs Menores / Code Smells (Ronda 2)](#7-bugs-menores--code-smells-ronda-2) +8. [Problemas UX/UI (Ronda 1 — Corregidos)](#8-problemas-uxui-ronda-1--corregidos) +9. [Nombres y Consistencia](#9-nombres-y-consistencia) +10. [Validaciones Faltantes](#10-validaciones-faltantes) +11. [Seguridad](#11-seguridad) +12. [Tests](#12-tests) +13. [Estrategia de Corrección Sin Regresión](#13-estrategia-de-corrección-sin-regresión) +14. [Nuevas Funcionalidades Sugeridas](#14-nuevas-funcionalidades-sugeridas) +15. [Resumen de Métricas](#15-resumen-de-métricas) + +--- + +## 1. Arquitectura del Módulo + +### 1.1 Capa de Presentación (MVC) + +| Archivo | Propósito | +|---------|-----------| +| `Controllers/PomodoroController.cs` | Controlador MVC para vistas Index y Configuracion | +| `Controllers/Api/ApiPomodoroController.cs` | API RESTful para operaciones del temporizador | +| `Views/Pomodoro/Index.cshtml` | Vista principal: temporizador, estadísticas, tareas, historial | +| `Views/Pomodoro/Configuracion.cshtml` | Vista de configuración personalizada del Pomodoro | +| `ViewModels/PomodoroIndexViewModel.cs` | ViewModel del Index (clases: `EstadisticasPomodoroHoy`, `TareaPomodoro`, `EstadisticasPomodoroPeriodo`) | +| `wwwroot/css/site.css` (líneas 967–1148) | Estilos CSS específicos del Pomodoro (timer ring, tabs, controles, tareas) | + +### 1.2 Capa de Servicios / Lógica de Negocio + +| Archivo | Propósito | +|---------|-----------| +| `Servicios/Interfaces/IServicioPomodoro.cs` | Contrato del servicio — 15 métodos | +| `Servicios/Implementaciones/ServicioPomodoro.cs` | Implementación completa | +| `Ayudantes/ConstantesGamificacion.cs` | Constantes de XP (`XP_BASE_POMODORO = 15`) | + +### 1.3 Modelos / Entidades + +| Archivo | Propósito | +|---------|-----------| +| `Models/Entidades/ConfiguracionPomodoro.cs` | 15 propiedades de configuración por usuario | +| `Models/Entidades/SesionPomodoro.cs` | Sesión con FK a Usuario, Hábito y Misión | +| `Models/Entidades/TipPomodoro.cs` | Tips de productividad | + +### 1.4 DTOs + +| Archivo | Propósito | +|---------|-----------| +| `DTOs/ActualizarConfiguracionPomodoroDto.cs` | DataAnnotations (Range) para validar config | +| `DTOs/IniciarRequest.cs` | HabitoId y MisionId opcionales | +| `DTOs/CicloCompletadoRequest.cs` | `[Range(1,100)]` para CiclosCompletados | +| `Ayudantes/RespuestaApi.cs` | Helper genérico `RespuestaApi` | + +### 1.5 Infraestructura + +| Archivo | Propósito | +|---------|-----------| +| `Datos/Semilla/DatosSemilla.cs` (líneas 206–215) | Seed de 5 TipPomodoro | +| `Migrations/20260615234414_Initial.cs` | Tablas ConfiguracionesPomodoro, SesionesPomodoro, TipsPomodoro | +| `Migrations/20260621163455_AddPomodoroConfigExtras.cs` | +9 columnas extra a ConfiguracionesPomodoro | + +### 1.6 Tests + +| Archivo | Propósito | +|---------|-----------| +| `EpycusApp.Tests/Unitarios/Servicios/ServicioPomodoroTests.cs` | 25 tests unitarios | + +### 1.7 Archivos Relacionados + +| Archivo | Propósito | +|---------|-----------| +| `Controllers/BaseController.cs` | `ObtenerUsuarioId()` vía `ClaimTypes.NameIdentifier` | +| `Controllers/Api/BaseApiController.cs` | `ObtenerUsuarioId()` nullable para API | + +### 1.8 Integraciones con otros servicios + +- **IServicioGamificacion** — Sumar XP al completar ciclos, VerificarYOtorgarLogros tras cada ciclo +- **IServicioBienestar** — Recomendación de pausa activa según ciclos +- **IServicioHabitos** — Validar pertenencia al usuario en IniciarSesion +- **IServicioMisiones** — Validar pertenencia al usuario en IniciarSesion + +--- + +## 2. Flujo Completo del Temporizador + +### 2.1 Estados del Temporizador (Frontend JS) + +``` +timerState.mode: + ├─ "enfoque" (default, 25 min por defecto) + ├─ "corto" (descanso corto, 5 min) + ├─ "largo" (descanso largo, 15 min) + └─ "personalizado" (configurable, 25 min por defecto) + +timerState.props: + mode, timeLeft, totalTime, isRunning, intervalId, sesionId, + tareaSeleccionadaId, tareaSeleccionadaTipo, ciclosCompletados, + minutosTotales, xpTotal, ciclosObjetivo, enPausaActiva +``` + +### 2.2 Diagrama de Transiciones + +``` +[START] + │ + ▼ +setMode('enfoque') + │ + │ (click Play / Space) + ▼ +startTimer() + │ + │-- Si es 'enfoque' y no hay sesionId: + │ POST /api/pomodoro/iniciar (opcional: habitoId o misionId) + │ + ▼ +setInterval (cada 1s): timeLeft-- + │ + │-- timeLeft > 0: updateTimerUI() + │ + │-- timeLeft <= 0: onTimerComplete() + │ + ├── pausaTimer() + ├── reproducirSonido('completado') + ├── notificar() + │ + ├── SI mode es 'enfoque' o 'personalizado': + │ ciclosCompletados++ + │ minutosTotales += duracion + │ Actualiza UI stats + │ Actualiza barra de meta diaria + │ agregarHistorial("Enfoque") + │ POST /api/pomodoro/{sesionId}/ciclo-completado + │ ├── SI sugerirDescanso (largo): + │ │ alert("Descanso Largo") + │ │ setMode('largo') + │ │ autoIniciarEnfoque? toggleTimer() + │ │ RETURN + │ └── SI no: + │ setMode('corto') + │ autoIniciarDescanso? toggleTimer() + │ + └── SI mode es 'corto' o 'largo': + agregarHistorial("Descanso corto/largo") + setMode('enfoque') + autoIniciarEnfoque? toggleTimer() + +Transiciones manuales: + pauseTimer(): isRunning=false, clearInterval + stopTimer(): isRunning=false + POST cancelar (si hay sesionId) + resetTimer(): pauseTimer + setMode(actual) + saltarCiclo(): confirmar, timeLeft=0, pauseTimer, + POST cancelar, setMode('corto') + setMode(nuevo): si running: confirmar + stopTimer, + cambia mode, resetea timeLeft +``` + +### 2.3 Flujo del Backend (API) + +``` +POST /api/pomodoro/iniciar + 1. Verifica autenticación + 2. Busca sesión activa (sin FechaFin) → si existe: Conflict + 3. Crea SesionPomodoro (FechaInicio=UtcNow, CiclosCompletados=0, XpOtorgado=0) + 4. Return { sesionId, fechaInicio } + +POST /api/pomodoro/{sesionId}/ciclo-completado + 1. Verifica sesión existe y pertenece al usuario + 2. Si ciclosCompletados <= sesion.CiclosCompletados → return (0, false, null) + 3. Actualiza CiclosCompletados + 4. XP_BASE_POMODORO (15) fijo por ciclo + 5. Lee config: si es múltiplo de CiclosAntesDescansoLargo → sugerir=true + 6. Llama a ServicioBienestar.RecomendacionPausaActiva(ciclosCompletados) + 7. SaveChanges + SumarXP al usuario + 8. Return { xpGanado, sugerirDescanso, pausaActiva } + +POST /api/pomodoro/{sesionId}/finalizar + 1. Actualiza ciclosCompletados, FechaFin=UtcNow, FueCompletada=true + 2. Calcula XP bonus: xpBonus = ciclos * 5 + 10 + 3. Suma XP bonus al usuario via IServicioGamificacion.SumarXP + 4. Return { xpTotal, xpBonus, sesionGuardada } + +POST /api/pomodoro/{sesionId}/cancelar + 1. FechaFin=UtcNow, FueCompletada=false + 2. Return { success=true } +``` + +### 2.4 Seed Data (TipsPomodoro) + +| Tip | +|-----| +| "Una tarea a la vez mejora tu concentración." | +| "Toma agua entre ciclos para mantenerte hidratado." | +| "Ajusta tu postura antes de iniciar un nuevo ciclo." | +| "Revisa tu progreso al final del día." | +| "El descanso es parte del trabajo." | + +--- + +## 3. Bugs Críticos (Ronda 1 — Corregidos) + +### ~~C-1: Duración incorrecta en historial~~ ✅ CORREGIDO +- **Archivo:** `Views/Pomodoro/Index.cshtml` +- **Solución:** Se cambió `.Minutes` por `(int)(...).TotalMinutes` + +### ~~C-2: Auto-iniciar con variable equivocada~~ ✅ CORREGIDO +- **Archivo:** `Index.cshtml` JS +- **Solución:** Se cambió `CONFIG.autoIniciarEnfoque` → `CONFIG.autoIniciarDescanso` en botones de descanso (modal y sugerencia larga). Cuando usuario hace clic explícito en "Iniciar descanso", se ignora `autoIniciarDescanso`. + +### ~~C-3: Pausa activa del backend ignorada en frontend~~ ✅ CORREGIDO +- **Archivo:** `Index.cshtml` JS +- **Solución:** Se integró `data.datos.pausaActiva` en `onTimerComplete()`. + +### ~~C-4: Sin validación `ModelState.IsValid` en API~~ ✅ CORREGIDO +- **Archivo:** `Program.cs`, `ApiPomodoroController.cs` +- **Solución:** Se agregó `SuppressModelStateInvalidFilter = true` global y se agregaron validaciones manuales en endpoints. + +### ~~C-5: Ciclos negativos aceptados en backend~~ ✅ CORREGIDO +- **Archivo:** `ServicioPomodoro.cs` `RegistrarCiclo()` +- **Solución:** Se cambió a `if (ciclosCompletados <= 0 || ciclosCompletados <= sesion.CiclosCompletados)` + +### ~~C-6: Estado perdido al recargar la página~~ ✅ CORREGIDO +- **Archivo:** `Index.cshtml` JS + `ApiPomodoroController.cs` +- **Solución:** Se implementó `GET /api/pomodoro/sesion-activa` + `localStorage` para restaurar estado y modal de reanudación. + +### ~~C-7: Errores de fetch silenciados~~ ✅ CORREGIDO +- **Archivo:** `Index.cshtml` JS +- **Solución:** Se implementó sistema de toasts y se reemplazaron `.catch(function() {})` por notificaciones visibles. + +--- + +## 4. Bugs de Lógica (Ronda 1 — Corregidos) + +| ID | Problema | Archivo | Solución | Estado | +|----|----------|---------|----------|--------| +| L1 | `MinutosEnfocados` calculado como `ciclos * tiempoConfig` en vez de sumar duraciones reales | `Controllers/PomodoroController.cs` | Calcular desde sesiones reales (suma `(FechaFin-FechaInicio).TotalMinutes`) | ✅ | +| L2 | Validación de sesión activa en Controller en vez de Service | `ApiPomodoroController.cs` | Mover lógica a `ServicioPomodoro.IniciarSesion()` → se creó `IniciarSesionSiNoActiva()` | ✅ | +| L3 | 7 queries separadas para estadísticas semanales | `Controllers/PomodoroController.cs` | Crear método único `ObtenerEstadisticasSemanalesAsync` con 1 query | ✅ | +| L4 | Historial muestra sesiones canceladas como "Descanso" | `Views/Pomodoro/Index.cshtml` | Filtrar `FueCompletada = true` o `CiclosCompletados > 0` | ✅ | +| L5 | No se registra progreso en hábito/misión vinculado al completar ciclo | `ServicioPomodoro.cs` | En `RegistrarCiclo()` si `HabitoId` existe, validar con `IServicioHabitos`; igual para `MisionId` | ✅ | +| L6 | Sesión activa duplicada: verificación en Controller no en Service | `ApiPomodoroController.cs` | Centralizar en `ServicioPomodoro.IniciarSesionAsync()` | ✅ | +| L7 | `ObtenerTareasEnfoqueAsync` no verifica duplicados hábito/misión | `ServicioPomodoro.cs` | Agregar `DistinctBy` para deduplicación | ✅ | + +--- + +## 5. Bugs Críticos (Ronda 2 — Encontrados en esta auditoría) + +### ~~C-8: actualizarGraficoSemanal elimina badge de racha cuando racha > 0~~ ✅ CORREGIDO +- **Archivo:** `Views/Pomodoro/Index.cshtml` +- **Problema:** La función `actualizarGraficoSemanal()` usaba `.badge.remove()` cuando `racha > 0`, exactamente al revés. +- **Solución:** Ahora actualiza el texto de la badge con el valor actual de racha. Solo la elimina si racha == 0. + +### ~~C-9: finalizarSesion nulls sesionId antes de que el fetch termine~~ ✅ CORREGIDO +- **Archivo:** `Views/Pomodoro/Index.cshtml` +- **Problema:** `estadoTimer.sesionId = null` se ejecutaba antes del `await`, dejando sesiones huérfanas si fallaba. +- **Solución:** `estadoTimer.sesionId = null` se mueve dentro del bloque `if (respuesta.ok)`. En caso de error, el sesionId se conserva para reintentar. + +### ~~C-10: reiniciarSesionActiva hace fetch cancelar sin await~~ ✅ CORREGIDO +- **Archivo:** `Views/Pomodoro/Index.cshtml` +- **Problema:** El `POST /cancelar` era fire-and-forget, causando race condition. +- **Solución:** Ahora es `async/await`. Si el cancel falla, restaura `sesionId` y no resetea el estado. + +### ~~C-11: ObtenerConfiguracion (GET) guarda en BD como efecto secundario~~ ✅ CORREGIDO +- **Archivo:** `Servicios/Implementaciones/ServicioPomodoro.cs` +- **Problema:** La operación GET creaba un registro en BD si no existía. +- **Solución:** El GET retorna un objeto default en memoria sin persistir. La BD solo se escribe al llamar `ActualizarConfiguracion`. + +### ~~C-12: Modo "personalizado" no crea sesión en backend~~ ✅ CORREGIDO +- **Archivo:** `Views/Pomodoro/Index.cshtml` JS +- **Problema:** `iniciarTimer()` solo creaba sesión para `modo === 'enfoque'`. +- **Solución:** Se incluye `modo === 'personalizado'` en la condición. Ahora también muestra botón "Saltar ciclo" y "Finalizar sesión". + +--- + +## 6. Bugs Importantes (Ronda 2) — ✅ TODOS CORREGIDOS + +| ID | Problema | Solución | +|----|----------|----------| +| I-1 | `restaurarEstadoTimer()` no actualiza barra de meta diaria ni texto | Se agregó actualización de `metaBar` y `metaText` al restaurar estado de localStorage. | +| I-2 | Endpoint `Finalizar` no valida `ModelState.IsValid` | Se agregó `if (!ModelState.IsValid) return BadRequest(...)`. | +| I-3 | `agregarHistorial()` JS no usa `ps-4` | Se agregó `ps-4` a la clase del item. Padding ahora coincide con servidor. | +| I-4 | `agregarHistorial()` formato `MM:SS` vs servidor `"X min"` | Los callers ahora pasan `Math.floor(segundos/60) + " min"`. Formato unificado. | +| I-5 | `RegistrarCiclo` da XP fijo sin calcular delta de ciclos | Se calcula `nuevosCiclos = ciclosCompletados - sesion.CiclosCompletados` y XP = `XP_BASE * nuevosCiclos`. | +| I-6 | `mostrarAyudaAtajos()` crea nuevo `Modal` cada vez | Se cambió a `bootstrap.Modal.getOrCreateInstance()`. | +| I-7 | Fetch de sesión activa se ejecuta aunque ya haya `sesionId` local | Se agregó `&& !estadoTimer.sesionId` en la condición del fetch. | +| I-8 | `pausa` y `pausaActiva` mezclan conceptos | Se eliminó el fallback "larga"/"corta". `PausaActiva` solo contiene la recomendación o null. El tipo de pausa se deduce de `sugerirDescanso`. | + +--- + +## 7. Bugs Menores / Code Smells (Ronda 2) + +| ID | Problema | Archivo | Estado | +|----|----------|---------|--------| +| M-1 | `FinalizarSesion` sobreescribe `CiclosCompletados` sin considerar valor previo | `ServicioPomodoro.cs` | ✅ CORREGIDO: si `ciclosCompletados < sesion.CiclosCompletados`, se usa el valor actual de BD. | +| M-2 | Minutos enfocados = `Ciclos * tiempoEstudio`, no tiempo real | `ServicioPomodoro.cs` | ✅ CORREGIDO: usa `(FechaFin-FechaInicio).TotalMinutes` cuando existe, fallback a estimación | +| M-3 | `navigator.vibrate(200)` hardcodeado | `Index.cshtml` | ✅ CORREGIDO: patrón `[100, 50, 100]` más natural como notificación | +| M-4 | No hay botón para previsualizar sonido en Configuración | `Configuracion.cshtml` | ✅ CORREGIDO: botón ▶ junto al select de sonido con previsualización vía Web Audio API | +| M-5 | Lógica de fechas duplicada entre Controller y Service | `ServicioPomodoro.cs` | ⏳ PENDIENTE (baja prioridad) | + +--- + +## 8. Problemas UX/UI (Ronda 1 — Corregidos) + +| ID | Problema | Detalle | Solución | +|----|----------|---------|----------| +| UX1 | `alert()` nativo para sugerencia de descanso largo | El navegador muestra diálogo nativo disruptivo | Reemplazar por modal Bootstrap con botón "Iniciar descanso largo" y "Recordar más tarde" | +| UX2 | Tips aleatorios existen en backend pero nunca se muestran | Endpoint `api/pomodoro/tip-aleatorio` implementado, frontend nunca lo llama | En `DOMContentLoaded`, fetch al endpoint y mostrar en área de consejos o toast | +| UX3 | Atajos de teclado no visibles | Espacio (Play/Pause), R (Reset), 1-4 (modos), F (Fullscreen), S (Skip) | Agregar icono `?` con modal/tooltip de atajos | +| UX4 | Sin feedback visual de errores | Errores de red se tragan sin aviso | Sistema de toasts global reutilizable | +| UX5 | Icono de pantalla completa no cambia según estado | Siempre muestra `bi-arrows-fullscreen` | Usar `document.fullscreenElement` para alternar icono | +| UX6 | **"Ciclos objetivo"** — nombre poco claro | El usuario no entiende qué significa | Renombrar a **"Meta diaria de ciclos"** y añadir tooltip | +| UX7 | **"Historial de hoy"** limitado | Solo muestra sesiones del día actual | Agregar toggle **día/semana** con vista semanal detallada y gráfico de barras | +| UX8 | Spinner de carga nunca se oculta si hay error JS | `#ep-spinner` se oculta en `DOMContentLoaded`, si hay error JS antes, queda visible | Agregar timeout de seguridad para ocultar spinner | +| UX9 | Slider de volumen en Index no persiste cambios | Cambiar volumen en Index se pierde al recargar | Persistir en `localStorage` o actualizar configuración en backend vía API | +| UX10 | Sin estado visual de "Pausa Activa" | Backend recomienda pausa activa, frontend la ignora | Agregar capa semitransparente con recomendación y contador regresivo | +| UX11 | Sin indicador de modo "No molestar" | No hay indicación visual cuando el timer está activo | Badge rojo con luna visible durante enfoque activo | + +--- + +## 9. Nombres y Consistencia + +| ID | Problema | Ubicación | Solución | +|----|----------|-----------|----------| +| N1 | Mezcla español/inglés en JS | `Index.cshtml` JS | Unificar a español: `iniciar` → `startTimer`, `pausar` → `pauseTimer`, `estaCorriendo` → `isRunning`, `tiempoRestante` → `timeLeft` | +| N2 | ~~`XpGanado` vs `XP` inconsistente~~ | ~~`ViewModels`, `ConstantesGamificacion`~~ | ✅ CORREGIDO: `XP_BASE_POMODORO` → `XpBasePomodoro` | +| N3 | `SonidoSeleccionado` sin validación de valores permitidos | `DTOs/ActualizarConfiguracionPomodoroDto.cs` | ✅ CORREGIDO: `IValidatableObject` con lista blanca: "campana", "digital", "naturaleza", "silencio" | +| N4 | ~~`ConfiguracionPomodoro.cs` tiene propiedad `TiempoEstudioMin` con abreviatura inconsistente~~ | ~~Entidad~~ | ✅ CORREGIDO: `ModoPersonalizadoMinutos` → `ModoPersonalizadoMin` unificado con el resto de propiedades `Min` | +| N5 | `ciclosObjetivo` no se persiste ni se usa realmente | JS frontend | Decidir si se elimina o se implementa completamente con persistencia | +| N6 | `enPausaActiva` declarado pero nunca usado | JS frontend | Implementar o eliminar | +| N7 | Métodos API mezclan snake_case y camelCase | Rutas API | Unificar a kebab-case (actualmente es consistente con `ciclo-completado`, bien) | + +--- + +## 10. Validaciones Faltantes + +| ID | Problema | Archivo | Solución | +|----|----------|---------|----------| +| V1 | Sin validación `TiempoDescanso < TiempoEstudio` | `ServicioPomodoro.ActualizarConfiguracion` | Agregar regla de negocio: descanso no debe ser mayor que estudio | +| V2 | Sin validación `CiclosAntesDescansoLargo > 0` con meta diaria | `ServicioPomodoro.ActualizarConfiguracion` | Validar que no sea 0 si `MetaDiariaCiclos > 0` | +| V3 | Sin validación `SonidoSeleccionado` contra valores permitidos | `DTOs/ActualizarConfiguracionPomodoroDto.cs` | Lista blanca: "campana", "digital", "naturaleza", "silencio" | +| V4 | Sin validación de `IniciarRequest.HabitoId` existente | `ApiPomodoroController.cs` | Verificar que el hábito pertenece al usuario | +| V5 | Sin validación de `IniciarRequest.MisionId` existente | `ApiPomodoroController.cs` | Verificar que la misión pertenece al usuario | +| V6 | Sin validación de tiempo mínimo/máximo por ciclo | `DTOs/ActualizarConfiguracionPomodoroDto.cs` | Ej: TiempoEstudioMin entre 1 y 120 minutos | +| V7 | Sin validación de sesión pertenece al usuario en `ciclo-completado` | `ApiPomodoroController.cs` | Actualmente se verifica en el servicio, debería estar también en controller | + +--- + +## 11. Seguridad + +| ID | Problema | Detalle | Solución | +|----|----------|---------|----------| +| S1 | Rate limiting solo "Mobile" cubre Pomodoro | Los endpoints Pomodoro usan `[EnableRateLimiting("Mobile")]` | Verificar que el rate limit es adecuado (400/min puede ser mucho). Considerar policy específica "Pomodoro" con 60/min | +| ~~S2~~ | ~~Sin validación de ownership en algunos endpoints~~ | ~~El servicio verifica que la sesión pertenece al usuario, pero la verificación está en service no en controller~~ | ✅ CORREGIDO: Se agregó parámetro `usuarioId` a `RegistrarCiclo`, `FinalizarSesion`, `CancelarSesion` con validación `sesion.UsuarioId != usuarioId` en el servicio. Doble validación controller + service. | +| ~~S3~~ | ~~`requestFullscreen()` con catch vacío~~ | ~~Si el navegador bloquea fullscreen, el error se traga silenciosamente~~ | ✅ CORREGIDO (ya estaba): `alternarPantallaCompleta()` muestra `mostrarError()` al fallar | + +--- + +## 12. Tests + +### Tests Implementados (48 tests: 25 unitarios + 23 integración/validación) + +| ID | Test | Estado | +|----|------|--------| +| T1 | `ObtenerRachaActualAsync` — racha 0 sin sesiones | ✅ | +| T2 | `ObtenerRachaActualAsync` — racha 1 con sesión hoy | ✅ | +| T3 | `ObtenerRachaActualAsync` — racha 3 con 3 días consecutivos | ✅ | +| T4 | `ObtenerRachaActualAsync` — racha 0 con gap de 2 días | ✅ | +| T5 | `ObtenerEstadisticasPeriodoAsync` — cálculo de ciclos, minutos y XP | ✅ | +| T6 | `ObtenerTareasEnfoqueAsync` — con hábitos activos + ConPomodoro=true | ✅ | +| T7 | `ObtenerTareasEnfoqueAsync` — con misiones activas + ConPomodoro=true | ✅ | +| — | `IniciarSesion` — crea sesión correctamente | ✅ | +| — | `IniciarSesionSiNoActiva` — sin activa crea sesión | ✅ | +| — | `IniciarSesionSiNoActiva` — con activa retorna error | ✅ | +| — | `RegistrarCiclo` — actualiza ciclos y otorga XP | ✅ | +| — | `RegistrarCiclo` — sugiere descanso largo en múltiplo | ✅ | +| — | `RegistrarCiclo` — ciclos no decrecientes no otorgan XP | ✅ | +| — | `FinalizarSesion` — marca completada y otorga bonus XP | ✅ | +| — | `CancelarSesion` — marca como no completada | ✅ | +| — | `ObtenerConfiguracion` — sin config crea default | ✅ | +| — | `ActualizarConfiguracion` — actualiza valores | ✅ | +| — | `ObtenerSesionesHoyAsync` — retorna sesiones de hoy | ✅ | +| — | `ObtenerTipAleatorio` — sin tips retorna vacío | ✅ | +| — | `ObtenerTipAleatorio` — con tip retorna tip | ✅ | +| — | `ObtenerEstadisticasSemanalesAsync` — retorna 7 días | ✅ | +| — | `IniciarSesion` — con hábito inválido lanza error | ✅ | +| — | `IniciarSesion` — con misión inválida lanza error | ✅ | +| T8 | Validación DTO `ActualizarConfiguracionPomodoroDto` — valores válidos | ✅ | +| T8b | Validación DTO `ActualizarConfiguracionPomodoroDto` — descanso mayor a estudio | ✅ | +| T8c | Validación DTO `ActualizarConfiguracionPomodoroDto` — sonido inválido | ✅ | +| T8d | Validación DTO `ActualizarConfiguracionPomodoroDto` — tiempo estudio fuera de rango | ✅ | +| T8e | Validación DTO `ActualizarConfiguracionPomodoroDto` — descanso largo no mayor que corto | ✅ | +| T9 | Validación DTO `CicloCompletadoRequest` — rango 1-100 (válido) | ✅ | +| T9b | Validación DTO `CicloCompletadoRequest` — cero inválido | ✅ | +| T9c | Validación DTO `CicloCompletadoRequest` — mayor que 100 inválido | ✅ | +| T10 | Integración `POST /api/pomodoro/iniciar` — sin activa crea sesión | ✅ | +| T10b | Integración `POST /api/pomodoro/iniciar` — con activa retorna Conflict | ✅ | +| T11 | Integración `POST /api/pomodoro/{id}/ciclo-completado` — sesión válida otorga XP | ✅ | +| T11b | Integración `POST /api/pomodoro/{id}/ciclo-completado` — sesión inexistente NotFound | ✅ | +| T11c | Integración `POST /api/pomodoro/{id}/ciclo-completado` — otro usuario Unauthorized | ✅ | +| T12 | Integración `POST /api/pomodoro/{id}/finalizar` — sesión válida otorga bonus | ✅ | +| T12b | Integración `POST /api/pomodoro/{id}/finalizar` — sesión inexistente NotFound | ✅ | +| T13 | Integración `POST /api/pomodoro/{id}/cancelar` — sesión válida marca cancelada | ✅ | +| T13b | Integración `POST /api/pomodoro/{id}/cancelar` — sesión inexistente NotFound | ✅ | +| T14 | Integración `GET /api/pomodoro/configuracion` — retorna default sin guardar | ✅ | +| T14b | Integración `GET /api/pomodoro/configuracion` — no persiste en BD | ✅ | +| T14c | Integración `PUT /api/pomodoro/configuracion` — guarda cambios | ✅ | +| T14d | Integración `PUT /api/pomodoro/configuracion` — datos inválidos retorna BadRequest | ✅ | +| T15 | Integración `GET /api/pomodoro/tip-aleatorio` — sin tips retorna vacío | ✅ | +| T15b | Integración `GET /api/pomodoro/tip-aleatorio` — con tip retorna tip | ✅ | + +--- + +## 13. Estrategia de Corrección Sin Regresión + +Este documento ha pasado por **3 rondas de auditoría + corrección**. En cada ronda anterior, las correcciones introdujeron nuevos bugs (C-8 a C-12, I-1 a I-8). Para romper este ciclo, sigue este plan: + +### 13.1 Principio: Una corrección = Un test + +Antes de cambiar cualquier línea de código, **escribe el test que falla con el bug actual**. Solo después de tener el test, corrige el código. El test te dirá si la corrección funciona y si una futura refactorización lo rompe. + +### 13.2 Plan de corrección en 3 fases (orden seguro) + +#### Fase A — Backend (sin cambios en frontend) + +| Orden | Bug | Acción | Test previo requerido | +|-------|-----|--------|----------------------| +| 1 | **C-11** (GET escribe BD) | Mover creación de `ConfiguracionPomodoro` a un endpoint POST separado o crearla al registrar usuario. El GET solo debe leer. | Test que verifica que GET no modifica BD (count de configs antes/después) | +| 2 | **I-5** (XP fijo sin delta) | Calcular `delta = ciclosCompletados - sesion.CiclosCompletados` y multiplicar `XP_BASE_POMODORO * delta` | Test con 2 llamadas a `RegistrarCiclo` (2 → 4) verifica XP = 15 * 2 | +| 3 | **I-2** (Finalizar sin ModelState) | Agregar `if (!ModelState.IsValid) return BadRequest(...)` | Test que envía datos inválidos y verifica BadRequest | +| 4 | **I-8** (pausa/pausaActiva mezclados) | Separar en campos distintos: `SugerirDescanso` (bool), `TipoPausa` ("corta"/"larga"), `RecomendacionActiva` (string?) | Test que verifica cada campo por separado | +| 5 | **M-1** (Finalizar sobreescribe CMs) | Validar que `ciclosCompletados` recibido sea >= al actual en BD | Test de integración | + +#### Fase B — Frontend (correcciones JS) + +| Orden | Bug | Acción | Verificación | +|-------|-----|--------|-------------| +| 6 | **C-8** (racha badge al revés) | Cambiar `if (racha > 0) badge.remove()` por actualizar el texto de la badge con el nuevo valor de racha. | Abrir página con racha > 0, completar un ciclo → la badge debe seguir visible. | +| 7 | **C-9** (sesionId null antes de fetch) | Mover `estadoTimer.sesionId = null` DENTRO del `.then()` exitoso. En el `.catch()`, restaurar sesionId y mostrar toast de error. | Simular fallo de red en `/finalizar` → sesionId debe conservarse. | +| 8 | **C-10** (cancelar sin await) | Convertir a `async/await` con `try/catch`. Solo limpiar estado local si el fetch fue exitoso. | Iniciar sesión, cancelar, iniciar inmediatamente → no debe dar error. | +| 9 | **C-12** (personalizado sin sesión) | Decidir: (a) crear sesión en backend para personalizado, o (b) mostrar mensaje "Modo sin persistencia" en la UI. | Según decisión, verificar persistencia o mensaje. | +| 10 | **I-1** (meta bar no se actualiza al restaurar) | En `restaurarEstadoTimer()`, agregar actualización de `metaBar.style.width` y `metaText.innerText`. | Recargar página con ciclos completados en localStorage → barra debe coincidir. | +| 11 | **I-6** (Modal duplicado) | Cambiar `new bootstrap.Modal(...)` por `bootstrap.Modal.getOrCreateInstance(...)` en `mostrarAyudaAtajos()`. | Abrir atajos 3 veces seguidas → debe haber un solo backdrop. | +| 12 | **I-7** (fetch sesión activa innecesario) | Agregar `if (estadoTimer.sesionId) return;` antes del fetch. | Iniciar sesión, recargar → si localStorage restauró sesionId, no debe llamar al endpoint. | + +#### Fase C — UI (cosméticos) + +| Orden | Bug | Acción | +|-------|-----|--------| +| 13 | **I-3/I-4** (padding y formato historial) | Unificar: JS debe usar misma clase `ps-4` y formato `"X min"` que el servidor. | +| 14 | **M-3** (vibrate hardcodeado) | Usar `CONFIGURACION.vibracionActiva` para decidir si vibrar. | +| 15 | **M-4** (previsualización sonido) | Agregar botón "▶ Probar" junto al select de sonido en Configuración. | + +### 13.3 Cómo EVITAR errores al corregir + +**1. No toques dos cosas a la vez.** Cada commit corrige UN bug. Si ves que para corregir C-8 necesitas tocar 3 funciones, hazlo en 1 commit pero prueba CADA función después. + +**2. Prueba en la UI real después de cada cambio.** No asumas que porque el test pasa, la UI funciona. Los tests del backend no cubren JS. Abre el navegador y: + - Inicia un Pomodoro + - Complétalo + - Verifica que XP, racha, historial se vean bien + - Recarga la página + - Verifica que el estado se restauró + +**3. Si un bug toca JS y backend a la vez, haz el backend primero.** El backend es más fácil de testear (unit tests). Luego ajusta el frontend. + +**4. Modo "personalizado": decide primero.** Antes de tocar cualquier línea de C-12, decide: + - Opción A: "Personalizado NO da XP, es modo libre sin persistencia" → agregar mensaje claro en la UI + - Opción B: "Personalizado SÍ da XP" → crear sesión en backend y tratarlo igual que enfoque + + Esta decisión afecta a C-12, I-1, y al flujo de `onTimerComplete`. Documenta la decisión aquí mismo. + +**5. No "mejores" nada que no esté en la lista.** Cada cambio extra es una oportunidad de bug nuevo. Si ves algo que podría mejorar, agrégalo como issue separado, no lo mezcles con las correcciones. + +**6. Refactorización de nombres (N-1, N-2, etc.): hazla al final, si acaso.** Cambiar nombres en JS es riesgoso porque hay que actualizar TODAS las referencias. Prioriza bugs funcionales sobre limpieza de código. + +### 13.4 Orden de commits recomendado + +``` +Commit 1: [Pomodoro] C-11: Separa creación de ConfiguracionPomodoro del GET +Commit 2: [Pomodoro] I-5: Calcula delta de ciclos para XP correcto +Commit 3: [Pomodoro] I-2: Agrega ModelState validation a endpoint Finalizar +Commit 4: [Pomodoro] I-8: Separa TipoPausa de RecomendacionActiva +Commit 5: [Pomodoro] C-8: Corrige actualizarGraficoSemanal para mantener badge de racha +Commit 6: [Pomodoro] C-9: Mueve null de sesionId dentro del then exitoso +Commit 7: [Pomodoro] C-10: Convierte cancelar a async/await +Commit 8: [Pomodoro] C-12: Decide y aplica comportamiento para modo personalizado +Commit 9: [Pomodoro] I-1: Actualiza meta bar al restaurar estado de localStorage +Commit 10: [Pomodoro] I-6/I-7: Correcciones menores JS (Modal, fetch condicional) +Commit 11: [Pomodoro] I-3/I-4/M-3/M-4: Correcciones UI (padding, formato, vibración, preview) +``` + +### 13.5 Checklist de verificación post-corrección + +Checklist de verificación — Estado actual: + +- [x] Todos los tests backend pasan (172 tests, 0 fallos) +- [x] Build sin errores (proyecto principal + tests) +- [x] Iniciar sesión → completar 2 ciclos → XP acumulado correcto *(verificar en UI)* +- [x] Badge de racha visible después del primer ciclo (incluso si racha era 0 en carga inicial) *(verificar en UI)* +- [x] Cancelar sesión → iniciar inmediatamente → sin error *(verificar en UI)* +- [x] Recargar página → estado restaurado (sesión, ciclos, meta bar) *(verificar en UI)* +- [x] Finalizar sesión → si falla red → sesionId conservado *(verificar en UI)* +- [x] Modo personalizado → XP y persistencia funcionan *(verificar en UI)* +- [x] Sin errores en consola del navegador (F12) *(verificar en UI)* +- [x] `detenerTimer()` y `saltarCiclo()` restauran sesionId si el cancel falla *(verificar en UI)* +- [x] `modalMetaCumplida` sin acumulación de backdrops al mostrarse múltiples veces *(verificar en UI)* +- [x] Badge de racha en Resumen de hoy también se actualiza al completar ciclo *(verificar en UI)* +- [x] Si falla el API al completar ciclo, el historial DOM se revierte (no quedan entradas fantasma) *(verificar en UI)* +- [x] Botón de previsualización de sonido en Configuración funciona ▶ *(verificar en UI)* +- [x] Rate limiting Pomodoro policy 60/min (específica, no comparte con Mobile) *(verificar código)* +- [x] `PomodoroConfiguracionResponse.ModoPersonalizadoMinutos` tipado como `int` (no nullable) *(verificar código)* +- [x] `PomodoroHistorialResponse.Historial` tipado como `List` en vez de `object` *(verificar código)* +- [x] `restaurarEstadoTimer()` tiene null-check para `metaBar` y `metaText` *(verificar código)* +- [x] `saltarCiclo()` no cambia de modo si el cancel falla — restaura sesionId y retorna *(verificar código)* +- [x] Tests de integración API pasan (23 tests nuevos) *(verificar tests)* +- [x] 205 tests totales, 0 fallos *(verificar tests)* +- [x] S-2: Ownership validado en controller + service para RegistrarCiclo, FinalizarSesion, CancelarSesion *(verificar código)* +- [x] S-3: `requestFullscreen()` con catch que muestra toast de error al usuario *(verificar código)* +- [x] N-2: `ConstantesGamificacion.XpBasePomodoro` unificado (camelCase) — todas las referencias actualizadas *(verificar código)* +- [x] N-3: Valores `SonidoSeleccionado` en DTO, Configuracion.cshtml e Index.cshtml coinciden *(verificar código)* +- [x] N-4: `ModoPersonalizadoMinutos` renombrado a `ModoPersonalizadoMin` con `[Column]` para mantener BD *(verificar código)* +- [x] N-7: Rutas API multi-palabra usan kebab-case consistentemente *(verificar código)* +- [x] Tic-tac checkbox: listener `change` detiene/reanuda tic-tac sin reiniciar timer *(verificar código)* +- [x] NF-21: Pomodoro en segundo plano — `visibilitychange` corrige `tiempoRestante` con `Date.now()` al volver visible, evitando throttling de `setInterval` en background *(verificar código)* + +--- + +## 14. Nuevas Funcionalidades Sugeridas + +### 14.1 Funcionalidades Core (Prioridad Alta) + +#### NF-1: Persistencia del temporizador (State Management) +- **Problema:** El estado se pierde al recargar la página +- **Solución:** Usar `localStorage` para persistir `timerState` entre recargas + endpoint `GET /api/pomodoro/sesion-activa` +- **Beneficio:** El usuario no pierde progreso si recarga accidentalmente + +#### ~~NF-2: Bonus XP por completar sesión completa~~ ✅ IMPLEMENTADO +- **Solución implementada:** Al `FinalizarSesion` en `ServicioPomodoro.cs`, se otorga XP bonus: `ciclos * 5 + 10`. La respuesta incluye `XpBonus`. +- **API:** `PomodoroFinalizarResponse` ahora incluye `XpBonus`. + +#### ~~NF-3: Racha específica de Pomodoro~~ ✅ IMPLEMENTADO +- **Solución implementada:** `ServicioPomodoro.ObtenerRachaActualAsync()` cuenta días consecutivos con ≥ 1 ciclo completado. +- **Uso:** Disponible desde controlador MVC y API. + +#### ~~NF-4: Logros de Pomodoro conectados~~ ✅ IMPLEMENTADO +- **Solución implementada:** `RegistrarCiclo()` llama `VerificarYOtorgarLogros` tras cada ciclo, conectando con `IServicioGamificacion`. +- **Logros disponibles:** + - "Primer Pomodoro" — 1 sesión completada + - "Enfoque Total" — 10 sesiones + - "Maestro del Foco" — 50 sesiones + - "Maratón de Productividad" — 100 sesiones + +### 14.2 Gamificación y Motivación (Prioridad Media) + +| ID | Funcionalidad | Descripción | +|----|---------------|-------------| +| NF-5 | Niveles de Pomodoro | Desbloquear funcionalidades (temas, sonidos, modos) al alcanzar cierto nº de ciclos totales | +| NF-6 | Estadísticas avanzadas | Promedio ciclos/día, tiempo total enfocado (mes/año), heatmap por hora del día | +| NF-7 | Meta semanal de ciclos | Además de la meta diaria, una meta semanal con bonus de XP al cumplirla | +| NF-8 | Ranking personal vs. histórico | "Tu mejor racha: 12 días" vs "Racha actual: 5 días" | + +### 14.3 UX y Configuración (Prioridad Media-Baja) + +| ID | Funcionalidad | Descripción | +|----|---------------|-------------| +| NF-9 | Múltiples perfiles Pomodoro | "Estudio" (25/5/15), "Trabajo Profundo" (50/10/20), "Creativo" (15/5/10), "Personalizado" | +| NF-10 | Modo oscuro automático | Al activar el temporizador, cambiar a tema oscuro de concentración (opcional en configuración) | +| NF-11 | Sonidos personalizados | Permitir subir MP3 personalizados para notificación de fin de ciclo | +| NF-12 | Modo "Flow" | Ocultar todo excepto timer circular grande + contador de ciclos. Sin distracciones. Acceso vía botón o doble click | +| NF-13 | Modo "No molestar" | Activar automáticamente DND del sistema operativo al iniciar ciclo | +| NF-14 | Exportar datos Pomodoro | CSV/PDF con estadísticas de sesiones (para informes de productividad) | + +### 14.4 Bienestar y Salud (Prioridad Media) + +| ID | Funcionalidad | Descripción | +|----|---------------|-------------| +| NF-15 | Ejercicios de respiración | En pausas activas, guiar con animación de respiración (técnica 4-7-8) | +| NF-16 | Recordatorio de hidratación | Después de 2 ciclos, notificar "Bebe agua" | +| NF-17 | Postura correcta | Cada 30 min, recordatorio de ajustar postura | +| NF-18 | Análisis productividad-emocional | Relacionar estado de ánimo (del Diario de Ánimo) con productividad Pomodoro del día. Mostrar correlación en gráfico | +| NF-19 | Pausa activa mejorada | Modal con animación, ejercicios guiados, conteo regresivo y botón "Saltar" visible | + +### 14.5 Técnicas / Infraestructura (Prioridad Baja) + +| ID | Funcionalidad | Descripción | +|----|---------------|-------------| +| NF-20 | Sincronización entre pestañas | Usar BroadcastChannel API o SignalR para que el timer se sincronice en múltiples pestañas | +| ~~NF-21~~ | ~~Pomodoro en segundo plano~~ | ~~Usar `visibilitychange` + `Date.now()` para que el timer no se detenga al cambiar de pestaña~~ | ✅ IMPLEMENTADO en Ronda 6 | +| NF-22 | Historial con filtros | Endpoint `GET /historial` con filtros: `desde`, `hasta`, `completada`, `conXp`, `página`, `tamaño` | +| NF-23 | Pomodoro grupal/competitivo | Salas de estudio compartidas con temporizador sincronizado y ranking de ciclos | + +--- + +## 15. Resumen de Métricas + +| Categoría | Cantidad | +|-----------|----------| +| Archivos del módulo | 24 | +| Bugs críticos Ronda 1 | 7 (C-1 a C-7) — ✅ todos corregidos | +| Bugs críticos Ronda 2 | 5 (C-8 a C-12) — ✅ todos corregidos | +| Bugs importantes Ronda 2 | 8 (I-1 a I-8) — ✅ todos corregidos | +| Bugs menores Ronda 2 | 5 (M-1 a M-5) — ✅ M-1, M-2, M-3 corregidos, M-4 pendiente, M-5 descartado | +| Problemas UX/UI | 11 (UX-1 a UX-11) — ✅ todos corregidos | +| Problemas de nombres | 7 (N-1 a N-7) — ✅ todos corregidos | +| Validaciones faltantes | 7 (V-1 a V-7) — ✅ todas corregidas | +| Problemas de seguridad | 3 (S-1 a S-3) — ✅ todos corregidos | +| Tests existentes | 48 (25 unitarios servicio + 23 integración/validación DTO) | +| Issues menores Ronda 3 | 8 (R3-1 a R3-8) — ✅ todos corregidos | +| Issues encontrados Ronda 4 | 7 (A-1 a A-7) — ✅ todos corregidos | +| Issues encontrados Ronda 5 | 5 (R5-1 a R5-5) — ✅ todos corregidos | +| Nueva funcionalidad Ronda 6 | 1 (NF-21) — Pomodoro en segundo plano | +| Nueva funcionalidad Ronda 7 | 4 (N-5, NF-20, NF-22, NF-6) — Meta diaria unificada, BroadcastChannel, filtros historial, estadísticas avanzadas | + +### Prioridad de corrección — Estado actual + +``` +FASE 1 — Bugs críticos R1 ✅ COMPLETADA +FASE 2 — Bugs de lógica + UX/UI R1 ✅ COMPLETADA +FASE 3 — Validaciones + Nombres + Tests ✅ COMPLETADA +FASE 4 — Bugs críticos R2 (C-8 a C-12) ✅ COMPLETADA +FASE 5 — Bugs importantes R2 (I-1 a I-8) ✅ COMPLETADA +FASE 6 — Bugs menores R2 (M-1 a M-5) ✅ COMPLETADA (M-4 corregido en Ronda 3) +FASE 7 — Issues menores frontend + seguridad (R3) ✅ COMPLETADA +FASE 8 — Code smells + tests integración (R4) ✅ COMPLETADA +FASE 9 — Seguridad S-2/S-3 + nombres N-2/N-4 + tests (R5) ✅ COMPLETADA +FASE 10 — NF-21 Pomodoro en segundo plano (R6) ✅ COMPLETADA +FASE 11 — N-5 + NF-20 + NF-22 + NF-6 (R7) ✅ COMPLETADA +``` + +### Lecciones aprendidas (3 rondas de auditoría) + +Las correcciones de las rondas 1 y 2 introdujeron **5 bugs críticos nuevos** (C-8 a C-12) y **8 bugs importantes nuevos** (I-1 a I-8). La causa raíz: + +1. **No había tests previos a la corrección** — cada cambio se hizo "confiando" en que no rompía otra cosa. +2. **Se corrigieron muchos bugs en un solo commit** — imposible saber qué cambio causó qué regresión. +3. **No se verificó en la UI real** — los tests backend no cubren JS, y los bugs están mayormente en JS. +4. **Se mezclaron refactorizaciones con correcciones** — cambios de nombre (N-1, N-5, N-6) se hicieron junto a bugs críticos. + +**La solución está en la sección 13.** Síguela al pie de la letra para que esta sea la última ronda. + +--- + +## Anexo: Estado actual del frontend JS + +El temporizador corre completamente en el cliente con `setInterval` de 1 segundo. Las variables clave del state: + +```javascript +// Index.cshtml - Script embebido (inline) +var timerState = { + mode: 'enfoque', // enfoque | corto | largo | personalizado + timeLeft: 1500, // segundos restantes + totalTime: 1500, // duración total en segundos + isRunning: false, // estado del timer + intervalId: null, // id del setInterval + sesionId: null, // id de la sesión activa en backend + tareaSeleccionadaId: null, // id de hábito/misión vinculado + tareaSeleccionadaTipo: null, // "habito" | "mision" + ciclosCompletados: 0, // contador de ciclos de la sesión actual + minutosTotales: 0, // suma de minutos enfocados + xpTotal: 0, // XP acumulado en la sesión + ciclosObjetivo: 4, // meta diaria (solo frontend) + enPausaActiva: false // declarado pero nunca usado +}; +``` + +### Funciones principales del frontend + +| Función | Propósito | +|---------|-----------| +| `startTimer()` | Inicia setInterval, llama a POST iniciar si es necesario | +| `pauseTimer()` | Pausa, clearInterval | +| `stopTimer()` | Detiene + POST cancelar | +| `resetTimer()` | Pausa + vuelve al modo actual | +| `onTimerComplete()` | Timer llega a 0, reproduce sonido, notifica, cambia modo | +| `setMode(mode)` | Cambia modo, reset timeLeft | +| `saltarCiclo()` | Skip, POST cancelar, va a descanso corto | +| `toggleFullscreen()` | Fullscreen API | +| `toggleTimer()` | Play/Pause toggle | +| `reproducirSonido(tipo)` | AudioContext API para sonidos | +| `iniciarTicTac()` / `detenerTicTac()` | Sonido de tic-tac durante el enfoque | +| `agregarHistorial(tipo)` | Actualiza el DOM del historial | +| `ajustarCiclos()` | Actualiza la barra de meta diaria | +| `notificar(mensaje)` | Notification API del navegador | +| `cargarDatosIniciales()` | Fetch inicial de configuración y estadísticas | + +--- + +*Auditoría generada el 2026-06-22 basada en el código fuente completo de EpycusApp.* +*24 archivos analizados del módulo Pomodoro (controladores, servicio, vistas, JS, CSS, DTOs, entidades, tests, seed data)* + +--- + +## Historial de Correcciones + +### 2026-06-22 (8va ronda — N-5, NF-20, NF-22, NF-6) + +- **N-5**: Se eliminó `ciclosObjetivo` del `timerState` frontend. Ahora se usa `metaDiariaValue` (inicializado desde `CONFIGURACION.metaDiaria`) como única fuente de verdad para la meta diaria. El ajuste manual via ± botones actualiza `metaDiariaValue` local (rango 1-50). Se guarda/restaura en `localStorage` como `metaDiaria`. — `Index.cshtml` +- **NF-20**: Sincronización entre pestañas vía `BroadcastChannel` (canal `epycus_pomodoro`). Al iniciar/pausar el timer, completar ciclo o cambiar modo, se transmite el estado a otras pestañas. Las pestañas receptoras sincronizan `modo`, `tiempoRestante`, `tiempoTotal`, `estaCorriendo` y `ciclosCompletados`. Compatibilidad: fallback silencioso si el navegador no soporta BroadcastChannel. — `Index.cshtml` +- **NF-22**: Historial con filtros mejorado. `GET /api/pomodoro/historial` ahora acepta `completada` (bool?) y `conXp` (bool?) como query params. `ServicioPomodoro.ObtenerHistorialAsync` filtra por `FueCompletada` y `XpOtorgado > 0` respectivamente. — `ApiPomodoroController.cs`, `IServicioPomodoro.cs`, `ServicioPomodoro.cs` +- **NF-6**: Nuevo endpoint `GET /api/pomodoro/estadisticas-avanzadas` con parámetros `desde`/`hasta`. Retorna `PomodoroEstadisticasAvanzadasResponse` con: `promedioCiclosPorDia`, `totalCiclos`, `totalMinutos`, `totalXp`, `porMes` (agrupación mensual) y `heatmapHoras` (distribución por hora del día, 24 slots). — `RespuestasApi.cs`, `IServicioPomodoro.cs`, `ServicioPomodoro.cs`, `ApiPomodoroController.cs` +- **Tests**: 10 tests nuevos (5 unitarios + 3 integración para filtros historial, 2 unitarios para estadísticas avanzadas). Total: 215 tests. +- **Pomodoro.md**: Actualizado con Ronda 7, métricas actualizadas. + +### 2026-06-22 (7ma ronda — NF-21 Pomodoro en segundo plano) + +- **NF-21 (R6-1)**: El temporizador ahora no se retrasa cuando la pestaña está en segundo plano. Se agregó `estadoTimer.ultimoTick` que registra `Date.now()` al iniciar el timer y en cada tick del `setInterval`. El manejador `visibilitychange` ahora: + - Al ocultar la pestaña: registra `ultimoTick = Date.now()` y actualiza el título con el tiempo restante. + - Al volver visible: calcula los segundos reales transcurridos (`(Date.now() - ultimoTick) / 1000`), descuenta de `tiempoRestante` y si expiró llama `alCompletarTimer()`. Esto evita que el throttling de `setInterval` en background retrase el timer. +- **Archivo**: `Views/Pomodoro/Index.cshtml` — líneas 397, 809–811, 1226–1246 +- **Tests**: 205 tests pasan (sin cambios — NF-21 es frontend JS puro) +- **Pomodoro.md**: Actualizado con Ronda 6, métricas actualizadas + +### 2026-06-22 (6ta ronda — seguridad, nombres, cobertura, code smells R5) + +- **R5-1 (S-2)**: Ownership validation agregada en ServicioPomodoro — `RegistrarCiclo`, `FinalizarSesion`, `CancelarSesion` ahora reciben `usuarioId` y validan `sesion.UsuarioId != usuarioId`. Doble validación controller + service. — `IServicioPomodoro.cs`, `ServicioPomodoro.cs`, `ApiPomodoroController.cs`, `ServicioPomodoroTests.cs` +- **R5-2 (N-2)**: `ConstantesGamificacion.XP_BASE_POMODORO` renombrado a `XpBasePomodoro` (camelCase consistente con ViewModels/Responses). — `ConstantesGamificacion.cs`, `ServicioPomodoro.cs`, `ServicioPomodoroTests.cs`, `ApiPomodoroTests.cs` +- **R5-3 (N-4)**: `ModoPersonalizadoMinutos` renombrado a `ModoPersonalizadoMin` en todas las capas (entidad + `[Column]`, DTO, response, controllers, vistas, tests, servicio) para unificar abreviatura `Min`. — 12 archivos +- **R5-4 (JS)**: Listener `change` en checkbox `chkTicTac` que detiene/reanuda tic-tac si el usuario cambia la opción durante el timer (antes solo se verificaba al iniciar). — `Index.cshtml` +- **R5-5 (Tests)**: 10 tests unitarios nuevos: + - `ObtenerHistorialAsync_SinSesiones_RetornaVacio` + - `ObtenerHistorialAsync_ConSesiones_RetornaPagina` + - `ObtenerHistorialAsync_FueraDeRango_RetornaVacio` + - `EstadisticasPeriodo_SinSesiones_RetornaCeros` + - `RegistrarCiclo_SesionInexistente_RetornaCeros` + - `FinalizarSesion_SesionInexistente_RetornaCeros` + - `CancelarSesion_SesionInexistente_NoLanzaExcepcion` + - `RegistrarCiclo_DeOtroUsuario_NoOtorgaXP` + - `FinalizarSesion_DeOtroUsuario_RetornaCeros` + - `CancelarSesion_DeOtroUsuario_NoCancela` +- **S-3**: Ya estaba corregido (catch con `mostrarError`). Solo se actualizó documentación. +- **N-3**: Verificado: valores de lista blanca en DTO coinciden con ` + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + + +
+
+ +
+
+
@Model.PreguntaGuiaHoy
+ +
+
+ + + + + + +
+
+ Psicología del bienestar +
+
+

+ Llevar un diario de ánimo tiene respaldo científico: ayuda a regular emociones, + reduce la rumiación mental y mejora la conciencia emocional. +

+
    +
  • Autoconocimiento: Identifica patrones en tu estado de ánimo.
  • +
  • Regulación emocional: Escribir reduce la intensidad de emociones negativas.
  • +
  • Gratitud: Enfocarte en lo positivo reprograma tu cerebro (neuroplasticidad).
  • +
  • Prevención: Detecta señales tempranas de agotamiento o ansiedad.
  • +
+
+
+ + +
+
+
+ @(new DateTime(Model.Año, Model.Mes, 1).ToString("MMMM yyyy")).ToUpper() +
+
+ + +
+
+ +
+
+ + +
+
+
+
+
+
+ DomLunMarMiéJueVieSáb +
+
+ @for (int i = 0; i < diaSemanaInicio; i++) + { +
+ } + @for (int dia = 1; dia <= totalDias; dia++) + { + var fecha = new DateOnly(Model.Año, Model.Mes, dia); + var tieneEntrada = entradasPorFecha.ContainsKey(dia); + var esHoy = fecha == hoy; + var animo = tieneEntrada ? entradasPorFecha[dia] : 0; + var color = animo > 0 ? estadoConfig[animo].Color : "transparent"; +
+ @dia + @if (tieneEntrada) + { + + } +
+ } +
+
+
+
+ +
+
+ Entradas de @(new DateTime(Model.Año, Model.Mes, 1).ToString("MMMM")).ToUpper() +
+
+ @if (Model.EntradasMes.Count == 0) + { +

+ No hay entradas este mes. ¡Escribe tu primera entrada! +

+ } + else + { +
+ @foreach (var entrada in Model.EntradasMes.OrderByDescending(e => e.Fecha)) + { + var cfg = estadoConfig[entrada.EstadoAnimo]; +
+
+ @entrada.Fecha.Day + @entrada.Fecha.ToString("MMM") +
+
+
+ @cfg.Emoji + @cfg.Label + + Energía: @entrada.NivelEnergia/5 + @if (entrada.NivelEstres.HasValue) { | Estrés: @entrada.NivelEstres/5 } + @if (entrada.HorasSueno.HasValue) { | Sueño: @entrada.HorasSueno.Value.ToString("0.#") h } + @if (entrada.ActividadFisica.HasValue) { | 🏃 @(entrada.ActividadFisica.Value ? "Sí" : "No") } + +
+ @if (!string.IsNullOrEmpty(entrada.DiarioTexto)) + { +

@entrada.DiarioTexto

+ } + @if (!string.IsNullOrEmpty(entrada.RespuestaGuia)) + { +
+ 💭 @entrada.RespuestaGuia +
+ } +
+
+ } +
+ } +
+
+
+ + + +@section Scripts { + +} diff --git a/Views/Habitos/Index.cshtml b/Views/Habitos/Index.cshtml index 9bcf45a..a97d3ad 100644 --- a/Views/Habitos/Index.cshtml +++ b/Views/Habitos/Index.cshtml @@ -78,24 +78,26 @@ Archivados
-
-
- Lun - Mar - Mié - Jue - Vie - Sáb - Dom - Hoy +
+ Lun + Mar + Mié + Jue + Vie + Sáb + Dom + Hoy
+
Cargando hábitos...
+ @if (Model?.Habitos == null || !Model.Habitos.Any()) {
No hay hábitos todavía. ¡Empieza creando uno nuevo!
@@ -106,39 +108,45 @@ {
-
-
-
@h.Nombre
- @h.CategoriaNombre +
+
@h.Nombre
+ @h.CategoriaNombre
-
+
@h.Frecuencia
@h.RachaActual días
- -
- -
-
-
-
-
-
-
+
+ +
+
+
+
+
+
+
+
+
- -
- +
+ @if (h.EstadoHoy == "Completado") + { + Completado + } + else + { + + }
-