Initial commit
This commit is contained in:
8
src/backend/Randall.slnx
Normal file
8
src/backend/Randall.slnx
Normal file
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Randall.Api/Randall.Api.csproj" />
|
||||
<Project Path="src/Randall.Application/Randall.Application.csproj" />
|
||||
<Project Path="src/Randall.Domain/Randall.Domain.csproj" />
|
||||
<Project Path="src/Randall.Infrastructure/Randall.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
70
src/backend/src/Randall.Api/Admin/AdminController.cs
Normal file
70
src/backend/src/Randall.Api/Admin/AdminController.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Randall.Application.Admin.ApproveUser;
|
||||
using Randall.Application.Admin.DeleteUser;
|
||||
using Randall.Application.Admin.GetAllUsers;
|
||||
using Randall.Application.Admin.GetPendingUsers;
|
||||
using Randall.Application.Admin.MakeAdmin;
|
||||
|
||||
namespace Randall.Api.Admin;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[Authorize]
|
||||
public class AdminController(
|
||||
GetAllUsersHandler getAllUsersHandler,
|
||||
GetPendingUsersHandler getPendingUsersHandler,
|
||||
ApproveUserHandler approveUserHandler,
|
||||
DeleteUserHandler deleteUserHandler,
|
||||
MakeAdminHandler makeAdminHandler) : ControllerBase
|
||||
{
|
||||
private bool IsAdmin =>
|
||||
User.FindFirstValue("isAdmin") == "true";
|
||||
|
||||
[HttpGet("users")]
|
||||
public async Task<IActionResult> GetAllUsers(CancellationToken ct)
|
||||
{
|
||||
if (!IsAdmin) return Forbid();
|
||||
var result = await getAllUsersHandler.HandleAsync(ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("users/pending")]
|
||||
public async Task<IActionResult> GetPendingUsers(CancellationToken ct)
|
||||
{
|
||||
if (!IsAdmin) return Forbid();
|
||||
var result = await getPendingUsersHandler.HandleAsync(ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("users/{id}/approve")]
|
||||
public async Task<IActionResult> ApproveUser(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (!IsAdmin) return Forbid();
|
||||
var result = await approveUserHandler.HandleAsync(new ApproveUserCommand(id), ct);
|
||||
if (!result.IsSuccess)
|
||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("users/{id}/make-admin")]
|
||||
public async Task<IActionResult> MakeAdmin(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (!IsAdmin) return Forbid();
|
||||
var result = await makeAdminHandler.HandleAsync(new MakeAdminCommand(id), ct);
|
||||
if (!result.IsSuccess)
|
||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("users/{id}")]
|
||||
public async Task<IActionResult> DeleteUser(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (!IsAdmin) return Forbid();
|
||||
var result = await deleteUserHandler.HandleAsync(new DeleteUserCommand(id), ct);
|
||||
if (!result.IsSuccess)
|
||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
39
src/backend/src/Randall.Api/Auth/AuthController.cs
Normal file
39
src/backend/src/Randall.Api/Auth/AuthController.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Randall.Application.Auth;
|
||||
using Randall.Application.Auth.Login;
|
||||
using Randall.Application.Auth.Register;
|
||||
|
||||
namespace Randall.Api.Auth;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController(RegisterHandler registerHandler, LoginHandler loginHandler) : ControllerBase
|
||||
{
|
||||
[HttpPost("register")]
|
||||
[ProducesResponseType<RegisterResponse>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Register([FromBody] AuthRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await registerHandler.HandleAsync(
|
||||
new RegisterCommand(request.Email, request.Name ?? string.Empty, request.Password), ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Login([FromBody] AuthRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await loginHandler.HandleAsync(
|
||||
new LoginCommand(request.Email, request.Password), ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
3
src/backend/src/Randall.Api/Auth/AuthRequest.cs
Normal file
3
src/backend/src/Randall.Api/Auth/AuthRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Api.Auth;
|
||||
|
||||
public record AuthRequest(string Email, string Password, string? Name);
|
||||
68
src/backend/src/Randall.Api/Program.cs
Normal file
68
src/backend/src/Randall.Api/Program.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Randall.Application;
|
||||
using Randall.Application.Common;
|
||||
using Randall.Infrastructure;
|
||||
using Randall.Infrastructure.Persistence;
|
||||
using Randall.Infrastructure.Persistence.Seeding;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(o =>
|
||||
o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.MapInboundClaims = false;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)),
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var hasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
|
||||
await DatabaseSeeder.SeedAsync(db, hasher);
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/health");
|
||||
app.UseCors();
|
||||
if (app.Environment.IsDevelopment()) app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
23
src/backend/src/Randall.Api/Properties/launchSettings.json
Normal file
23
src/backend/src/Randall.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5180",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7004;http://localhost:5180",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/backend/src/Randall.Api/Randall.Api.csproj
Normal file
24
src/backend/src/Randall.Api/Randall.Api.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<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.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Randall.Application\Randall.Application.csproj" />
|
||||
<ProjectReference Include="..\Randall.Infrastructure\Randall.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
src/backend/src/Randall.Api/Randall.Api.http
Normal file
6
src/backend/src/Randall.Api/Randall.Api.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@Randall.Api_HostAddress = http://localhost:5180
|
||||
|
||||
GET {{Randall.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Api.Reservations;
|
||||
|
||||
public record CreateReservationRequest(Guid WorkplaceId, DateOnly Date);
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Randall.Application.Reservations.CancelReservation;
|
||||
using Randall.Application.Reservations.CreateReservation;
|
||||
using Randall.Application.Reservations.GetMyReservations;
|
||||
using Randall.Application.Reservations.GetReservationById;
|
||||
|
||||
namespace Randall.Api.Reservations;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/reservations")]
|
||||
[Authorize]
|
||||
public class ReservationsController(
|
||||
CreateReservationHandler createHandler,
|
||||
CancelReservationHandler cancelHandler,
|
||||
GetMyReservationsHandler getMyHandler,
|
||||
GetReservationByIdHandler getByIdHandler) : ControllerBase
|
||||
{
|
||||
private string UserEmail => User.FindFirstValue(JwtRegisteredClaimNames.Email)!;
|
||||
private string UserName => User.FindFirstValue(JwtRegisteredClaimNames.Name)!;
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType<CreatedReservationDto>(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateReservationRequest request, CancellationToken ct)
|
||||
{
|
||||
var command = new CreateReservationCommand(request.WorkplaceId, UserEmail, UserName, request.Date);
|
||||
var result = await createHandler.HandleAsync(command, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Value!.Id }, result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType<ReservationDetailDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await getByIdHandler.HandleAsync(new GetReservationByIdQuery(id), ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return NotFound(new ProblemDetails { Detail = result.Error });
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("my")]
|
||||
[ProducesResponseType<IReadOnlyList<ReservationDto>>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetMy(CancellationToken ct)
|
||||
{
|
||||
var result = await getMyHandler.HandleAsync(new GetMyReservationsQuery(UserEmail), ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
|
||||
{
|
||||
var command = new CancelReservationCommand(id, UserEmail);
|
||||
var result = await cancelHandler.HandleAsync(command, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
if (result.Error!.Contains("not found"))
|
||||
return NotFound(new ProblemDetails { Detail = result.Error });
|
||||
|
||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Randall.Application.Workplaces.GetAllWorkplaces;
|
||||
using Randall.Application.Workplaces.GetAvailableWorkplaces;
|
||||
using Randall.Application.Workplaces.GetWorkplaceSchedule;
|
||||
|
||||
namespace Randall.Api.Workplaces;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/workplaces")]
|
||||
[Authorize]
|
||||
public class WorkplacesController(
|
||||
GetAllWorkplacesHandler getAllHandler,
|
||||
GetAvailableWorkplacesHandler getAvailableHandler,
|
||||
GetWorkplaceScheduleHandler getScheduleHandler) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType<IReadOnlyList<WorkplaceDto>>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetAll(CancellationToken ct)
|
||||
{
|
||||
var result = await getAllHandler.HandleAsync(ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("available")]
|
||||
[ProducesResponseType<IReadOnlyList<AvailableWorkplaceDto>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> GetAvailable([FromQuery] DateOnly date, CancellationToken ct)
|
||||
{
|
||||
var result = await getAvailableHandler.HandleAsync(new GetAvailableWorkplacesQuery(date), ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("schedule")]
|
||||
[ProducesResponseType<IReadOnlyList<WorkplaceScheduleDto>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> GetSchedule([FromQuery] DateOnly date, CancellationToken ct)
|
||||
{
|
||||
var result = await getScheduleHandler.HandleAsync(date, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
8
src/backend/src/Randall.Api/appsettings.Development.json
Normal file
8
src/backend/src/Randall.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/backend/src/Randall.Api/appsettings.json
Normal file
17
src/backend/src/Randall.Api/appsettings.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data Source=randall.db"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "randall-super-secret-jwt-key-change-in-production-32chars",
|
||||
"Issuer": "randall-api",
|
||||
"Audience": "randall-app"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Admin.ApproveUser;
|
||||
|
||||
public record ApproveUserCommand(Guid UserId);
|
||||
@@ -0,0 +1,18 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Users;
|
||||
|
||||
namespace Randall.Application.Admin.ApproveUser;
|
||||
|
||||
public class ApproveUserHandler(IUserRepository userRepository)
|
||||
{
|
||||
public async Task<Result> HandleAsync(ApproveUserCommand command, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(command.UserId, ct);
|
||||
if (user is null)
|
||||
return Result.Failure("User not found.");
|
||||
|
||||
user.Approve();
|
||||
await userRepository.SaveChangesAsync(ct);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Admin.DeleteUser;
|
||||
|
||||
public record DeleteUserCommand(Guid UserId);
|
||||
@@ -0,0 +1,21 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Users;
|
||||
|
||||
namespace Randall.Application.Admin.DeleteUser;
|
||||
|
||||
public class DeleteUserHandler(IUserRepository userRepository)
|
||||
{
|
||||
public async Task<Result> HandleAsync(DeleteUserCommand command, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(command.UserId, ct);
|
||||
if (user is null)
|
||||
return Result.Failure("User not found.");
|
||||
|
||||
if (user.IsAdmin)
|
||||
return Result.Failure("Admin users cannot be deleted.");
|
||||
|
||||
userRepository.Delete(user);
|
||||
await userRepository.SaveChangesAsync(ct);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Admin.GetAllUsers;
|
||||
|
||||
public record AdminUserDto(Guid Id, string Name, string Email, bool IsApproved, bool IsAdmin);
|
||||
@@ -0,0 +1,14 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Users;
|
||||
|
||||
namespace Randall.Application.Admin.GetAllUsers;
|
||||
|
||||
public class GetAllUsersHandler(IUserRepository userRepository)
|
||||
{
|
||||
public async Task<Result<List<AdminUserDto>>> HandleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var users = await userRepository.GetAllAsync(ct);
|
||||
var dtos = users.Select(u => new AdminUserDto(u.Id, u.Name, u.Email, u.IsApproved, u.IsAdmin)).ToList();
|
||||
return Result.Success(dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Users;
|
||||
|
||||
namespace Randall.Application.Admin.GetPendingUsers;
|
||||
|
||||
public class GetPendingUsersHandler(IUserRepository userRepository)
|
||||
{
|
||||
public async Task<Result<List<PendingUserDto>>> HandleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var users = await userRepository.GetPendingAsync(ct);
|
||||
var dtos = users.Select(u => new PendingUserDto(u.Id, u.Name, u.Email)).ToList();
|
||||
return Result.Success(dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Admin.GetPendingUsers;
|
||||
|
||||
public record PendingUserDto(Guid Id, string Name, string Email);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Admin.MakeAdmin;
|
||||
|
||||
public record MakeAdminCommand(Guid UserId);
|
||||
@@ -0,0 +1,18 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Users;
|
||||
|
||||
namespace Randall.Application.Admin.MakeAdmin;
|
||||
|
||||
public class MakeAdminHandler(IUserRepository userRepository)
|
||||
{
|
||||
public async Task<Result> HandleAsync(MakeAdminCommand command, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(command.UserId, ct);
|
||||
if (user is null)
|
||||
return Result.Failure("User not found.");
|
||||
|
||||
user.MakeAdmin();
|
||||
await userRepository.SaveChangesAsync(ct);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
3
src/backend/src/Randall.Application/Auth/AuthResponse.cs
Normal file
3
src/backend/src/Randall.Application/Auth/AuthResponse.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Auth;
|
||||
|
||||
public record AuthResponse(string Token, string Name, string Email, bool IsAdmin);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Auth.Login;
|
||||
|
||||
public record LoginCommand(string Email, string Password);
|
||||
@@ -0,0 +1,24 @@
|
||||
using Randall.Application.Common;
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Users;
|
||||
|
||||
namespace Randall.Application.Auth.Login;
|
||||
|
||||
public class LoginHandler(
|
||||
IUserRepository userRepository,
|
||||
IPasswordHasher passwordHasher,
|
||||
IJwtTokenService jwtTokenService)
|
||||
{
|
||||
public async Task<Result<AuthResponse>> HandleAsync(LoginCommand command, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userRepository.GetByEmailAsync(command.Email, ct);
|
||||
if (user is null || !passwordHasher.Verify(command.Password, user.PasswordHash))
|
||||
return Result.Failure<AuthResponse>("Invalid email or password.");
|
||||
|
||||
if (!user.IsApproved)
|
||||
return Result.Failure<AuthResponse>("Your account is pending approval by an administrator.");
|
||||
|
||||
var token = jwtTokenService.GenerateToken(user.Id, user.Email, user.Name, user.IsAdmin);
|
||||
return Result.Success(new AuthResponse(token, user.Name, user.Email, user.IsAdmin));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Auth.Register;
|
||||
|
||||
public record RegisterCommand(string Email, string Name, string Password);
|
||||
@@ -0,0 +1,26 @@
|
||||
using Randall.Application.Common;
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Users;
|
||||
|
||||
namespace Randall.Application.Auth.Register;
|
||||
|
||||
public class RegisterHandler(IUserRepository userRepository, IPasswordHasher passwordHasher)
|
||||
{
|
||||
public async Task<Result<RegisterResponse>> HandleAsync(RegisterCommand command, CancellationToken ct = default)
|
||||
{
|
||||
var exists = await userRepository.ExistsByEmailAsync(command.Email, ct);
|
||||
if (exists)
|
||||
return Result.Failure<RegisterResponse>("An account with this email already exists.");
|
||||
|
||||
var hash = passwordHasher.Hash(command.Password);
|
||||
|
||||
var result = User.Create(command.Email, command.Name, hash);
|
||||
if (!result.IsSuccess)
|
||||
return Result.Failure<RegisterResponse>(result.Error!);
|
||||
|
||||
await userRepository.AddAsync(result.Value!, ct);
|
||||
await userRepository.SaveChangesAsync(ct);
|
||||
|
||||
return Result.Success(new RegisterResponse("Your account is pending approval by an administrator."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Auth.Register;
|
||||
|
||||
public record RegisterResponse(string Message);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Randall.Application.Common;
|
||||
|
||||
public interface IJwtTokenService
|
||||
{
|
||||
string GenerateToken(Guid userId, string email, string name, bool isAdmin);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Randall.Application.Common;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string Hash(string password);
|
||||
bool Verify(string password, string hash);
|
||||
}
|
||||
41
src/backend/src/Randall.Application/DependencyInjection.cs
Normal file
41
src/backend/src/Randall.Application/DependencyInjection.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Randall.Application.Admin.ApproveUser;
|
||||
using Randall.Application.Admin.DeleteUser;
|
||||
using Randall.Application.Admin.GetAllUsers;
|
||||
using Randall.Application.Admin.GetPendingUsers;
|
||||
using Randall.Application.Admin.MakeAdmin;
|
||||
using Randall.Application.Auth.Login;
|
||||
using Randall.Application.Auth.Register;
|
||||
using Randall.Application.Reservations.CancelReservation;
|
||||
using Randall.Application.Reservations.CreateReservation;
|
||||
using Randall.Application.Reservations.GetMyReservations;
|
||||
using Randall.Application.Reservations.GetReservationById;
|
||||
using Randall.Application.Workplaces.GetAllWorkplaces;
|
||||
using Randall.Application.Workplaces.GetAvailableWorkplaces;
|
||||
using Randall.Application.Workplaces.GetWorkplaceSchedule;
|
||||
|
||||
namespace Randall.Application;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<RegisterHandler>();
|
||||
services.AddScoped<LoginHandler>();
|
||||
services.AddScoped<GetAllUsersHandler>();
|
||||
services.AddScoped<GetPendingUsersHandler>();
|
||||
services.AddScoped<ApproveUserHandler>();
|
||||
services.AddScoped<DeleteUserHandler>();
|
||||
services.AddScoped<MakeAdminHandler>();
|
||||
|
||||
services.AddScoped<GetAllWorkplacesHandler>();
|
||||
services.AddScoped<GetAvailableWorkplacesHandler>();
|
||||
services.AddScoped<GetWorkplaceScheduleHandler>();
|
||||
services.AddScoped<CreateReservationHandler>();
|
||||
services.AddScoped<CancelReservationHandler>();
|
||||
services.AddScoped<GetMyReservationsHandler>();
|
||||
services.AddScoped<GetReservationByIdHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Randall.Domain\Randall.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Reservations.CancelReservation;
|
||||
|
||||
public record CancelReservationCommand(Guid ReservationId, string EmployeeEmail);
|
||||
@@ -0,0 +1,30 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Reservations;
|
||||
|
||||
namespace Randall.Application.Reservations.CancelReservation;
|
||||
|
||||
public class CancelReservationHandler(IReservationRepository reservationRepository)
|
||||
{
|
||||
public async Task<Result> HandleAsync(
|
||||
CancelReservationCommand command,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var reservation = await reservationRepository.GetByIdAsync(command.ReservationId, ct);
|
||||
if (reservation is null)
|
||||
return Result.Failure("Reservation not found.");
|
||||
|
||||
if (!string.Equals(reservation.EmployeeEmail, command.EmployeeEmail, StringComparison.OrdinalIgnoreCase))
|
||||
return Result.Failure("You are not allowed to cancel this reservation.");
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (reservation.Date < today)
|
||||
return Result.Failure("Cannot cancel a reservation for a past date.");
|
||||
|
||||
var result = reservation.Cancel();
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
|
||||
await reservationRepository.SaveChangesAsync(ct);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Randall.Application.Reservations.CreateReservation;
|
||||
|
||||
public record CreateReservationCommand(
|
||||
Guid WorkplaceId,
|
||||
string EmployeeEmail,
|
||||
string EmployeeName,
|
||||
DateOnly Date);
|
||||
@@ -0,0 +1,51 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Reservations;
|
||||
using Randall.Domain.Workplaces;
|
||||
|
||||
namespace Randall.Application.Reservations.CreateReservation;
|
||||
|
||||
public class CreateReservationHandler(
|
||||
IWorkplaceRepository workplaceRepository,
|
||||
IReservationRepository reservationRepository)
|
||||
{
|
||||
public async Task<Result<CreatedReservationDto>> HandleAsync(
|
||||
CreateReservationCommand command,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var workplace = await workplaceRepository.GetByIdAsync(command.WorkplaceId, ct);
|
||||
if (workplace is null || !workplace.IsActive)
|
||||
return Result.Failure<CreatedReservationDto>("Workplace not found or inactive.");
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
var alreadyBookedByEmployee = await reservationRepository
|
||||
.ExistsActiveForEmployeeOnDateAsync(command.EmployeeEmail, command.Date, ct);
|
||||
if (alreadyBookedByEmployee)
|
||||
return Result.Failure<CreatedReservationDto>("You already have a reservation on this date.");
|
||||
|
||||
var workplaceTaken = await reservationRepository
|
||||
.ExistsActiveForWorkplaceOnDateAsync(command.WorkplaceId, command.Date, ct);
|
||||
if (workplaceTaken)
|
||||
return Result.Failure<CreatedReservationDto>("This workplace is already reserved on the requested date.");
|
||||
|
||||
var result = Reservation.Create(
|
||||
command.WorkplaceId,
|
||||
command.EmployeeEmail,
|
||||
command.EmployeeName,
|
||||
command.Date,
|
||||
today);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return Result.Failure<CreatedReservationDto>(result.Error!);
|
||||
|
||||
var reservation = result.Value!;
|
||||
await reservationRepository.AddAsync(reservation, ct);
|
||||
await reservationRepository.SaveChangesAsync(ct);
|
||||
|
||||
return Result.Success(new CreatedReservationDto(
|
||||
reservation.Id,
|
||||
reservation.WorkplaceId,
|
||||
reservation.EmployeeName,
|
||||
reservation.Date));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Reservations.CreateReservation;
|
||||
|
||||
public record CreatedReservationDto(Guid Id, Guid WorkplaceId, string EmployeeName, DateOnly Date);
|
||||
@@ -0,0 +1,33 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Reservations;
|
||||
using Randall.Domain.Workplaces;
|
||||
|
||||
namespace Randall.Application.Reservations.GetMyReservations;
|
||||
|
||||
public class GetMyReservationsHandler(
|
||||
IReservationRepository reservationRepository,
|
||||
IWorkplaceRepository workplaceRepository)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<ReservationDto>>> HandleAsync(
|
||||
GetMyReservationsQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var reservations = await reservationRepository.GetByEmployeeAsync(query.EmployeeEmail, ct);
|
||||
|
||||
var dtos = new List<ReservationDto>();
|
||||
foreach (var r in reservations.OrderByDescending(r => r.Date))
|
||||
{
|
||||
var workplace = await workplaceRepository.GetByIdAsync(r.WorkplaceId, ct);
|
||||
dtos.Add(new ReservationDto(
|
||||
r.Id,
|
||||
r.WorkplaceId,
|
||||
workplace?.Name ?? "Unknown",
|
||||
workplace?.Location ?? "Unknown",
|
||||
r.Date,
|
||||
r.Status,
|
||||
r.CreatedAt));
|
||||
}
|
||||
|
||||
return Result.Success<IReadOnlyList<ReservationDto>>(dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Reservations.GetMyReservations;
|
||||
|
||||
public record GetMyReservationsQuery(string EmployeeEmail);
|
||||
@@ -0,0 +1,12 @@
|
||||
using Randall.Domain.Reservations;
|
||||
|
||||
namespace Randall.Application.Reservations.GetMyReservations;
|
||||
|
||||
public record ReservationDto(
|
||||
Guid Id,
|
||||
Guid WorkplaceId,
|
||||
string WorkplaceName,
|
||||
string WorkplaceLocation,
|
||||
DateOnly Date,
|
||||
ReservationStatus Status,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,32 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Reservations;
|
||||
using Randall.Domain.Workplaces;
|
||||
|
||||
namespace Randall.Application.Reservations.GetReservationById;
|
||||
|
||||
public class GetReservationByIdHandler(
|
||||
IReservationRepository reservationRepository,
|
||||
IWorkplaceRepository workplaceRepository)
|
||||
{
|
||||
public async Task<Result<ReservationDetailDto>> HandleAsync(
|
||||
GetReservationByIdQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var reservation = await reservationRepository.GetByIdAsync(query.ReservationId, ct);
|
||||
if (reservation is null)
|
||||
return Result.Failure<ReservationDetailDto>("Reservation not found.");
|
||||
|
||||
var workplace = await workplaceRepository.GetByIdAsync(reservation.WorkplaceId, ct);
|
||||
|
||||
return Result.Success(new ReservationDetailDto(
|
||||
reservation.Id,
|
||||
reservation.WorkplaceId,
|
||||
workplace?.Name ?? "Unknown",
|
||||
workplace?.Location ?? "Unknown",
|
||||
reservation.EmployeeName,
|
||||
reservation.EmployeeEmail,
|
||||
reservation.Date,
|
||||
reservation.Status,
|
||||
reservation.CreatedAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Reservations.GetReservationById;
|
||||
|
||||
public record GetReservationByIdQuery(Guid ReservationId);
|
||||
@@ -0,0 +1,14 @@
|
||||
using Randall.Domain.Reservations;
|
||||
|
||||
namespace Randall.Application.Reservations.GetReservationById;
|
||||
|
||||
public record ReservationDetailDto(
|
||||
Guid Id,
|
||||
Guid WorkplaceId,
|
||||
string WorkplaceName,
|
||||
string WorkplaceLocation,
|
||||
string EmployeeName,
|
||||
string EmployeeEmail,
|
||||
DateOnly Date,
|
||||
ReservationStatus Status,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,19 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Workplaces;
|
||||
|
||||
namespace Randall.Application.Workplaces.GetAllWorkplaces;
|
||||
|
||||
public record WorkplaceDto(Guid Id, string Name, string Location);
|
||||
|
||||
public class GetAllWorkplacesHandler(IWorkplaceRepository workplaceRepository)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<WorkplaceDto>>> HandleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var workplaces = await workplaceRepository.GetAllActiveAsync(ct);
|
||||
var dtos = workplaces
|
||||
.Select(w => new WorkplaceDto(w.Id, w.Name, w.Location))
|
||||
.ToList();
|
||||
|
||||
return Result.Success<IReadOnlyList<WorkplaceDto>>(dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Randall.Application.Workplaces.GetAvailableWorkplaces;
|
||||
|
||||
public record AvailableWorkplaceDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Location);
|
||||
@@ -0,0 +1,36 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Reservations;
|
||||
using Randall.Domain.Workplaces;
|
||||
|
||||
namespace Randall.Application.Workplaces.GetAvailableWorkplaces;
|
||||
|
||||
public class GetAvailableWorkplacesHandler(
|
||||
IWorkplaceRepository workplaceRepository,
|
||||
IReservationRepository reservationRepository)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<AvailableWorkplaceDto>>> HandleAsync(
|
||||
GetAvailableWorkplacesQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
if (query.Date < today)
|
||||
return Result.Failure<IReadOnlyList<AvailableWorkplaceDto>>("Date cannot be in the past.");
|
||||
|
||||
if (query.Date > today.AddDays(Reservation.MaxAdvanceDays))
|
||||
return Result.Failure<IReadOnlyList<AvailableWorkplaceDto>>(
|
||||
$"Date cannot be more than {Reservation.MaxAdvanceDays} days in advance.");
|
||||
|
||||
var workplaces = await workplaceRepository.GetAllActiveAsync(ct);
|
||||
|
||||
var available = new List<AvailableWorkplaceDto>();
|
||||
foreach (var workplace in workplaces)
|
||||
{
|
||||
var isTaken = await reservationRepository.ExistsActiveForWorkplaceOnDateAsync(workplace.Id, query.Date, ct);
|
||||
if (!isTaken)
|
||||
available.Add(new AvailableWorkplaceDto(workplace.Id, workplace.Name, workplace.Location));
|
||||
}
|
||||
|
||||
return Result.Success<IReadOnlyList<AvailableWorkplaceDto>>(available);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Workplaces.GetAvailableWorkplaces;
|
||||
|
||||
public record GetAvailableWorkplacesQuery(DateOnly Date);
|
||||
@@ -0,0 +1,42 @@
|
||||
using Randall.Domain.Common;
|
||||
using Randall.Domain.Reservations;
|
||||
using Randall.Domain.Workplaces;
|
||||
|
||||
namespace Randall.Application.Workplaces.GetWorkplaceSchedule;
|
||||
|
||||
public class GetWorkplaceScheduleHandler(
|
||||
IWorkplaceRepository workplaceRepository,
|
||||
IReservationRepository reservationRepository)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<WorkplaceScheduleDto>>> HandleAsync(
|
||||
DateOnly date,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
if (date < today)
|
||||
return Result.Failure<IReadOnlyList<WorkplaceScheduleDto>>("Date cannot be in the past.");
|
||||
|
||||
if (date > today.AddDays(Reservation.MaxAdvanceDays))
|
||||
return Result.Failure<IReadOnlyList<WorkplaceScheduleDto>>(
|
||||
$"Date cannot be more than {Reservation.MaxAdvanceDays} days in advance.");
|
||||
|
||||
var workplaces = await workplaceRepository.GetAllActiveAsync(ct);
|
||||
var reservations = await reservationRepository.GetActiveReservationsForDateAsync(date, ct);
|
||||
|
||||
var reservationByWorkplace = reservations.ToDictionary(r => r.WorkplaceId);
|
||||
|
||||
var schedule = workplaces.Select(w =>
|
||||
{
|
||||
var hasReservation = reservationByWorkplace.TryGetValue(w.Id, out var reservation);
|
||||
return new WorkplaceScheduleDto(
|
||||
w.Id,
|
||||
w.Name,
|
||||
w.Location,
|
||||
IsAvailable: !hasReservation,
|
||||
ReservedBy: hasReservation ? reservation!.EmployeeName : null);
|
||||
}).ToList();
|
||||
|
||||
return Result.Success<IReadOnlyList<WorkplaceScheduleDto>>(schedule);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Randall.Application.Workplaces.GetWorkplaceSchedule;
|
||||
|
||||
public record WorkplaceScheduleDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Location,
|
||||
bool IsAvailable,
|
||||
string? ReservedBy);
|
||||
9
src/backend/src/Randall.Domain/Common/Entity.cs
Normal file
9
src/backend/src/Randall.Domain/Common/Entity.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Randall.Domain.Common;
|
||||
|
||||
public abstract class Entity
|
||||
{
|
||||
public Guid Id { get; protected set; }
|
||||
|
||||
protected Entity() => Id = Guid.NewGuid();
|
||||
protected Entity(Guid id) => Id = id;
|
||||
}
|
||||
33
src/backend/src/Randall.Domain/Common/Result.cs
Normal file
33
src/backend/src/Randall.Domain/Common/Result.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace Randall.Domain.Common;
|
||||
|
||||
public class Result
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public string? Error { get; }
|
||||
|
||||
protected Result(bool isSuccess, string? error)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public static Result Success() => new(true, null);
|
||||
public static Result Failure(string error) => new(false, error);
|
||||
|
||||
public static Result<T> Success<T>(T value) => Result<T>.Ok(value);
|
||||
public static Result<T> Failure<T>(string error) => Result<T>.Fail(error);
|
||||
}
|
||||
|
||||
public class Result<T> : Result
|
||||
{
|
||||
public T? Value { get; }
|
||||
|
||||
private Result(bool isSuccess, T? value, string? error)
|
||||
: base(isSuccess, error)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public static Result<T> Ok(T value) => new(true, value, null);
|
||||
public static Result<T> Fail(string error) => new(false, default, error);
|
||||
}
|
||||
9
src/backend/src/Randall.Domain/Randall.Domain.csproj
Normal file
9
src/backend/src/Randall.Domain/Randall.Domain.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,13 @@
|
||||
namespace Randall.Domain.Reservations;
|
||||
|
||||
public interface IReservationRepository
|
||||
{
|
||||
Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Reservation>> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Reservation>> GetByWorkplaceAndDateAsync(Guid workplaceId, DateOnly date, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Reservation>> GetActiveReservationsForDateAsync(DateOnly date, CancellationToken ct = default);
|
||||
Task<bool> ExistsActiveForEmployeeOnDateAsync(string employeeEmail, DateOnly date, CancellationToken ct = default);
|
||||
Task<bool> ExistsActiveForWorkplaceOnDateAsync(Guid workplaceId, DateOnly date, CancellationToken ct = default);
|
||||
Task AddAsync(Reservation reservation, CancellationToken ct = default);
|
||||
Task SaveChangesAsync(CancellationToken ct = default);
|
||||
}
|
||||
58
src/backend/src/Randall.Domain/Reservations/Reservation.cs
Normal file
58
src/backend/src/Randall.Domain/Reservations/Reservation.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Randall.Domain.Common;
|
||||
|
||||
namespace Randall.Domain.Reservations;
|
||||
|
||||
public class Reservation : Entity
|
||||
{
|
||||
public static readonly int MaxAdvanceDays = 14;
|
||||
|
||||
public Guid WorkplaceId { get; private set; }
|
||||
public string EmployeeEmail { get; private set; }
|
||||
public string EmployeeName { get; private set; }
|
||||
public DateOnly Date { get; private set; }
|
||||
public ReservationStatus Status { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
|
||||
// EF Core constructor
|
||||
private Reservation() : base()
|
||||
{
|
||||
EmployeeEmail = string.Empty;
|
||||
EmployeeName = string.Empty;
|
||||
}
|
||||
|
||||
private Reservation(Guid workplaceId, string employeeEmail, string employeeName, DateOnly date)
|
||||
: base()
|
||||
{
|
||||
WorkplaceId = workplaceId;
|
||||
EmployeeEmail = employeeEmail;
|
||||
EmployeeName = employeeName;
|
||||
Date = date;
|
||||
Status = ReservationStatus.Active;
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static Result<Reservation> Create(
|
||||
Guid workplaceId,
|
||||
string employeeEmail,
|
||||
string employeeName,
|
||||
DateOnly date,
|
||||
DateOnly today)
|
||||
{
|
||||
if (date < today)
|
||||
return Result.Failure<Reservation>("Cannot reserve a workplace in the past.");
|
||||
|
||||
if (date > today.AddDays(MaxAdvanceDays))
|
||||
return Result.Failure<Reservation>($"Cannot reserve more than {MaxAdvanceDays} days in advance.");
|
||||
|
||||
return Result.Success(new Reservation(workplaceId, employeeEmail, employeeName, date));
|
||||
}
|
||||
|
||||
public Result Cancel()
|
||||
{
|
||||
if (Status == ReservationStatus.Cancelled)
|
||||
return Result.Failure("Reservation is already cancelled.");
|
||||
|
||||
Status = ReservationStatus.Cancelled;
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Randall.Domain.Reservations;
|
||||
|
||||
public enum ReservationStatus
|
||||
{
|
||||
Active,
|
||||
Cancelled
|
||||
}
|
||||
14
src/backend/src/Randall.Domain/Users/IUserRepository.cs
Normal file
14
src/backend/src/Randall.Domain/Users/IUserRepository.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Randall.Domain.Users;
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetByEmailAsync(string email, CancellationToken ct = default);
|
||||
Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default);
|
||||
Task<List<User>> GetPendingAsync(CancellationToken ct = default);
|
||||
Task<List<User>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default);
|
||||
Task AddAsync(User user, CancellationToken ct = default);
|
||||
void Delete(User user);
|
||||
Task SaveChangesAsync(CancellationToken ct = default);
|
||||
}
|
||||
47
src/backend/src/Randall.Domain/Users/User.cs
Normal file
47
src/backend/src/Randall.Domain/Users/User.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Randall.Domain.Common;
|
||||
|
||||
namespace Randall.Domain.Users;
|
||||
|
||||
public class User : Entity
|
||||
{
|
||||
public string Email { get; private set; }
|
||||
public string Name { get; private set; }
|
||||
public string PasswordHash { get; private set; }
|
||||
public bool IsApproved { get; private set; }
|
||||
public bool IsAdmin { get; private set; }
|
||||
|
||||
private User() : base()
|
||||
{
|
||||
Email = string.Empty;
|
||||
Name = string.Empty;
|
||||
PasswordHash = string.Empty;
|
||||
}
|
||||
|
||||
private User(string email, string name, string passwordHash, bool isAdmin) : base()
|
||||
{
|
||||
Email = email;
|
||||
Name = name;
|
||||
PasswordHash = passwordHash;
|
||||
IsAdmin = isAdmin;
|
||||
IsApproved = isAdmin; // admins are auto-approved
|
||||
}
|
||||
|
||||
public static Result<User> Create(string email, string name, string passwordHash, bool isAdmin = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return Result.Failure<User>("Email is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result.Failure<User>("Name is required.");
|
||||
|
||||
return Result.Success(new User(email.ToLowerInvariant(), name.Trim(), passwordHash, isAdmin));
|
||||
}
|
||||
|
||||
public void Approve() => IsApproved = true;
|
||||
|
||||
public void MakeAdmin()
|
||||
{
|
||||
IsAdmin = true;
|
||||
IsApproved = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Randall.Domain.Workplaces;
|
||||
|
||||
public interface IWorkplaceRepository
|
||||
{
|
||||
Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Workplace>> GetAllActiveAsync(CancellationToken ct = default);
|
||||
}
|
||||
26
src/backend/src/Randall.Domain/Workplaces/Workplace.cs
Normal file
26
src/backend/src/Randall.Domain/Workplaces/Workplace.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Randall.Domain.Common;
|
||||
|
||||
namespace Randall.Domain.Workplaces;
|
||||
|
||||
public class Workplace : Entity
|
||||
{
|
||||
public string Name { get; private set; }
|
||||
public string Location { get; private set; }
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
private Workplace() : base()
|
||||
{
|
||||
Name = string.Empty;
|
||||
Location = string.Empty;
|
||||
}
|
||||
|
||||
public Workplace(string name, string location) : base()
|
||||
{
|
||||
Name = name;
|
||||
Location = location;
|
||||
IsActive = true;
|
||||
}
|
||||
|
||||
public void Deactivate() => IsActive = false;
|
||||
public void Activate() => IsActive = true;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Randall.Application.Common;
|
||||
using Randall.Domain.Reservations;
|
||||
using Randall.Domain.Users;
|
||||
using Randall.Domain.Workplaces;
|
||||
using Randall.Infrastructure.Persistence;
|
||||
using Randall.Infrastructure.Persistence.Reservations;
|
||||
using Randall.Infrastructure.Persistence.Users;
|
||||
using Randall.Infrastructure.Persistence.Workplaces;
|
||||
using Randall.Infrastructure.Security;
|
||||
|
||||
namespace Randall.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlite(configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
services.AddScoped<IWorkplaceRepository, WorkplaceRepository>();
|
||||
services.AddScoped<IReservationRepository, ReservationRepository>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
|
||||
services.AddSingleton<IPasswordHasher, PasswordHasher>();
|
||||
services.AddSingleton<IJwtTokenService, JwtTokenService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
86
src/backend/src/Randall.Infrastructure/Migrations/20260321184811_InitialCreate.Designer.cs
generated
Normal file
86
src/backend/src/Randall.Infrastructure/Migrations/20260321184811_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,86 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Randall.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Randall.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260321184811_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Reservations.Reservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmployeeEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmployeeName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("WorkplaceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmployeeEmail", "Date");
|
||||
|
||||
b.HasIndex("WorkplaceId", "Date");
|
||||
|
||||
b.ToTable("Reservations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Workplaces.Workplace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Workplaces");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Randall.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Reservations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
WorkplaceId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
EmployeeEmail = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
EmployeeName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Reservations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Workplaces",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
Location = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Workplaces", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reservations_EmployeeEmail_Date",
|
||||
table: "Reservations",
|
||||
columns: new[] { "EmployeeEmail", "Date" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reservations_WorkplaceId_Date",
|
||||
table: "Reservations",
|
||||
columns: new[] { "WorkplaceId", "Date" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Reservations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Workplaces");
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/backend/src/Randall.Infrastructure/Migrations/20260321191704_AddUsers.Designer.cs
generated
Normal file
114
src/backend/src/Randall.Infrastructure/Migrations/20260321191704_AddUsers.Designer.cs
generated
Normal file
@@ -0,0 +1,114 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Randall.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Randall.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260321191704_AddUsers")]
|
||||
partial class AddUsers
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Reservations.Reservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmployeeEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmployeeName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("WorkplaceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmployeeEmail", "Date");
|
||||
|
||||
b.HasIndex("WorkplaceId", "Date");
|
||||
|
||||
b.ToTable("Reservations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Workplaces.Workplace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Workplaces");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Randall.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUsers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Email = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_Email",
|
||||
table: "Users",
|
||||
column: "Email",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
120
src/backend/src/Randall.Infrastructure/Migrations/20260323120000_AddUserApproval.Designer.cs
generated
Normal file
120
src/backend/src/Randall.Infrastructure/Migrations/20260323120000_AddUserApproval.Designer.cs
generated
Normal file
@@ -0,0 +1,120 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Randall.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Randall.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260323120000_AddUserApproval")]
|
||||
partial class AddUserApproval
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Reservations.Reservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmployeeEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmployeeName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("WorkplaceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmployeeEmail", "Date");
|
||||
|
||||
b.HasIndex("WorkplaceId", "Date");
|
||||
|
||||
b.ToTable("Reservations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Workplaces.Workplace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Workplaces");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Randall.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserApproval : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsAdmin",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsApproved",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "IsAdmin", table: "Users");
|
||||
migrationBuilder.DropColumn(name: "IsApproved", table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Randall.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Randall.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Reservations.Reservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmployeeEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmployeeName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("WorkplaceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmployeeEmail", "Date");
|
||||
|
||||
b.HasIndex("WorkplaceId", "Date");
|
||||
|
||||
b.ToTable("Reservations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Workplaces.Workplace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Workplaces");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Domain.Reservations;
|
||||
using Randall.Domain.Users;
|
||||
using Randall.Domain.Workplaces;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Workplace> Workplaces => Set<Workplace>();
|
||||
public DbSet<Reservation> Reservations => Set<Reservation>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Workplace>(entity =>
|
||||
{
|
||||
entity.HasKey(w => w.Id);
|
||||
entity.Property(w => w.Name).IsRequired().HasMaxLength(100);
|
||||
entity.Property(w => w.Location).IsRequired().HasMaxLength(200);
|
||||
entity.Property(w => w.IsActive).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Reservation>(entity =>
|
||||
{
|
||||
entity.HasKey(r => r.Id);
|
||||
entity.Property(r => r.WorkplaceId).IsRequired();
|
||||
entity.Property(r => r.EmployeeEmail).IsRequired().HasMaxLength(200);
|
||||
entity.Property(r => r.EmployeeName).IsRequired().HasMaxLength(200);
|
||||
entity.Property(r => r.Date).IsRequired();
|
||||
entity.Property(r => r.Status).IsRequired();
|
||||
entity.Property(r => r.CreatedAt).IsRequired();
|
||||
|
||||
entity.HasIndex(r => new { r.WorkplaceId, r.Date });
|
||||
entity.HasIndex(r => new { r.EmployeeEmail, r.Date });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<User>(entity =>
|
||||
{
|
||||
entity.HasKey(u => u.Id);
|
||||
entity.Property(u => u.Email).IsRequired().HasMaxLength(200);
|
||||
entity.Property(u => u.Name).IsRequired().HasMaxLength(200);
|
||||
entity.Property(u => u.PasswordHash).IsRequired();
|
||||
entity.Property(u => u.IsApproved).IsRequired();
|
||||
entity.Property(u => u.IsAdmin).IsRequired();
|
||||
entity.HasIndex(u => u.Email).IsUnique();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Domain.Reservations;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence.Reservations;
|
||||
|
||||
public class ReservationRepository(AppDbContext context) : IReservationRepository
|
||||
{
|
||||
public Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||
context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
|
||||
public async Task<IReadOnlyList<Reservation>> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default) =>
|
||||
await context.Reservations
|
||||
.Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower())
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<Reservation>> GetByWorkplaceAndDateAsync(
|
||||
Guid workplaceId, DateOnly date, CancellationToken ct = default) =>
|
||||
await context.Reservations
|
||||
.Where(r => r.WorkplaceId == workplaceId && r.Date == date)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<bool> ExistsActiveForEmployeeOnDateAsync(
|
||||
string employeeEmail, DateOnly date, CancellationToken ct = default) =>
|
||||
context.Reservations.AnyAsync(
|
||||
r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower()
|
||||
&& r.Date == date
|
||||
&& r.Status == ReservationStatus.Active,
|
||||
ct);
|
||||
|
||||
public Task<bool> ExistsActiveForWorkplaceOnDateAsync(
|
||||
Guid workplaceId, DateOnly date, CancellationToken ct = default) =>
|
||||
context.Reservations.AnyAsync(
|
||||
r => r.WorkplaceId == workplaceId
|
||||
&& r.Date == date
|
||||
&& r.Status == ReservationStatus.Active,
|
||||
ct);
|
||||
|
||||
public async Task<IReadOnlyList<Reservation>> GetActiveReservationsForDateAsync(
|
||||
DateOnly date, CancellationToken ct = default) =>
|
||||
await context.Reservations
|
||||
.Where(r => r.Date == date && r.Status == ReservationStatus.Active)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task AddAsync(Reservation reservation, CancellationToken ct = default) =>
|
||||
await context.Reservations.AddAsync(reservation, ct);
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||
context.SaveChangesAsync(ct);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Application.Common;
|
||||
using Randall.Domain.Users;
|
||||
using Randall.Domain.Workplaces;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence.Seeding;
|
||||
|
||||
public static class DatabaseSeeder
|
||||
{
|
||||
private static readonly (string Name, string Location)[] ExpectedWorkplaces =
|
||||
[
|
||||
("D13", "Pod A"), ("D14", "Pod A"), ("D15", "Pod A"), ("D16", "Pod A"),
|
||||
("D9", "Pod A"), ("D10", "Pod A"), ("D11", "Pod A"), ("D12", "Pod A"),
|
||||
("D5", "Pod B"), ("D6", "Pod B"), ("D7", "Pod B"), ("D8", "Pod B"),
|
||||
("D1", "Pod B"), ("D2", "Pod B"), ("D3", "Pod B"), ("D4", "Pod B"),
|
||||
];
|
||||
|
||||
private const string AdminEmail = "admin@randall.local";
|
||||
private const string AdminPassword = "Admin@123";
|
||||
|
||||
public static async Task SeedAsync(AppDbContext context, IPasswordHasher passwordHasher)
|
||||
{
|
||||
await context.Database.MigrateAsync();
|
||||
|
||||
// Seed workplaces
|
||||
var existing = await context.Workplaces.ToListAsync();
|
||||
var expectedSet = ExpectedWorkplaces.Select(w => w.Name + w.Location).ToHashSet();
|
||||
var existingSet = existing.Select(w => w.Name + w.Location).ToHashSet();
|
||||
|
||||
if (!expectedSet.SetEquals(existingSet))
|
||||
{
|
||||
context.Workplaces.RemoveRange(existing);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var workplaces = ExpectedWorkplaces.Select(w => new Workplace(w.Name, w.Location));
|
||||
context.Workplaces.AddRange(workplaces);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Seed admin user
|
||||
var adminExists = await context.Users.AnyAsync(u => u.Email == AdminEmail);
|
||||
if (!adminExists)
|
||||
{
|
||||
var hash = passwordHasher.Hash(AdminPassword);
|
||||
var admin = User.Create(AdminEmail, "Admin", hash, isAdmin: true).Value!;
|
||||
context.Users.Add(admin);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Domain.Users;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence.Users;
|
||||
|
||||
public class UserRepository(AppDbContext context) : IUserRepository
|
||||
{
|
||||
public Task<User?> GetByEmailAsync(string email, CancellationToken ct = default) =>
|
||||
context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);
|
||||
|
||||
public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||
context.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||
|
||||
public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default) =>
|
||||
context.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct);
|
||||
|
||||
public Task<List<User>> GetPendingAsync(CancellationToken ct = default) =>
|
||||
context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct);
|
||||
|
||||
public Task<List<User>> GetAllAsync(CancellationToken ct = default) =>
|
||||
context.Users.OrderBy(u => u.Name).ToListAsync(ct);
|
||||
|
||||
public Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default) =>
|
||||
context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct);
|
||||
|
||||
public async Task AddAsync(User user, CancellationToken ct = default) =>
|
||||
await context.Users.AddAsync(user, ct);
|
||||
|
||||
public void Delete(User user) =>
|
||||
context.Users.Remove(user);
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||
context.SaveChangesAsync(ct);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Domain.Workplaces;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence.Workplaces;
|
||||
|
||||
public class WorkplaceRepository(AppDbContext context) : IWorkplaceRepository
|
||||
{
|
||||
public Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||
context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct);
|
||||
|
||||
public async Task<IReadOnlyList<Workplace>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||
await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Randall.Domain\Randall.Domain.csproj" />
|
||||
<ProjectReference Include="..\Randall.Application\Randall.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Randall.Application.Common;
|
||||
|
||||
namespace Randall.Infrastructure.Security;
|
||||
|
||||
public class JwtTokenService(IConfiguration configuration) : IJwtTokenService
|
||||
{
|
||||
public string GenerateToken(Guid userId, string email, string name, bool isAdmin)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"]!));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, email),
|
||||
new Claim(JwtRegisteredClaimNames.Name, name),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim("isAdmin", isAdmin ? "true" : "false"),
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: configuration["Jwt:Issuer"],
|
||||
audience: configuration["Jwt:Audience"],
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddDays(7),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Security.Cryptography;
|
||||
using Randall.Application.Common;
|
||||
|
||||
namespace Randall.Infrastructure.Security;
|
||||
|
||||
public class PasswordHasher : IPasswordHasher
|
||||
{
|
||||
private const int Iterations = 100_000;
|
||||
private const int HashSize = 32;
|
||||
private const int SaltSize = 16;
|
||||
|
||||
public string Hash(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
|
||||
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
public bool Verify(string password, string hashedPassword)
|
||||
{
|
||||
var parts = hashedPassword.Split(':');
|
||||
if (parts.Length != 2) return false;
|
||||
|
||||
var salt = Convert.FromBase64String(parts[0]);
|
||||
var storedHash = Convert.FromBase64String(parts[1]);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
|
||||
return CryptographicOperations.FixedTimeEquals(hash, storedHash);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user