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:
2026-03-31 17:36:04 -03:00
commit 21e7e7b044
34 changed files with 795 additions and 0 deletions

50
.gga Normal file
View 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
View 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
View 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
View 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"]

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

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

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

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

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

View File

@@ -0,0 +1,6 @@
@PruebaGentle.API_HostAddress = http://localhost:5082
GET {{PruebaGentle.API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View 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": "*"
}

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

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

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

View File

@@ -0,0 +1,7 @@
namespace PruebaGentle.Core.DTOs;
public class LoginResponseDto
{
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
}

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

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

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

View File

@@ -0,0 +1,7 @@
namespace PruebaGentle.Core.Interfaces;
public interface IPasswordHasher
{
string Hash(string password);
bool Verify(string password, string hash);
}

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

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

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

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

View 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

View 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

View 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

View 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

View 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

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