From 87c48fbf06a7ece88679ba894749330b6762665f Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Fri, 20 Mar 2026 12:19:00 +0100 Subject: [PATCH 01/11] refactor(api): add centralized error handling middleware and structured error model hierarchy Introduce ResourceError base error/exception class with ResourceType/ResourceId Replace controllers-level try/catch with centerlized ExceptionHandlingMiddleware Configure structured logging and replace Console.WriteLine with proper logging --- api/Extensions/LoggingExtensions.cs | 21 +++++ .../ExceptionHandlingMiddleware.cs | 83 +++++++++++++++++++ api/Models/Errors/InvalidRoomNumber.cs | 8 -- api/Models/Errors/NotFoundException.cs | 4 +- api/Models/Errors/ResourceException.cs | 13 +++ api/Models/Errors/ValidationException.cs | 6 ++ api/Models/Room.cs | 2 +- api/Program.cs | 12 ++- api/Repositories/GuestRepository.cs | 2 +- api/Repositories/ReservationRepository.cs | 6 +- api/Repositories/RoomRepository.cs | 2 +- tests/api.IntegrationTests/.gitignore | 2 + .../ExceptionHandlingTests.cs | 79 ++++++++++++++++++ .../api.IntegrationTests.csproj | 26 ++++++ 14 files changed, 247 insertions(+), 19 deletions(-) create mode 100644 api/Extensions/LoggingExtensions.cs create mode 100644 api/Middlewares/ExceptionHandlingMiddleware.cs delete mode 100644 api/Models/Errors/InvalidRoomNumber.cs create mode 100644 api/Models/Errors/ResourceException.cs create mode 100644 api/Models/Errors/ValidationException.cs create mode 100644 tests/api.IntegrationTests/.gitignore create mode 100644 tests/api.IntegrationTests/ExceptionHandlingTests.cs create mode 100644 tests/api.IntegrationTests/api.IntegrationTests.csproj diff --git a/api/Extensions/LoggingExtensions.cs b/api/Extensions/LoggingExtensions.cs new file mode 100644 index 0000000..e49d4af --- /dev/null +++ b/api/Extensions/LoggingExtensions.cs @@ -0,0 +1,21 @@ +namespace Extensions +{ + public static class LoggingExtensions + { + public static void ConfigureLogging(this IServiceCollection services, IConfiguration config, string env) + { + services.AddLogging(logging => + { + logging.ClearProviders(); + logging.AddConfiguration(config.GetSection("Logging")); + logging.AddConsole(); + +#if DEBUG + logging.AddDebug(); +#endif + + logging.SetMinimumLevel(LogLevel.Information); + }); + } + } +} diff --git a/api/Middlewares/ExceptionHandlingMiddleware.cs b/api/Middlewares/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..c941cfc --- /dev/null +++ b/api/Middlewares/ExceptionHandlingMiddleware.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Models.Errors; + +namespace Middlewares +{ + + /// + /// Middleware that handles unhandled exceptions, logs them, and returns standardized error responses. + /// Also measures and logs request duration. + /// + internal class ExceptionHandlingMiddleware( + RequestDelegate next, + ILogger _logger) + { + + public async Task InvokeAsync(HttpContext httpContext) + { + try + { + await next(httpContext); + } + catch (NotFoundException e) + { + await SetResponse(e, httpContext, HttpStatusCode.NotFound); + } + catch (ValidationException e) + { + await SetResponse(e, httpContext, HttpStatusCode.BadRequest); + } + catch(Exception e) + { + await SetResponse(e, httpContext, HttpStatusCode.InternalServerError); + } + } + + private async Task SetResponse(Exception e, HttpContext httpContext, HttpStatusCode code) + { + if (httpContext.Response.HasStarted) + { + _logger.LogWarning("Response has already started, cannot write error details"); + return; + } + + var response = e is ResourceException resourceException + ? new + { + resourceException.ResourceType, + resourceException.ResourceId, + Detail = GetMessage(resourceException, code), + Title = code.ToString(), + } + : new + { + ResourceType = "", + ResourceId = "", + Detail = e.Message, + Title = code.ToString() + }; + + httpContext.Response.StatusCode = (int)code; + httpContext.Response.ContentType = "application/json"; + + var content = JsonSerializer.Serialize(response); // pascal case + await httpContext.Response.WriteAsync(content); + } + + private string GetMessage(ResourceException e, HttpStatusCode code) + { + return code switch + { + HttpStatusCode.NotFound => !string.IsNullOrEmpty(e.Message) ? e.Message : $"{e.ResourceType} not found", + HttpStatusCode.Conflict => !string.IsNullOrEmpty(e.Message) ? e.Message : $"{e.ResourceType} conflict occurred", + HttpStatusCode.Unauthorized => !string.IsNullOrEmpty(e.Message) ? e.Message : "Unauthorized access", + HttpStatusCode.Forbidden => !string.IsNullOrEmpty(e.Message) ? e.Message : "Access forbidden", + HttpStatusCode.BadRequest => !string.IsNullOrEmpty(e.Message) ? e.Message : "Invalid request", + _ => !string.IsNullOrEmpty(e.Message) ? e.Message : "An error occurred" + }; + } + + } +} \ No newline at end of file diff --git a/api/Models/Errors/InvalidRoomNumber.cs b/api/Models/Errors/InvalidRoomNumber.cs deleted file mode 100644 index 59a690b..0000000 --- a/api/Models/Errors/InvalidRoomNumber.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Models.Errors -{ - public class InvalidRoomNumber : Exception - { - public InvalidRoomNumber(string invalidRoomNumber) - : base($"The value ${invalidRoomNumber} is not a valid") { } - } -} diff --git a/api/Models/Errors/NotFoundException.cs b/api/Models/Errors/NotFoundException.cs index ec73642..ac3c552 100644 --- a/api/Models/Errors/NotFoundException.cs +++ b/api/Models/Errors/NotFoundException.cs @@ -1,8 +1,6 @@ namespace Models.Errors { - public class NotFoundException : Exception + public class NotFoundException(string resourceType, string resourceId) : ResourceException(resourceType, resourceId, $"{resourceType} {resourceId} not found") { - public NotFoundException(string message) - : base(message) { } } } diff --git a/api/Models/Errors/ResourceException.cs b/api/Models/Errors/ResourceException.cs new file mode 100644 index 0000000..b3dd727 --- /dev/null +++ b/api/Models/Errors/ResourceException.cs @@ -0,0 +1,13 @@ +namespace Models.Errors +{ + public abstract class ResourceException(Exception? innerException, string resourceType, string resourceId, string message) : Exception(message, innerException) + { + public string ResourceType { get; } = resourceType; + public string ResourceId { get; } = resourceId; + + + public ResourceException(string resourceType, string resourceId, string message) : this(null, resourceType, resourceId, message) + { + } + } +} \ No newline at end of file diff --git a/api/Models/Errors/ValidationException.cs b/api/Models/Errors/ValidationException.cs new file mode 100644 index 0000000..a440e1e --- /dev/null +++ b/api/Models/Errors/ValidationException.cs @@ -0,0 +1,6 @@ +namespace Models.Errors +{ + public class ValidationException(string resourceType, string resourceId, string message) : ResourceException(resourceType, resourceId, message) + { + } +} diff --git a/api/Models/Room.cs b/api/Models/Room.cs index cbd6536..81c0121 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -34,7 +34,7 @@ public static int ConvertRoomNumberToInt(string roomNumber) var success = int.TryParse(roomNumber, out int roomNumberInt); if (!success) { - throw new InvalidRoomNumber(roomNumber); + throw new ValidationException(nameof(Room), roomNumber, $"The value {roomNumber} is not a valid room number"); } return roomNumberInt; diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..2cc38e8 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -2,12 +2,15 @@ using Db; using Microsoft.Data.Sqlite; using Repositories; +using Extensions; +using Middlewares; var builder = WebApplication.CreateBuilder(args); { var Services = builder.Services; + Services.ConfigureLogging(builder.Configuration, builder.Environment.EnvironmentName); var connectionString = builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; @@ -27,7 +30,7 @@ } var app = builder.Build(); - +var logger = app.Services.GetRequiredService>(); { try @@ -36,12 +39,13 @@ } catch (Exception ex) { - Console.WriteLine("Failed to setup the database, aborting"); - Console.WriteLine(ex.ToString()); + logger.LogCritical(ex, "Failed to setup the database, aborting"); Environment.Exit(1); return; } + app.UseMiddleware(); + app.UsePathBase("/api") .UseMvc() .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) @@ -50,3 +54,5 @@ } app.Run(); + +public partial class Program { } diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 54182bc..4014b56 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -35,7 +35,7 @@ public async Task GetGuestByEmail(string guestEmail) if (guest == null) { - throw new NotFoundException($"Guest {guestEmail} not found"); + throw new NotFoundException(nameof(Guest), guestEmail); } return guest; diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..8d0adb8 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -34,14 +34,16 @@ public async Task> GetReservations() /// public async Task GetReservation(Guid reservationId) { + var reservationIdStr = reservationId.ToString(); + var reservation = await _db.QueryFirstOrDefaultAsync( "SELECT * FROM Reservations WHERE Id = @reservationIdStr;", - new { reservationIdStr = reservationId.ToString() } + new { reservationIdStr = reservationIdStr } ); if (reservation == null) { - throw new NotFoundException($"Room {reservationId} not found"); + throw new NotFoundException(nameof(Reservation), reservationIdStr); } return reservation.ToDomain(); diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 2b9f904..ee82afe 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -31,7 +31,7 @@ public async Task GetRoom(string roomNumber) if (room == null) { - throw new NotFoundException($"Room {roomNumber} not found"); + throw new NotFoundException(nameof(Room), roomNumber); } return room.ToDomain(); diff --git a/tests/api.IntegrationTests/.gitignore b/tests/api.IntegrationTests/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/tests/api.IntegrationTests/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/tests/api.IntegrationTests/ExceptionHandlingTests.cs b/tests/api.IntegrationTests/ExceptionHandlingTests.cs new file mode 100644 index 0000000..85e3340 --- /dev/null +++ b/tests/api.IntegrationTests/ExceptionHandlingTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace api.IntegrationTests; + +public class ExceptionHandlingTests : IClassFixture> +{ + private readonly HttpClient _client; + + public ExceptionHandlingTests(WebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task Returns_404_with_error_body_when_resource_not_found() + { + var fakeId = Guid.NewGuid(); + var response = await _client.GetAsync($"/api/reservations/{fakeId}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + var body = await DeserializeResponse(response); + Assert.Equal("Reservation", body.ResourceType); + Assert.Equal(fakeId.ToString(), body.ResourceId); + Assert.Equal("NotFound", body.Title); + Assert.False(string.IsNullOrEmpty(body.Detail)); + } + + [Fact] + public async Task Returns_400_with_error_body_when_validation_fails() + { + var response = await _client.GetAsync("/api/rooms/abc"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + var body = await DeserializeResponse(response); + Assert.Equal("Room", body.ResourceType); + Assert.Equal("abc", body.ResourceId); + Assert.Equal("BadRequest", body.Title); + Assert.Contains("not a valid room number", body.Detail); + } + + [Fact] + public async Task Returns_json_content_type_for_error_responses() + { + var fakeId = Guid.NewGuid(); + var response = await _client.GetAsync($"/api/reservations/{fakeId}"); + + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Error_response_contains_all_expected_fields() + { + var fakeId = Guid.NewGuid(); + var response = await _client.GetAsync($"/api/reservations/{fakeId}"); + var content = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("ResourceType", out _)); + Assert.True(root.TryGetProperty("ResourceId", out _)); + Assert.True(root.TryGetProperty("Detail", out _)); + Assert.True(root.TryGetProperty("Title", out _)); + } + + private static async Task DeserializeResponse(HttpResponseMessage response) + { + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content) + ?? throw new Exception("Failed to deserialize error response"); + } + + private record ErrorResponse(string ResourceType, string ResourceId, string Detail, string Title); +} diff --git a/tests/api.IntegrationTests/api.IntegrationTests.csproj b/tests/api.IntegrationTests/api.IntegrationTests.csproj new file mode 100644 index 0000000..a4a1bcd --- /dev/null +++ b/tests/api.IntegrationTests/api.IntegrationTests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file From c668075ee1e88efe6bd70877b96ddf706d4e347c Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Fri, 20 Mar 2026 12:25:00 +0100 Subject: [PATCH 02/11] refactor(api): pluralize controller names and routes for REST conventions --- ...GuestController.cs => GuestsController.cs} | 6 ++-- ...ontroller.cs => ReservationsController.cs} | 33 +++++-------------- .../{RoomController.cs => RoomsController.cs} | 23 +++---------- ui/src/reservations/api.ts | 2 +- 4 files changed, 17 insertions(+), 47 deletions(-) rename api/Controllers/{GuestController.cs => GuestsController.cs} (73%) rename api/Controllers/{ReservationController.cs => ReservationsController.cs} (62%) rename api/Controllers/{RoomController.cs => RoomsController.cs} (73%) diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestsController.cs similarity index 73% rename from api/Controllers/GuestController.cs rename to api/Controllers/GuestsController.cs index 095d570..f10e746 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestsController.cs @@ -4,12 +4,12 @@ namespace Controllers { - [Tags("Guests"), Route("guest")] - public class GuestController : Controller + [Tags("Guests"), Route("guests")] + public class GuestsController : Controller { private GuestRepository _repo; - public GuestController(GuestRepository guestRepository) + public GuestsController(GuestRepository guestRepository) { _repo = guestRepository; } diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationsController.cs similarity index 62% rename from api/Controllers/ReservationController.cs rename to api/Controllers/ReservationsController.cs index f17fe4d..d7ff5f8 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationsController.cs @@ -1,16 +1,15 @@ using Microsoft.AspNetCore.Mvc; using Models; -using Models.Errors; using Repositories; namespace Controllers { - [Tags("Reservations"), Route("reservation")] - public class ReservationController : Controller + [Tags("Reservations"), Route("reservations")] + public class ReservationsController : Controller { private ReservationRepository _repo { get; set; } - public ReservationController(ReservationRepository reservationRepository) + public ReservationsController(ReservationRepository reservationRepository) { _repo = reservationRepository; } @@ -26,15 +25,9 @@ public async Task> GetReservations() [HttpGet, Produces("application/json"), Route("{reservationId}")] public async Task> GetRoom(Guid reservationId) { - try - { - var reservation = await _repo.GetReservation(reservationId); - return Json(reservation); - } - catch (NotFoundException) - { - return NotFound(); - } + var reservation = await _repo.GetReservation(reservationId); + + return Json(reservation); } /// @@ -53,18 +46,8 @@ [FromBody] Reservation newBooking newBooking.Id = Guid.NewGuid(); } - try - { - var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); - } - catch (Exception ex) - { - Console.WriteLine("An error occured when trying to book a reservation:"); - Console.WriteLine(ex.ToString()); - - return BadRequest("Invalid reservation"); - } + var createdReservation = await _repo.CreateReservation(newBooking); + return Created($"/reservation/${createdReservation.Id}", createdReservation); } [HttpDelete, Produces("application/json"), Route("{reservationId}")] diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomsController.cs similarity index 73% rename from api/Controllers/RoomController.cs rename to api/Controllers/RoomsController.cs index 6e97650..e69b55d 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomsController.cs @@ -1,16 +1,15 @@ using Microsoft.AspNetCore.Mvc; using Models; -using Models.Errors; using Repositories; namespace Controllers { - [Tags("Rooms"), Route("room")] - public class RoomController : Controller + [Tags("Rooms"), Route("rooms")] + public class RoomsController : Controller { private RoomRepository _repo { get; set; } - public RoomController(RoomRepository roomRepository) + public RoomsController(RoomRepository roomRepository) { _repo = roomRepository; } @@ -31,21 +30,9 @@ public async Task> GetRooms() [HttpGet, Produces("application/json"), Route("{roomNumber}")] public async Task> GetRoom(string roomNumber) { - if (roomNumber.Length != 3) - { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); - } - - try - { - var room = await _repo.GetRoom(roomNumber); + var room = await _repo.GetRoom(roomNumber); - return Json(room); - } - catch (NotFoundException) - { - return NotFound(); - } + return Json(room); } [HttpPost, Produces("application/json"), Route("")] diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..e8e0c3a 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -43,6 +43,6 @@ const RoomListSchema = RoomSchema.array(); export function useGetRooms() { return useQuery({ queryKey: ["rooms"], - queryFn: () => ky.get("api/room").json().then(RoomListSchema.parseAsync), + queryFn: () => ky.get("api/rooms").json().then(RoomListSchema.parseAsync), }); } From 1502032ad39fab947f73add4574e1a7c88d36f19 Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Fri, 20 Mar 2026 13:48:00 +0100 Subject: [PATCH 03/11] feat(api): implement guest room booking with validation Reservation/booking endpoint has FluentValidation for request validation (dates, email, room number format), room existence check, and implicit guest creation. Validation errors tunnled through exception handled middleware. Room numbers stored as TEXT with proper format validation. --- api/Contracts/ErrorResponse.cs | 12 ++ api/Contracts/ReservationRequest.cs | 10 + api/Controllers/ReservationsController.cs | 44 +++-- api/Db/GuidTypeHandler.cs | 18 ++ api/Db/Seed.cs | 29 +++ api/Db/Setup.cs | 10 +- .../ExceptionHandlingMiddleware.cs | 58 ++++-- api/Models/Room.cs | 24 +-- api/Program.cs | 13 +- api/Repositories/ReservationRepository.cs | 103 ++++------ api/Repositories/RoomRepository.cs | 59 ++---- api/Validators/ReservationRequestValidator.cs | 51 +++++ api/api.csproj | 1 + .../ExceptionHandlingTests.cs | 4 +- .../ReservationsEndpointTests.cs | 152 +++++++++++++++ .../TestWebApplicationFactory.cs | 39 ++++ .../api.IntegrationTests.csproj | 1 + tests/api.UnitTests/.gitignore | 2 + .../ReservationRequestValidatorTests.cs | 181 ++++++++++++++++++ tests/api.UnitTests/api.UnitTests.csproj | 26 +++ 20 files changed, 674 insertions(+), 163 deletions(-) create mode 100644 api/Contracts/ErrorResponse.cs create mode 100644 api/Contracts/ReservationRequest.cs create mode 100644 api/Db/GuidTypeHandler.cs create mode 100644 api/Db/Seed.cs create mode 100644 api/Validators/ReservationRequestValidator.cs create mode 100644 tests/api.IntegrationTests/ReservationsEndpointTests.cs create mode 100644 tests/api.IntegrationTests/TestWebApplicationFactory.cs create mode 100644 tests/api.UnitTests/.gitignore create mode 100644 tests/api.UnitTests/ReservationRequestValidatorTests.cs create mode 100644 tests/api.UnitTests/api.UnitTests.csproj diff --git a/api/Contracts/ErrorResponse.cs b/api/Contracts/ErrorResponse.cs new file mode 100644 index 0000000..20a03f4 --- /dev/null +++ b/api/Contracts/ErrorResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Contracts +{ + public record ErrorResponse( + string Title, + string Detail, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ResourceType = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ResourceId = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] Dictionary? Errors = null + ); +} diff --git a/api/Contracts/ReservationRequest.cs b/api/Contracts/ReservationRequest.cs new file mode 100644 index 0000000..cc10be6 --- /dev/null +++ b/api/Contracts/ReservationRequest.cs @@ -0,0 +1,10 @@ +namespace Contracts +{ + public class ReservationRequest + { + public required string RoomNumber { get; set; } + public required string GuestEmail { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } +} diff --git a/api/Controllers/ReservationsController.cs b/api/Controllers/ReservationsController.cs index d7ff5f8..7d022c4 100644 --- a/api/Controllers/ReservationsController.cs +++ b/api/Controllers/ReservationsController.cs @@ -1,61 +1,67 @@ using Microsoft.AspNetCore.Mvc; +using Contracts; +using FluentValidation; using Models; using Repositories; namespace Controllers { + [ApiController] [Tags("Reservations"), Route("reservations")] - public class ReservationsController : Controller + public class ReservationsController : ControllerBase { private ReservationRepository _repo { get; set; } + private IValidator _validator { get; set; } - public ReservationsController(ReservationRepository reservationRepository) + public ReservationsController(ReservationRepository reservationRepository, IValidator validator) { _repo = reservationRepository; + _validator = validator; } [HttpGet, Produces("application/json"), Route("")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] public async Task> GetReservations() { var reservations = await _repo.GetReservations(); - return Json(reservations); + return Ok(reservations); } [HttpGet, Produces("application/json"), Route("{reservationId}")] + [ProducesResponseType(typeof(Reservation), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] public async Task> GetRoom(Guid reservationId) { var reservation = await _repo.GetReservation(reservationId); - return Json(reservation); + return Ok(reservation); } /// /// Create a new reservation, to generate the GUID ID on the server, send an Empty GUID (all 0s) /// - /// - /// + /// The request/booking data. + /// The created reservation. [HttpPost, Produces("application/json"), Route("")] - public async Task> BookReservation( - [FromBody] Reservation newBooking - ) + [ProducesResponseType(typeof(Reservation), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + public async Task> BookReservation([FromBody] ReservationRequest request) { - // Provide a real ID if one is not provided - if (newBooking.Id == Guid.Empty) - { - newBooking.Id = Guid.NewGuid(); - } - - var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); + await _validator.ValidateAndThrowAsync(request); + + var createdReservation = await _repo.CreateReservation(request); + return Created($"/reservations/{createdReservation.Id}", createdReservation); } [HttpDelete, Produces("application/json"), Route("{reservationId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] public async Task DeleteReservation(Guid reservationId) { - var result = await _repo.DeleteReservation(reservationId); + await _repo.DeleteReservation(reservationId); - return result ? NoContent() : NotFound(); + return NoContent(); } } } diff --git a/api/Db/GuidTypeHandler.cs b/api/Db/GuidTypeHandler.cs new file mode 100644 index 0000000..db64576 --- /dev/null +++ b/api/Db/GuidTypeHandler.cs @@ -0,0 +1,18 @@ +using System.Data; +using Dapper; + +namespace Db +{ + public class GuidTypeHandler : SqlMapper.TypeHandler + { + public override Guid Parse(object value) + { + return Guid.Parse((string)value); + } + + public override void SetValue(IDbDataParameter parameter, Guid value) + { + parameter.Value = value.ToString(); + } + } +} diff --git a/api/Db/Seed.cs b/api/Db/Seed.cs new file mode 100644 index 0000000..e4cd263 --- /dev/null +++ b/api/Db/Seed.cs @@ -0,0 +1,29 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Models; + +namespace Db +{ + public static class Seed + { + public static async Task SeedData(SqliteConnection db) + { + await SeedRooms(db); + } + + private static async Task SeedRooms(SqliteConnection db) + { + var existingRooms = await db.QueryFirstOrDefaultAsync("SELECT COUNT(*) FROM Rooms"); + if (existingRooms > 0) return; + + var rooms = new[] { "101", "102", "103", "104", "105", "201", "202", "203" }; + foreach (var room in rooms) + { + await db.ExecuteAsync( + "INSERT OR IGNORE INTO Rooms(Number, State) VALUES(@Number, @State)", + new { Number = room, State = (int)State.Ready } + ); + } + } + } +} diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..c5de04d 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -9,10 +9,8 @@ public static class Setup /// /// Ensures the DB is available and the requried tables are made /// - public static async void EnsureDb(IServiceScope scope) + public static async Task EnsureDb(SqliteConnection db) { - using var db = scope.ServiceProvider.GetRequiredService(); - // SQLite WAL (write-ahead log) go brrrr await db.ExecuteAsync("PRAGMA journal_mode = wal;"); // SQLite does not enforce FKs by default @@ -30,7 +28,7 @@ CREATE TABLE IF NOT EXISTS Guests ( await db.ExecuteAsync( $@" CREATE TABLE IF NOT Exists Rooms ( - {nameof(Room.Number)} INT PRIMARY KEY NOT NULL, + {nameof(Room.Number)} TEXT PRIMARY KEY NOT NULL, {nameof(Room.State)} INT NOT NULL ); " @@ -41,7 +39,7 @@ await db.ExecuteAsync( CREATE TABLE IF NOT EXISTS Reservations ( {nameof(Reservation.Id)} TEXT PRIMARY KEY NOT NULL, {nameof(Reservation.GuestEmail)} TEXT NOT NULL, - {nameof(Reservation.RoomNumber)} INT NOT NULL, + {nameof(Reservation.RoomNumber)} TEXT NOT NULL, {nameof(Reservation.Start)} INT NOT NULL, {nameof(Reservation.End)} INT NOT NULL, {nameof(Reservation.CheckedIn)} INT NOT NULL DEFAULT FALSE, @@ -54,5 +52,7 @@ REFERENCES Rooms ({nameof(Room.Number)}) " ); } + } } + diff --git a/api/Middlewares/ExceptionHandlingMiddleware.cs b/api/Middlewares/ExceptionHandlingMiddleware.cs index c941cfc..8ea7790 100644 --- a/api/Middlewares/ExceptionHandlingMiddleware.cs +++ b/api/Middlewares/ExceptionHandlingMiddleware.cs @@ -1,7 +1,8 @@ using System.Net; using System.Text.Json; -using System.Text.Json.Serialization; +using Contracts; using Models.Errors; +using FluentValidationException = FluentValidation.ValidationException; namespace Middlewares { @@ -29,6 +30,10 @@ public async Task InvokeAsync(HttpContext httpContext) { await SetResponse(e, httpContext, HttpStatusCode.BadRequest); } + catch (FluentValidationException e) + { + await SetValidationResponse(e, httpContext); + } catch(Exception e) { await SetResponse(e, httpContext, HttpStatusCode.InternalServerError); @@ -44,25 +49,46 @@ private async Task SetResponse(Exception e, HttpContext httpContext, HttpStatusC } var response = e is ResourceException resourceException - ? new - { - resourceException.ResourceType, - resourceException.ResourceId, - Detail = GetMessage(resourceException, code), - Title = code.ToString(), - } - : new - { - ResourceType = "", - ResourceId = "", - Detail = e.Message, - Title = code.ToString() - }; + ? new ErrorResponse( + Title: code.ToString(), + Detail: GetMessage(resourceException, code), + ResourceType: resourceException.ResourceType, + ResourceId: resourceException.ResourceId + ) + : new ErrorResponse( + Title: code.ToString(), + Detail: e.Message + ); httpContext.Response.StatusCode = (int)code; httpContext.Response.ContentType = "application/json"; - var content = JsonSerializer.Serialize(response); // pascal case + var content = JsonSerializer.Serialize(response); + await httpContext.Response.WriteAsync(content); + } + + private async Task SetValidationResponse(FluentValidationException e, HttpContext httpContext) + { + if (httpContext.Response.HasStarted) + { + _logger.LogWarning("Response has already started, cannot write error details"); + return; + } + + var errors = e.Errors + .GroupBy(f => f.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(f => f.ErrorMessage).ToArray()); + + var response = new ErrorResponse( + Title: "BadRequest", + Detail: "One or more validation errors occurred", + Errors: errors + ); + + httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + httpContext.Response.ContentType = "application/json"; + + var content = JsonSerializer.Serialize(response); await httpContext.Response.WriteAsync(content); } diff --git a/api/Models/Room.cs b/api/Models/Room.cs index 81c0121..abedd2d 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Models.Errors; namespace Models @@ -8,9 +9,8 @@ namespace Models public class Room { /// - /// PKID For Rooms. MewsHotel format is a three digit number with the first - /// number being the floor number (up to 9) and the remaining two digits - /// as the number of the door on the floor + /// PKID For Rooms. Format is "###" where first digit is floor (1-9) + /// and last two digits are the door number (01-99). /// public required string Number { get; set; } @@ -19,25 +19,17 @@ public class Room /// public State State { get; set; } = State.Ready; + private static readonly Regex RoomNumberPattern = new(@"^[1-9]\d{2}$"); + /// - /// Formats the room number filling it with 0s - /// to get a three digit string + /// Validates the room number format. Must be 3 digits, first digit 1-9, last two not "00". /// - /// - public static string FormatRoomNumber(int number) + public static void ValidateRoomNumber(string roomNumber) { - return number.ToString().PadLeft(3, '0'); - } - - public static int ConvertRoomNumberToInt(string roomNumber) - { - var success = int.TryParse(roomNumber, out int roomNumberInt); - if (!success) + if (!RoomNumberPattern.IsMatch(roomNumber) || roomNumber[1..] == "00") { throw new ValidationException(nameof(Room), roomNumber, $"The value {roomNumber} is not a valid room number"); } - - return roomNumberInt; } } diff --git a/api/Program.cs b/api/Program.cs index 2cc38e8..f13f249 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,10 +1,14 @@ using System.Data; +using Dapper; using Db; using Microsoft.Data.Sqlite; using Repositories; using Extensions; +using FluentValidation; using Middlewares; +SqlMapper.AddTypeHandler(new GuidTypeHandler()); + var builder = WebApplication.CreateBuilder(args); @@ -24,6 +28,7 @@ { opt.EnableEndpointRouting = false; }); + Services.AddValidatorsFromAssemblyContaining(); Services.AddCors(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); @@ -35,7 +40,13 @@ { try { - Setup.EnsureDb(app.Services.CreateScope()); + var db = app.Services.GetRequiredService(); + await Setup.EnsureDb(db); + + if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Testing") + { + await Seed.SeedData(db); + } } catch (Exception ex) { diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 8d0adb8..64e564e 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -1,6 +1,7 @@ using System.Data; using Dapper; using Models; +using Contracts; using Models.Errors; namespace Repositories @@ -16,99 +17,79 @@ public ReservationRepository(IDbConnection db) public async Task> GetReservations() { - var reservations = await _db.QueryAsync("SELECT * FROM Reservations"); + var reservations = await _db.QueryAsync("SELECT * FROM Reservations"); if (reservations == null) { return []; } - return reservations.Select(r => r.ToDomain()); + return reservations; } /// /// Find a reservation by its Guid ID, throwing if not found /// - /// - /// An existing reservation /// public async Task GetReservation(Guid reservationId) { - var reservationIdStr = reservationId.ToString(); - - var reservation = await _db.QueryFirstOrDefaultAsync( - "SELECT * FROM Reservations WHERE Id = @reservationIdStr;", - new { reservationIdStr = reservationIdStr } + var reservation = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Reservations WHERE Id = @reservationId;", + new { reservationId } ); if (reservation == null) { - throw new NotFoundException(nameof(Reservation), reservationIdStr); + throw new NotFoundException(nameof(Reservation), reservationId.ToString()); } - return reservation.ToDomain(); + return reservation; } - public async Task CreateReservation(Reservation newReservation) + public async Task CreateReservation(ReservationRequest request) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } + + ArgumentNullException.ThrowIfNull(request); + + var room = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Rooms WHERE Number = @RoomNumber;", + new { request.RoomNumber } + ) ?? throw new NotFoundException(nameof(Room), request.RoomNumber); + + await _db.ExecuteAsync( + "INSERT OR IGNORE INTO Guests(Email, Name) VALUES(@GuestEmail, @GuestEmail)", + new { request.GuestEmail } ); - } - public async Task DeleteReservation(Guid reservationId) - { - var deleted = await _db.ExecuteAsync( - "DELETE FROM Reservations WHERE Id = @reservationIdStr;", - new { reservationIdStr = reservationId.ToString() } + var reservation = new Reservation + { + Id = Guid.NewGuid(), + RoomNumber = request.RoomNumber, + GuestEmail = request.GuestEmail, + Start = request.Start, + End = request.End + }; + + var created = await _db.QuerySingleAsync( + @"INSERT INTO Reservations(Id, GuestEmail, RoomNumber, Start, End) + VALUES(@Id, @GuestEmail, @RoomNumber, @Start, @End) + RETURNING *", + reservation ); - return deleted > 0; + return created; } - private class ReservationDb + public async Task DeleteReservation(Guid reservationId) { - public string Id { get; set; } - public int RoomNumber { get; set; } - - public string GuestEmail { get; set; } - - public DateTime Start { get; set; } - public DateTime End { get; set; } - public bool CheckedIn { get; set; } - public bool CheckedOut { get; set; } - - public ReservationDb() - { - Id = Guid.Empty.ToString(); - RoomNumber = 0; - GuestEmail = ""; - } - - public ReservationDb(Reservation reservation) - { - Id = reservation.Id.ToString(); - RoomNumber = Room.ConvertRoomNumberToInt(reservation.RoomNumber); - GuestEmail = reservation.GuestEmail; - Start = reservation.Start; - End = reservation.End; - CheckedIn = reservation.CheckedIn; - CheckedOut = reservation.CheckedOut; - } + var deleted = await _db.ExecuteAsync( + "DELETE FROM Reservations WHERE Id = @reservationId;", + new { reservationId } + ); - public Reservation ToDomain() + if (deleted == 0) { - return new Reservation - { - Id = Guid.Parse(Id), - RoomNumber = Room.FormatRoomNumber(RoomNumber), - GuestEmail = GuestEmail, - Start = Start, - End = End, - CheckedIn = CheckedIn, - CheckedOut = CheckedOut - }; + throw new NotFoundException(nameof(Reservation), reservationId.ToString()); } } } diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index ee82afe..f7fadac 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -15,18 +15,16 @@ public RoomRepository(IDbConnection db) } /// - /// Find a room by its formatted room number, throwing if not found + /// Find a room by its room number, throwing if not found /// - /// - /// An existing room /// public async Task GetRoom(string roomNumber) { - var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + Room.ValidateRoomNumber(roomNumber); - var room = await _db.QueryFirstOrDefaultAsync( - "SELECT * FROM Rooms WHERE Number = @roomNumberInt;", - new { roomNumberInt } + var room = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Rooms WHERE Number = @roomNumber;", + new { roomNumber } ); if (room == null) @@ -34,68 +32,43 @@ public async Task GetRoom(string roomNumber) throw new NotFoundException(nameof(Room), roomNumber); } - return room.ToDomain(); + return room; } public async Task> GetRooms() { - var rooms = await _db.QueryAsync("SELECT * FROM Rooms"); + var rooms = await _db.QueryAsync("SELECT * FROM Rooms"); if (rooms == null) { return []; } - return rooms.Select(r => r.ToDomain()); + return rooms; } public async Task CreateRoom(Room newRoom) { - var createdRoom = await _db.QuerySingleAsync( + Room.ValidateRoomNumber(newRoom.Number); + + var createdRoom = await _db.QuerySingleAsync( "INSERT INTO Rooms(Number, State) Values(@Number, @State) RETURNING *", - new RoomDb(newRoom) + newRoom ); - return createdRoom.ToDomain(); + return createdRoom; } public async Task DeleteRoom(string roomNumber) { - var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + Room.ValidateRoomNumber(roomNumber); var deleted = await _db.ExecuteAsync( - "DELETE FROM Rooms WHERE Number = @roomNumberInt;", - new { roomNumberInt } + "DELETE FROM Rooms WHERE Number = @roomNumber;", + new { roomNumber } ); return deleted > 0; } - - // Inner class to hide the details of a direct mapping to SQLite - private class RoomDb - { - /// - /// PKID For Rooms. SQLite stores as an integer - /// - public int Number { get; set; } - - /// - /// Whether the room is available for reservation - /// - public State State { get; set; } = State.Ready; - - public RoomDb() { } - - public RoomDb(Room room) - { - Number = Room.ConvertRoomNumberToInt(room.Number); - State = room.State; - } - - public Room ToDomain() - { - return new Room { Number = Room.FormatRoomNumber(Number), State = State }; - } - } } } diff --git a/api/Validators/ReservationRequestValidator.cs b/api/Validators/ReservationRequestValidator.cs new file mode 100644 index 0000000..5623fe4 --- /dev/null +++ b/api/Validators/ReservationRequestValidator.cs @@ -0,0 +1,51 @@ +using System.Text.RegularExpressions; +using FluentValidation; +using Contracts; + +namespace Validators +{ + public class ReservationRequestValidator : AbstractValidator + { + private static readonly Regex RoomNumberPattern = new(@"^[1-9]\d{2}$"); + + public ReservationRequestValidator() + { + RuleFor(x => x.GuestEmail) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Email must be a valid email address") + .Must(email => email.Contains('.')).WithMessage("Email must include a domain"); + + RuleFor(x => x.RoomNumber) + .NotEmpty().WithMessage("Room number is required") + .Must(BeValidRoomNumber).WithMessage("Room number must be in format '###' (e.g. 101, 202)"); + + RuleFor(x => x.Start) + .NotEmpty().WithMessage("Start date is required") + .LessThan(x => x.End).WithMessage("Start date must be before end date") + .Must(start => start >= DateTime.Today).WithMessage("Time travellers are not welcomed in the Mewstel"); + + RuleFor(x => x.End) + .NotEmpty().WithMessage("End date is required") + .GreaterThan(x => x.Start).WithMessage("End date must be after start date"); + + RuleFor(x => x) + .Must(HaveMinimumDuration).WithMessage("Minimum booking duration is 1 day") + .Must(HaveMaximumDuration).WithMessage("Maximum booking duration is 30 days"); + } + + private static bool BeValidRoomNumber(string roomNumber) + { + return RoomNumberPattern.IsMatch(roomNumber) && roomNumber[1..] != "00"; + } + + private static bool HaveMinimumDuration(ReservationRequest request) + { + return (request.End - request.Start).TotalDays >= 1; + } + + private static bool HaveMaximumDuration(ReservationRequest request) + { + return (request.End - request.Start).TotalDays <= 30; + } + } +} diff --git a/api/api.csproj b/api/api.csproj index ef55adc..a9c5ece 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/api.IntegrationTests/ExceptionHandlingTests.cs b/tests/api.IntegrationTests/ExceptionHandlingTests.cs index 85e3340..3fff8da 100644 --- a/tests/api.IntegrationTests/ExceptionHandlingTests.cs +++ b/tests/api.IntegrationTests/ExceptionHandlingTests.cs @@ -4,11 +4,11 @@ namespace api.IntegrationTests; -public class ExceptionHandlingTests : IClassFixture> +public class ExceptionHandlingTests : IClassFixture { private readonly HttpClient _client; - public ExceptionHandlingTests(WebApplicationFactory factory) + public ExceptionHandlingTests(TestWebApplicationFactory factory) { _client = factory.CreateClient(); } diff --git a/tests/api.IntegrationTests/ReservationsEndpointTests.cs b/tests/api.IntegrationTests/ReservationsEndpointTests.cs new file mode 100644 index 0000000..0138f92 --- /dev/null +++ b/tests/api.IntegrationTests/ReservationsEndpointTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Net.Http.Json; +using Contracts; +using Models; + +namespace api.IntegrationTests; + +public class ReservationsEndpointTests : IClassFixture +{ + private readonly HttpClient _client; + + public ReservationsEndpointTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + + [Fact] + public async Task Post_valid_reservation_returns_201() + { + var booking = new ReservationRequest + { + RoomNumber = "101", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", booking); + + Assert.True( + response.StatusCode == HttpStatusCode.Created, + $"Expected 201 but got {(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}" + ); + + var reservation = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(reservation); + Assert.NotEqual(Guid.Empty, reservation.Id); + Assert.Equal("101", reservation.RoomNumber); + Assert.Equal("guest@mjail.com", reservation.GuestEmail); + } + + [Fact] + public async Task Get_reservations_returns_200() + { + var response = await _client.GetAsync("/api/reservations"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var reservations = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(reservations); + } + + [Fact] + public async Task Get_nonexistent_reservation_returns_404() + { + var fakeId = Guid.NewGuid(); + var response = await _client.GetAsync($"/api/reservations/{fakeId}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var error = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(error); + Assert.Equal("NotFound", error.Title); + Assert.Equal("Reservation", error.ResourceType); + } + + + [Fact] + public async Task Post_reservation_with_nonexistent_room_returns_404() + { + var booking = new ReservationRequest + { + RoomNumber = "999", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", booking); + var error = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.NotNull(error); + Assert.Equal("NotFound", error.Title); + Assert.Equal("Room", error.ResourceType); + Assert.Equal("999", error.ResourceId); + } + + [Fact] + public async Task Post_reservation_with_invalid_data_returns_400_with_errors() + { + var invalidBooking = new ReservationRequest + { + RoomNumber = "000", + GuestEmail = "not-an-email", + Start = DateTime.Today.AddDays(-1), + End = DateTime.Today.AddDays(-1) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", invalidBooking); + var error = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(error); + Assert.Equal("BadRequest", error.Title); + Assert.NotNull(error.Errors); + Assert.True(error.Errors.ContainsKey("RoomNumber")); + Assert.True(error.Errors.ContainsKey("GuestEmail")); + Assert.True(error.Errors.ContainsKey("Start")); + } + + [Fact] + public async Task Post_reservation_with_invalid_room_number_returns_room_error() + { + var booking = new ReservationRequest + { + RoomNumber = "abc", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", booking); + var error = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(error?.Errors); + Assert.True(error.Errors.ContainsKey("RoomNumber")); + Assert.False(error.Errors.ContainsKey("GuestEmail")); + } + + [Fact] + public async Task Post_reservation_with_invalid_email_returns_email_error() + { + var booking = new ReservationRequest + { + RoomNumber = "101", + GuestEmail = "nodomain", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", booking); + var error = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(error?.Errors); + Assert.True(error.Errors.ContainsKey("GuestEmail")); + Assert.False(error.Errors.ContainsKey("RoomNumber")); + } +} diff --git a/tests/api.IntegrationTests/TestWebApplicationFactory.cs b/tests/api.IntegrationTests/TestWebApplicationFactory.cs new file mode 100644 index 0000000..687a9a5 --- /dev/null +++ b/tests/api.IntegrationTests/TestWebApplicationFactory.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Data; + +namespace api.IntegrationTests; + +public class TestWebApplicationFactory : WebApplicationFactory +{ + private readonly SqliteConnection _connection = new("Data Source=InMemory;Mode=Memory;Cache=Shared"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + _connection.Open(); + + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // Remove existing DB registrations + var descriptors = services + .Where(d => d.ServiceType == typeof(SqliteConnection) || d.ServiceType == typeof(IDbConnection)) + .ToList(); + foreach (var d in descriptors) services.Remove(d); + + // Add in-memory connection + services.AddSingleton(_connection); + services.AddSingleton(_connection); + }); + } + + protected override void Dispose(bool disposing) + { + _connection.Close(); + base.Dispose(disposing); + } +} diff --git a/tests/api.IntegrationTests/api.IntegrationTests.csproj b/tests/api.IntegrationTests/api.IntegrationTests.csproj index a4a1bcd..99b02db 100644 --- a/tests/api.IntegrationTests/api.IntegrationTests.csproj +++ b/tests/api.IntegrationTests/api.IntegrationTests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/api.UnitTests/.gitignore b/tests/api.UnitTests/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/tests/api.UnitTests/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/tests/api.UnitTests/ReservationRequestValidatorTests.cs b/tests/api.UnitTests/ReservationRequestValidatorTests.cs new file mode 100644 index 0000000..29e46fb --- /dev/null +++ b/tests/api.UnitTests/ReservationRequestValidatorTests.cs @@ -0,0 +1,181 @@ +using Contracts; +using FluentValidation.TestHelper; +using Validators; + +namespace api.UnitTests; + +public class ReservationRequestValidatorTests +{ + private readonly ReservationRequestValidator _validator = new(); + + private static ReservationRequest ValidRequest() => new() + { + RoomNumber = "101", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + + [Theory] + [InlineData("101")] + [InlineData("201")] + [InlineData("999")] + [InlineData("110")] + public void Valid_room_numbers_pass(string roomNumber) + { + // Arrange + var request = ValidRequest(); + request.RoomNumber = roomNumber; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.RoomNumber); + } + + [Theory] + [InlineData("000")] + [InlineData("100")] + [InlineData("200")] + [InlineData("0")] + [InlineData("1")] + [InlineData("2020")] + [InlineData("-101")] + [InlineData("abc")] + [InlineData("")] + public void Invalid_room_numbers_fail(string roomNumber) + { + var request = ValidRequest(); + request.RoomNumber = roomNumber; + + var result = _validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(x => x.RoomNumber); + } + + + [Theory] + [InlineData("someone@test.com")] + [InlineData("someonelse@hotel.co.uk")] + public void Valid_emails_pass(string email) + { + var request = ValidRequest(); + request.GuestEmail = email; + + var result = _validator.TestValidate(request); + + result.ShouldNotHaveValidationErrorFor(x => x.GuestEmail); + } + + [Theory] + [InlineData("")] + [InlineData("notanemail")] + [InlineData("cecin'estpasunemail")] + public void Invalid_emails_fail(string email) + { + var request = ValidRequest(); + request.GuestEmail = email; + + var result = _validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(x => x.GuestEmail); + } + + + [Fact] + public void Start_date_must_be_before_end_date() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(5); + request.End = DateTime.Today.AddDays(3); + + var result = _validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(x => x.Start); + } + + [Fact] + public void Time_travellers_are_not_allowed() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(-1); + request.End = DateTime.Today.AddDays(-2); + + var result = _validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(x => x.Start) + .WithErrorMessage("Time travellers are not welcomed in the Mewstel");; + } + + [Fact] + public void Start_date_today_is_valid() + { + var request = ValidRequest(); + request.Start = DateTime.Today; + request.End = DateTime.Today.AddDays(2); + + var result = _validator.TestValidate(request); + + result.ShouldNotHaveValidationErrorFor(x => x.Start); + } + + + [Fact] + public void Minimum_duration_is_1_day() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(1); + request.End = DateTime.Today.AddDays(1).AddHours(12); + + var result = _validator.TestValidate(request); + + result.ShouldHaveAnyValidationError() + .WithErrorMessage("Minimum booking duration is 1 day"); + } + + [Fact] + public void Maximum_duration_is_30_days() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(1); + request.End = DateTime.Today.AddDays(32); + + var result = _validator.TestValidate(request); + + result.ShouldHaveAnyValidationError() + .WithErrorMessage("Maximum booking duration is 30 days"); + } + + [Fact] + public void Exactly_30_days_is_valid() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(1); + request.End = DateTime.Today.AddDays(31); + + var result = _validator.TestValidate(request); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Exactly_1_day_is_valid() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(1); + request.End = DateTime.Today.AddDays(2); + + var result = _validator.TestValidate(request); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Valid_request_has_no_errors() + { + var result = _validator.TestValidate(ValidRequest()); + result.ShouldNotHaveAnyValidationErrors(); + } +} diff --git a/tests/api.UnitTests/api.UnitTests.csproj b/tests/api.UnitTests/api.UnitTests.csproj new file mode 100644 index 0000000..b424567 --- /dev/null +++ b/tests/api.UnitTests/api.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file From e143a99512dc35f527aa7d17ccff6a14f9ccf10f Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Fri, 20 Mar 2026 14:14:00 +0100 Subject: [PATCH 04/11] feat(ui): implement booking flow with validation errors and confirmation dialog --- api/Controllers/RoomsController.cs | 11 +-- .../ExceptionHandlingMiddleware.cs | 9 ++- api/Validators/ReservationRequestValidator.cs | 2 +- .../ReservationRequestValidatorTests.cs | 2 +- ui/src/components/ErrorToast.tsx | 43 +++++++++++ .../reservations/BookingConfirmationModal.tsx | 52 +++++++++++++ ui/src/reservations/BookingDetailsModal.tsx | 10 +-- ui/src/reservations/ReservationPage.tsx | 30 ++++++-- ui/src/reservations/api.ts | 77 +++++++++++++------ ui/src/utils/datetime.ts | 10 ++- ui/src/utils/toasts.tsx | 12 +++ 11 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 ui/src/components/ErrorToast.tsx create mode 100644 ui/src/reservations/BookingConfirmationModal.tsx diff --git a/api/Controllers/RoomsController.cs b/api/Controllers/RoomsController.cs index e69b55d..8a21a3a 100644 --- a/api/Controllers/RoomsController.cs +++ b/api/Controllers/RoomsController.cs @@ -4,8 +4,9 @@ namespace Controllers { + [ApiController] [Tags("Rooms"), Route("rooms")] - public class RoomsController : Controller + public class RoomsController : ControllerBase { private RoomRepository _repo { get; set; } @@ -21,10 +22,10 @@ public async Task> GetRooms() if (rooms == null) { - return Json(Enumerable.Empty()); + return Ok(Enumerable.Empty()); } - return Json(rooms); + return Ok(rooms); } [HttpGet, Produces("application/json"), Route("{roomNumber}")] @@ -32,7 +33,7 @@ public async Task> GetRoom(string roomNumber) { var room = await _repo.GetRoom(roomNumber); - return Json(room); + return Ok(room); } [HttpPost, Produces("application/json"), Route("")] @@ -45,7 +46,7 @@ public async Task> CreateRoom([FromBody] Room newRoom) return NotFound(); } - return Json(createdRoom); + return Ok(createdRoom); } [HttpDelete, Produces("application/json"), Route("{roomNumber}")] diff --git a/api/Middlewares/ExceptionHandlingMiddleware.cs b/api/Middlewares/ExceptionHandlingMiddleware.cs index 8ea7790..470e30a 100644 --- a/api/Middlewares/ExceptionHandlingMiddleware.cs +++ b/api/Middlewares/ExceptionHandlingMiddleware.cs @@ -15,6 +15,11 @@ internal class ExceptionHandlingMiddleware( RequestDelegate next, ILogger _logger) { + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; public async Task InvokeAsync(HttpContext httpContext) { @@ -63,7 +68,7 @@ private async Task SetResponse(Exception e, HttpContext httpContext, HttpStatusC httpContext.Response.StatusCode = (int)code; httpContext.Response.ContentType = "application/json"; - var content = JsonSerializer.Serialize(response); + var content = JsonSerializer.Serialize(response, _jsonOptions); await httpContext.Response.WriteAsync(content); } @@ -88,7 +93,7 @@ private async Task SetValidationResponse(FluentValidationException e, HttpContex httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; httpContext.Response.ContentType = "application/json"; - var content = JsonSerializer.Serialize(response); + var content = JsonSerializer.Serialize(response, _jsonOptions); await httpContext.Response.WriteAsync(content); } diff --git a/api/Validators/ReservationRequestValidator.cs b/api/Validators/ReservationRequestValidator.cs index 5623fe4..fa95624 100644 --- a/api/Validators/ReservationRequestValidator.cs +++ b/api/Validators/ReservationRequestValidator.cs @@ -22,7 +22,7 @@ public ReservationRequestValidator() RuleFor(x => x.Start) .NotEmpty().WithMessage("Start date is required") .LessThan(x => x.End).WithMessage("Start date must be before end date") - .Must(start => start >= DateTime.Today).WithMessage("Time travellers are not welcomed in the Mewstel"); + .Must(start => start >= DateTime.Today).WithMessage("Time travellers are not welcomed in Mewstel"); RuleFor(x => x.End) .NotEmpty().WithMessage("End date is required") diff --git a/tests/api.UnitTests/ReservationRequestValidatorTests.cs b/tests/api.UnitTests/ReservationRequestValidatorTests.cs index 29e46fb..954aaa4 100644 --- a/tests/api.UnitTests/ReservationRequestValidatorTests.cs +++ b/tests/api.UnitTests/ReservationRequestValidatorTests.cs @@ -106,7 +106,7 @@ public void Time_travellers_are_not_allowed() var result = _validator.TestValidate(request); result.ShouldHaveValidationErrorFor(x => x.Start) - .WithErrorMessage("Time travellers are not welcomed in the Mewstel");; + .WithErrorMessage("Time travellers are not welcomed in Mewstel");; } [Fact] diff --git a/ui/src/components/ErrorToast.tsx b/ui/src/components/ErrorToast.tsx new file mode 100644 index 0000000..e6738ed --- /dev/null +++ b/ui/src/components/ErrorToast.tsx @@ -0,0 +1,43 @@ +import { Text, Box } from "@radix-ui/themes"; +import { useCallback } from "react"; +import { toast } from "sonner"; +import styled from "styled-components"; + +export interface ErrorToastProps { + toastId: string | number; + messages: string[]; +} + +const BorderedErrorBox = styled(Box)` + background-color: var(--red-5); + border-radius: var(--radius-4); + border: 1px solid var(--red-9); +`; + +const ErrorList = styled.ul` + margin: 4px 0 0 0; + padding-left: 18px; +`; + +/** An error toast */ +export function ErrorToast({ toastId, messages }: ErrorToastProps) { + const closeToast = useCallback(() => toast.dismiss(toastId), [toastId]); + + return ( + + {messages.length === 1 ? ( + + {messages[0]} + + ) : ( + + {messages.map((msg, i) => ( +
  • + {msg} +
  • + ))} +
    + )} +
    + ); +} diff --git a/ui/src/reservations/BookingConfirmationModal.tsx b/ui/src/reservations/BookingConfirmationModal.tsx new file mode 100644 index 0000000..ed3d285 --- /dev/null +++ b/ui/src/reservations/BookingConfirmationModal.tsx @@ -0,0 +1,52 @@ +import { Box, Button, Dialog, Separator, Text } from "@radix-ui/themes"; +import { Reservation } from "./api"; + +interface BookingConfirmationModalProps { + reservation: Reservation; + onClose: () => void; +} + +export function BookingConfirmationModal({ + reservation, + onClose, +}: BookingConfirmationModalProps) { + const startDate = new Date(reservation.start).toLocaleDateString(); + const endDate = new Date(reservation.end).toLocaleDateString(); + + return ( + !open && onClose()}> + + Booking Confirmed + + Your reservation has been successful. We are happy to welcome you! + + + + + Confirmation ID: {reservation.id} + + + Room: #{reservation.roomNumber} + + + Email: {reservation.guestEmail} + + + Check-in: {startDate} + + + Check-out: {endDate} + + + + + + + + + + + ); +} diff --git a/ui/src/reservations/BookingDetailsModal.tsx b/ui/src/reservations/BookingDetailsModal.tsx index 6f1edfe..499a6bd 100644 --- a/ui/src/reservations/BookingDetailsModal.tsx +++ b/ui/src/reservations/BookingDetailsModal.tsx @@ -55,7 +55,6 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { null, ]); const [focusedInput, setFocusedInput] = useState(null); - const showProcessingToast = useShowInfoToast("Processing booking..."); const showNoInfoToast = useShowInfoToast("Missing email or dates."); function handleSubmit(evt: React.MouseEvent) { @@ -65,12 +64,11 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { return false; } - showProcessingToast(); onSubmit({ - RoomNumber: roomNumber, - GuestEmail: email, - Start: fromDateStringToIso(dateRange[0]), - End: fromDateStringToIso(dateRange[1]), + roomNumber, + guestEmail: email, + start: fromDateStringToIso(dateRange[0]), + end: fromDateStringToIso(dateRange[1]), }); return true; } diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 06a0036..62a0dc3 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,10 +1,12 @@ import { useState } from "react"; -import { useShowSuccessToast } from "../utils/toasts"; +import { useShowErrorToast } from "../utils/toasts"; +import { toast } from "sonner"; import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; -import { bookRoom, NewReservation, useGetRooms } from "./api"; +import { bookRoom, parseApiError, NewReservation, Reservation, useGetRooms } from "./api"; import { LoadingCard } from "../components/LoadingCard"; import { BookingDetailsModal } from "./BookingDetailsModal"; +import { BookingConfirmationModal } from "./BookingConfirmationModal"; const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { sm: "1", @@ -18,14 +20,25 @@ export function ReservationPage() { const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); - const showToast = useShowSuccessToast("We have received your booking!"); + const [confirmedReservation, setConfirmedReservation] = useState(null); + const showError = useShowErrorToast(); function onClose() { setSelectedRoomNumber(""); } - function onSubmit(booking: NewReservation) { - bookRoom(booking).then(onClose).then(showToast); + async function onSubmit(booking: NewReservation) { + const toastId = toast.loading("Processing booking..."); + try { + const reservation = await bookRoom(booking); + toast.dismiss(toastId); + onClose(); + setConfirmedReservation(reservation); + } catch (error) { + toast.dismiss(toastId); + const errors = await parseApiError(error); + showError(errors); + } } const createClickHandler = (roomNumber: string) => () => { @@ -56,6 +69,13 @@ export function ReservationPage() { /> + + {confirmedReservation && ( + setConfirmedReservation(null)} + /> + )} ); } diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index e8e0c3a..e51815f 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -1,37 +1,36 @@ import { useQuery } from "@tanstack/react-query"; import { ISO8601String, toIsoStr } from "../utils/datetime"; -import ky from "ky"; +import ky, { HTTPError } from "ky"; import { z } from "zod"; export interface NewReservation { - RoomNumber: string; - GuestEmail: string; - Start: ISO8601String; - End: ISO8601String; + roomNumber: string; + guestEmail: string; + start: ISO8601String; + end: ISO8601String; } -/** The schema the API returns */ +/** ----- The schemas the API returns ---- */ + const ReservationSchema = z.object({ - Id: z.string(), - RoomNumber: z.string(), - GuestEmail: z.string().email(), - Start: z.string(), - End: z.string(), + id: z.string(), + roomNumber: z.string(), + guestEmail: z.string().email(), + start: z.string(), + end: z.string(), }); -type Reservation = z.infer; +export type Reservation = z.infer; -export function bookRoom(booking: NewReservation) { - // unwrap branded types - const newReservation = { - ...booking, - Start: toIsoStr(booking.Start), - End: toIsoStr(booking.End), - }; +const ErrorResponseSchema = z.object({ + title: z.string(), + detail: z.string(), + resourceType: z.string().nullish(), + resourceId: z.string().nullish(), + errors: z.record(z.array(z.string())).nullish(), +}); - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); -} +export type ErrorResponse = z.infer; const RoomSchema = z.object({ number: z.string(), @@ -40,6 +39,40 @@ const RoomSchema = z.object({ const RoomListSchema = RoomSchema.array(); + +/** ----- API ---- */ + +export async function bookRoom(booking: NewReservation): Promise { + const body = { + ...booking, + start: toIsoStr(booking.start), + end: toIsoStr(booking.end), + }; + + return ky + .post("api/reservations", { json: body }) + .json() + .then(ReservationSchema.parseAsync); +} + +export async function parseApiError(error: unknown): Promise { + if (error instanceof HTTPError) { + try { + const body = await error.response.json(); + const parsed = ErrorResponseSchema.safeParse(body); + if (parsed.success) { + if (parsed.data.errors) { + return Object.values(parsed.data.errors).flat(); + } + return [parsed.data.detail]; + } + } catch { + // fall through + } + } + return ["An unexpected error occurred"]; +} + export function useGetRooms() { return useQuery({ queryKey: ["rooms"], diff --git a/ui/src/utils/datetime.ts b/ui/src/utils/datetime.ts index 17d55a7..23aa547 100644 --- a/ui/src/utils/datetime.ts +++ b/ui/src/utils/datetime.ts @@ -10,11 +10,19 @@ export type ISO8601String = { readonly _value: string; }; +/** Format as YYYY-MM-DD to avoid timezone shifting */ +function toLocalDateString(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}T00:00:00`; +} + /** Any date will pass through transparently */ function fromDate(date: Date): ISO8601String { return { [DateTimeSym]: "utils/datetime/iso8601", - _value: date.toISOString(), + _value: toLocalDateString(date), _dateValue: date, }; } diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index d3358f7..c686d67 100644 --- a/ui/src/utils/toasts.tsx +++ b/ui/src/utils/toasts.tsx @@ -1,5 +1,6 @@ import { SuccessToast } from "../components/SuccessToast"; import { InfoToast } from "../components/InfoToast"; +import { ErrorToast } from "../components/ErrorToast"; import { ExternalToast, toast } from "sonner"; import { useCallback } from "react"; @@ -30,3 +31,14 @@ export function useShowInfoToast(message: string) { [message], ); } + +export function useShowErrorToast() { + return useCallback( + (messages: string[]) => + toast.custom( + (t) => , + { duration: 5_000 }, + ), + [], + ); +} From db6a734a375b2df6a33ca117e522431c6df866a4 Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Fri, 20 Mar 2026 14:34:00 +0100 Subject: [PATCH 05/11] feat(api): guarding against reservation conflicts/overlaps --- .../ExceptionHandlingMiddleware.cs | 4 + api/Models/Errors/ConflictException.cs | 6 ++ api/Repositories/ReservationRepository.cs | 83 +++++++++++----- .../ExceptionHandlingTests.cs | 15 ++- .../ReservationsEndpointTests.cs | 99 +++++++++++++++++-- 5 files changed, 169 insertions(+), 38 deletions(-) create mode 100644 api/Models/Errors/ConflictException.cs diff --git a/api/Middlewares/ExceptionHandlingMiddleware.cs b/api/Middlewares/ExceptionHandlingMiddleware.cs index 470e30a..0f37a59 100644 --- a/api/Middlewares/ExceptionHandlingMiddleware.cs +++ b/api/Middlewares/ExceptionHandlingMiddleware.cs @@ -31,6 +31,10 @@ public async Task InvokeAsync(HttpContext httpContext) { await SetResponse(e, httpContext, HttpStatusCode.NotFound); } + catch (ConflictException e) + { + await SetResponse(e, httpContext, HttpStatusCode.Conflict); + } catch (ValidationException e) { await SetResponse(e, httpContext, HttpStatusCode.BadRequest); diff --git a/api/Models/Errors/ConflictException.cs b/api/Models/Errors/ConflictException.cs new file mode 100644 index 0000000..1870e65 --- /dev/null +++ b/api/Models/Errors/ConflictException.cs @@ -0,0 +1,6 @@ +namespace Models.Errors +{ + public class ConflictException(string resourceType, string resourceId, string message) : ResourceException(resourceType, resourceId, message) + { + } +} diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 64e564e..4f08519 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -48,36 +48,67 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(ReservationRequest request) { - ArgumentNullException.ThrowIfNull(request); - var room = await _db.QueryFirstOrDefaultAsync( - "SELECT * FROM Rooms WHERE Number = @RoomNumber;", - new { request.RoomNumber } - ) ?? throw new NotFoundException(nameof(Room), request.RoomNumber); - - await _db.ExecuteAsync( - "INSERT OR IGNORE INTO Guests(Email, Name) VALUES(@GuestEmail, @GuestEmail)", - new { request.GuestEmail } - ); + using var transaction = _db.BeginTransaction(IsolationLevel.Serializable); - var reservation = new Reservation + try { - Id = Guid.NewGuid(), - RoomNumber = request.RoomNumber, - GuestEmail = request.GuestEmail, - Start = request.Start, - End = request.End - }; - - var created = await _db.QuerySingleAsync( - @"INSERT INTO Reservations(Id, GuestEmail, RoomNumber, Start, End) - VALUES(@Id, @GuestEmail, @RoomNumber, @Start, @End) - RETURNING *", - reservation - ); - - return created; + var room = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Rooms WHERE Number = @RoomNumber;", + new { request.RoomNumber }, + transaction + ) ?? throw new NotFoundException(nameof(Room), request.RoomNumber); + + var conflict = await _db.QueryFirstOrDefaultAsync( + @"SELECT * FROM Reservations + WHERE RoomNumber = @RoomNumber + AND Start < @End + AND [End] > @Start", + new { request.RoomNumber, request.Start, request.End }, + transaction + ); + + if (conflict != null) + { + throw new ConflictException( + nameof(Reservation), + request.RoomNumber, + $"Room {request.RoomNumber} is already booked from {conflict.Start:yyyy-MM-dd} to {conflict.End:yyyy-MM-dd}" + ); + } + + await _db.ExecuteAsync( + "INSERT OR IGNORE INTO Guests(Email, Name) VALUES(@GuestEmail, @GuestEmail)", + new { request.GuestEmail }, + transaction + ); + + var reservation = new Reservation + { + Id = Guid.NewGuid(), + RoomNumber = request.RoomNumber, + GuestEmail = request.GuestEmail, + Start = request.Start, + End = request.End + }; + + var created = await _db.QuerySingleAsync( + @"INSERT INTO Reservations(Id, GuestEmail, RoomNumber, Start, End) + VALUES(@Id, @GuestEmail, @RoomNumber, @Start, @End) + RETURNING *", + reservation, + transaction + ); + + transaction.Commit(); + return created; + } + catch + { + transaction.Rollback(); + throw; + } } public async Task DeleteReservation(Guid reservationId) diff --git a/tests/api.IntegrationTests/ExceptionHandlingTests.cs b/tests/api.IntegrationTests/ExceptionHandlingTests.cs index 3fff8da..e11485a 100644 --- a/tests/api.IntegrationTests/ExceptionHandlingTests.cs +++ b/tests/api.IntegrationTests/ExceptionHandlingTests.cs @@ -62,16 +62,21 @@ public async Task Error_response_contains_all_expected_fields() var doc = JsonDocument.Parse(content); var root = doc.RootElement; - Assert.True(root.TryGetProperty("ResourceType", out _)); - Assert.True(root.TryGetProperty("ResourceId", out _)); - Assert.True(root.TryGetProperty("Detail", out _)); - Assert.True(root.TryGetProperty("Title", out _)); + Assert.True(root.TryGetProperty("resourceType", out _)); + Assert.True(root.TryGetProperty("resourceId", out _)); + Assert.True(root.TryGetProperty("detail", out _)); + Assert.True(root.TryGetProperty("title", out _)); } + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + private static async Task DeserializeResponse(HttpResponseMessage response) { var content = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(content) + return JsonSerializer.Deserialize(content, _jsonOptions) ?? throw new Exception("Failed to deserialize error response"); } diff --git a/tests/api.IntegrationTests/ReservationsEndpointTests.cs b/tests/api.IntegrationTests/ReservationsEndpointTests.cs index 0138f92..2d0ef7b 100644 --- a/tests/api.IntegrationTests/ReservationsEndpointTests.cs +++ b/tests/api.IntegrationTests/ReservationsEndpointTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using Contracts; using Models; @@ -8,6 +9,10 @@ namespace api.IntegrationTests; public class ReservationsEndpointTests : IClassFixture { private readonly HttpClient _client; + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; public ReservationsEndpointTests(TestWebApplicationFactory factory) { @@ -33,7 +38,7 @@ public async Task Post_valid_reservation_returns_201() $"Expected 201 but got {(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}" ); - var reservation = await response.Content.ReadFromJsonAsync(); + var reservation = await response.Content.ReadFromJsonAsync(_jsonOptions); Assert.NotNull(reservation); Assert.NotEqual(Guid.Empty, reservation.Id); Assert.Equal("101", reservation.RoomNumber); @@ -47,7 +52,7 @@ public async Task Get_reservations_returns_200() Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var reservations = await response.Content.ReadFromJsonAsync>(); + var reservations = await response.Content.ReadFromJsonAsync>(_jsonOptions); Assert.NotNull(reservations); } @@ -59,7 +64,7 @@ public async Task Get_nonexistent_reservation_returns_404() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - var error = await response.Content.ReadFromJsonAsync(); + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); Assert.NotNull(error); Assert.Equal("NotFound", error.Title); Assert.Equal("Reservation", error.ResourceType); @@ -78,7 +83,7 @@ public async Task Post_reservation_with_nonexistent_room_returns_404() }; var response = await _client.PostAsJsonAsync("/api/reservations", booking); - var error = await response.Content.ReadFromJsonAsync(); + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.NotNull(error); @@ -99,7 +104,7 @@ public async Task Post_reservation_with_invalid_data_returns_400_with_errors() }; var response = await _client.PostAsJsonAsync("/api/reservations", invalidBooking); - var error = await response.Content.ReadFromJsonAsync(); + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.NotNull(error); @@ -122,7 +127,7 @@ public async Task Post_reservation_with_invalid_room_number_returns_room_error() }; var response = await _client.PostAsJsonAsync("/api/reservations", booking); - var error = await response.Content.ReadFromJsonAsync(); + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.NotNull(error?.Errors); @@ -142,11 +147,91 @@ public async Task Post_reservation_with_invalid_email_returns_email_error() }; var response = await _client.PostAsJsonAsync("/api/reservations", booking); - var error = await response.Content.ReadFromJsonAsync(); + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.NotNull(error?.Errors); Assert.True(error.Errors.ContainsKey("GuestEmail")); Assert.False(error.Errors.ContainsKey("RoomNumber")); } + + [Fact] + public async Task Post_double_booking_same_dates_returns_409() + { + var booking = new ReservationRequest + { + RoomNumber = "201", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(10), + End = DateTime.Today.AddDays(13) + }; + + var first = await _client.PostAsJsonAsync("/api/reservations", booking); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var second = await _client.PostAsJsonAsync("/api/reservations", booking); + var error = await second.Content.ReadFromJsonAsync(_jsonOptions); + + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + Assert.NotNull(error); + Assert.Equal("Conflict", error.Title); + Assert.Equal("Reservation", error.ResourceType); + Assert.Contains("201", error.Detail); + } + + [Fact] + public async Task Post_overlapping_booking_returns_409() + { + var first = new ReservationRequest + { + RoomNumber = "202", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(10), + End = DateTime.Today.AddDays(15) + }; + + var firstResponse = await _client.PostAsJsonAsync("/api/reservations", first); + Assert.Equal(HttpStatusCode.Created, firstResponse.StatusCode); + + var overlapping = new ReservationRequest + { + RoomNumber = "202", + GuestEmail = "other@mjail.com", + Start = DateTime.Today.AddDays(13), + End = DateTime.Today.AddDays(18) + }; + + var secondResponse = await _client.PostAsJsonAsync("/api/reservations", overlapping); + var error = await secondResponse.Content.ReadFromJsonAsync(_jsonOptions); + + Assert.Equal(HttpStatusCode.Conflict, secondResponse.StatusCode); + Assert.NotNull(error); + Assert.Equal("Conflict", error.Title); + } + + [Fact] + public async Task Post_non_overlapping_booking_same_room_succeeds() + { + var first = new ReservationRequest + { + RoomNumber = "203", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(10), + End = DateTime.Today.AddDays(13) + }; + + var firstResponse = await _client.PostAsJsonAsync("/api/reservations", first); + Assert.Equal(HttpStatusCode.Created, firstResponse.StatusCode); + + var nonOverlapping = new ReservationRequest + { + RoomNumber = "203", + GuestEmail = "other@mjail.com", + Start = DateTime.Today.AddDays(13), + End = DateTime.Today.AddDays(16) + }; + + var secondResponse = await _client.PostAsJsonAsync("/api/reservations", nonOverlapping); + Assert.Equal(HttpStatusCode.Created, secondResponse.StatusCode); + } } From 04e277ca87cf6151a49b555b939fa21dbb4127a6 Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Fri, 20 Mar 2026 14:40:00 +0100 Subject: [PATCH 06/11] fix(api): concurrent ovelapping booking requests Scoped DB connections/abstractions No point in using WAL mode with one singleton connection = sqlite serlializes concurrent reentrance --- api/Program.cs | 10 +++---- .../ReservationsEndpointTests.cs | 26 +++++++++++++++++++ .../TestWebApplicationFactory.cs | 23 ++++++++++------ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/api/Program.cs b/api/Program.cs index f13f249..e58c95d 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -19,11 +19,11 @@ builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; - Services.AddSingleton(_ => new SqliteConnection(connectionString)); - Services.AddSingleton(sp => sp.GetRequiredService()); - Services.AddSingleton(); - Services.AddSingleton(); - Services.AddSingleton(); + Services.AddScoped(_ => new SqliteConnection(connectionString)); + Services.AddScoped(sp => sp.GetRequiredService()); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); Services.AddMvc(opt => { opt.EnableEndpointRouting = false; diff --git a/tests/api.IntegrationTests/ReservationsEndpointTests.cs b/tests/api.IntegrationTests/ReservationsEndpointTests.cs index 2d0ef7b..cb4522e 100644 --- a/tests/api.IntegrationTests/ReservationsEndpointTests.cs +++ b/tests/api.IntegrationTests/ReservationsEndpointTests.cs @@ -6,6 +6,9 @@ namespace api.IntegrationTests; +/// +/// Tests run against in-memory sqlite. Room numbers (existing) should differ along tests to avoid tests conflicts. +/// public class ReservationsEndpointTests : IClassFixture { private readonly HttpClient _client; @@ -234,4 +237,27 @@ public async Task Post_non_overlapping_booking_same_room_succeeds() var secondResponse = await _client.PostAsJsonAsync("/api/reservations", nonOverlapping); Assert.Equal(HttpStatusCode.Created, secondResponse.StatusCode); } + + [Fact] + public async Task Post_concurrent_double_booking_same_dates_returns_409() + { + var booking = new ReservationRequest + { + RoomNumber = "103", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(10), + End = DateTime.Today.AddDays(13) + }; + + var task1 = _client.PostAsJsonAsync("/api/reservations", booking); + var task2 = _client.PostAsJsonAsync("/api/reservations", booking); + var task3 = _client.PostAsJsonAsync("/api/reservations", booking); + var task4 = _client.PostAsJsonAsync("/api/reservations", booking); + + var responses = await Task.WhenAll(task1, task2, task3, task4); + + var statuses = responses.Select(r => r.StatusCode).ToList(); + Assert.Equal(1, statuses.Count(s => s == HttpStatusCode.Created)); + Assert.Equal(3, statuses.Count(s => s == HttpStatusCode.Conflict)); + } } diff --git a/tests/api.IntegrationTests/TestWebApplicationFactory.cs b/tests/api.IntegrationTests/TestWebApplicationFactory.cs index 687a9a5..75af634 100644 --- a/tests/api.IntegrationTests/TestWebApplicationFactory.cs +++ b/tests/api.IntegrationTests/TestWebApplicationFactory.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System.Data; @@ -9,31 +8,39 @@ namespace api.IntegrationTests; public class TestWebApplicationFactory : WebApplicationFactory { - private readonly SqliteConnection _connection = new("Data Source=InMemory;Mode=Memory;Cache=Shared"); + private const string ConnectionString = "Data Source=IntegrationTest;Mode=Memory;Cache=Shared"; + + // keep one dummy connection alive for the lifetime of the factory + // so the in-memory DB is not destroyed between scoped connections + private readonly SqliteConnection _keepAlive = new(ConnectionString); protected override void ConfigureWebHost(IWebHostBuilder builder) { - _connection.Open(); + _keepAlive.Open(); builder.UseEnvironment("Testing"); builder.ConfigureServices(services => { - // Remove existing DB registrations var descriptors = services .Where(d => d.ServiceType == typeof(SqliteConnection) || d.ServiceType == typeof(IDbConnection)) .ToList(); foreach (var d in descriptors) services.Remove(d); - // Add in-memory connection - services.AddSingleton(_connection); - services.AddSingleton(_connection); + // each scope gets its own open connection + services.AddScoped(_ => + { + var conn = new SqliteConnection(ConnectionString); + conn.Open(); + return conn; + }); + services.AddScoped(sp => sp.GetRequiredService()); }); } protected override void Dispose(bool disposing) { - _connection.Close(); + _keepAlive.Close(); base.Dispose(disposing); } } From 84980e12ed464c617b194b784c624a83a6442c11 Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Fri, 20 Mar 2026 15:54:00 +0100 Subject: [PATCH 07/11] feat: staff login with JWT auth and reservations view Add JWT authentication for staff using shared access code Reservations endpoint supports filtering by date range, room, and email UI includes login dialog, staff reservations table with filters Add 401 interceptor for auto-logout on token expiry --- api/Controllers/ReservationsController.cs | 11 +- api/Controllers/StaffController.cs | 71 +++----- .../ExceptionHandlingMiddleware.cs | 2 +- api/Program.cs | 22 ++- api/Repositories/ReservationRepository.cs | 37 +++- api/api.csproj | 1 + api/appsettings.json | 6 +- .../ReservationsEndpointTests.cs | 11 -- .../StaffEndpointTests.cs | 171 ++++++++++++++++++ ui/src/App.tsx | 7 +- ui/src/LandingPage.tsx | 67 ++++--- ui/src/Layout.tsx | 33 +++- ui/src/reservations/api.ts | 11 +- ui/src/router.tsx | 6 + ui/src/staff/StaffLoginDialog.tsx | 62 +++++++ ui/src/staff/StaffReservationsPage.tsx | 119 ++++++++++++ ui/src/staff/api.ts | 55 ++++++ ui/src/utils/api-client.ts | 17 ++ ui/src/utils/auth.tsx | 38 ++++ 19 files changed, 632 insertions(+), 115 deletions(-) create mode 100644 tests/api.IntegrationTests/StaffEndpointTests.cs create mode 100644 ui/src/staff/StaffLoginDialog.tsx create mode 100644 ui/src/staff/StaffReservationsPage.tsx create mode 100644 ui/src/staff/api.ts create mode 100644 ui/src/utils/api-client.ts create mode 100644 ui/src/utils/auth.tsx diff --git a/api/Controllers/ReservationsController.cs b/api/Controllers/ReservationsController.cs index 7d022c4..2b8b103 100644 --- a/api/Controllers/ReservationsController.cs +++ b/api/Controllers/ReservationsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Contracts; using FluentValidation; @@ -19,11 +20,16 @@ public ReservationsController(ReservationRepository reservationRepository, IVali _validator = validator; } + [Authorize] [HttpGet, Produces("application/json"), Route("")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public async Task> GetReservations() + public async Task>> GetReservations( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] string? roomNumber, + [FromQuery] string? guestEmail) { - var reservations = await _repo.GetReservations(); + var reservations = await _repo.GetReservations(from, to, roomNumber, guestEmail); return Ok(reservations); } @@ -54,6 +60,7 @@ public async Task> BookReservation([FromBody] Reservat return Created($"/reservations/{createdReservation.Id}", createdReservation); } + [Authorize] [HttpDelete, Produces("application/json"), Route("{reservationId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..e8acd4d 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,69 +1,44 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; namespace Controllers { + [ApiController] [Route("staff")] - public class StaffController : Controller + public class StaffController : ControllerBase { - private IConfiguration Config { get; set; } + private readonly IConfiguration _config; public StaffController(IConfiguration config) { - Config = config; + _config = config; } - /// - /// Checks if the request is from a staff member, if not returns true and a 403 result - /// - /// - private bool IsNotStaff(HttpRequest request, out IActionResult? result) + [HttpPost("login")] + public IActionResult Login([FromHeader(Name = "X-Staff-Code")] string accessCode) { - // TODO explore UseAuthentication - request.Cookies.TryGetValue("access", out string? accessValue); - - if (accessValue == null || accessValue == "0") + var configuredSecret = _config.GetValue("staffAccessCode"); + if (accessCode != configuredSecret) { - result = StatusCode(403); - return true; + return Unauthorized(new { detail = "Invalid access code" }); } - result = null; - return false; - } + var key = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(_config["Jwt:Key"]!)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expiryHours = _config.GetValue("Jwt:ExpiryHours"); - [HttpGet, Route("login")] - public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) - { - var configuredSecret = Config.GetValue("staffAccessCode"); - if (configuredSecret != accessCode) - { - // don't set cookie, don't indicate anything - return NoContent(); - } - Response.Cookies.Append( - "access", - "1", - new CookieOptions - // TODO evaluate cookie options & auth mechanism for best security practices - { - IsEssential = true, - SameSite = SameSiteMode.Strict, - HttpOnly = true, - Secure = true - } + var token = new JwtSecurityToken( + claims: [new Claim(ClaimTypes.Role, "Staff")], + expires: DateTime.UtcNow.AddHours(expiryHours), + signingCredentials: credentials ); - return NoContent(); - } - [HttpGet, Route("check")] - public IActionResult CheckCookie() - { - if (IsNotStaff(Request, out IActionResult? result)) - { - return result!; - } - - return Ok("Authorized"); + return Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token) }); } } + } diff --git a/api/Middlewares/ExceptionHandlingMiddleware.cs b/api/Middlewares/ExceptionHandlingMiddleware.cs index 0f37a59..cf0ac5f 100644 --- a/api/Middlewares/ExceptionHandlingMiddleware.cs +++ b/api/Middlewares/ExceptionHandlingMiddleware.cs @@ -44,7 +44,7 @@ public async Task InvokeAsync(HttpContext httpContext) await SetValidationResponse(e, httpContext); } catch(Exception e) - { + { await SetResponse(e, httpContext, HttpStatusCode.InternalServerError); } } diff --git a/api/Program.cs b/api/Program.cs index e58c95d..60c6baf 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,7 +1,10 @@ using System.Data; +using System.Text; using Dapper; using Db; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Data.Sqlite; +using Microsoft.IdentityModel.Tokens; using Repositories; using Extensions; using FluentValidation; @@ -29,6 +32,20 @@ opt.EnableEndpointRouting = false; }); Services.AddValidatorsFromAssemblyContaining(); + Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)) + }; + }); + Services.AddAuthorization(); Services.AddCors(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); @@ -40,7 +57,8 @@ { try { - var db = app.Services.GetRequiredService(); + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); await Setup.EnsureDb(db); if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Testing") @@ -58,6 +76,8 @@ app.UseMiddleware(); app.UsePathBase("/api") + .UseAuthentication() + .UseAuthorization() .UseMvc() .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) .UseSwagger() diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 4f08519..10237a7 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -15,16 +15,43 @@ public ReservationRepository(IDbConnection db) _db = db; } - public async Task> GetReservations() + public async Task> GetReservations( + DateTime? from = null, + DateTime? to = null, + string? roomNumber = null, + string? guestEmail = null) { - var reservations = await _db.QueryAsync("SELECT * FROM Reservations"); + var sql = "SELECT * FROM Reservations WHERE 1=1"; + var parameters = new DynamicParameters(); - if (reservations == null) + if (from.HasValue) { - return []; + sql += " AND [End] >= @From"; + parameters.Add("From", from.Value); } - return reservations; + if (to.HasValue) + { + sql += " AND Start <= @To"; + parameters.Add("To", to.Value); + } + + if (!string.IsNullOrEmpty(roomNumber)) + { + sql += " AND RoomNumber = @RoomNumber"; + parameters.Add("RoomNumber", roomNumber); + } + + if (!string.IsNullOrEmpty(guestEmail)) + { + sql += " AND GuestEmail LIKE @GuestEmail"; + parameters.Add("GuestEmail", $"%{guestEmail}%"); + } + + sql += " ORDER BY Start"; + + var reservations = await _db.QueryAsync(sql, parameters); + return reservations ?? []; } /// diff --git a/api/api.csproj b/api/api.csproj index a9c5ece..0afb679 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -9,6 +9,7 @@ + diff --git a/api/appsettings.json b/api/appsettings.json index c06ebf6..1c4c193 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -6,5 +6,9 @@ } }, "AllowedHosts": "*", - "staffAccessCode": "pass" + "staffAccessCode": "pass", + "Jwt": { + "Key": "MewstelSuperSecretKeyForJwtSigning", + "ExpiryHours": 8 + } } diff --git a/tests/api.IntegrationTests/ReservationsEndpointTests.cs b/tests/api.IntegrationTests/ReservationsEndpointTests.cs index cb4522e..d98f1a4 100644 --- a/tests/api.IntegrationTests/ReservationsEndpointTests.cs +++ b/tests/api.IntegrationTests/ReservationsEndpointTests.cs @@ -48,17 +48,6 @@ public async Task Post_valid_reservation_returns_201() Assert.Equal("guest@mjail.com", reservation.GuestEmail); } - [Fact] - public async Task Get_reservations_returns_200() - { - var response = await _client.GetAsync("/api/reservations"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var reservations = await response.Content.ReadFromJsonAsync>(_jsonOptions); - Assert.NotNull(reservations); - } - [Fact] public async Task Get_nonexistent_reservation_returns_404() { diff --git a/tests/api.IntegrationTests/StaffEndpointTests.cs b/tests/api.IntegrationTests/StaffEndpointTests.cs new file mode 100644 index 0000000..bb57de2 --- /dev/null +++ b/tests/api.IntegrationTests/StaffEndpointTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Contracts; +using Models; + +namespace api.IntegrationTests; + +public class StaffEndpointTests : IClassFixture +{ + private readonly HttpClient _client; + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public StaffEndpointTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + private async Task GetStaffToken() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/staff/login"); + request.Headers.Add("X-Staff-Code", "pass"); + + var response = await _client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("token").GetString()!; + } + + private async Task GetAuthenticatedClient() + { + var token = await GetStaffToken(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return _client; + } + + [Fact] + public async Task Login_with_valid_code_returns_token() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/staff/login"); + request.Headers.Add("X-Staff-Code", "pass"); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync(); + Assert.True(body.TryGetProperty("token", out var token)); + Assert.False(string.IsNullOrEmpty(token.GetString())); + } + + [Fact] + public async Task Login_with_invalid_code_returns_401() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/staff/login"); + request.Headers.Add("X-Staff-Code", "wrong"); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Get_reservations_without_token_returns_401() + { + var client = _client; + client.DefaultRequestHeaders.Authorization = null; + + var response = await client.GetAsync("/api/reservations"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Get_reservations_with_token_returns_200() + { + var client = await GetAuthenticatedClient(); + + var response = await client.GetAsync("/api/reservations"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Get_reservations_filtered_by_from_date() + { + var client = await GetAuthenticatedClient(); + + // Create a reservation for the future + var booking = new ReservationRequest + { + RoomNumber = "104", + GuestEmail = "staff-test@mjail.com", + Start = DateTime.Today.AddDays(50), + End = DateTime.Today.AddDays(53) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + // should include our booking + var from = DateTime.Today.AddDays(1).ToString("yyyy-MM-dd"); + var response = await client.GetAsync($"/api/reservations?from={from}"); + var reservations = await response.Content.ReadFromJsonAsync>(_jsonOptions); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(reservations); + Assert.Contains(reservations, r => r.RoomNumber == "104"); + } + + [Fact] + public async Task Get_reservations_filtered_by_room_number() + { + var client = await GetAuthenticatedClient(); + + // Create a reservation + var booking = new ReservationRequest + { + RoomNumber = "105", + GuestEmail = "room-filter@mjail.com", + Start = DateTime.Today.AddDays(60), + End = DateTime.Today.AddDays(63) + }; + await client.PostAsJsonAsync("/api/reservations", booking); + + var response = await client.GetAsync("/api/reservations?roomNumber=105"); + var reservations = await response.Content.ReadFromJsonAsync>(_jsonOptions); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(reservations); + Assert.All(reservations, r => Assert.Equal("105", r.RoomNumber)); + } + + [Fact] + public async Task Get_reservations_filtered_by_guest_email() + { + var client = await GetAuthenticatedClient(); + + // Create a reservation + var booking = new ReservationRequest + { + RoomNumber = "201", + GuestEmail = "email-filter@mjail.com", + Start = DateTime.Today.AddDays(70), + End = DateTime.Today.AddDays(73) + }; + await client.PostAsJsonAsync("/api/reservations", booking); + + var response = await client.GetAsync("/api/reservations?guestEmail=email-filter@mjail.com"); + var reservations = await response.Content.ReadFromJsonAsync>(_jsonOptions); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(reservations); + Assert.All(reservations, r => Assert.Equal("email-filter@mjail.com", r.GuestEmail)); + } + + [Fact] + public async Task Delete_reservation_without_token_returns_401() + { + var client = _client; + client.DefaultRequestHeaders.Authorization = null; + + var response = await client.DeleteAsync($"/api/reservations/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0181fa0..52bd361 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,7 +1,12 @@ import "./App.css"; import { RouterProvider } from "@tanstack/react-router"; import { router } from "./router"; +import { AuthProvider } from "./utils/auth"; export function App() { - return ; + return ( + + + + ); } diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..a25f2b8 100644 --- a/ui/src/LandingPage.tsx +++ b/ui/src/LandingPage.tsx @@ -1,42 +1,49 @@ -import { Box, Card, Flex, Heading, Inset } from "@radix-ui/themes"; +import { Card, Flex, Heading, Inset } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; +import { useAuth } from "./utils/auth"; +import { StaffLoginDialog } from "./staff/StaffLoginDialog"; -function handleLogin() { - // TODO have a staff view - alert("Not implemented"); +const STAFF_IMG = "https://images.unsplash.com/photo-1550527882-b71dea5f8089?q=80&w=700&h=600&auto=format&fit=crop"; +const RESERVE_IMG = "https://images.unsplash.com/photo-1531576788337-610fa9c67107?q=80&w=700&h=600&auto=format&fit=crop"; + +const IMG_STYLE: React.CSSProperties = { + width: 350, + height: 300, + objectFit: "cover", +}; + +function LandingCard({ img, alt, label }: { img: string; alt: string; label: string }) { + return ( + <> + + {alt} + + {label} + + ); } export function LandingPage() { + const { isAuthenticated } = useAuth(); + return ( - - - - Key on wood board - - Login - - + {isAuthenticated ? ( + + + + + + ) : ( + + + + + + )} - - Clean Bed - - Reserve + diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index c32639c..9f830da 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -1,26 +1,39 @@ -import { Box, Text } from "@radix-ui/themes"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { Link, Outlet, useNavigate } from "@tanstack/react-router"; import React from "react"; +import { useAuth } from "./utils/auth"; const TOP_BAR_ACCENT_BACKGROUND: React.CSSProperties = { backgroundColor: "var(--accent-10)", }; -const UNDERLINE_HEADING: React.CSSProperties = { - textDecoration: "underline", - textDecorationColor: "var(--accent-2)", +const HEADING: React.CSSProperties = { + textDecoration: "none", }; export const Layout = () => { + const { isAuthenticated, logout } = useAuth(); + const navigate = useNavigate(); + + function handleLogout() { + logout(); + navigate({ to: "/" }); + } + return ( - - - - Reservations @ Mewstel + + + + Mewstel - + {isAuthenticated && ( + + )} + ); diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index e51815f..7ca513c 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { ISO8601String, toIsoStr } from "../utils/datetime"; -import ky, { HTTPError } from "ky"; +import { HTTPError } from "ky"; +import { api } from "../utils/api-client"; import { z } from "zod"; export interface NewReservation { @@ -40,7 +41,7 @@ const RoomSchema = z.object({ const RoomListSchema = RoomSchema.array(); -/** ----- API ---- */ +/**----- API ---- */ export async function bookRoom(booking: NewReservation): Promise { const body = { @@ -49,8 +50,8 @@ export async function bookRoom(booking: NewReservation): Promise { end: toIsoStr(booking.end), }; - return ky - .post("api/reservations", { json: body }) + return api + .post("/api/reservations", { json: body }) .json() .then(ReservationSchema.parseAsync); } @@ -76,6 +77,6 @@ export async function parseApiError(error: unknown): Promise { export function useGetRooms() { return useQuery({ queryKey: ["rooms"], - queryFn: () => ky.get("api/rooms").json().then(RoomListSchema.parseAsync), + queryFn: () => api.get("/api/rooms").json().then(RoomListSchema.parseAsync), }); } diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e3020bd..87069ae 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -6,6 +6,7 @@ import { import { Layout } from "./Layout"; import { LandingPage } from "./LandingPage"; import { ReservationPage } from "./reservations/ReservationPage"; +import { StaffReservationsPage } from "./staff/StaffReservationsPage"; const rootRoute = createRootRoute({ component: Layout, @@ -26,6 +27,11 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + createRoute({ + path: "/staff/reservations", + getParentRoute: getRootRoute, + component: StaffReservationsPage, + }), ]; const routeTree = rootRoute.addChildren(ROUTES); diff --git a/ui/src/staff/StaffLoginDialog.tsx b/ui/src/staff/StaffLoginDialog.tsx new file mode 100644 index 0000000..3188a5c --- /dev/null +++ b/ui/src/staff/StaffLoginDialog.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { Button, Dialog, Separator, TextField } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; +import { staffLogin } from "./api"; +import { useAuth } from "../utils/auth"; +import { useShowErrorToast } from "../utils/toasts"; + +interface StaffLoginDialogProps { + children: React.ReactNode; +} + +export function StaffLoginDialog({ children }: StaffLoginDialogProps) { + const [accessCode, setAccessCode] = useState(""); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + const showError = useShowErrorToast(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + try { + const token = await staffLogin(accessCode); + login(token); + setOpen(false); + setAccessCode(""); + navigate({ to: "/staff/reservations" }); + } catch { + showError(["Invalid access code"]); + } finally { + setLoading(false); + } + } + + return ( + + {children} + + Staff Login + + Enter your staff access code + + +
    + setAccessCode(e.target.value)} + size="3" + mb="4" + /> + + +
    +
    + ); +} diff --git a/ui/src/staff/StaffReservationsPage.tsx b/ui/src/staff/StaffReservationsPage.tsx new file mode 100644 index 0000000..69e3f83 --- /dev/null +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { Box, Card, Flex, Heading, Section, Select, Table, Text, TextField } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; +import { useAuth } from "../utils/auth"; +import { ReservationFilters, useGetStaffReservations } from "./api"; +import { useGetRooms } from "../reservations/api"; +import { LoadingCard } from "../components/LoadingCard"; + +function todayStr() { + return new Date().toISOString().split("T")[0]; +} + +export function StaffReservationsPage() { + const { token, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + const [filters, setFilters] = useState({ + from: todayStr(), + }); + + const { data: reservations, isLoading } = useGetStaffReservations(token, filters); + const { data: rooms } = useGetRooms(); + + if (!isAuthenticated) { + navigate({ to: "/" }); + return null; + } + + function updateFilter(key: keyof ReservationFilters, value: string) { + setFilters((prev) => ({ ...prev, [key]: value || undefined })); + } + + return ( +
    + + Reservations + + + + + + From + updateFilter("from", e.target.value)} + size="2" + /> + + + To + updateFilter("to", e.target.value)} + size="2" + /> + + + Room + updateFilter("roomNumber", v === "all" ? "" : v)} + size="2" + > + + + All rooms + {rooms?.map((room) => ( + + #{room.number} + + ))} + + + + + Guest Email + updateFilter("guestEmail", e.target.value)} + size="2" + /> + + + + + {isLoading && } + + {reservations && reservations.length === 0 && ( + No reservations found. + )} + + {reservations && reservations.length > 0 && ( + + + + Room + Guest Email + Check-in + Check-out + + + + {reservations.map((r) => ( + + #{r.roomNumber} + {r.guestEmail} + {new Date(r.start).toLocaleDateString()} + {new Date(r.end).toLocaleDateString()} + + ))} + + + )} +
    + ); +} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts new file mode 100644 index 0000000..19bb116 --- /dev/null +++ b/ui/src/staff/api.ts @@ -0,0 +1,55 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../utils/api-client"; +import { z } from "zod"; + +export async function staffLogin(accessCode: string): Promise { + const response = await api + .post("/api/staff/login", { + headers: { "X-Staff-Code": accessCode }, + }) + .json<{ token: string }>(); + + return response.token; +} + +const StaffReservationSchema = z.object({ + id: z.string(), + roomNumber: z.string(), + guestEmail: z.string(), + start: z.string(), + end: z.string(), +}); + +export type StaffReservation = z.infer; + +const StaffReservationListSchema = StaffReservationSchema.array(); + +export interface ReservationFilters { + from?: string; + to?: string; + roomNumber?: string; + guestEmail?: string; +} + +export function useGetStaffReservations(token: string | null, filters: ReservationFilters) { + const searchParams: Record = {}; + if (filters.from) searchParams.from = filters.from; + if (filters.to) searchParams.to = filters.to; + if (filters.roomNumber) searchParams.roomNumber = filters.roomNumber; + if (filters.guestEmail) searchParams.guestEmail = filters.guestEmail; + + return useQuery({ + queryKey: ["staff-reservations", token, filters], + enabled: !!token, + retry: false, + refetchOnWindowFocus: false, + queryFn: () => + api + .get("/api/reservations", { + searchParams, + headers: { Authorization: `Bearer ${token}` }, + }) + .json() + .then(StaffReservationListSchema.parseAsync), + }); +} diff --git a/ui/src/utils/api-client.ts b/ui/src/utils/api-client.ts new file mode 100644 index 0000000..c7abbad --- /dev/null +++ b/ui/src/utils/api-client.ts @@ -0,0 +1,17 @@ +import ky from "ky"; + +let redirecting = false; + +export const api = ky.create({ + hooks: { + afterResponse: [ + (_request, _options, response) => { + if (response.status === 401 && !redirecting) { + redirecting = true; + sessionStorage.removeItem("staffToken"); + window.location.href = "/"; + } + }, + ], + }, +}); diff --git a/ui/src/utils/auth.tsx b/ui/src/utils/auth.tsx new file mode 100644 index 0000000..b26ce0b --- /dev/null +++ b/ui/src/utils/auth.tsx @@ -0,0 +1,38 @@ +import { createContext, useCallback, useContext, useState } from "react"; + +interface AuthContextType { + token: string | null; + login: (token: string) => void; + logout: () => void; + isAuthenticated: boolean; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [token, setToken] = useState( + () => sessionStorage.getItem("staffToken") + ); + + const login = useCallback((newToken: string) => { + sessionStorage.setItem("staffToken", newToken); + setToken(newToken); + }, []); + + const logout = useCallback(() => { + sessionStorage.removeItem("staffToken"); + setToken(null); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} From 64c3bb1ac1c793ccc1f5935808e2058e9f0e4071 Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Fri, 20 Mar 2026 16:04:00 +0100 Subject: [PATCH 08/11] fix(api): open create reservation transactions if closed Dapper takes care of the connection for other queries, but the ADO transaction we added need to get opened if closed. Integration tests were passing desbite this becuase of the test factory opens connections eagerly on creation. --- api/Controllers/ReservationsController.cs | 2 +- api/Extensions/DbConnectionExtensions.cs | 17 +++++++++++++++++ api/Repositories/ReservationRepository.cs | 4 +++- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 api/Extensions/DbConnectionExtensions.cs diff --git a/api/Controllers/ReservationsController.cs b/api/Controllers/ReservationsController.cs index 2b8b103..d47e541 100644 --- a/api/Controllers/ReservationsController.cs +++ b/api/Controllers/ReservationsController.cs @@ -37,7 +37,7 @@ public async Task>> GetReservations( [HttpGet, Produces("application/json"), Route("{reservationId}")] [ProducesResponseType(typeof(Reservation), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] - public async Task> GetRoom(Guid reservationId) + public async Task> GetReservation(Guid reservationId) { var reservation = await _repo.GetReservation(reservationId); diff --git a/api/Extensions/DbConnectionExtensions.cs b/api/Extensions/DbConnectionExtensions.cs new file mode 100644 index 0000000..df45185 --- /dev/null +++ b/api/Extensions/DbConnectionExtensions.cs @@ -0,0 +1,17 @@ +using System.Data; + +namespace Extensions +{ + public static class DbConnectionExtensions + { + public static IDbTransaction BeginSerializableTransaction(this IDbConnection dbConnection) + { + // Dapper takes care of the connection for other queries, + // but using ADO transactions we need to open it if closed + if(dbConnection.State is ConnectionState.Closed) + dbConnection.Open(); + + return dbConnection.BeginTransaction(IsolationLevel.Serializable); + } + } +} diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 10237a7..3b60db1 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -3,6 +3,7 @@ using Models; using Contracts; using Models.Errors; +using Extensions; namespace Repositories { @@ -76,8 +77,9 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(ReservationRequest request) { ArgumentNullException.ThrowIfNull(request); + - using var transaction = _db.BeginTransaction(IsolationLevel.Serializable); + using var transaction = _db.BeginSerializableTransaction(); try { From 3dcc3451c194ac21fb0d28e3ede40660ec5ca4da Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Fri, 20 Mar 2026 22:17:34 +0100 Subject: [PATCH 09/11] feat: staff can check in guests Add check-in endpoint that validates guest email, reservation dates and marks room as occupied. Staff UI shows check-in status badges and check-in button for reservations. --- api/Contracts/CheckInRequest.cs | 4 ++ api/Controllers/ReservationsController.cs | 12 ++++ api/Repositories/ReservationRepository.cs | 33 ++++++++++ .../StaffEndpointTests.cs | 62 +++++++++++++++++++ ui/src/staff/StaffReservationsPage.tsx | 51 ++++++++++++++- ui/src/staff/api.ts | 11 ++++ 6 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 api/Contracts/CheckInRequest.cs diff --git a/api/Contracts/CheckInRequest.cs b/api/Contracts/CheckInRequest.cs new file mode 100644 index 0000000..aecc264 --- /dev/null +++ b/api/Contracts/CheckInRequest.cs @@ -0,0 +1,4 @@ +namespace Contracts +{ + public record CheckInRequest(string GuestEmail); +} diff --git a/api/Controllers/ReservationsController.cs b/api/Controllers/ReservationsController.cs index d47e541..0866d71 100644 --- a/api/Controllers/ReservationsController.cs +++ b/api/Controllers/ReservationsController.cs @@ -60,6 +60,18 @@ public async Task> BookReservation([FromBody] Reservat return Created($"/reservations/{createdReservation.Id}", createdReservation); } + [Authorize] + [HttpPost, Produces("application/json"), Route("{reservationId}/check-in")] + [ProducesResponseType(typeof(Reservation), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status409Conflict)] + public async Task> CheckIn(Guid reservationId, [FromBody] CheckInRequest request) + { + var reservation = await _repo.CheckIn(reservationId, request.GuestEmail); + return Ok(reservation); + } + [Authorize] [HttpDelete, Produces("application/json"), Route("{reservationId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 3b60db1..2b14b9f 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -140,6 +140,39 @@ await _db.ExecuteAsync( } } + public async Task CheckIn(Guid reservationId, string guestEmail) + { + var reservation = await GetReservation(reservationId); + + if (!string.Equals(reservation.GuestEmail, guestEmail, StringComparison.OrdinalIgnoreCase)) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Guest email does not match the reservation"); + } + + if (reservation.CheckedIn) + { + throw new ConflictException(nameof(Reservation), reservationId.ToString(), "Reservation is already checked in"); + } + + if (reservation.Start > DateTime.Today || reservation.End <= DateTime.Today) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Check-in is only allowed on the reservation start date"); + } + + await _db.ExecuteAsync( + "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @reservationId", + new { reservationId } + ); + + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @State WHERE Number = @RoomNumber", + new { State = (int)State.Occupied, reservation.RoomNumber } + ); + + reservation.CheckedIn = true; + return reservation; + } + public async Task DeleteReservation(Guid reservationId) { var deleted = await _db.ExecuteAsync( diff --git a/tests/api.IntegrationTests/StaffEndpointTests.cs b/tests/api.IntegrationTests/StaffEndpointTests.cs index bb57de2..eb4c82c 100644 --- a/tests/api.IntegrationTests/StaffEndpointTests.cs +++ b/tests/api.IntegrationTests/StaffEndpointTests.cs @@ -168,4 +168,66 @@ public async Task Delete_reservation_without_token_returns_401() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + + [Fact] + public async Task CheckIn_today_reservation_returns_200_and_marks_checked_in() + { + var client = await GetAuthenticatedClient(); + + var booking = new ReservationRequest + { + RoomNumber = "104", + GuestEmail = "checkin-test@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(3) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + // Check in + var checkInResponse = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-in", + new { guestEmail = "checkin-test@mjail.com" }); + + Assert.Equal(HttpStatusCode.OK, checkInResponse.StatusCode); + var checkedIn = await checkInResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(checkedIn); + Assert.True(checkedIn.CheckedIn); + + // Verify room is marked as occupied + var roomResponse = await client.GetAsync($"/api/rooms/{created.RoomNumber}"); + var room = await roomResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(room); + Assert.Equal((int)State.Occupied, (int)room.State); + } + + [Fact] + public async Task CheckIn_with_wrong_email_returns_400() + { + var client = await GetAuthenticatedClient(); + + var booking = new ReservationRequest + { + RoomNumber = "105", + GuestEmail = "correct@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(2) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + // Check in with wrong email + var checkInResponse = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-in", + new { guestEmail = "wrong@mjail.com" }); + + Assert.Equal(HttpStatusCode.BadRequest, checkInResponse.StatusCode); + var error = await checkInResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(error); + Assert.Contains("email", error.Detail, StringComparison.OrdinalIgnoreCase); + } } diff --git a/ui/src/staff/StaffReservationsPage.tsx b/ui/src/staff/StaffReservationsPage.tsx index 69e3f83..eaca0c3 100644 --- a/ui/src/staff/StaffReservationsPage.tsx +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -1,9 +1,12 @@ import { useState } from "react"; -import { Box, Card, Flex, Heading, Section, Select, Table, Text, TextField } from "@radix-ui/themes"; +import { Badge, Box, Button, Card, Flex, Heading, Section, Select, Table, Text, TextField } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "../utils/auth"; -import { ReservationFilters, useGetStaffReservations } from "./api"; +import { checkInReservation, ReservationFilters, StaffReservation, useGetStaffReservations } from "./api"; import { useGetRooms } from "../reservations/api"; +import { parseApiError } from "../reservations/api"; +import { useShowErrorToast, useShowSuccessToast } from "../utils/toasts"; import { LoadingCard } from "../components/LoadingCard"; function todayStr() { @@ -20,6 +23,29 @@ export function StaffReservationsPage() { const { data: reservations, isLoading } = useGetStaffReservations(token, filters); const { data: rooms } = useGetRooms(); + const queryClient = useQueryClient(); + const showError = useShowErrorToast(); + const showSuccess = useShowSuccessToast("Guest checked in successfully!"); + const [checkingIn, setCheckingIn] = useState(null); + + function coversToday(start: string, end: string) { + const today = new Date().toDateString(); + return new Date(start) <= new Date(today) && new Date(end) > new Date(today); + } + + async function handleCheckIn(r: StaffReservation) { + setCheckingIn(r.id); + try { + await checkInReservation(token!, r.id, r.guestEmail); + showSuccess(); + queryClient.invalidateQueries({ queryKey: ["staff-reservations"] }); + } catch (error) { + const errors = await parseApiError(error); + showError(errors); + } finally { + setCheckingIn(null); + } + } if (!isAuthenticated) { navigate({ to: "/" }); @@ -100,6 +126,8 @@ export function StaffReservationsPage() { Guest Email Check-in Check-out + Status + @@ -109,6 +137,25 @@ export function StaffReservationsPage() { {r.guestEmail} {new Date(r.start).toLocaleDateString()} {new Date(r.end).toLocaleDateString()} + + {r.checkedOut + ? Checked Out + : r.checkedIn + ? Checked In + : Pending} + + + {!r.checkedIn && coversToday(r.start, r.end) && ( + + )} + ))} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts index 19bb116..1cb3875 100644 --- a/ui/src/staff/api.ts +++ b/ui/src/staff/api.ts @@ -18,6 +18,8 @@ const StaffReservationSchema = z.object({ guestEmail: z.string(), start: z.string(), end: z.string(), + checkedIn: z.boolean(), + checkedOut: z.boolean(), }); export type StaffReservation = z.infer; @@ -31,6 +33,15 @@ export interface ReservationFilters { guestEmail?: string; } +export async function checkInReservation(token: string, reservationId: string, guestEmail: string) { + return api + .post(`/api/reservations/${reservationId}/check-in`, { + json: { guestEmail }, + headers: { Authorization: `Bearer ${token}` }, + }) + .json(); +} + export function useGetStaffReservations(token: string | null, filters: ReservationFilters) { const searchParams: Record = {}; if (filters.from) searchParams.from = filters.from; From 0027eb17230ac198fe5e22ee8991b9f3067916a9 Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Sat, 21 Mar 2026 00:32:00 +0100 Subject: [PATCH 10/11] feat: staff room management with CSV import and room listing UI --- api/Contracts/ImportResult.cs | 11 ++ api/Controllers/GuestsController.cs | 6 +- api/Controllers/RoomsController.cs | 58 +++++- .../ExceptionHandlingMiddleware.cs | 2 +- api/Models/Room.cs | 11 +- api/Repositories/RoomRepository.cs | 51 ++++++ api/Validators/ReservationRequestValidator.cs | 11 +- test-rooms.csv | 103 +++++++++++ .../StaffEndpointTests.cs | 80 +++++++-- ui/src/Layout.tsx | 14 +- ui/src/reservations/ReservationCard.tsx | 8 +- ui/src/reservations/ReservationPage.tsx | 33 +++- ui/src/router.tsx | 6 + ui/src/staff/ImportRoomsDialog.tsx | 166 ++++++++++++++++++ ui/src/staff/StaffReservationsPage.tsx | 2 +- ui/src/staff/StaffRoomsPage.tsx | 161 +++++++++++++++++ ui/src/staff/api.ts | 52 +++++- 17 files changed, 732 insertions(+), 43 deletions(-) create mode 100644 api/Contracts/ImportResult.cs create mode 100644 test-rooms.csv create mode 100644 ui/src/staff/ImportRoomsDialog.tsx create mode 100644 ui/src/staff/StaffRoomsPage.tsx diff --git a/api/Contracts/ImportResult.cs b/api/Contracts/ImportResult.cs new file mode 100644 index 0000000..1759644 --- /dev/null +++ b/api/Contracts/ImportResult.cs @@ -0,0 +1,11 @@ +namespace Contracts +{ + public record ImportResult( + int TotalRows, + int Imported, + int Failed, + List Errors + ); + + public record ImportError(int Line, string RoomNumber, string Message); +} diff --git a/api/Controllers/GuestsController.cs b/api/Controllers/GuestsController.cs index f10e746..db75dab 100644 --- a/api/Controllers/GuestsController.cs +++ b/api/Controllers/GuestsController.cs @@ -4,8 +4,9 @@ namespace Controllers { + [ApiController] [Tags("Guests"), Route("guests")] - public class GuestsController : Controller + public class GuestsController : ControllerBase { private GuestRepository _repo; @@ -15,11 +16,12 @@ public GuestsController(GuestRepository guestRepository) } [HttpGet, Produces("application/json"), Route("")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] public async Task> GetGuests() { var guests = await _repo.GetGuests(); - return Json(guests); + return Ok(guests); } } } diff --git a/api/Controllers/RoomsController.cs b/api/Controllers/RoomsController.cs index 8a21a3a..dd7f74c 100644 --- a/api/Controllers/RoomsController.cs +++ b/api/Controllers/RoomsController.cs @@ -1,4 +1,6 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Contracts; using Models; using Repositories; @@ -16,7 +18,8 @@ public RoomsController(RoomRepository roomRepository) } [HttpGet, Produces("application/json"), Route("")] - public async Task> GetRooms() + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> GetRooms() { var rooms = await _repo.GetRooms(); @@ -29,6 +32,8 @@ public async Task> GetRooms() } [HttpGet, Produces("application/json"), Route("{roomNumber}")] + [ProducesResponseType(typeof(Room), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] public async Task> GetRoom(string roomNumber) { var room = await _repo.GetRoom(roomNumber); @@ -37,6 +42,8 @@ public async Task> GetRoom(string roomNumber) } [HttpPost, Produces("application/json"), Route("")] + [ProducesResponseType(typeof(Room), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> CreateRoom([FromBody] Room newRoom) { var createdRoom = await _repo.CreateRoom(newRoom); @@ -49,7 +56,56 @@ public async Task> CreateRoom([FromBody] Room newRoom) return Ok(createdRoom); } + [Authorize] + [HttpPost, Produces("application/json"), Route("import")] + [ProducesResponseType(typeof(ImportResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + public async Task> ImportRooms(IFormFile file) + { + if (file == null || file.Length == 0) + return BadRequest(new { detail = "No file provided" }); + + if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) + return BadRequest(new { detail = "Only CSV files are accepted" }); + + if (file.Length > 50_000) // ~500 rooms max + return BadRequest(new { detail = "File is too large. Maximum 500 rooms supported" }); + + var errors = new List(); + var validRooms = new List<(int Line, Room Room)>(); + var lineNumber = 0; + + // Parse and validate + using var reader = new StreamReader(file.OpenReadStream()); + while (await reader.ReadLineAsync() is { } line) + { + lineNumber++; + var roomNumber = line.Trim(); + + if (string.IsNullOrEmpty(roomNumber)) + continue; + + if (lineNumber == 1 && roomNumber.Equals("number", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!Room.IsValidRoomNumber(roomNumber)) + { + errors.Add(new ImportError(lineNumber, roomNumber, $"Invalid room number: {roomNumber}")); + continue; + } + + validRooms.Add((lineNumber, new Room { Number = roomNumber })); + } + + var imported = await _repo.CreateRoomsBatch(validRooms, errors); + + return Ok(new ImportResult(lineNumber, imported, errors.Count, errors)); + } + [HttpDelete, Produces("application/json"), Route("{roomNumber}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] public async Task DeleteRoom(string roomNumber) { if (roomNumber.Length != 3) diff --git a/api/Middlewares/ExceptionHandlingMiddleware.cs b/api/Middlewares/ExceptionHandlingMiddleware.cs index cf0ac5f..0f37a59 100644 --- a/api/Middlewares/ExceptionHandlingMiddleware.cs +++ b/api/Middlewares/ExceptionHandlingMiddleware.cs @@ -44,7 +44,7 @@ public async Task InvokeAsync(HttpContext httpContext) await SetValidationResponse(e, httpContext); } catch(Exception e) - { + { await SetResponse(e, httpContext, HttpStatusCode.InternalServerError); } } diff --git a/api/Models/Room.cs b/api/Models/Room.cs index abedd2d..d3ffccb 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -8,6 +8,8 @@ namespace Models ///
    public class Room { + private static readonly Regex RoomNumberPattern = new(@"^[1-9]\d{2}$"); + /// /// PKID For Rooms. Format is "###" where first digit is floor (1-9) /// and last two digits are the door number (01-99). @@ -19,14 +21,17 @@ public class Room /// public State State { get; set; } = State.Ready; - private static readonly Regex RoomNumberPattern = new(@"^[1-9]\d{2}$"); - /// /// Validates the room number format. Must be 3 digits, first digit 1-9, last two not "00". /// + public static bool IsValidRoomNumber(string roomNumber) + { + return RoomNumberPattern.IsMatch(roomNumber) && roomNumber[1..] != "00"; + } + public static void ValidateRoomNumber(string roomNumber) { - if (!RoomNumberPattern.IsMatch(roomNumber) || roomNumber[1..] == "00") + if (!IsValidRoomNumber(roomNumber)) { throw new ValidationException(nameof(Room), roomNumber, $"The value {roomNumber} is not a valid room number"); } diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index f7fadac..d2443ce 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -1,5 +1,6 @@ using System.Data; using Dapper; +using Extensions; using Models; using Models.Errors; @@ -59,6 +60,56 @@ public async Task CreateRoom(Room newRoom) return createdRoom; } + public async Task CreateRoomsBatch(List<(int Line, Room Room)> rooms, List errors) + { + if (rooms.Count == 0) return 0; + + using var transaction = _db.BeginSerializableTransaction(); + var imported = 0; + + try + { + var existingNumbers = (await _db.QueryAsync( + "SELECT Number FROM Rooms WHERE Number IN @Numbers", + new { Numbers = rooms.Select(r => r.Room.Number).ToList() }, + transaction + )).ToHashSet(); + + foreach (var (line, room) in rooms) + { + if (existingNumbers.Contains(room.Number)) + { + errors.Add(new Contracts.ImportError(line, room.Number, $"Room {room.Number} already exists")); + continue; + } + + await _db.ExecuteAsync( + "INSERT INTO Rooms(Number, State) VALUES(@Number, @State)", + room, + transaction + ); + imported++; + } + + transaction.Commit(); + return imported; + } + catch + { + transaction.Rollback(); + throw; + } + } + + public async Task RoomExists(string roomNumber) + { + var count = await _db.ExecuteScalarAsync( + "SELECT COUNT(1) FROM Rooms WHERE Number = @roomNumber", + new { roomNumber } + ); + return count > 0; + } + public async Task DeleteRoom(string roomNumber) { Room.ValidateRoomNumber(roomNumber); diff --git a/api/Validators/ReservationRequestValidator.cs b/api/Validators/ReservationRequestValidator.cs index fa95624..743d984 100644 --- a/api/Validators/ReservationRequestValidator.cs +++ b/api/Validators/ReservationRequestValidator.cs @@ -1,13 +1,11 @@ -using System.Text.RegularExpressions; using FluentValidation; using Contracts; +using Models; namespace Validators { public class ReservationRequestValidator : AbstractValidator { - private static readonly Regex RoomNumberPattern = new(@"^[1-9]\d{2}$"); - public ReservationRequestValidator() { RuleFor(x => x.GuestEmail) @@ -17,7 +15,7 @@ public ReservationRequestValidator() RuleFor(x => x.RoomNumber) .NotEmpty().WithMessage("Room number is required") - .Must(BeValidRoomNumber).WithMessage("Room number must be in format '###' (e.g. 101, 202)"); + .Must(Room.IsValidRoomNumber).WithMessage("Room number must be in format '###' (e.g. 101, 202)"); RuleFor(x => x.Start) .NotEmpty().WithMessage("Start date is required") @@ -33,11 +31,6 @@ public ReservationRequestValidator() .Must(HaveMaximumDuration).WithMessage("Maximum booking duration is 30 days"); } - private static bool BeValidRoomNumber(string roomNumber) - { - return RoomNumberPattern.IsMatch(roomNumber) && roomNumber[1..] != "00"; - } - private static bool HaveMinimumDuration(ReservationRequest request) { return (request.End - request.Start).TotalDays >= 1; diff --git a/test-rooms.csv b/test-rooms.csv new file mode 100644 index 0000000..73a3e52 --- /dev/null +++ b/test-rooms.csv @@ -0,0 +1,103 @@ +number +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +000 +abc +100 diff --git a/tests/api.IntegrationTests/StaffEndpointTests.cs b/tests/api.IntegrationTests/StaffEndpointTests.cs index eb4c82c..8b952a1 100644 --- a/tests/api.IntegrationTests/StaffEndpointTests.cs +++ b/tests/api.IntegrationTests/StaffEndpointTests.cs @@ -20,6 +20,8 @@ public StaffEndpointTests(TestWebApplicationFactory factory) _client = factory.CreateClient(); } + #region Helpers + private async Task GetStaffToken() { var request = new HttpRequestMessage(HttpMethod.Post, "/api/staff/login"); @@ -39,6 +41,18 @@ private async Task GetAuthenticatedClient() return _client; } + private static MultipartFormDataContent CreateCsvFile(string content, string fileName = "rooms.csv") + { + var formData = new MultipartFormDataContent(); + var fileContent = new StringContent(content); + formData.Add(fileContent, "file", fileName); + return formData; + } + + #endregion + + #region Auth + [Fact] public async Task Login_with_valid_code_returns_token() { @@ -76,6 +90,21 @@ public async Task Get_reservations_without_token_returns_401() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task Delete_reservation_without_token_returns_401() + { + var client = _client; + client.DefaultRequestHeaders.Authorization = null; + + var response = await client.DeleteAsync($"/api/reservations/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + #endregion + + #region Reservations + [Fact] public async Task Get_reservations_with_token_returns_200() { @@ -91,7 +120,6 @@ public async Task Get_reservations_filtered_by_from_date() { var client = await GetAuthenticatedClient(); - // Create a reservation for the future var booking = new ReservationRequest { RoomNumber = "104", @@ -117,7 +145,6 @@ public async Task Get_reservations_filtered_by_room_number() { var client = await GetAuthenticatedClient(); - // Create a reservation var booking = new ReservationRequest { RoomNumber = "105", @@ -140,7 +167,6 @@ public async Task Get_reservations_filtered_by_guest_email() { var client = await GetAuthenticatedClient(); - // Create a reservation var booking = new ReservationRequest { RoomNumber = "201", @@ -158,16 +184,9 @@ public async Task Get_reservations_filtered_by_guest_email() Assert.All(reservations, r => Assert.Equal("email-filter@mjail.com", r.GuestEmail)); } - [Fact] - public async Task Delete_reservation_without_token_returns_401() - { - var client = _client; - client.DefaultRequestHeaders.Authorization = null; - - var response = await client.DeleteAsync($"/api/reservations/{Guid.NewGuid()}"); + #endregion - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + #region Check-in [Fact] public async Task CheckIn_today_reservation_returns_200_and_marks_checked_in() @@ -230,4 +249,41 @@ public async Task CheckIn_with_wrong_email_returns_400() Assert.NotNull(error); Assert.Contains("email", error.Detail, StringComparison.OrdinalIgnoreCase); } + + #endregion + + #region Import + + [Fact] + public async Task Import_valid_csv_returns_200_with_results() + { + var client = await GetAuthenticatedClient(); + var csv = "number\n301\n302\n303"; + + var response = await client.PostAsync("/api/rooms/import", CreateCsvFile(csv)); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, result.GetProperty("imported").GetInt32()); + Assert.Equal(0, result.GetProperty("failed").GetInt32()); + } + + [Fact] + public async Task Import_csv_with_invalid_rooms_returns_errors() + { + var client = await GetAuthenticatedClient(); + var csv = "number\n401\n000\nabc\n402"; + + var response = await client.PostAsync("/api/rooms/import", CreateCsvFile(csv)); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, result.GetProperty("imported").GetInt32()); + Assert.Equal(2, result.GetProperty("failed").GetInt32()); + + var errors = result.GetProperty("errors"); + Assert.Equal(2, errors.GetArrayLength()); + } + + #endregion } diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index 9f830da..b7556d5 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -29,9 +29,17 @@ export const Layout = () => { {isAuthenticated && ( - + + + Reservations + + + Rooms + + + )} diff --git a/ui/src/reservations/ReservationCard.tsx b/ui/src/reservations/ReservationCard.tsx index d2c8f52..c72495d 100644 --- a/ui/src/reservations/ReservationCard.tsx +++ b/ui/src/reservations/ReservationCard.tsx @@ -4,10 +4,10 @@ import styled from "styled-components"; /** 600px wide image for Rooms */ const RoomImg = styled.img` - min-width: 300px; width: 100%; - max-width: 700px; - height: auto; + height: 200px; + object-fit: cover; + display: block; `; export type ReservationCardProps = PropsWithChildren<{ @@ -20,7 +20,7 @@ export type ReservationCardProps = PropsWithChildren<{ export function ReservationCard(props: ReservationCardProps) { return ( - + diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 62a0dc3..2e9aeea 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useShowErrorToast } from "../utils/toasts"; import { toast } from "sonner"; -import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; +import { Box, Button, Flex, Grid, Heading, Section, Text, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; import { bookRoom, parseApiError, NewReservation, Reservation, useGetRooms } from "./api"; import { LoadingCard } from "../components/LoadingCard"; @@ -14,9 +14,12 @@ const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { lg: "4", }; +const PAGE_SIZE = 12; + export function ReservationPage() { const { isLoading, data: rooms } = useGetRooms(); const [selectedRoomNumber, setSelectedRoomNumber] = useState(""); + const [page, setPage] = useState(0); const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); @@ -41,12 +44,18 @@ export function ReservationPage() { } } + const totalPages = useMemo(() => Math.ceil((rooms?.length ?? 0) / PAGE_SIZE), [rooms]); + const pagedRooms = useMemo( + () => rooms?.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) ?? [], + [rooms, page] + ); + const createClickHandler = (roomNumber: string) => () => { setSelectedRoomNumber(roomNumber); }; return ( -
    +
    Rooms @@ -54,7 +63,7 @@ export function ReservationPage() { {isLoading && } - {rooms?.map((room) => ( + {pagedRooms.map((room) => ( + {totalPages > 1 && ( + + + Page {page + 1} of {totalPages} ({rooms?.length} rooms) + + + + + + + )} + {confirmedReservation && ( (null); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + function reset() { + setFile(null); + setResult(null); + setDragging(false); + setLoading(false); + } + + function handleClose() { + reset(); + setOpen(false); + } + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile && droppedFile.name.endsWith(".csv")) { + setFile(droppedFile); + setResult(null); + } + }, []); + + function handleFileSelect(e: React.ChangeEvent) { + const selected = e.target.files?.[0]; + if (selected) { + setFile(selected); + setResult(null); + } + } + + async function handleImport() { + if (!file || !token) return; + setLoading(true); + try { + const res = await importRooms(token, file); + setResult(res); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + if (res.failed === 0) showSuccess(); + } catch { + showError(["Failed to import rooms"]); + } finally { + setLoading(false); + } + } + + return ( + { setOpen(o); if (!o) reset(); }}> + {children} + + Import Rooms + + Upload a CSV file with room numbers (one per line). + + + + {!result ? ( + <> + { e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + style={{ + border: `2px dashed ${dragging ? "var(--accent-9)" : "var(--gray-6)"}`, + borderRadius: "var(--radius-3)", + padding: "40px 20px", + textAlign: "center", + backgroundColor: dragging ? "var(--accent-3)" : "var(--gray-2)", + cursor: "pointer", + transition: "all 0.2s", + }} + onClick={() => document.getElementById("csv-file-input")?.click()} + > + + {file ? file.name : "Drag & drop a CSV file here, or click to browse"} + + + + + + + + + + + + ) : ( + <> + + {result.imported} imported + {result.failed > 0 && ( + {result.failed} failed + )} + + + {result.errors.length > 0 && ( + + + + + Line + Room + Error + + + + {result.errors.map((err, i) => ( + + {err.line} + {err.roomNumber} + {err.message} + + ))} + + + + )} + + + + + + + )} + + + ); +} diff --git a/ui/src/staff/StaffReservationsPage.tsx b/ui/src/staff/StaffReservationsPage.tsx index eaca0c3..8db649f 100644 --- a/ui/src/staff/StaffReservationsPage.tsx +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -57,7 +57,7 @@ export function StaffReservationsPage() { } return ( -
    +
    Reservations diff --git a/ui/src/staff/StaffRoomsPage.tsx b/ui/src/staff/StaffRoomsPage.tsx new file mode 100644 index 0000000..d77d274 --- /dev/null +++ b/ui/src/staff/StaffRoomsPage.tsx @@ -0,0 +1,161 @@ +import { useMemo, useState } from "react"; +import { Badge, Box, Button, Card, Flex, Heading, Section, Select, Table, Text } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; +import { useAuth } from "../utils/auth"; +import { useGetRooms } from "../reservations/api"; +import { RoomStateLabels } from "./api"; +import { ImportRoomsDialog } from "./ImportRoomsDialog"; +import { LoadingCard } from "../components/LoadingCard"; + +const PAGE_SIZE = 20; + +const STATE_COLORS: Record = { + 0: "green", + 1: "orange", + 2: "yellow", +}; + +export function StaffRoomsPage() { + const { isAuthenticated } = useAuth(); + const navigate = useNavigate(); + const { data: rooms, isLoading } = useGetRooms(); + const [page, setPage] = useState(0); + const [statusFilter, setStatusFilter] = useState("all"); + const [floorFilter, setFloorFilter] = useState("all"); + + if (!isAuthenticated) { + navigate({ to: "/" }); + return null; + } + + const floors = useMemo( + () => [...new Set(rooms?.map((r) => r.number[0]) ?? [])].sort(), + [rooms] + ); + + const filteredRooms = useMemo(() => { + let result = rooms ?? []; + if (statusFilter !== "all") { + result = result.filter((r) => r.state === Number(statusFilter)); + } + if (floorFilter !== "all") { + result = result.filter((r) => r.number[0] === floorFilter); + } + return result; + }, [rooms, statusFilter, floorFilter]); + + const totalPages = useMemo(() => Math.ceil(filteredRooms.length / PAGE_SIZE), [filteredRooms]); + const pagedRooms = useMemo( + () => filteredRooms.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE), + [filteredRooms, page] + ); + + return ( +
    + + + Rooms + + + + + + + + + + Status + { setStatusFilter(v); setPage(0); }} + size="2" + > + + + All + Ready + Occupied + Dirty + + + + + Floor + { setFloorFilter(v); setPage(0); }} + size="2" + > + + + All floors + {floors.map((f) => ( + Floor {f} + ))} + + + + + + + {isLoading && } + + {!isLoading && filteredRooms.length === 0 && ( + No rooms found. + )} + + {pagedRooms.length > 0 && ( + <> + + + + Room Number + Floor + Status + + + + {pagedRooms.map((room) => ( + + #{room.number} + Floor {room.number[0]} + + + {RoomStateLabels[room.state] ?? "Unknown"} + + + + ))} + + + + {totalPages > 1 && ( + + + Page {page + 1} of {totalPages} ({filteredRooms.length} rooms) + + + + + + + )} + + )} +
    + ); +} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts index 1cb3875..dae6e4e 100644 --- a/ui/src/staff/api.ts +++ b/ui/src/staff/api.ts @@ -33,10 +33,47 @@ export interface ReservationFilters { guestEmail?: string; } -export async function checkInReservation(token: string, reservationId: string, guestEmail: string) { + +const RoomStateLabels: Record = { + 0: "Ready", + 1: "Occupied", + 2: "Dirty", +}; + +export { RoomStateLabels }; + +const ImportErrorSchema = z.object({ + line: z.number(), + roomNumber: z.string(), + message: z.string(), +}); + +const ImportResultSchema = z.object({ + totalRows: z.number(), + imported: z.number(), + failed: z.number(), + errors: ImportErrorSchema.array(), +}); + +export type ImportResult = z.infer; +export type ImportError = z.infer; + +export async function importRooms(token: string, file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + return api - .post(`/api/reservations/${reservationId}/check-in`, { - json: { guestEmail }, + .post("/api/rooms/import", { + body: formData, + headers: { Authorization: `Bearer ${token}` }, + }) + .json() + .then(ImportResultSchema.parseAsync); +} + +export async function deleteRoom(token: string, roomNumber: string) { + return api + .delete(`/api/rooms/${roomNumber}`, { headers: { Authorization: `Bearer ${token}` }, }) .json(); @@ -64,3 +101,12 @@ export function useGetStaffReservations(token: string | null, filters: Reservati .then(StaffReservationListSchema.parseAsync), }); } + +export async function checkInReservation(token: string, reservationId: string, guestEmail: string) { + return api + .post(`/api/reservations/${reservationId}/check-in`, { + json: { guestEmail }, + headers: { Authorization: `Bearer ${token}` }, + }) + .json(); +} \ No newline at end of file From fe0047b6af76c82c259ec0dd435b250d4e40974a Mon Sep 17 00:00:00 2001 From: Ibrahim Saad Date: Sat, 21 Mar 2026 18:53:26 +0100 Subject: [PATCH 11/11] feat: add check-out, room state management, and housekeeping Add check-out flow (API endpoint, repository, UI), room state PATCH endpoint, and housekeeping controls (mark clean/dirty) --- api/Contracts/UpdateRoomStateRequest.cs | 6 + api/Controllers/ReservationsController.cs | 12 ++ api/Controllers/RoomsController.cs | 13 ++ api/Db/Setup.cs | 1 + api/Models/Reservation.cs | 1 + api/Models/Room.cs | 2 +- api/Repositories/ReservationRepository.cs | 88 +++++++- api/Repositories/RoomRepository.cs | 13 ++ backlog/RE-006.md | 2 +- .../StaffEndpointTests.cs | 191 ++++++++++++++++++ ui/src/reservations/api.ts | 2 + ui/src/staff/StaffReservationsPage.tsx | 59 +++++- ui/src/staff/StaffRoomsPage.tsx | 51 ++++- ui/src/staff/api.ts | 19 ++ 14 files changed, 440 insertions(+), 20 deletions(-) create mode 100644 api/Contracts/UpdateRoomStateRequest.cs diff --git a/api/Contracts/UpdateRoomStateRequest.cs b/api/Contracts/UpdateRoomStateRequest.cs new file mode 100644 index 0000000..eff4f36 --- /dev/null +++ b/api/Contracts/UpdateRoomStateRequest.cs @@ -0,0 +1,6 @@ +using Models; + +namespace Contracts +{ + public record UpdateRoomStateRequest(State State); +} diff --git a/api/Controllers/ReservationsController.cs b/api/Controllers/ReservationsController.cs index 0866d71..b9bd89a 100644 --- a/api/Controllers/ReservationsController.cs +++ b/api/Controllers/ReservationsController.cs @@ -72,6 +72,18 @@ public async Task> CheckIn(Guid reservationId, [FromBo return Ok(reservation); } + [Authorize] + [HttpPost, Produces("application/json"), Route("{reservationId}/check-out")] + [ProducesResponseType(typeof(Reservation), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status409Conflict)] + public async Task> CheckOut(Guid reservationId, [FromBody] CheckInRequest request) + { + var reservation = await _repo.CheckOut(reservationId, request.GuestEmail); + return Ok(reservation); + } + [Authorize] [HttpDelete, Produces("application/json"), Route("{reservationId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] diff --git a/api/Controllers/RoomsController.cs b/api/Controllers/RoomsController.cs index dd7f74c..cb7161a 100644 --- a/api/Controllers/RoomsController.cs +++ b/api/Controllers/RoomsController.cs @@ -41,6 +41,7 @@ public async Task> GetRoom(string roomNumber) return Ok(room); } + [Authorize] [HttpPost, Produces("application/json"), Route("")] [ProducesResponseType(typeof(Room), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] @@ -102,6 +103,18 @@ public async Task> ImportRooms(IFormFile file) return Ok(new ImportResult(lineNumber, imported, errors.Count, errors)); } + [Authorize] + [HttpPatch, Produces("application/json"), Route("{roomNumber}")] + [ProducesResponseType(typeof(Room), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateRoomState(string roomNumber, [FromBody] UpdateRoomStateRequest request) + { + var room = await _repo.UpdateRoomState(roomNumber, request.State); + return Ok(room); + } + + [Authorize] [HttpDelete, Produces("application/json"), Route("{roomNumber}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index c5de04d..3db619d 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS Reservations ( {nameof(Reservation.End)} INT NOT NULL, {nameof(Reservation.CheckedIn)} INT NOT NULL DEFAULT FALSE, {nameof(Reservation.CheckedOut)} INT NOT NULL DEFAULT FALSE, + {nameof(Reservation.CheckedOutAt)} TEXT, FOREIGN KEY ({nameof(Reservation.GuestEmail)}) REFERENCES Guests ({nameof(Guest.Email)}), FOREIGN KEY ({nameof(Reservation.RoomNumber)}) diff --git a/api/Models/Reservation.cs b/api/Models/Reservation.cs index 978929a..114e954 100644 --- a/api/Models/Reservation.cs +++ b/api/Models/Reservation.cs @@ -15,5 +15,6 @@ public class Reservation public DateTime End { get; set; } public bool CheckedIn { get; set; } public bool CheckedOut { get; set; } + public DateTime? CheckedOutAt { get; set; } } } diff --git a/api/Models/Room.cs b/api/Models/Room.cs index d3ffccb..ca627e2 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -8,7 +8,7 @@ namespace Models /// public class Room { - private static readonly Regex RoomNumberPattern = new(@"^[1-9]\d{2}$"); + private static readonly Regex RoomNumberPattern = new(@"^[0-9]\d{2}$"); // allow ground floor 0 /// /// PKID For Rooms. Format is "###" where first digit is floor (1-9) diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 2b14b9f..865ac50 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -156,23 +156,95 @@ public async Task CheckIn(Guid reservationId, string guestEmail) if (reservation.Start > DateTime.Today || reservation.End <= DateTime.Today) { - throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Check-in is only allowed on the reservation start date"); + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Check-in is only allowed during the reservation period"); } - await _db.ExecuteAsync( - "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @reservationId", - new { reservationId } + var room = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Rooms WHERE Number = @RoomNumber", + new { reservation.RoomNumber } ); - await _db.ExecuteAsync( - "UPDATE Rooms SET State = @State WHERE Number = @RoomNumber", - new { State = (int)State.Occupied, reservation.RoomNumber } - ); + if (room != null && room.State == State.Dirty) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Cannot check in to a dirty room. Room must be cleaned first"); + } + + using var transaction = _db.BeginSerializableTransaction(); + try + { + await _db.ExecuteAsync( + "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @reservationId", + new { reservationId }, + transaction + ); + + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @State WHERE Number = @RoomNumber", + new { State = (int)State.Occupied, reservation.RoomNumber }, + transaction + ); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } reservation.CheckedIn = true; return reservation; } + public async Task CheckOut(Guid reservationId, string guestEmail) + { + var reservation = await GetReservation(reservationId); + + if (!string.Equals(reservation.GuestEmail, guestEmail, StringComparison.OrdinalIgnoreCase)) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Guest email does not match the reservation"); + } + + if (!reservation.CheckedIn) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Guest has not checked in yet"); + } + + if (reservation.CheckedOut) + { + throw new ConflictException(nameof(Reservation), reservationId.ToString(), "Reservation is already checked out"); + } + + var checkedOutAt = DateTime.Now; + + using var transaction = _db.BeginSerializableTransaction(); + try + { + await _db.ExecuteAsync( + "UPDATE Reservations SET CheckedOut = 1, CheckedOutAt = @checkedOutAt WHERE Id = @reservationId", + new { reservationId, checkedOutAt }, + transaction + ); + + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @State WHERE Number = @RoomNumber", + new { State = (int)State.Dirty, reservation.RoomNumber }, + transaction + ); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + + reservation.CheckedOut = true; + reservation.CheckedOutAt = checkedOutAt; + return reservation; + } + public async Task DeleteReservation(Guid reservationId) { var deleted = await _db.ExecuteAsync( diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index d2443ce..0194ea2 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -110,6 +110,19 @@ public async Task RoomExists(string roomNumber) return count > 0; } + public async Task UpdateRoomState(string roomNumber, State state) + { + var room = await GetRoom(roomNumber); + + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @State WHERE Number = @roomNumber", + new { State = (int)state, roomNumber } + ); + + room.State = state; + return room; + } + public async Task DeleteRoom(string roomNumber) { Room.ValidateRoomNumber(roomNumber); diff --git a/backlog/RE-006.md b/backlog/RE-006.md index 8a5162d..dae5dbd 100644 --- a/backlog/RE-006.md +++ b/backlog/RE-006.md @@ -2,5 +2,5 @@ Staff must be able to mark a room as clean or dirty -- Check in automatically marks the room as dirty +- Check out automatically marks the room as dirty - Staff cannot check in a guest to a dirty room diff --git a/tests/api.IntegrationTests/StaffEndpointTests.cs b/tests/api.IntegrationTests/StaffEndpointTests.cs index 8b952a1..309dfd5 100644 --- a/tests/api.IntegrationTests/StaffEndpointTests.cs +++ b/tests/api.IntegrationTests/StaffEndpointTests.cs @@ -250,6 +250,150 @@ public async Task CheckIn_with_wrong_email_returns_400() Assert.Contains("email", error.Detail, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task CheckIn_dirty_room_returns_400() + { + var client = await GetAuthenticatedClient(); + + // Create the room first + await client.PostAsJsonAsync("/api/rooms", new { number = "501", state = 0 }); + + var booking1 = new ReservationRequest + { + RoomNumber = "501", + GuestEmail = "first@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(1) + }; + var create1 = await client.PostAsJsonAsync("/api/reservations", booking1); + var res1 = await create1.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(res1); + + await client.PostAsJsonAsync($"/api/reservations/{res1.Id}/check-in", + new { guestEmail = "first@mjail.com" }); + + // Check out to mark room dirty + await client.PostAsJsonAsync($"/api/reservations/{res1.Id}/check-out", + new { guestEmail = "first@mjail.com" }); + + await client.DeleteAsync($"/api/reservations/{res1.Id}"); + + // Try to check in another reservation + var booking2 = new ReservationRequest + { + RoomNumber = "501", + GuestEmail = "second@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(3) + }; + var create2 = await client.PostAsJsonAsync("/api/reservations", booking2); + var res2 = await create2.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(res2); + + var checkInResponse = await client.PostAsJsonAsync( + $"/api/reservations/{res2.Id}/check-in", + new { guestEmail = "second@mjail.com" }); + + Assert.Equal(HttpStatusCode.BadRequest, checkInResponse.StatusCode); + var error = await checkInResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(error); + Assert.Contains("dirty", error.Detail, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Check-out + + [Fact] + public async Task CheckOut_checked_in_reservation_returns_200_and_marks_room_dirty() + { + var client = await GetAuthenticatedClient(); + + await client.PostAsJsonAsync("/api/rooms", new { number = "601", state = 0 }); + + var booking = new ReservationRequest + { + RoomNumber = "601", + GuestEmail = "checkout@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(2) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + await client.PostAsJsonAsync($"/api/reservations/{created.Id}/check-in", + new { guestEmail = "checkout@mjail.com" }); + + var checkOutResponse = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-out", + new { guestEmail = "checkout@mjail.com" }); + + Assert.Equal(HttpStatusCode.OK, checkOutResponse.StatusCode); + var result = await checkOutResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(result); + Assert.True(result.CheckedOut); + Assert.NotNull(result.CheckedOutAt); + + var roomResponse = await client.GetFromJsonAsync("/api/rooms/601"); + Assert.Equal(2, roomResponse.GetProperty("state").GetInt32()); // Dirty + } + + [Fact] + public async Task CheckOut_without_check_in_returns_400() + { + var client = await GetAuthenticatedClient(); + + await client.PostAsJsonAsync("/api/rooms", new { number = "602", state = 0 }); + + var booking = new ReservationRequest + { + RoomNumber = "602", + GuestEmail = "notin@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(2) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + var checkOutResponse = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-out", + new { guestEmail = "notin@mjail.com" }); + + Assert.Equal(HttpStatusCode.BadRequest, checkOutResponse.StatusCode); + } + + [Fact] + public async Task CheckOut_already_checked_out_returns_409() + { + var client = await GetAuthenticatedClient(); + + await client.PostAsJsonAsync("/api/rooms", new { number = "603", state = 0 }); + + var booking = new ReservationRequest + { + RoomNumber = "603", + GuestEmail = "double-out@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(2) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + await client.PostAsJsonAsync($"/api/reservations/{created.Id}/check-in", + new { guestEmail = "double-out@mjail.com" }); + await client.PostAsJsonAsync($"/api/reservations/{created!.Id}/check-out", + new { guestEmail = "double-out@mjail.com" }); + + var secondCheckOut = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-out", + new { guestEmail = "double-out@mjail.com" }); + + Assert.Equal(HttpStatusCode.Conflict, secondCheckOut.StatusCode); + } + #endregion #region Import @@ -286,4 +430,51 @@ public async Task Import_csv_with_invalid_rooms_returns_errors() } #endregion + + #region Room Status + + [Fact] + public async Task Patch_room_state_to_ready_returns_200() + { + var client = await GetAuthenticatedClient(); + + await client.PostAsJsonAsync("/api/rooms", new { number = "701", state = 0 }); + + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/rooms/701") + { + Content = JsonContent.Create(new { state = 2 }) // Dirty + }; + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var room = await response.Content.ReadFromJsonAsync(); + Assert.Equal(2, room.GetProperty("state").GetInt32()); + + // Now mark as ready + var request2 = new HttpRequestMessage(HttpMethod.Patch, "/api/rooms/701") + { + Content = JsonContent.Create(new { state = 0 }) + }; + var response2 = await client.SendAsync(request2); + + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + var room2 = await response2.Content.ReadFromJsonAsync(); + Assert.Equal(0, room2.GetProperty("state").GetInt32()); + } + + [Fact] + public async Task Patch_nonexistent_room_returns_404() + { + var client = await GetAuthenticatedClient(); + + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/rooms/999") + { + Content = JsonContent.Create(new { state = 0 }) + }; + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + #endregion } diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 7ca513c..8794900 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -38,6 +38,8 @@ const RoomSchema = z.object({ state: z.number(), }); +export type Room = z.infer; + const RoomListSchema = RoomSchema.array(); diff --git a/ui/src/staff/StaffReservationsPage.tsx b/ui/src/staff/StaffReservationsPage.tsx index 8db649f..0fd1151 100644 --- a/ui/src/staff/StaffReservationsPage.tsx +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -3,8 +3,8 @@ import { Badge, Box, Button, Card, Flex, Heading, Section, Select, Table, Text, import { useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "../utils/auth"; -import { checkInReservation, ReservationFilters, StaffReservation, useGetStaffReservations } from "./api"; -import { useGetRooms } from "../reservations/api"; +import { checkInReservation, checkOutReservation, RoomStateLabels, ReservationFilters, StaffReservation, useGetStaffReservations } from "./api"; +import { Room, useGetRooms } from "../reservations/api"; import { parseApiError } from "../reservations/api"; import { useShowErrorToast, useShowSuccessToast } from "../utils/toasts"; import { LoadingCard } from "../components/LoadingCard"; @@ -25,20 +25,30 @@ export function StaffReservationsPage() { const { data: rooms } = useGetRooms(); const queryClient = useQueryClient(); const showError = useShowErrorToast(); - const showSuccess = useShowSuccessToast("Guest checked in successfully!"); + const showCheckInSuccess = useShowSuccessToast("Guest checked in successfully!"); + const showCheckOutSuccess = useShowSuccessToast("Guest checked out successfully!"); const [checkingIn, setCheckingIn] = useState(null); + const [checkingOut, setCheckingOut] = useState(null); + + const roomsByNumber = new Map(rooms?.map((r) => [r.number, r]) ?? []); function coversToday(start: string, end: string) { const today = new Date().toDateString(); return new Date(start) <= new Date(today) && new Date(end) > new Date(today); } + function isRoomDirty(roomNumber: string) { + const room = roomsByNumber.get(roomNumber); + return room?.state === 2; + } + async function handleCheckIn(r: StaffReservation) { setCheckingIn(r.id); try { await checkInReservation(token!, r.id, r.guestEmail); - showSuccess(); + showCheckInSuccess(); queryClient.invalidateQueries({ queryKey: ["staff-reservations"] }); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); } catch (error) { const errors = await parseApiError(error); showError(errors); @@ -47,6 +57,21 @@ export function StaffReservationsPage() { } } + async function handleCheckOut(r: StaffReservation) { + setCheckingOut(r.id); + try { + await checkOutReservation(token!, r.id, r.guestEmail); + showCheckOutSuccess(); + queryClient.invalidateQueries({ queryKey: ["staff-reservations"] }); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + } catch (error) { + const errors = await parseApiError(error); + showError(errors); + } finally { + setCheckingOut(null); + } + } + if (!isAuthenticated) { navigate({ to: "/" }); return null; @@ -126,7 +151,8 @@ export function StaffReservationsPage() { Guest Email Check-in Check-out - Status + Reservation Status + Room Status @@ -139,22 +165,41 @@ export function StaffReservationsPage() { {new Date(r.end).toLocaleDateString()} {r.checkedOut - ? Checked Out + ? Checked Out {r.checkedOutAt && {new Date(r.checkedOutAt).toLocaleDateString()}} : r.checkedIn ? Checked In : Pending} + + {(() => { + const room = roomsByNumber.get(r.roomNumber); + const label = room ? RoomStateLabels[room.state] ?? "Unknown" : "—"; + const color = room?.state === 0 ? "green" : room?.state === 1 ? "orange" : room?.state === 2 ? "yellow" : "gray"; + return {label}; + })()} + {!r.checkedIn && coversToday(r.start, r.end) && ( )} + {r.checkedIn && !r.checkedOut && ( + + )} ))} diff --git a/ui/src/staff/StaffRoomsPage.tsx b/ui/src/staff/StaffRoomsPage.tsx index d77d274..1646521 100644 --- a/ui/src/staff/StaffRoomsPage.tsx +++ b/ui/src/staff/StaffRoomsPage.tsx @@ -1,11 +1,13 @@ import { useMemo, useState } from "react"; import { Badge, Box, Button, Card, Flex, Heading, Section, Select, Table, Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "../utils/auth"; -import { useGetRooms } from "../reservations/api"; -import { RoomStateLabels } from "./api"; +import { useGetRooms, parseApiError } from "../reservations/api"; +import { RoomStateLabels, updateRoomState } from "./api"; import { ImportRoomsDialog } from "./ImportRoomsDialog"; import { LoadingCard } from "../components/LoadingCard"; +import { useShowErrorToast, useShowSuccessToast } from "../utils/toasts"; const PAGE_SIZE = 20; @@ -16,12 +18,31 @@ const STATE_COLORS: Record = { }; export function StaffRoomsPage() { - const { isAuthenticated } = useAuth(); + const { token, isAuthenticated } = useAuth(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { data: rooms, isLoading } = useGetRooms(); + const showError = useShowErrorToast(); + const showSuccess = useShowSuccessToast("Room status updated!"); const [page, setPage] = useState(0); const [statusFilter, setStatusFilter] = useState("all"); const [floorFilter, setFloorFilter] = useState("all"); + const [updating, setUpdating] = useState(null); + + async function handleStateChange(roomNumber: string, newState: number) { + if (!token) return; + setUpdating(roomNumber); + try { + await updateRoomState(token, roomNumber, newState); + showSuccess(); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + } catch (error) { + const errors = await parseApiError(error); + showError(errors); + } finally { + setUpdating(null); + } + } if (!isAuthenticated) { navigate({ to: "/" }); @@ -112,6 +133,7 @@ export function StaffRoomsPage() { Room Number Floor Status + @@ -124,6 +146,29 @@ export function StaffRoomsPage() { {RoomStateLabels[room.state] ?? "Unknown"} + + {room.state === 2 && ( + + )} + {room.state === 0 && ( + + )} + ))} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts index dae6e4e..f19f10b 100644 --- a/ui/src/staff/api.ts +++ b/ui/src/staff/api.ts @@ -20,6 +20,7 @@ const StaffReservationSchema = z.object({ end: z.string(), checkedIn: z.boolean(), checkedOut: z.boolean(), + checkedOutAt: z.string().nullable(), }); export type StaffReservation = z.infer; @@ -71,6 +72,15 @@ export async function importRooms(token: string, file: File): Promise