Initial commit

This commit is contained in:
Robert van Diest
2026-03-24 20:13:07 +01:00
commit 1301a01d6d
123 changed files with 7642 additions and 0 deletions

8
src/backend/Randall.slnx Normal file
View 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>

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

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Api.Auth;
public record AuthRequest(string Email, string Password, string? Name);

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

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

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

View File

@@ -0,0 +1,6 @@
@Randall.Api_HostAddress = http://localhost:5180
GET {{Randall.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,3 @@
namespace Randall.Api.Reservations;
public record CreateReservationRequest(Guid WorkplaceId, DateOnly Date);

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Admin.ApproveUser;
public record ApproveUserCommand(Guid UserId);

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Admin.DeleteUser;
public record DeleteUserCommand(Guid UserId);

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Admin.GetAllUsers;
public record AdminUserDto(Guid Id, string Name, string Email, bool IsApproved, bool IsAdmin);

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Admin.GetPendingUsers;
public record PendingUserDto(Guid Id, string Name, string Email);

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Admin.MakeAdmin;
public record MakeAdminCommand(Guid UserId);

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Auth;
public record AuthResponse(string Token, string Name, string Email, bool IsAdmin);

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Auth.Login;
public record LoginCommand(string Email, string Password);

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Auth.Register;
public record RegisterCommand(string Email, string Name, string Password);

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Auth.Register;
public record RegisterResponse(string Message);

View File

@@ -0,0 +1,6 @@
namespace Randall.Application.Common;
public interface IJwtTokenService
{
string GenerateToken(Guid userId, string email, string name, bool isAdmin);
}

View File

@@ -0,0 +1,7 @@
namespace Randall.Application.Common;
public interface IPasswordHasher
{
string Hash(string password);
bool Verify(string password, string hash);
}

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

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Reservations.CancelReservation;
public record CancelReservationCommand(Guid ReservationId, string EmployeeEmail);

View File

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

View File

@@ -0,0 +1,7 @@
namespace Randall.Application.Reservations.CreateReservation;
public record CreateReservationCommand(
Guid WorkplaceId,
string EmployeeEmail,
string EmployeeName,
DateOnly Date);

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Reservations.CreateReservation;
public record CreatedReservationDto(Guid Id, Guid WorkplaceId, string EmployeeName, DateOnly Date);

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Reservations.GetMyReservations;
public record GetMyReservationsQuery(string EmployeeEmail);

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Reservations.GetReservationById;
public record GetReservationByIdQuery(Guid ReservationId);

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
namespace Randall.Application.Workplaces.GetAvailableWorkplaces;
public record AvailableWorkplaceDto(
Guid Id,
string Name,
string Location);

View File

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

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Workplaces.GetAvailableWorkplaces;
public record GetAvailableWorkplacesQuery(DateOnly Date);

View File

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

View File

@@ -0,0 +1,8 @@
namespace Randall.Application.Workplaces.GetWorkplaceSchedule;
public record WorkplaceScheduleDto(
Guid Id,
string Name,
string Location,
bool IsAvailable,
string? ReservedBy);

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

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

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

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

View File

@@ -0,0 +1,7 @@
namespace Randall.Domain.Reservations;
public enum ReservationStatus
{
Active,
Cancelled
}

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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