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

24
src/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
src/frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
src/frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3372
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
src/frontend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
src/frontend/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

48
src/frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import type { AuthResponse } from './api/types';
import { AuthPage } from './pages/AuthPage';
import { AdminPage } from './pages/AdminPage';
import { PlannerPage } from './pages/PlannerPage';
function getStoredAuth(): AuthResponse | null {
const token = localStorage.getItem('token');
const name = localStorage.getItem('userName');
const email = localStorage.getItem('userEmail');
const isAdmin = localStorage.getItem('isAdmin') === 'true';
if (token && name && email) return { token, name, email, isAdmin };
return null;
}
export default function App() {
const [auth, setAuth] = useState<AuthResponse | null>(getStoredAuth);
function handleAuth(authData: AuthResponse) {
localStorage.setItem('token', authData.token);
localStorage.setItem('userName', authData.name);
localStorage.setItem('userEmail', authData.email);
localStorage.setItem('isAdmin', String(authData.isAdmin));
setAuth(authData);
}
function handleLogout() {
localStorage.removeItem('token');
localStorage.removeItem('userName');
localStorage.removeItem('userEmail');
localStorage.removeItem('isAdmin');
setAuth(null);
}
if (!auth) return <AuthPage onAuth={handleAuth} />;
return (
<Routes>
<Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} />
<Route
path="/admin"
element={auth.isAdmin ? <AdminPage /> : <Navigate to="/" replace />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -0,0 +1,116 @@
import type { AdminUser, AuthResponse, CreateReservationRequest, PendingUser, RegisterPendingResponse, Reservation, WorkplaceScheduleItem } from './types';
const BASE = '/api';
function getToken(): string | null {
return localStorage.getItem('token');
}
function authHeaders(): HeadersInit {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.detail ?? `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
export const api = {
async login(email: string, password: string): Promise<AuthResponse> {
const res = await fetch(`${BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return handleResponse<AuthResponse>(res);
},
async register(email: string, name: string, password: string): Promise<RegisterPendingResponse> {
const res = await fetch(`${BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name, password }),
});
return handleResponse<RegisterPendingResponse>(res);
},
getAdminUsers(): Promise<AdminUser[]> {
return fetch(`${BASE}/admin/users`, {
headers: authHeaders(),
}).then(handleResponse<AdminUser[]>);
},
getPendingUsers(): Promise<PendingUser[]> {
return fetch(`${BASE}/admin/users/pending`, {
headers: authHeaders(),
}).then(handleResponse<PendingUser[]>);
},
async makeAdmin(id: string): Promise<void> {
const res = await fetch(`${BASE}/admin/users/${id}/make-admin`, {
method: 'POST',
headers: authHeaders(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.detail ?? `Request failed: ${res.status}`);
}
},
async approveUser(id: string): Promise<void> {
const res = await fetch(`${BASE}/admin/users/${id}/approve`, {
method: 'POST',
headers: authHeaders(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.detail ?? `Request failed: ${res.status}`);
}
},
async deleteUser(id: string): Promise<void> {
const res = await fetch(`${BASE}/admin/users/${id}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.detail ?? `Request failed: ${res.status}`);
}
},
getWorkplaceSchedule(date: string): Promise<WorkplaceScheduleItem[]> {
return fetch(`${BASE}/workplaces/schedule?date=${date}`, {
headers: authHeaders(),
}).then(handleResponse<WorkplaceScheduleItem[]>);
},
createReservation(data: CreateReservationRequest): Promise<{ id: string }> {
return fetch(`${BASE}/reservations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(data),
}).then(handleResponse<{ id: string }>);
},
getMyReservations(): Promise<Reservation[]> {
return fetch(`${BASE}/reservations/my`, {
headers: authHeaders(),
}).then(handleResponse<Reservation[]>);
},
async cancelReservation(id: string): Promise<void> {
const res = await fetch(`${BASE}/reservations/${id}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.detail ?? `Request failed: ${res.status}`);
}
},
};

View File

