From 21e7e7b044538fb1fce745b585171b5c57cce1a9 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 31 Mar 2026 17:36:04 -0300 Subject: [PATCH] feat: Sistema de Usuarios - Backend CRUD + JWT Auth (Issue #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementación fundacional del proyecto PruebaGentle: - Arquitectura Clean/Hexagonal: Core, Infrastructure, API - 6 Stored Procedures para CRUD + autenticación - JWT authentication con BCrypt password hashing - Docker Compose (SQL Server + Backend) - Solución .NET 10 con Dapper + SqlClient Closes #1 --- .gga | 50 +++++++++ .gitignore | 24 ++++ AGENT.md | 53 +++++++++ Backend/Dockerfile | 21 ++++ .../Controllers/AuthController.cs | 73 ++++++++++++ .../Controllers/UsersController.cs | 106 ++++++++++++++++++ Backend/PruebaGentle.API/Program.cs | 74 ++++++++++++ .../Properties/launchSettings.json | 14 +++ .../PruebaGentle.API/PruebaGentle.API.csproj | 19 ++++ .../PruebaGentle.API/PruebaGentle.API.http | 6 + .../appsettings.Development.json | 8 ++ Backend/PruebaGentle.API/appsettings.json | 16 +++ .../PruebaGentle.Core/Config/JwtSettings.cs | 7 ++ .../PruebaGentle.Core/DTOs/CreateUserDto.cs | 9 ++ Backend/PruebaGentle.Core/DTOs/LoginDto.cs | 7 ++ .../DTOs/LoginResponseDto.cs | 7 ++ .../PruebaGentle.Core/DTOs/UpdateUserDto.cs | 8 ++ .../PruebaGentle.Core/DTOs/UserResponseDto.cs | 10 ++ Backend/PruebaGentle.Core/Entities/User.cs | 11 ++ .../Interfaces/IPasswordHasher.cs | 7 ++ .../Interfaces/IUserRepository.cs | 13 +++ .../PruebaGentle.Core.csproj | 9 ++ .../PruebaGentle.Infrastructure.csproj | 20 ++++ .../Repositories/UserRepository.cs | 89 +++++++++++++++ .../Services/PasswordHasher.cs | 16 +++ Backend/PruebaGentle.slnx | 5 + Backend/Sql/CreateUsersTable.sql | 8 ++ Backend/Sql/sp_User_Create.sql | 13 +++ Backend/Sql/sp_User_Delete.sql | 9 ++ Backend/Sql/sp_User_GetAll.sql | 9 ++ Backend/Sql/sp_User_GetById.sql | 10 ++ Backend/Sql/sp_User_GetByUsername.sql | 10 ++ Backend/Sql/sp_User_Update.sql | 16 +++ docker-compose.yml | 38 +++++++ 34 files changed, 795 insertions(+) create mode 100644 .gga create mode 100644 .gitignore create mode 100644 AGENT.md create mode 100644 Backend/Dockerfile create mode 100644 Backend/PruebaGentle.API/Controllers/AuthController.cs create mode 100644 Backend/PruebaGentle.API/Controllers/UsersController.cs create mode 100644 Backend/PruebaGentle.API/Program.cs create mode 100644 Backend/PruebaGentle.API/Properties/launchSettings.json create mode 100644 Backend/PruebaGentle.API/PruebaGentle.API.csproj create mode 100644 Backend/PruebaGentle.API/PruebaGentle.API.http create mode 100644 Backend/PruebaGentle.API/appsettings.Development.json create mode 100644 Backend/PruebaGentle.API/appsettings.json create mode 100644 Backend/PruebaGentle.Core/Config/JwtSettings.cs create mode 100644 Backend/PruebaGentle.Core/DTOs/CreateUserDto.cs create mode 100644 Backend/PruebaGentle.Core/DTOs/LoginDto.cs create mode 100644 Backend/PruebaGentle.Core/DTOs/LoginResponseDto.cs create mode 100644 Backend/PruebaGentle.Core/DTOs/UpdateUserDto.cs create mode 100644 Backend/PruebaGentle.Core/DTOs/UserResponseDto.cs create mode 100644 Backend/PruebaGentle.Core/Entities/User.cs create mode 100644 Backend/PruebaGentle.Core/Interfaces/IPasswordHasher.cs create mode 100644 Backend/PruebaGentle.Core/Interfaces/IUserRepository.cs create mode 100644 Backend/PruebaGentle.Core/PruebaGentle.Core.csproj create mode 100644 Backend/PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj create mode 100644 Backend/PruebaGentle.Infrastructure/Repositories/UserRepository.cs create mode 100644 Backend/PruebaGentle.Infrastructure/Services/PasswordHasher.cs create mode 100644 Backend/PruebaGentle.slnx create mode 100644 Backend/Sql/CreateUsersTable.sql create mode 100644 Backend/Sql/sp_User_Create.sql create mode 100644 Backend/Sql/sp_User_Delete.sql create mode 100644 Backend/Sql/sp_User_GetAll.sql create mode 100644 Backend/Sql/sp_User_GetById.sql create mode 100644 Backend/Sql/sp_User_GetByUsername.sql create mode 100644 Backend/Sql/sp_User_Update.sql create mode 100644 docker-compose.yml diff --git a/.gga b/.gga new file mode 100644 index 0000000..4b63f44 --- /dev/null +++ b/.gga @@ -0,0 +1,50 @@ +# Gentleman Guardian Angel Configuration +# https://github.com/your-org/gga + +# AI Provider (required) +# Options: claude, gemini, codex, opencode, ollama:, lmstudio[:model], github: +# Examples: +# PROVIDER="claude" +# PROVIDER="gemini" +# PROVIDER="codex" +# PROVIDER="opencode" +# PROVIDER="opencode:anthropic/claude-opus-4-5" +# PROVIDER="ollama:llama3.2" +# PROVIDER="ollama:codellama" +# PROVIDER="lmstudio" +# PROVIDER="lmstudio:qwen2.5-coder-7b-instruct" +# PROVIDER="github:gpt-4o" +# PROVIDER="github:deepseek-r1" +PROVIDER="claude" + +# File patterns to include in review (comma-separated) +# Default: * (all files) +# Examples: +# FILE_PATTERNS="*.ts,*.tsx" +# FILE_PATTERNS="*.py" +# FILE_PATTERNS="*.go,*.mod" +FILE_PATTERNS="*.ts,*.tsx,*.js,*.jsx" + +# File patterns to exclude from review (comma-separated) +# Default: none +# Examples: +# EXCLUDE_PATTERNS="*.test.ts,*.spec.ts" +# EXCLUDE_PATTERNS="*_test.go,*.mock.ts" +EXCLUDE_PATTERNS="*.test.ts,*.spec.ts,*.test.tsx,*.spec.tsx,*.d.ts" + +# File containing code review rules +# Default: AGENTS.md +RULES_FILE="AGENTS.md" + +# Strict mode: fail if AI response is ambiguous +# Default: true +STRICT_MODE="true" + +# Timeout in seconds for AI provider response +# Default: 300 (5 minutes) +# Increase for large changesets or slow connections +TIMEOUT="300" + +# Base branch for --pr-mode (auto-detects main/master/develop if empty) +# Default: auto-detect +# PR_BASE_BRANCH="main" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f874a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +## .NET +**/bin/ +**/obj/ +**/out/ + +## NuGet +**/packages/ +*.nuget.props +*.nuget.targets + +## IDE +.vs/ +.vscode/ +*.suo +*.user +*.userosscache +*.sln.docstates + +## OS +.DS_Store +Thumbs.db + +## Docker +docker-compose.override.yml diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..d6ae1b7 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,53 @@ +# 🏛️ Tech Stack, Architecture & DevOps Standards + +> **IMPORTANTE:** El idioma oficial de este proyecto es el ESPAÑOL. Todas las respuestas, explicaciones, comentarios de código y logs deben ser exclusivamente en español. + +Este proyecto utiliza C# (.NET Web API), React (TypeScript + Vite + Tailwind CSS), SQL Server, Docker y Gitea. +DEBES cumplir estrictamente estas reglas en TODAS las fases del SDD (Spec, Design, Apply, Verify). + +## 1. 📁 Estructura Estricta de Directorios +- Raíz del proyecto: SIEMPRE dividida en `[NombreProyecto]/Backend` y `[NombreProyecto]/Frontend`. +- La carpeta raíz solo debe contener: `.gitignore`, `docker-compose.yml`, `README.md`, `AGENTS.md` y archivos de configuración global. +- PROHIBIDO mezclar dependencias de Node en la raíz o archivos de solución `.sln` fuera de la carpeta `Backend`. + +## 2. ⚙️ Backend (C# MVC + Dapper + SPs) +Sigue el patrón de la skill **"implementing-dapper-queries"** adaptado a nuestra estructura: + +- **Arquitectura:** MVC Pragmático. + - `Controllers/`: Endpoints limpios. + - `Core/Interfaces/`: Definición de interfaces para repositorios. + - `Infrastructure/Dapper/Repositories/`: Implementación de interfaces usando Dapper. + - `Infrastructure/Models/`: DTOs y modelos de datos. +- **Micro ORM & Acceso a Datos:** + - SIEMPRE usa **Dapper**. PROHIBIDO Entity Framework. + - **Stored Procedures (SPs):** Toda lógica de persistencia o consulta compleja DEBE residir en un SP en SQL Server. + - **Workflow de Datos:** + 1. Crear/Actualizar el SP en la base de datos (usando el MCP de mssql para validar esquema). + 2. Crear el script de migración/SQL en `Backend/Sql/StoredProcedures/`. + 3. Implementar el Repositorio en C# llamando al SP. +- **Async:** Todos los métodos de repositorio y acciones del controlador deben ser `async Task`. + +## 3. 🎨 Frontend (React + Vite + Tailwind) +- **Estructura:** Seguir patrón de componentes funcionales y hooks. +- **Tipado:** TypeScript estricto. PROHIBIDO el uso de `any`. Definir interfaces en `Frontend/src/types/`. +- **Estilos:** Tailwind CSS exclusivo vía `className`. PROHIBIDO CSS puro a menos que sea para animaciones complejas. +- **API Client:** Centralizar llamadas en `Frontend/src/api/` usando `import.meta.env` para la URL base. + +## 4. 🐳 Entornos & Docker (Linux Target) +- **Dockerfiles:** Cada carpeta (`Backend` y `Frontend`) debe tener su propio `Dockerfile` optimizado para Linux. +- **Orquestación:** El `docker-compose.yml` en la raíz debe configurar la comunicación entre contenedores y el acceso a SQL Server. +- **Configuración:** Usa variables de entorno para las cadenas de conexión y puertos, permitiendo la convivencia entre local (localhost) y contenedor (nombre del servicio). + +## 5. 🐙 Gitea Workflow & Control de Versiones +Sigue estrictamente la skill **"gitea-workflow"**: + +- **Ramas:** Cada tarea de SDD debe ocurrir en una rama dedicada (ej: `feature/nombre-tarea`). +- **Commits:** Conventional Commits obligatorios (`feat:`, `fix:`, `chore:`). +- **Trazabilidad:** Referenciar SIEMPRE el ID de la incidencia de Gitea (ej: `Refs #12`). +- **Calidad:** Antes de marcar una tarea como completada, verificar que el código cumple con este AGENT.md para evitar rechazos en Pull Requests. +- **Automatización de Tareas:** + - Si al iniciar una tarea con `/sdd-new` no existe una incidencia (Issue) relacionada en Gitea, DEBES usar el MCP de Gitea para crearla y obtener su ID. + - Al finalizar con éxito la fase `verify`, DEBES crear un Pull Request (PR) desde tu rama de trabajo hacia `main`, describiendo los cambios realizados y referenciando la incidencia (ej: `Closes #12`). + +## 6. Reglas de entorno +- **Comandos:** Para todos los comandos de terminal, utiliza PowerShell o comandos directos de Windows, no uses 'bash' ya que mi entorno no lo soporta correctamente. \ No newline at end of file diff --git a/Backend/Dockerfile b/Backend/Dockerfile new file mode 100644 index 0000000..b245930 --- /dev/null +++ b/Backend/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY PruebaGentle.Core/PruebaGentle.Core.csproj PruebaGentle.Core/ +COPY PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj PruebaGentle.Infrastructure/ +COPY PruebaGentle.API/PruebaGentle.API.csproj PruebaGentle.API/ +RUN dotnet restore PruebaGentle.API/PruebaGentle.API.csproj +COPY . . +WORKDIR /src/PruebaGentle.API +RUN dotnet build -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "PruebaGentle.API.dll"] diff --git a/Backend/PruebaGentle.API/Controllers/AuthController.cs b/Backend/PruebaGentle.API/Controllers/AuthController.cs new file mode 100644 index 0000000..92d85b2 --- /dev/null +++ b/Backend/PruebaGentle.API/Controllers/AuthController.cs @@ -0,0 +1,73 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using PruebaGentle.Core.Config; +using PruebaGentle.Core.DTOs; +using PruebaGentle.Core.Interfaces; + +namespace PruebaGentle.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IUserRepository _userRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly JwtSettings _jwtSettings; + + public AuthController( + IUserRepository userRepository, + IPasswordHasher passwordHasher, + IOptions jwtSettings) + { + _userRepository = userRepository; + _passwordHasher = passwordHasher; + _jwtSettings = jwtSettings.Value; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginDto dto) + { + var user = await _userRepository.GetByUsernameAsync(dto.Username); + if (user == null) + return Unauthorized(new { error = "Credenciales inválidas." }); + + if (!_passwordHasher.Verify(dto.Password, user.PasswordHash)) + return Unauthorized(new { error = "Credenciales inválidas." }); + + var expiresAt = DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours); + var token = GenerateJwtToken(user.Id, user.Username, user.Email, expiresAt); + + return Ok(new LoginResponseDto + { + Token = token, + ExpiresAt = expiresAt + }); + } + + private string GenerateJwtToken(int userId, string username, string email, DateTime expiresAt) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim(JwtRegisteredClaimNames.UniqueName, username), + new Claim(JwtRegisteredClaimNames.Email, email), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var token = new JwtSecurityToken( + issuer: "PruebaGentle", + audience: "PruebaGentle", + claims: claims, + expires: expiresAt, + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/Backend/PruebaGentle.API/Controllers/UsersController.cs b/Backend/PruebaGentle.API/Controllers/UsersController.cs new file mode 100644 index 0000000..fc721db --- /dev/null +++ b/Backend/PruebaGentle.API/Controllers/UsersController.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PruebaGentle.Core.DTOs; +using PruebaGentle.Core.Entities; +using PruebaGentle.Core.Interfaces; + +namespace PruebaGentle.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class UsersController : ControllerBase +{ + private readonly IUserRepository _userRepository; + private readonly IPasswordHasher _passwordHasher; + + public UsersController(IUserRepository userRepository, IPasswordHasher passwordHasher) + { + _userRepository = userRepository; + _passwordHasher = passwordHasher; + } + + [HttpPost] + public async Task Create([FromBody] CreateUserDto dto) + { + var existingUser = await _userRepository.GetByUsernameAsync(dto.Username); + if (existingUser != null) + return BadRequest(new { error = "El username ya existe." }); + + var user = new User + { + Username = dto.Username, + PasswordHash = _passwordHasher.Hash(dto.Password), + Email = dto.Email, + NombreCompleto = dto.NombreCompleto + }; + + var created = await _userRepository.CreateAsync(user); + + return CreatedAtAction(nameof(GetById), new { id = created.Id }, MapToResponse(created)); + } + + [HttpGet] + public async Task GetAll() + { + var users = await _userRepository.GetAllAsync(); + return Ok(users.Select(MapToResponse)); + } + + [HttpGet("{id}")] + public async Task GetById(int id) + { + var user = await _userRepository.GetByIdAsync(id); + if (user == null) + return NotFound(new { error = "Usuario no encontrado." }); + + return Ok(MapToResponse(user)); + } + + [HttpPut("{id}")] + public async Task Update(int id, [FromBody] UpdateUserDto dto) + { + var existing = await _userRepository.GetByIdAsync(id); + if (existing == null) + return NotFound(new { error = "Usuario no encontrado." }); + + // Check username/email conflicts with other users + var duplicateUser = await _userRepository.GetByUsernameAsync(dto.Username); + if (duplicateUser != null && duplicateUser.Id != id) + return BadRequest(new { error = "El username ya está en uso por otro usuario." }); + + var user = new User + { + Id = id, + Username = dto.Username, + Email = dto.Email, + NombreCompleto = dto.NombreCompleto + }; + + var updated = await _userRepository.UpdateAsync(user); + return Ok(MapToResponse(updated!)); + } + + [HttpDelete("{id}")] + public async Task Delete(int id) + { + var existing = await _userRepository.GetByIdAsync(id); + if (existing == null) + return NotFound(new { error = "Usuario no encontrado." }); + + await _userRepository.DeleteAsync(id); + return NoContent(); + } + + private static UserResponseDto MapToResponse(User user) + { + return new UserResponseDto + { + Id = user.Id, + Username = user.Username, + Email = user.Email, + NombreCompleto = user.NombreCompleto, + FechaCreacion = user.FechaCreacion + }; + } +} diff --git a/Backend/PruebaGentle.API/Program.cs b/Backend/PruebaGentle.API/Program.cs new file mode 100644 index 0000000..66c667b --- /dev/null +++ b/Backend/PruebaGentle.API/Program.cs @@ -0,0 +1,74 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using PruebaGentle.Core.Config; +using PruebaGentle.Core.Interfaces; +using PruebaGentle.Infrastructure.Repositories; +using PruebaGentle.Infrastructure.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Bind JwtSettings +builder.Services.Configure( + builder.Configuration.GetSection("JwtSettings")); + +// Dependency Injection +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +// JWT Authentication +var jwtSettings = builder.Configuration.GetSection("JwtSettings"); +var secretKey = jwtSettings["Secret"] ?? throw new InvalidOperationException("JWT Secret not configured."); + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = "PruebaGentle", + ValidAudience = "PruebaGentle", + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)) + }; +}); + +builder.Services.AddAuthorization(); + +// Controllers +builder.Services.AddControllers(); + +// OpenAPI (native .NET 10) +builder.Services.AddOpenApi(); + +// CORS +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +// Middleware pipeline +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/Backend/PruebaGentle.API/Properties/launchSettings.json b/Backend/PruebaGentle.API/Properties/launchSettings.json new file mode 100644 index 0000000..b8867db --- /dev/null +++ b/Backend/PruebaGentle.API/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5082", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Backend/PruebaGentle.API/PruebaGentle.API.csproj b/Backend/PruebaGentle.API/PruebaGentle.API.csproj new file mode 100644 index 0000000..3423b64 --- /dev/null +++ b/Backend/PruebaGentle.API/PruebaGentle.API.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/Backend/PruebaGentle.API/PruebaGentle.API.http b/Backend/PruebaGentle.API/PruebaGentle.API.http new file mode 100644 index 0000000..f98aed9 --- /dev/null +++ b/Backend/PruebaGentle.API/PruebaGentle.API.http @@ -0,0 +1,6 @@ +@PruebaGentle.API_HostAddress = http://localhost:5082 + +GET {{PruebaGentle.API_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Backend/PruebaGentle.API/appsettings.Development.json b/Backend/PruebaGentle.API/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Backend/PruebaGentle.API/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Backend/PruebaGentle.API/appsettings.json b/Backend/PruebaGentle.API/appsettings.json new file mode 100644 index 0000000..ab1fafa --- /dev/null +++ b/Backend/PruebaGentle.API/appsettings.json @@ -0,0 +1,16 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=PruebaGentle;User Id=sa;Password=YourStrong@Password123;TrustServerCertificate=true;" + }, + "JwtSettings": { + "Secret": "ThisIsA32CharacterLongSecretKey!!", + "ExpirationHours": 24 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Backend/PruebaGentle.Core/Config/JwtSettings.cs b/Backend/PruebaGentle.Core/Config/JwtSettings.cs new file mode 100644 index 0000000..2f44224 --- /dev/null +++ b/Backend/PruebaGentle.Core/Config/JwtSettings.cs @@ -0,0 +1,7 @@ +namespace PruebaGentle.Core.Config; + +public class JwtSettings +{ + public string Secret { get; set; } = string.Empty; + public int ExpirationHours { get; set; } = 24; +} diff --git a/Backend/PruebaGentle.Core/DTOs/CreateUserDto.cs b/Backend/PruebaGentle.Core/DTOs/CreateUserDto.cs new file mode 100644 index 0000000..6ce1c18 --- /dev/null +++ b/Backend/PruebaGentle.Core/DTOs/CreateUserDto.cs @@ -0,0 +1,9 @@ +namespace PruebaGentle.Core.DTOs; + +public class CreateUserDto +{ + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string NombreCompleto { get; set; } = string.Empty; +} diff --git a/Backend/PruebaGentle.Core/DTOs/LoginDto.cs b/Backend/PruebaGentle.Core/DTOs/LoginDto.cs new file mode 100644 index 0000000..b862ca8 --- /dev/null +++ b/Backend/PruebaGentle.Core/DTOs/LoginDto.cs @@ -0,0 +1,7 @@ +namespace PruebaGentle.Core.DTOs; + +public class LoginDto +{ + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} diff --git a/Backend/PruebaGentle.Core/DTOs/LoginResponseDto.cs b/Backend/PruebaGentle.Core/DTOs/LoginResponseDto.cs new file mode 100644 index 0000000..87fc8e2 --- /dev/null +++ b/Backend/PruebaGentle.Core/DTOs/LoginResponseDto.cs @@ -0,0 +1,7 @@ +namespace PruebaGentle.Core.DTOs; + +public class LoginResponseDto +{ + public string Token { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } +} diff --git a/Backend/PruebaGentle.Core/DTOs/UpdateUserDto.cs b/Backend/PruebaGentle.Core/DTOs/UpdateUserDto.cs new file mode 100644 index 0000000..c94b112 --- /dev/null +++ b/Backend/PruebaGentle.Core/DTOs/UpdateUserDto.cs @@ -0,0 +1,8 @@ +namespace PruebaGentle.Core.DTOs; + +public class UpdateUserDto +{ + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string NombreCompleto { get; set; } = string.Empty; +} diff --git a/Backend/PruebaGentle.Core/DTOs/UserResponseDto.cs b/Backend/PruebaGentle.Core/DTOs/UserResponseDto.cs new file mode 100644 index 0000000..20a9393 --- /dev/null +++ b/Backend/PruebaGentle.Core/DTOs/UserResponseDto.cs @@ -0,0 +1,10 @@ +namespace PruebaGentle.Core.DTOs; + +public class UserResponseDto +{ + public int Id { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string NombreCompleto { get; set; } = string.Empty; + public DateTime FechaCreacion { get; set; } +} diff --git a/Backend/PruebaGentle.Core/Entities/User.cs b/Backend/PruebaGentle.Core/Entities/User.cs new file mode 100644 index 0000000..74a4d78 --- /dev/null +++ b/Backend/PruebaGentle.Core/Entities/User.cs @@ -0,0 +1,11 @@ +namespace PruebaGentle.Core.Entities; + +public class User +{ + public int Id { get; set; } + public string Username { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string NombreCompleto { get; set; } = string.Empty; + public DateTime FechaCreacion { get; set; } +} diff --git a/Backend/PruebaGentle.Core/Interfaces/IPasswordHasher.cs b/Backend/PruebaGentle.Core/Interfaces/IPasswordHasher.cs new file mode 100644 index 0000000..62da825 --- /dev/null +++ b/Backend/PruebaGentle.Core/Interfaces/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace PruebaGentle.Core.Interfaces; + +public interface IPasswordHasher +{ + string Hash(string password); + bool Verify(string password, string hash); +} diff --git a/Backend/PruebaGentle.Core/Interfaces/IUserRepository.cs b/Backend/PruebaGentle.Core/Interfaces/IUserRepository.cs new file mode 100644 index 0000000..4606125 --- /dev/null +++ b/Backend/PruebaGentle.Core/Interfaces/IUserRepository.cs @@ -0,0 +1,13 @@ +using PruebaGentle.Core.Entities; + +namespace PruebaGentle.Core.Interfaces; + +public interface IUserRepository +{ + Task CreateAsync(User user); + Task GetByIdAsync(int id); + Task> GetAllAsync(); + Task UpdateAsync(User user); + Task DeleteAsync(int id); + Task GetByUsernameAsync(string username); +} diff --git a/Backend/PruebaGentle.Core/PruebaGentle.Core.csproj b/Backend/PruebaGentle.Core/PruebaGentle.Core.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/Backend/PruebaGentle.Core/PruebaGentle.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Backend/PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj b/Backend/PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj new file mode 100644 index 0000000..8d237b6 --- /dev/null +++ b/Backend/PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/Backend/PruebaGentle.Infrastructure/Repositories/UserRepository.cs b/Backend/PruebaGentle.Infrastructure/Repositories/UserRepository.cs new file mode 100644 index 0000000..2e40fbb --- /dev/null +++ b/Backend/PruebaGentle.Infrastructure/Repositories/UserRepository.cs @@ -0,0 +1,89 @@ +using System.Data; +using Dapper; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using PruebaGentle.Core.Entities; +using PruebaGentle.Core.Interfaces; + +namespace PruebaGentle.Infrastructure.Repositories; + +public class UserRepository : IUserRepository +{ + private readonly string _connectionString; + + public UserRepository(IConfiguration configuration) + { + _connectionString = configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); + } + + private IDbConnection CreateConnection() => new SqlConnection(_connectionString); + + public async Task CreateAsync(User user) + { + using var connection = CreateConnection(); + var result = await connection.QuerySingleAsync( + "sp_User_Create", + new + { + user.Username, + user.PasswordHash, + user.Email, + user.NombreCompleto + }, + commandType: CommandType.StoredProcedure); + + return result; + } + + public async Task GetByIdAsync(int id) + { + using var connection = CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "sp_User_GetById", + new { Id = id }, + commandType: CommandType.StoredProcedure); + } + + public async Task> GetAllAsync() + { + using var connection = CreateConnection(); + return await connection.QueryAsync( + "sp_User_GetAll", + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateAsync(User user) + { + using var connection = CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "sp_User_Update", + new + { + user.Id, + user.Username, + user.Email, + user.NombreCompleto + }, + commandType: CommandType.StoredProcedure); + } + + public async Task DeleteAsync(int id) + { + using var connection = CreateConnection(); + var affected = await connection.ExecuteAsync( + "sp_User_Delete", + new { Id = id }, + commandType: CommandType.StoredProcedure); + return affected > 0; + } + + public async Task GetByUsernameAsync(string username) + { + using var connection = CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "sp_User_GetByUsername", + new { Username = username }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/Backend/PruebaGentle.Infrastructure/Services/PasswordHasher.cs b/Backend/PruebaGentle.Infrastructure/Services/PasswordHasher.cs new file mode 100644 index 0000000..e34656c --- /dev/null +++ b/Backend/PruebaGentle.Infrastructure/Services/PasswordHasher.cs @@ -0,0 +1,16 @@ +using PruebaGentle.Core.Interfaces; + +namespace PruebaGentle.Infrastructure.Services; + +public class PasswordHasher : IPasswordHasher +{ + public string Hash(string password) + { + return BCrypt.Net.BCrypt.HashPassword(password); + } + + public bool Verify(string password, string hash) + { + return BCrypt.Net.BCrypt.Verify(password, hash); + } +} diff --git a/Backend/PruebaGentle.slnx b/Backend/PruebaGentle.slnx new file mode 100644 index 0000000..1f96d4e --- /dev/null +++ b/Backend/PruebaGentle.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/Backend/Sql/CreateUsersTable.sql b/Backend/Sql/CreateUsersTable.sql new file mode 100644 index 0000000..4b9d86a --- /dev/null +++ b/Backend/Sql/CreateUsersTable.sql @@ -0,0 +1,8 @@ +CREATE TABLE Users ( + Id INT IDENTITY(1,1) PRIMARY KEY, + Username NVARCHAR(50) NOT NULL UNIQUE, + PasswordHash NVARCHAR(255) NOT NULL, + Email NVARCHAR(100) NOT NULL UNIQUE, + NombreCompleto NVARCHAR(100) NOT NULL, + FechaCreacion DATETIME NOT NULL DEFAULT GETDATE() +); diff --git a/Backend/Sql/sp_User_Create.sql b/Backend/Sql/sp_User_Create.sql new file mode 100644 index 0000000..006942c --- /dev/null +++ b/Backend/Sql/sp_User_Create.sql @@ -0,0 +1,13 @@ +CREATE OR ALTER PROCEDURE sp_User_Create + @Username NVARCHAR(50), + @PasswordHash NVARCHAR(255), + @Email NVARCHAR(100), + @NombreCompleto NVARCHAR(100) +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO Users (Username, PasswordHash, Email, NombreCompleto) + OUTPUT INSERTED.Id, INSERTED.Username, INSERTED.Email, INSERTED.NombreCompleto, INSERTED.FechaCreacion + VALUES (@Username, @PasswordHash, @Email, @NombreCompleto); +END diff --git a/Backend/Sql/sp_User_Delete.sql b/Backend/Sql/sp_User_Delete.sql new file mode 100644 index 0000000..e77e64e --- /dev/null +++ b/Backend/Sql/sp_User_Delete.sql @@ -0,0 +1,9 @@ +CREATE OR ALTER PROCEDURE sp_User_Delete + @Id INT +AS +BEGIN + SET NOCOUNT ON; + + DELETE FROM Users + WHERE Id = @Id; +END diff --git a/Backend/Sql/sp_User_GetAll.sql b/Backend/Sql/sp_User_GetAll.sql new file mode 100644 index 0000000..5a5a25c --- /dev/null +++ b/Backend/Sql/sp_User_GetAll.sql @@ -0,0 +1,9 @@ +CREATE OR ALTER PROCEDURE sp_User_GetAll +AS +BEGIN + SET NOCOUNT ON; + + SELECT Id, Username, Email, NombreCompleto, FechaCreacion + FROM Users + ORDER BY FechaCreacion DESC; +END diff --git a/Backend/Sql/sp_User_GetById.sql b/Backend/Sql/sp_User_GetById.sql new file mode 100644 index 0000000..ff366da --- /dev/null +++ b/Backend/Sql/sp_User_GetById.sql @@ -0,0 +1,10 @@ +CREATE OR ALTER PROCEDURE sp_User_GetById + @Id INT +AS +BEGIN + SET NOCOUNT ON; + + SELECT Id, Username, Email, NombreCompleto, FechaCreacion + FROM Users + WHERE Id = @Id; +END diff --git a/Backend/Sql/sp_User_GetByUsername.sql b/Backend/Sql/sp_User_GetByUsername.sql new file mode 100644 index 0000000..7983edd --- /dev/null +++ b/Backend/Sql/sp_User_GetByUsername.sql @@ -0,0 +1,10 @@ +CREATE OR ALTER PROCEDURE sp_User_GetByUsername + @Username NVARCHAR(50) +AS +BEGIN + SET NOCOUNT ON; + + SELECT Id, Username, PasswordHash, Email, NombreCompleto, FechaCreacion + FROM Users + WHERE Username = @Username; +END diff --git a/Backend/Sql/sp_User_Update.sql b/Backend/Sql/sp_User_Update.sql new file mode 100644 index 0000000..d0e9177 --- /dev/null +++ b/Backend/Sql/sp_User_Update.sql @@ -0,0 +1,16 @@ +CREATE OR ALTER PROCEDURE sp_User_Update + @Id INT, + @Username NVARCHAR(50), + @Email NVARCHAR(100), + @NombreCompleto NVARCHAR(100) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE Users + SET Username = @Username, + Email = @Email, + NombreCompleto = @NombreCompleto + OUTPUT INSERTED.Id, INSERTED.Username, INSERTED.Email, INSERTED.NombreCompleto, INSERTED.FechaCreacion + WHERE Id = @Id; +END diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..67a4c01 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +version: '3.8' + +services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: "YourStrong@Password123" + MSSQL_PID: "Developer" + ports: + - "1433:1433" + volumes: + - sqlserver_data:/var/opt/mssql + - ./Backend/Sql:/docker-entrypoint-initdb.d + healthcheck: + test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "YourStrong@Password123" -Q "SELECT 1" || exit 1 + interval: 10s + timeout: 3s + retries: 10 + start_period: 30s + + backend: + build: + context: . + dockerfile: Backend/Dockerfile + ports: + - "5000:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=Server=sqlserver;Database=PruebaGentle;User Id=sa;Password=YourStrong@Password123;TrustServerCertificate=true; + - JwtSettings__Secret=ThisIsA32CharacterLongSecretKey!! + - JwtSettings__ExpirationHours=24 + depends_on: + sqlserver: + condition: service_healthy + +volumes: + sqlserver_data: