feat: Sistema de Usuarios - Backend CRUD + JWT Auth (Issue #1)
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
This commit is contained in:
50
.gga
Normal file
50
.gga
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Gentleman Guardian Angel Configuration
|
||||||
|
# https://github.com/your-org/gga
|
||||||
|
|
||||||
|
# AI Provider (required)
|
||||||
|
# Options: claude, gemini, codex, opencode, ollama:<model>, lmstudio[:model], github:<model>
|
||||||
|
# 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"
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -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
|
||||||
53
AGENT.md
Normal file
53
AGENT.md
Normal file
@@ -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.
|
||||||
21
Backend/Dockerfile
Normal file
21
Backend/Dockerfile
Normal file
@@ -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"]
|
||||||
73
Backend/PruebaGentle.API/Controllers/AuthController.cs
Normal file
73
Backend/PruebaGentle.API/Controllers/AuthController.cs
Normal file
@@ -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> jwtSettings)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_passwordHasher = passwordHasher;
|
||||||
|
_jwtSettings = jwtSettings.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
Backend/PruebaGentle.API/Controllers/UsersController.cs
Normal file
106
Backend/PruebaGentle.API/Controllers/UsersController.cs
Normal file
@@ -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<IActionResult> 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<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
var users = await _userRepository.GetAllAsync();
|
||||||
|
return Ok(users.Select(MapToResponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Backend/PruebaGentle.API/Program.cs
Normal file
74
Backend/PruebaGentle.API/Program.cs
Normal file
@@ -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<JwtSettings>(
|
||||||
|
builder.Configuration.GetSection("JwtSettings"));
|
||||||
|
|
||||||
|
// Dependency Injection
|
||||||
|
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||||
|
builder.Services.AddSingleton<IPasswordHasher, PasswordHasher>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
14
Backend/PruebaGentle.API/Properties/launchSettings.json
Normal file
14
Backend/PruebaGentle.API/Properties/launchSettings.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Backend/PruebaGentle.API/PruebaGentle.API.csproj
Normal file
19
Backend/PruebaGentle.API/PruebaGentle.API.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\PruebaGentle.Core\PruebaGentle.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\PruebaGentle.Infrastructure\PruebaGentle.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
Backend/PruebaGentle.API/PruebaGentle.API.http
Normal file
6
Backend/PruebaGentle.API/PruebaGentle.API.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@PruebaGentle.API_HostAddress = http://localhost:5082
|
||||||
|
|
||||||
|
GET {{PruebaGentle.API_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
8
Backend/PruebaGentle.API/appsettings.Development.json
Normal file
8
Backend/PruebaGentle.API/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Backend/PruebaGentle.API/appsettings.json
Normal file
16
Backend/PruebaGentle.API/appsettings.json
Normal file
@@ -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": "*"
|
||||||
|
}
|
||||||
7
Backend/PruebaGentle.Core/Config/JwtSettings.cs
Normal file
7
Backend/PruebaGentle.Core/Config/JwtSettings.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace PruebaGentle.Core.Config;
|
||||||
|
|
||||||
|
public class JwtSettings
|
||||||
|
{
|
||||||
|
public string Secret { get; set; } = string.Empty;
|
||||||
|
public int ExpirationHours { get; set; } = 24;
|
||||||
|
}
|
||||||
9
Backend/PruebaGentle.Core/DTOs/CreateUserDto.cs
Normal file
9
Backend/PruebaGentle.Core/DTOs/CreateUserDto.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
7
Backend/PruebaGentle.Core/DTOs/LoginDto.cs
Normal file
7
Backend/PruebaGentle.Core/DTOs/LoginDto.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
7
Backend/PruebaGentle.Core/DTOs/LoginResponseDto.cs
Normal file
7
Backend/PruebaGentle.Core/DTOs/LoginResponseDto.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace PruebaGentle.Core.DTOs;
|
||||||
|
|
||||||
|
public class LoginResponseDto
|
||||||
|
{
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
8
Backend/PruebaGentle.Core/DTOs/UpdateUserDto.cs
Normal file
8
Backend/PruebaGentle.Core/DTOs/UpdateUserDto.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
10
Backend/PruebaGentle.Core/DTOs/UserResponseDto.cs
Normal file
10
Backend/PruebaGentle.Core/DTOs/UserResponseDto.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
11
Backend/PruebaGentle.Core/Entities/User.cs
Normal file
11
Backend/PruebaGentle.Core/Entities/User.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
7
Backend/PruebaGentle.Core/Interfaces/IPasswordHasher.cs
Normal file
7
Backend/PruebaGentle.Core/Interfaces/IPasswordHasher.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace PruebaGentle.Core.Interfaces;
|
||||||
|
|
||||||
|
public interface IPasswordHasher
|
||||||
|
{
|
||||||
|
string Hash(string password);
|
||||||
|
bool Verify(string password, string hash);
|
||||||
|
}
|
||||||
13
Backend/PruebaGentle.Core/Interfaces/IUserRepository.cs
Normal file
13
Backend/PruebaGentle.Core/Interfaces/IUserRepository.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using PruebaGentle.Core.Entities;
|
||||||
|
|
||||||
|
namespace PruebaGentle.Core.Interfaces;
|
||||||
|
|
||||||
|
public interface IUserRepository
|
||||||
|
{
|
||||||
|
Task<User> CreateAsync(User user);
|
||||||
|
Task<User?> GetByIdAsync(int id);
|
||||||
|
Task<IEnumerable<User>> GetAllAsync();
|
||||||
|
Task<User?> UpdateAsync(User user);
|
||||||
|
Task<bool> DeleteAsync(int id);
|
||||||
|
Task<User?> GetByUsernameAsync(string username);
|
||||||
|
}
|
||||||
9
Backend/PruebaGentle.Core/PruebaGentle.Core.csproj
Normal file
9
Backend/PruebaGentle.Core/PruebaGentle.Core.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\PruebaGentle.Core\PruebaGentle.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.5" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<User> CreateAsync(User user)
|
||||||
|
{
|
||||||
|
using var connection = CreateConnection();
|
||||||
|
var result = await connection.QuerySingleAsync<User>(
|
||||||
|
"sp_User_Create",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
user.Username,
|
||||||
|
user.PasswordHash,
|
||||||
|
user.Email,
|
||||||
|
user.NombreCompleto
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User?> GetByIdAsync(int id)
|
||||||
|
{
|
||||||
|
using var connection = CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<User>(
|
||||||
|
"sp_User_GetById",
|
||||||
|
new { Id = id },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<User>> GetAllAsync()
|
||||||
|
{
|
||||||
|
using var connection = CreateConnection();
|
||||||
|
return await connection.QueryAsync<User>(
|
||||||
|
"sp_User_GetAll",
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User?> UpdateAsync(User user)
|
||||||
|
{
|
||||||
|
using var connection = CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<User>(
|
||||||
|
"sp_User_Update",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.Email,
|
||||||
|
user.NombreCompleto
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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<User?> GetByUsernameAsync(string username)
|
||||||
|
{
|
||||||
|
using var connection = CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<User>(
|
||||||
|
"sp_User_GetByUsername",
|
||||||
|
new { Username = username },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Backend/PruebaGentle.slnx
Normal file
5
Backend/PruebaGentle.slnx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="PruebaGentle.API/PruebaGentle.API.csproj" />
|
||||||
|
<Project Path="PruebaGentle.Core/PruebaGentle.Core.csproj" />
|
||||||
|
<Project Path="PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj" />
|
||||||
|
</Solution>
|
||||||
8
Backend/Sql/CreateUsersTable.sql
Normal file
8
Backend/Sql/CreateUsersTable.sql
Normal file
@@ -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()
|
||||||
|
);
|
||||||
13
Backend/Sql/sp_User_Create.sql
Normal file
13
Backend/Sql/sp_User_Create.sql
Normal file
@@ -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
|
||||||
9
Backend/Sql/sp_User_Delete.sql
Normal file
9
Backend/Sql/sp_User_Delete.sql
Normal file
@@ -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
|
||||||
9
Backend/Sql/sp_User_GetAll.sql
Normal file
9
Backend/Sql/sp_User_GetAll.sql
Normal file
@@ -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
|
||||||
10
Backend/Sql/sp_User_GetById.sql
Normal file
10
Backend/Sql/sp_User_GetById.sql
Normal file
@@ -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
|
||||||
10
Backend/Sql/sp_User_GetByUsername.sql
Normal file
10
Backend/Sql/sp_User_GetByUsername.sql
Normal file
@@ -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
|
||||||
16
Backend/Sql/sp_User_Update.sql
Normal file
16
Backend/Sql/sp_User_Update.sql
Normal file
@@ -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
|
||||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@@ -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:
|
||||||
Reference in New Issue
Block a user