@@ -0,0 +1,61 @@
export interface Workplace {
id: string;
name: string;
location: string;
}
export interface AvailableWorkplace {
id: string;
name: string;
location: string;
}
export interface Reservation {
id: string;
workplaceId: string;
workplaceName: string;
workplaceLocation: string;
employeeName: string;
employeeEmail: string;
date: string;
status: 'Active' | 'Cancelled';
createdAt: string;
}
export interface WorkplaceScheduleItem {
id: string;
name: string;
location: string;
isAvailable: boolean;
reservedBy: string | null;
}
export interface CreateReservationRequest {
workplaceId: string;
date: string;
}
export interface AuthResponse {
token: string;
name: string;
email: string;
isAdmin: boolean;
}
export interface RegisterPendingResponse {
message: string;
}
export interface PendingUser {
id: string;
name: string;
email: string;
}
export interface AdminUser {
id: string;
name: string;
email: string;
isApproved: boolean;
isAdmin: boolean;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,58 @@
import { useState } from 'react';
interface CancelModalProps {
deskName: string;
date: string;
onConfirm: () => Promise<void>;
onClose: () => void;
}
export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function handleConfirm() {
setLoading(true);
setError('');
try {
await onConfirm();
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold text-slate-800 mb-1">Cancel reservation</h2>
<p className="text-sm text-slate-500 mb-6">
Cancel your booking for <span className="font-medium text-slate-700">{deskName}</span> on{' '}
<span className="font-medium text-slate-700">{date}</span>?
</p>
{error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
>
Keep it
</button>
<button
onClick={handleConfirm}
disabled={loading}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-500 rounded-lg hover:bg-red-600 disabled:opacity-50 transition-colors"
>
{loading ? 'Cancelling…' : 'Cancel reservation'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
interface DeskProps {
name: string;
available: boolean;
reserved: boolean; // reserved by the current user
reservedBy?: string; // name of whoever reserved it (when taken by someone else)
rotate?: 'cw' | 'ccw';
onClick: () => void;
}
export function Desk({ name, available, reserved, reservedBy, rotate, onClick }: DeskProps) {
let bgColor: string;
let borderColor: string;
let textColor: string;
let deskColor: string;
let cursor: string;
let title: string;
if (reserved) {
bgColor = 'bg-blue-50';
borderColor = 'border-blue-400';
textColor = 'text-blue-700';
deskColor = '#93c5fd';
cursor = 'cursor-pointer hover:bg-blue-100';
title = 'Your reservation — click to cancel';
} else if (available) {
bgColor = 'bg-emerald-50';
borderColor = 'border-emerald-400';
textColor = 'text-emerald-700';
deskColor = '#6ee7b7';
cursor = 'cursor-pointer hover:bg-emerald-100 hover:scale-105';
title = `Reserve ${name}`;
} else {
bgColor = 'bg-slate-50';
borderColor = 'border-slate-300';
textColor = 'text-slate-500';
deskColor = '#cbd5e1';
cursor = 'cursor-default';
title = reservedBy ? `Reserved by ${reservedBy}` : `${name} is taken`;
}
// Truncate long names to fit the tile
const displayName = reservedBy && reservedBy.length > 7
? reservedBy.slice(0, 6) + '…'
: reservedBy;
return (
<button
className={`flex flex-col items-center justify-center w-20 rounded-xl border-2 font-semibold transition-all select-none py-2 px-1 gap-0.5 ${bgColor} ${borderColor} ${textColor} ${cursor}`}
onClick={available || reserved ? onClick : undefined}
title={title}
>
{/* Top-down desk: rectangular surface + screen stripe */}
<svg viewBox="0 0 48 48" className={`w-10 h-10 ${rotate === 'cw' ? 'rotate-90' : rotate === 'ccw' ? '-rotate-90' : ''}`} fill="none" aria-hidden="true">
{/* Desk surface (wide rectangle, ~1.6:1 ratio) */}
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={deskColor} />
{/* Screen stripe — centered, ~30% of desk width, small margin from back edge */}
<rect x="17" y="13" width="14" height="5" rx="1.5" fill="#1e293b" />
{/* Chair — small square in front of desk */}
<rect x="18" y="41" width="12" height="8" rx="2" fill={deskColor} />
</svg>
<span className="text-xs font-semibold leading-none">{name}</span>
{reserved && <span className="text-[10px] opacity-75 leading-none">Mine</span>}
{available && <span className="text-[10px] opacity-75 leading-none">Free</span>}
{!reserved && !available && (
<span className="text-[10px] opacity-75 leading-tight text-center px-0.5">
{displayName ?? 'Taken'}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,58 @@
import { Desk } from './Desk';
interface ScheduleItem {
id: string;
name: string;
location: string;
isAvailable: boolean;
reservedBy: string | null;
}
interface DeskPodProps {
desks: ScheduleItem[];
myReservedIds: Set<string>;
onDeskClick: (desk: ScheduleItem) => void;
}
export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
const left = [...desks.slice(0, 4)].reverse();
const right = [...desks.slice(4, 8)].reverse();
return (
<div className="flex flex-col items-center gap-2">
<div className="bg-white border-2 border-slate-200 rounded-2xl p-5 shadow-sm">
<div className="flex gap-6">
<div className="flex flex-col gap-3">
{left.map((desk) => (
<Desk
key={desk.id}
name={desk.name}
available={desk.isAvailable}
reserved={myReservedIds.has(desk.id)}
reservedBy={desk.reservedBy ?? undefined}
rotate="cw"
onClick={() => onDeskClick(desk)}
/>
))}
</div>
<div className="w-px bg-slate-100" />
<div className="flex flex-col gap-3">
{right.map((desk) => (
<Desk
key={desk.id}
name={desk.name}
available={desk.isAvailable}
reserved={myReservedIds.has(desk.id)}
reservedBy={desk.reservedBy ?? undefined}
rotate="ccw"
onClick={() => onDeskClick(desk)}
/>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { Reservation } from '../api/types';
interface MyReservationsProps {
reservations: Reservation[];
onCancel: (reservation: Reservation) => void;
}
export function MyReservations({ reservations, onCancel }: MyReservationsProps) {
const upcoming = reservations
.filter((r) => r.status === 'Active')
.sort((a, b) => a.date.localeCompare(b.date));
if (upcoming.length === 0) {
return (
<p className="text-sm text-slate-400 text-center py-4">No upcoming reservations.</p>
);
}
return (
<ul className="flex flex-col gap-2">
{upcoming.map((r) => (
<li
key={r.id}
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-4 py-3"
>
<div>
<span className="font-medium text-slate-800">{r.workplaceName}</span>
<div className="text-xs text-slate-400 mt-0.5">{r.date}</div>
</div>
<button
onClick={() => onCancel(r)}
className="text-xs text-red-500 hover:text-red-700 font-medium px-3 py-1 rounded-lg hover:bg-red-50 transition-colors"
>
Cancel
</button>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,57 @@
import { useState } from 'react';
interface ReservationModalProps {
deskName: string;
date: string;
onConfirm: () => Promise<void>;
onClose: () => void;
}
export function ReservationModal({ deskName, date, onConfirm, onClose }: ReservationModalProps) {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleConfirm() {
setError('');
setLoading(true);
try {
await onConfirm();
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold text-slate-800 mb-1">Reserve desk</h2>
<p className="text-sm text-slate-500 mb-6">
<span className="font-medium text-slate-700">{deskName}</span> &mdash; {date}
</p>
{error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={loading}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors"
>
{loading ? 'Reserving…' : 'Confirm'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
@import "tailwindcss";
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
background-color: #f8fafc;
color: #0f172a;
}

13
src/frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View File

@@ -0,0 +1,212 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import type { AdminUser } from '../api/types';
export function AdminPage() {
const navigate = useNavigate();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [approvingId, setApprovingId] = useState<string | null>(null);
const [makingAdminId, setMakingAdminId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmAdminId, setConfirmAdminId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
async function load() {
setLoading(true);
setError('');
try {
setUsers(await api.getAdminUsers());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users');
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
async function handleApprove(id: string) {
setApprovingId(id);
try {
await api.approveUser(id);
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, isApproved: true } : u));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to approve user');
} finally {
setApprovingId(null);
}
}
async function handleMakeAdmin(id: string) {
setMakingAdminId(id);
try {
await api.makeAdmin(id);
setUsers((prev) => prev.filter((u) => u.id !== id));
setConfirmAdminId(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to make user admin');
} finally {
setMakingAdminId(null);
}
}
async function handleDelete(id: string) {
setDeletingId(id);
try {
await api.deleteUser(id);
setUsers((prev) => prev.filter((u) => u.id !== id));
setConfirmDeleteId(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
} finally {
setDeletingId(null);
}
}
const pending = users.filter((u) => !u.isApproved);
const approved = users.filter((u) => u.isApproved);
function UserRow({ user }: { user: AdminUser }) {
const busy = approvingId === user.id || makingAdminId === user.id || deletingId === user.id;
return (
<li className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4 shadow-sm">
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-slate-800">{user.name}</p>
{user.isAdmin && (
<span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">
Admin
</span>
)}
</div>
<p className="text-sm text-slate-500">{user.email}</p>
</div>
<div className="flex gap-2 items-center">
{!user.isApproved && (
<button
onClick={() => handleApprove(user.id)}
disabled={busy || confirmAdminId === user.id || confirmDeleteId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 disabled:opacity-50 transition-colors"
>
{approvingId === user.id ? '…' : 'Approve'}
</button>
)}
{!user.isAdmin && confirmAdminId === user.id ? (
<>
<span className="text-sm text-slate-500">Make admin?</span>
<button
onClick={() => handleMakeAdmin(user.id)}
disabled={makingAdminId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
{makingAdminId === user.id ? '…' : 'Yes'}
</button>
<button
onClick={() => setConfirmAdminId(null)}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
>
Cancel
</button>
</>
) : (
!user.isAdmin && (
<button
onClick={() => setConfirmAdminId(user.id)}
disabled={busy || confirmDeleteId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
Make admin
</button>
)
)}
{confirmDeleteId === user.id ? (
<>
<span className="text-sm text-slate-500">Sure?</span>
<button
onClick={() => handleDelete(user.id)}
disabled={deletingId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
>
{deletingId === user.id ? '…' : 'Yes, delete'}
</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setConfirmDeleteId(user.id)}
disabled={busy || confirmAdminId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
>
Delete
</button>
)}
</div>
</li>
);
}
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm">
<div className="max-w-3xl mx-auto flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-slate-800">Admin Portal</h1>
<p className="text-xs text-slate-400 mt-0.5">Manage user accounts</p>
</div>
<button
onClick={() => navigate('/')}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
>
Back to planner
</button>
</div>
</header>
<main className="max-w-3xl mx-auto px-6 py-8 flex flex-col gap-8">
{loading && <p className="text-sm text-slate-400 text-center py-12">Loading</p>}
{error && <p className="text-sm text-red-500 text-center py-4">{error}</p>}
{!loading && (
<>
<section>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
Pending approval
</h2>
{pending.length === 0 ? (
<p className="text-sm text-slate-400">No pending accounts.</p>
) : (
<ul className="flex flex-col gap-3">
{pending.map((u) => <UserRow key={u.id} user={u} />)}
</ul>
)}
</section>
<section>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
Approved accounts
</h2>
{approved.length === 0 ? (
<p className="text-sm text-slate-400">No approved accounts yet.</p>
) : (
<ul className="flex flex-col gap-3">
{approved.map((u) => <UserRow key={u.id} user={u} />)}
</ul>
)}
</section>
</>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { useState } from 'react';
import { api } from '../api/client';
import type { AuthResponse } from '../api/types';
interface AuthPageProps {
onAuth: (auth: AuthResponse) => void;
}
export function AuthPage({ onAuth }: AuthPageProps) {
const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: { preventDefault: () => void }) {
e.preventDefault();
setError('');
setLoading(true);
try {
if (mode === 'login') {
const auth = await api.login(email, password);
onAuth(auth);
} else {
await api.register(email, name, password);
setMode('pending');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-slate-800">Office Planner</h1>
<p className="text-sm text-slate-400 mt-1">Reserve your workspace</p>
</div>
{mode === 'pending' && (
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center">
<div className="text-3xl mb-4"></div>
<h2 className="text-base font-semibold text-slate-800 mb-2">Account pending approval</h2>
<p className="text-sm text-slate-500 mb-6">
Your account has been created. An administrator will review and approve it shortly.
</p>
<button
onClick={() => { setMode('login'); setError(''); }}
className="text-sm text-emerald-600 hover:text-emerald-700 font-medium"
>
Back to sign in
</button>
</div>
)}
{mode !== 'pending' && <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
<div className="flex rounded-lg bg-slate-100 p-1 mb-6">
<button
onClick={() => { setMode('login'); setError(''); }}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
mode === 'login' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
Sign in
</button>
<button
onClick={() => { setMode('register'); setError(''); }}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
mode === 'register' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
Create account
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{mode === 'register' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Full name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Jane Smith"
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Work email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="jane@company.com"
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors mt-1"
>
{loading ? '…' : mode === 'login' ? 'Sign in' : 'Create account'}
</button>
</form>
</div>}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More