From 8e58091b263729ee656b8c648c212a83fc7d9102 Mon Sep 17 00:00:00 2001 From: Jack O'Reilly <38988042+samuraijack16@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:10:24 +0100 Subject: [PATCH 1/5] feat: Implement reservation system with validation and conflict detection RE-001 & RE-002 - Added ReservationController with methods for booking and managing reservations. - Introduced ReservationValidator for validating reservation data (dates, email, room number). - Created interfaces for repositories (IReservationRepository, IRoomRepository, IGuestRepository) to adhere to SOLID principles. - Implemented in-memory SQLite database for testing repository functionality. - Added unit tests for ReservationController, ReservationRepository, and ReservationValidator. - Enhanced error handling in the booking process with user-friendly messages. - Updated UI components to reflect validation requirements and improved user experience. - Configured .gitignore to exclude build artifacts and added changelog for project documentation. Co-authored-by: Copilot --- .config/dotnet-tools.json | 13 ++ .gitignore | 11 +- .../Controllers/ReservationContollerTests.cs | 128 ++++++++++++++++++ .../ReservationRepositoryTests.cs | 109 +++++++++++++++ .../Validators/ReservationValidatorTests.cs | 96 +++++++++++++ api.tests/api.tests.csproj | 27 ++++ api/Controllers/ReservationController.cs | 82 +++++++++-- api/Models/Room.cs | 2 +- api/Program.cs | 12 +- api/Repositories/GuestRepository.cs | 3 +- .../Interfaces/IGuestRepository.cs | 12 ++ .../Interfaces/IReservationRepository.cs | 13 ++ .../Interfaces/IRoomRepository.cs | 12 ++ api/Repositories/ReservationRepository.cs | 46 ++++++- api/Repositories/RoomRepository.cs | 3 +- .../Interfaces/IReservationValidator.cs | 9 ++ api/Validators/ReservationValidator.cs | 52 +++++++ api/api.csproj | 6 + changelog.md | 27 ++++ global.json | 5 + reservations-interview.sln | 30 ++++ ui/package-lock.json | 4 +- ui/src/components/ErrorToast.tsx | 28 ++++ ui/src/reservations/BookingDetailsModal.tsx | 32 ++++- ui/src/reservations/ReservationPage.tsx | 29 +++- ui/src/reservations/api.ts | 26 ++-- ui/src/utils/toasts.tsx | 12 ++ 27 files changed, 787 insertions(+), 42 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 api.tests/Controllers/ReservationContollerTests.cs create mode 100644 api.tests/Repositories/ReservationRepositoryTests.cs create mode 100644 api.tests/Validators/ReservationValidatorTests.cs create mode 100644 api.tests/api.tests.csproj create mode 100644 api/Repositories/Interfaces/IGuestRepository.cs create mode 100644 api/Repositories/Interfaces/IReservationRepository.cs create mode 100644 api/Repositories/Interfaces/IRoomRepository.cs create mode 100644 api/Validators/Interfaces/IReservationValidator.cs create mode 100644 api/Validators/ReservationValidator.cs create mode 100644 changelog.md create mode 100644 global.json create mode 100644 reservations-interview.sln create mode 100644 ui/src/components/ErrorToast.tsx diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..97f37dc --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.2.6", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 496ee2c..837cd8d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,10 @@ -.DS_Store \ No newline at end of file +.DS_Store +# Build results +[Bb]in/ +[Oo]bj/ + +# DLLs and Executables +*.dll +*.exe +*.pdb +*.user \ No newline at end of file diff --git a/api.tests/Controllers/ReservationContollerTests.cs b/api.tests/Controllers/ReservationContollerTests.cs new file mode 100644 index 0000000..5b2be46 --- /dev/null +++ b/api.tests/Controllers/ReservationContollerTests.cs @@ -0,0 +1,128 @@ +using Controllers; +using Microsoft.AspNetCore.Mvc; +using Models; +using Models.Errors; +using Moq; +using Repositories.Interfaces; +using Validators.Interfaces; + +namespace api.tests.Controllers +{ + public class ReservationControllerTests + { + private readonly Mock _mockRepo; + private readonly Mock _mockRoomRepo; + private readonly Mock _mockGuestRepo; + private readonly Mock _mockValidator; + private readonly ReservationController _controller; + + public ReservationControllerTests() + { + _mockRepo = new Mock(); + _mockRoomRepo = new Mock(); + _mockGuestRepo = new Mock(); + _mockValidator = new Mock(); + + _controller = new ReservationController( + _mockRepo.Object, + _mockRoomRepo.Object, + _mockGuestRepo.Object, + _mockValidator.Object + ); + } + + [Fact] + public async Task BookReservation_ReturnsBadRequest_WhenValidationFails() + { + // Arrange + var booking = new Reservation { RoomNumber = "101", GuestEmail = "test@test.com" }; + _mockValidator + .Setup(v => v.Validate(booking)) + .Returns(new List { "Invalid date range" }); + + // Act + var result = await _controller.BookReservation(booking); + + // Assert + var badRequest = Assert.IsType(result.Result); + Assert.NotNull(badRequest.Value); + } + + [Fact] + public async Task BookReservation_ReturnsBadRequest_WhenRoomDoesNotExist() + { + // Arrange + var booking = new Reservation { RoomNumber = "999", GuestEmail = "test@test.com" }; + _mockValidator.Setup(v => v.Validate(booking)).Returns(new List()); + _mockRoomRepo + .Setup(r => r.GetRoom(booking.RoomNumber)) + .ThrowsAsync(new NotFoundException("Room not found")); + + // Act + var result = await _controller.BookReservation(booking); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task BookReservation_CreatesGuest_WhenGuestIsNew() + { + // Arrange + var booking = new Reservation + { + RoomNumber = "101", + GuestEmail = "new@guest.com", + Start = DateTime.Now, + End = DateTime.Now.AddDays(1), + }; + + _mockValidator.Setup(v => v.Validate(booking)).Returns(new List()); + _mockRoomRepo + .Setup(r => r.GetRoom(booking.RoomNumber)) + .ReturnsAsync(new Room { Number = "101" }); + + // Simulate Guest NOT found + _mockGuestRepo + .Setup(g => g.GetGuestByEmail(booking.GuestEmail)) + .ThrowsAsync(new NotFoundException("Guest not found")); + + _mockRepo + .Setup(r => r.CreateReservation(It.IsAny())) + .ReturnsAsync(booking); + + // Act + await _controller.BookReservation(booking); + + // Assert + _mockGuestRepo.Verify( + g => g.CreateGuest(It.Is(gt => gt.Email == booking.GuestEmail)), + Times.Once + ); + } + + [Fact] + public async Task BookReservation_ReturnsConflict_WhenOverlapExists() + { + // Arrange + var booking = new Reservation { RoomNumber = "101", GuestEmail = "test@test.com" }; + _mockValidator.Setup(v => v.Validate(booking)).Returns(new List()); + _mockRoomRepo + .Setup(r => r.GetRoom(booking.RoomNumber)) + .ReturnsAsync(new Room { Number = "101" }); + _mockGuestRepo + .Setup(g => g.GetGuestByEmail(booking.GuestEmail)) + .ReturnsAsync(new Guest { Email = "test@test.com", Name = "Test" }); + + _mockRepo + .Setup(r => r.CreateReservation(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Room is already booked")); + + // Act + var result = await _controller.BookReservation(booking); + + // Assert + Assert.IsType(result.Result); + } + } +} diff --git a/api.tests/Repositories/ReservationRepositoryTests.cs b/api.tests/Repositories/ReservationRepositoryTests.cs new file mode 100644 index 0000000..b118a78 --- /dev/null +++ b/api.tests/Repositories/ReservationRepositoryTests.cs @@ -0,0 +1,109 @@ +using System.Data; +using Microsoft.Data.Sqlite; +using Models; +using Repositories; +using Xunit; + +namespace api.tests.Repositories +{ + public class ReservationRepositoryTests : IDisposable + { + private readonly IDbConnection _db; + private readonly ReservationRepository _repo; + + public ReservationRepositoryTests() + { + // Set up a fresh in-memory SQLite database for every test + _db = new SqliteConnection("Data Source=:memory:"); + _db.Open(); + + // Initialize the schema (Tables: Guests, Rooms, Reservations) + // You can use your existing Setup.cs logic or a simplified script + InitializeSchema(); + + _repo = new ReservationRepository(_db); + } + + private void InitializeSchema() + { + using var command = _db.CreateCommand(); + command.CommandText = + @" + CREATE TABLE Guests (Email TEXT PRIMARY KEY, Name TEXT); + CREATE TABLE Rooms (Number TEXT PRIMARY KEY, State INTEGER); + CREATE TABLE Reservations ( + Id GUID PRIMARY KEY, + RoomNumber TEXT, + GuestEmail TEXT, + Start DATETIME, + End DATETIME, + CheckedIn INTEGER DEFAULT 0, + CheckedOut INTEGER DEFAULT 0 + ); + INSERT INTO Rooms (Number, State) VALUES ('101', 1); + INSERT INTO Guests (Email, Name) VALUES ('test@test.com', 'Test Guest'); + "; + command.ExecuteNonQuery(); + } + + [Fact] + public async Task CreateReservation_SavesToDatabase() + { + // Arrange + var reservation = new Reservation + { + Id = Guid.NewGuid(), + RoomNumber = "101", + GuestEmail = "test@test.com", + Start = DateTime.Now.AddDays(1), + End = DateTime.Now.AddDays(2), + }; + + // Act + var result = await _repo.CreateReservation(reservation); + + // Assert + Assert.NotNull(result); + var all = await _repo.GetReservations(); + Assert.Single(all); + } + + [Fact] + public async Task HasConflict_ReturnsTrue_WhenDatesOverlap() + { + // Arrange: Existing booking for tomorrow + var start = DateTime.Today.AddDays(1); + var end = DateTime.Today.AddDays(3); + + await _repo.CreateReservation( + new Reservation + { + Id = Guid.NewGuid(), + RoomNumber = "101", + GuestEmail = "a@a.com", + Start = start, + End = end, + } + ); + + // Act: Try to book an overlapping slot + var overlap = new Reservation + { + RoomNumber = "101", + Start = start.AddDays(1), + End = end.AddDays(1), + GuestEmail = "", + }; + var hasConflict = await _repo.HasConflict(overlap); + + // Assert + Assert.True(hasConflict); + } + + public void Dispose() + { + _db.Close(); + _db.Dispose(); + } + } +} diff --git a/api.tests/Validators/ReservationValidatorTests.cs b/api.tests/Validators/ReservationValidatorTests.cs new file mode 100644 index 0000000..acf092c --- /dev/null +++ b/api.tests/Validators/ReservationValidatorTests.cs @@ -0,0 +1,96 @@ +using Models; +using Validators; + +namespace api.tests.Validators +{ + public class ReservationValidatorTests + { + private readonly ReservationValidator _validator; + + public ReservationValidatorTests() + { + _validator = new ReservationValidator(); + } + + [Fact] + public void Validate_WithValidReservation_ReturnsEmptyList() + { + // Arrange + var validReservation = new Reservation + { + Start = DateTime.Now, + End = DateTime.Now.AddDays(3), + GuestEmail = "test@example.com", + RoomNumber = "101", + }; + + // Act + var errors = _validator.Validate(validReservation); + + // Assert + Assert.Empty(errors); + } + + [Fact] + public void Validate_WithInvalidDates_ReturnsDateError() + { + // Arrange (Start is after End) + var invalidReservation = new Reservation + { + Start = DateTime.Now.AddDays(5), + End = DateTime.Now, + GuestEmail = "test@example.com", + RoomNumber = "101", + }; + + // Act + var errors = _validator.Validate(invalidReservation); + + // Assert + Assert.Contains("Start date must be before end date", errors); + Assert.Contains("Duration must be between 1 and 30 days", errors); + } + + [Theory] + [InlineData("testexample.com")] // Missing @ + [InlineData("test@example")] // Missing TLD + [InlineData(" test@example.com")] // Leading space + public void Validate_WithInvalidEmail_ReturnsEmailError(string invalidEmail) + { + var reservation = new Reservation + { + Start = DateTime.Now, + End = DateTime.Now.AddDays(2), + GuestEmail = invalidEmail, + RoomNumber = "101", + }; + + var errors = _validator.Validate(reservation); + + Assert.Contains("Email must be valid and include a domain", errors); + } + + [Theory] + [InlineData("000")] // Door 00 is invalid + [InlineData("10")] // Only 2 digits + [InlineData("-101")] // Negative + [InlineData("A01")] // Letters + public void Validate_WithInvalidRoomNumber_ReturnsRoomError(string invalidRoom) + { + var reservation = new Reservation + { + Start = DateTime.Now, + End = DateTime.Now.AddDays(2), + GuestEmail = "test@example.com", + RoomNumber = invalidRoom, + }; + + var errors = _validator.Validate(reservation); + + Assert.Contains( + "Room number must be 3 digits, floor 0-9, and door 01-99 (e.g., 001, 102)", + errors + ); + } + } +} diff --git a/api.tests/api.tests.csproj b/api.tests/api.tests.csproj new file mode 100644 index 0000000..a3929fe --- /dev/null +++ b/api.tests/api.tests.csproj @@ -0,0 +1,27 @@ + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..8d1a3f4 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -1,24 +1,50 @@ +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; using Repositories; +using Repositories.Interfaces; +using Validators; +using Validators.Interfaces; namespace Controllers { [Tags("Reservations"), Route("reservation")] + [ApiController] public class ReservationController : Controller { - private ReservationRepository _repo { get; set; } + private readonly IReservationRepository _reservationRepo; + private readonly IRoomRepository _roomRepo; + private readonly IGuestRepository _guestRepo; + private readonly IReservationValidator _reservationValidator; - public ReservationController(ReservationRepository reservationRepository) + private static readonly Regex EmailRegex = new Regex( + @"^[^@\s]+@[^@\s]+\.[^@\s]+$", + RegexOptions.Compiled + ); + private static readonly Regex RoomNumberRegex = new Regex( + @"^[0-9](0[1-9]|[1-9][0-9])$", + RegexOptions.Compiled + ); + private static readonly string[] error = new[] { "Request payload is missing or invalid." }; + + public ReservationController( + IReservationRepository reservationRepository, + IRoomRepository roomRepository, + IGuestRepository guestRepository, + IReservationValidator reservationValidator + ) { - _repo = reservationRepository; + _reservationRepo = reservationRepository; + _roomRepo = roomRepository; + _guestRepo = guestRepository; + _reservationValidator = reservationValidator; } [HttpGet, Produces("application/json"), Route("")] public async Task> GetReservations() { - var reservations = await _repo.GetReservations(); + var reservations = await _reservationRepo.GetReservations(); return Json(reservations); } @@ -28,7 +54,7 @@ public async Task> GetRoom(Guid reservationId) { try { - var reservation = await _repo.GetReservation(reservationId); + var reservation = await _reservationRepo.GetReservation(reservationId); return Json(reservation); } catch (NotFoundException) @@ -47,6 +73,42 @@ public async Task> BookReservation( [FromBody] Reservation newBooking ) { + if (newBooking == null) + { + return BadRequest(new { errors = error }); + } + + var validationErrors = _reservationValidator.Validate(newBooking); + if (validationErrors.Any()) + { + return BadRequest(new { errors = validationErrors }); + } + + // Verify room exists + try + { + await _roomRepo.GetRoom(newBooking.RoomNumber); + } + catch (NotFoundException) + { + return BadRequest(new { errors = new[] { "Room does not exist." } }); + } + + try + { + await _guestRepo.GetGuestByEmail(newBooking.GuestEmail); + } + catch (NotFoundException) + { + await _guestRepo.CreateGuest( + new Guest + { + Email = newBooking.GuestEmail, + Name = string.Empty, + } + ); + } + // Provide a real ID if one is not provided if (newBooking.Id == Guid.Empty) { @@ -55,8 +117,12 @@ [FromBody] Reservation newBooking try { - var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); + var createdReservation = await _reservationRepo.CreateReservation(newBooking); + return Created($"/reservation/{createdReservation.Id}", createdReservation); + } + catch (InvalidOperationException ex) + { + return Conflict(new { errors = new[] { ex.Message } }); } catch (Exception ex) { @@ -70,7 +136,7 @@ [FromBody] Reservation newBooking [HttpDelete, Produces("application/json"), Route("{reservationId}")] public async Task DeleteReservation(Guid reservationId) { - var result = await _repo.DeleteReservation(reservationId); + var result = await _reservationRepo.DeleteReservation(reservationId); return result ? NoContent() : NotFound(); } diff --git a/api/Models/Room.cs b/api/Models/Room.cs index cbd6536..e888581 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -45,6 +45,6 @@ public enum State { Ready = 0, Occupied = 1, - Dirty = 2 + Dirty = 2, } } diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..8fed936 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -2,10 +2,12 @@ using Db; using Microsoft.Data.Sqlite; using Repositories; +using Repositories.Interfaces; +using Validators; +using Validators.Interfaces; var builder = WebApplication.CreateBuilder(args); - { var Services = builder.Services; var connectionString = @@ -14,9 +16,10 @@ Services.AddSingleton(_ => new SqliteConnection(connectionString)); Services.AddSingleton(sp => sp.GetRequiredService()); - Services.AddSingleton(); - Services.AddSingleton(); - Services.AddSingleton(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddSingleton(); Services.AddMvc(opt => { opt.EnableEndpointRouting = false; @@ -28,7 +31,6 @@ var app = builder.Build(); - { try { diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 54182bc..d6ae663 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -2,10 +2,11 @@ using Dapper; using Models; using Models.Errors; +using Repositories.Interfaces; namespace Repositories { - public class GuestRepository + public class GuestRepository : IGuestRepository { private IDbConnection _db { get; set; } diff --git a/api/Repositories/Interfaces/IGuestRepository.cs b/api/Repositories/Interfaces/IGuestRepository.cs new file mode 100644 index 0000000..4cea1d7 --- /dev/null +++ b/api/Repositories/Interfaces/IGuestRepository.cs @@ -0,0 +1,12 @@ +using Models; + +namespace Repositories.Interfaces +{ + public interface IGuestRepository + { + Task> GetGuests(); + Task GetGuestByEmail(string guestEmail); + Task CreateGuest(Guest newGuest); + Task DeleteGuestByEmail(string guestEmail); + } +} \ No newline at end of file diff --git a/api/Repositories/Interfaces/IReservationRepository.cs b/api/Repositories/Interfaces/IReservationRepository.cs new file mode 100644 index 0000000..3d3dbc6 --- /dev/null +++ b/api/Repositories/Interfaces/IReservationRepository.cs @@ -0,0 +1,13 @@ +using Models; + +namespace Repositories.Interfaces +{ + public interface IReservationRepository + { + Task> GetReservations(); + Task GetReservation(Guid reservationId); + Task CreateReservation(Reservation newReservation); + Task HasConflict(Reservation reservation); + Task DeleteReservation(Guid reservationId); + } +} diff --git a/api/Repositories/Interfaces/IRoomRepository.cs b/api/Repositories/Interfaces/IRoomRepository.cs new file mode 100644 index 0000000..b98b095 --- /dev/null +++ b/api/Repositories/Interfaces/IRoomRepository.cs @@ -0,0 +1,12 @@ +using Models; + +namespace Repositories.Interfaces +{ + public interface IRoomRepository + { + Task GetRoom(string roomNumber); + Task> GetRooms(); + Task CreateRoom(Room newRoom); + Task DeleteRoom(string roomNumber); + } +} diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..7027f8b 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -2,10 +2,11 @@ using Dapper; using Models; using Models.Errors; +using Repositories.Interfaces; namespace Repositories { - public class ReservationRepository + public class ReservationRepository : IReservationRepository { private IDbConnection _db { get; set; } @@ -49,10 +50,45 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } + // Check for overlapping reservations + var hasConflict = await HasConflict(newReservation); + if (hasConflict) + { + throw new InvalidOperationException("Room is already booked for these dates"); + } + + var createdReservation = await _db.QuerySingleAsync( + @"INSERT INTO Reservations(Id, RoomNumber, GuestEmail, Start, End, CheckedIn, CheckedOut) + Values(@Id, @RoomNumber, @GuestEmail, @Start, @End, @CheckedIn, @CheckedOut) + RETURNING *", + new ReservationDb(newReservation) + ); + + return createdReservation.ToDomain(); + } + + /// + /// Check if a reservation conflicts with existing reservations for the same room + /// + public async Task HasConflict(Reservation reservation) + { + var roomNumberInt = Room.ConvertRoomNumberToInt(reservation.RoomNumber); + + // Overlap formula: (newStart < existingEnd) AND (newEnd > existingStart) + var existingReservations = await _db.QueryAsync( + @"SELECT * FROM Reservations + WHERE RoomNumber = @roomNumberInt + AND Start < @endDate + AND End > @startDate", + new + { + roomNumberInt, + startDate = reservation.Start, + endDate = reservation.End, + } ); + + return existingReservations.Any(); } public async Task DeleteReservation(Guid reservationId) @@ -105,7 +141,7 @@ public Reservation ToDomain() Start = Start, End = End, CheckedIn = CheckedIn, - CheckedOut = CheckedOut + CheckedOut = CheckedOut, }; } } diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 2b9f904..19566c3 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -2,10 +2,11 @@ using Dapper; using Models; using Models.Errors; +using Repositories.Interfaces; namespace Repositories { - public class RoomRepository + public class RoomRepository : IRoomRepository { private IDbConnection _db { get; set; } diff --git a/api/Validators/Interfaces/IReservationValidator.cs b/api/Validators/Interfaces/IReservationValidator.cs new file mode 100644 index 0000000..2775f4d --- /dev/null +++ b/api/Validators/Interfaces/IReservationValidator.cs @@ -0,0 +1,9 @@ +using Models; + +namespace Validators.Interfaces +{ + public interface IReservationValidator + { + List Validate(Reservation reservation); + } +} diff --git a/api/Validators/ReservationValidator.cs b/api/Validators/ReservationValidator.cs new file mode 100644 index 0000000..b616f9e --- /dev/null +++ b/api/Validators/ReservationValidator.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; +using Models; +using Validators.Interfaces; + +namespace Validators +{ + public class ReservationValidator : IReservationValidator + { + private static readonly Regex EmailRegex = new Regex( + @"^[^@\s]+@[^@\s]+\.[^@\s]+$", + RegexOptions.Compiled + ); + private static readonly Regex RoomNumberRegex = new Regex( + @"^[0-9](0[1-9]|[1-9][0-9])$", + RegexOptions.Compiled + ); + + public List Validate(Reservation reservation) + { + var errors = new List(); + + if (reservation == null) + { + errors.Add("Reservation payload is missing or invalid."); + return errors; + } + + if (reservation.Start >= reservation.End) + errors.Add("Start date must be before end date"); + + var duration = (reservation.End - reservation.Start).Days; + if (duration is < 1 or > 30) + errors.Add("Duration must be between 1 and 30 days"); + + if ( + string.IsNullOrWhiteSpace(reservation.GuestEmail) + || !EmailRegex.IsMatch(reservation.GuestEmail) + ) + errors.Add("Email must be valid and include a domain"); + + if ( + string.IsNullOrWhiteSpace(reservation.RoomNumber) + || !RoomNumberRegex.IsMatch(reservation.RoomNumber) + ) + errors.Add( + "Room number must be 3 digits, floor 0-9, and door 01-99 (e.g., 001, 102)" + ); + + return errors; + } + } +} diff --git a/api/api.csproj b/api/api.csproj index ef55adc..87418a8 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -12,4 +12,10 @@ + + + <_Parameter1>api.tests + + + diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..1ec677f --- /dev/null +++ b/changelog.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added +- **Guest Booking Validation (RE-001):** + - Implemented requirement for Start Date to be before End Date. + - Enforced a minimum booking duration of 1 day and a maximum of 30 days. + - Added email validation requiring the presence of a domain. + - Integrated 3-digit room number validation ("###") ensuring floor levels 0–9 and prohibiting door number "00". + - Added check to ensure room numbers correspond to existing rooms. +- **Conflict Prevention (RE-002):** + - Developed a conflict detection system to prevent double bookings for the same room. + - Implemented overlap logic that identifies conflicts if any part of a reservation's duration overlaps with an existing booking. +- **Infrastructure & Testing:** + - Extracted interfaces (`IReservationRepository`, `IRoomRepository`, `IGuestRepository`, `IReservationValidator`) to support SOLID principles and mockability. + - Added an xUnit test project (`api.tests`) featuring unit tests for the controller and validator layers. + - Implemented repository integration tests using an in-memory SQLite provider. + - Switched repository service lifetimes to **Scoped** in `Program.cs` to ensure thread safety and proper connection disposal. + +### Changed +- Updated the frontend `BookingForm` to include email domain requirement hints. + +### Fixed +- Configured `.gitignore` to prevent tracking of build artifacts (`.dll`, `.exe`, `.pdb`). \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..2419786 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "8.0.420" + } +} \ No newline at end of file diff --git a/reservations-interview.sln b/reservations-interview.sln new file mode 100644 index 0000000..60cd5e4 --- /dev/null +++ b/reservations-interview.sln @@ -0,0 +1,30 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api", "api\api.csproj", "{CBFA08EA-AECE-205F-7B85-326FD0B81BAD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api.tests", "api.tests\api.tests.csproj", "{DDEEB8B8-0B8E-488E-966A-483337417DDF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Release|Any CPU.Build.0 = Release|Any CPU + {DDEEB8B8-0B8E-488E-966A-483337417DDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DDEEB8B8-0B8E-488E-966A-483337417DDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDEEB8B8-0B8E-488E-966A-483337417DDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DDEEB8B8-0B8E-488E-966A-483337417DDF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {129819DB-D7AA-4752-A996-A5850A7BA246} + EndGlobalSection +EndGlobal diff --git a/ui/package-lock.json b/ui/package-lock.json index cfc4957..06c0e87 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "0.0.2", + "version": "0.0.3", "dependencies": { "@datepicker-react/styled": "^2.8.4", "@radix-ui/react-dialog": "^1.1.2", diff --git a/ui/src/components/ErrorToast.tsx b/ui/src/components/ErrorToast.tsx new file mode 100644 index 0000000..f6a6e56 --- /dev/null +++ b/ui/src/components/ErrorToast.tsx @@ -0,0 +1,28 @@ +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; + message: string; +} + +const BorderedErrorBox = styled(Box)` + background-color: var(--red-5); + border-radius: var(--radius-4); + border: 1px solid var(--indigo-9); +`; + +/** An error toast */ +export function ErrorToast({ toastId, message }: ErrorToastProps) { + const closeToast = useCallback(() => toast.dismiss(toastId), [toastId]); + + return ( + + + {message} + + + ); +} diff --git a/ui/src/reservations/BookingDetailsModal.tsx b/ui/src/reservations/BookingDetailsModal.tsx index 6f1edfe..3b9596e 100644 --- a/ui/src/reservations/BookingDetailsModal.tsx +++ b/ui/src/reservations/BookingDetailsModal.tsx @@ -1,5 +1,4 @@ import { useShowInfoToast } from "../utils/toasts"; -import { fromDateStringToIso } from "../utils/datetime"; import { DateRangeInput, FocusedInput, @@ -55,23 +54,36 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { null, ]); const [focusedInput, setFocusedInput] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); const showProcessingToast = useShowInfoToast("Processing booking..."); - const showNoInfoToast = useShowInfoToast("Missing email or dates."); + const showNoInfoToast = useShowInfoToast("Missing valid email or dates."); + + const emailRegex = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + const isValidEmail = emailRegex.test(email); + const isEmailError = email.length > 0 && !isValidEmail; + const isFormValid = isValidEmail && dateRange[0] !== null && dateRange[1] !== null; function handleSubmit(evt: React.MouseEvent) { - if (!email || !dateRange[0] || !dateRange[1]) { + if (!isFormValid) { showNoInfoToast(); evt.preventDefault(); return false; } + if (isSubmitting) { + evt.preventDefault(); + return false; + } + + setIsSubmitting(true); showProcessingToast(); onSubmit({ RoomNumber: roomNumber, GuestEmail: email, - Start: fromDateStringToIso(dateRange[0]), - End: fromDateStringToIso(dateRange[1]), + Start: dateRange[0], + End: dateRange[1], }); + return true; } @@ -101,6 +113,7 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { type="email" size="3" mb="4" + color={isEmailError ? "red" : undefined} > Email @@ -119,10 +132,17 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { focusedInput={focusedInput} onFocusChange={setFocusedInput} showResetDates={false} + minBookingDate={new Date()} /> - diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 06a0036..1a9c01c 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useShowSuccessToast } from "../utils/toasts"; +import { useShowErrorToast, useShowSuccessToast } from "../utils/toasts"; import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; import { bookRoom, NewReservation, useGetRooms } from "./api"; @@ -20,13 +20,36 @@ export function ReservationPage() { const showToast = useShowSuccessToast("We have received your booking!"); + const showErrorToast = useShowErrorToast(); + function onClose() { setSelectedRoomNumber(""); } - function onSubmit(booking: NewReservation) { - bookRoom(booking).then(onClose).then(showToast); +async function onSubmit(booking: NewReservation) { + try { + await bookRoom(booking); + onClose(); + showToast(); + } catch (err: any) { + let errorMessage = "An unexpected error occurred."; + + if (err.name === "HTTPError") { + try { + const data = await err.response.json(); + const errors = Array.isArray(data.errors) + ? data.errors + : Object.values(data.errors || {}).flat(); + + errorMessage = errors.length > 0 ? errors.join("\n") : "Room may be unavailable."; + } catch { + errorMessage = (await err.response.text()) || "Unknown error."; + } + } + + showErrorToast(`Booking failed: ${errorMessage}`); } +} const createClickHandler = (roomNumber: string) => () => { setSelectedRoomNumber(roomNumber); diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..6548846 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -1,13 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import { ISO8601String, toIsoStr } from "../utils/datetime"; import ky from "ky"; import { z } from "zod"; export interface NewReservation { RoomNumber: string; GuestEmail: string; - Start: ISO8601String; - End: ISO8601String; + Start: Date | null; + End: Date | null; } /** The schema the API returns */ @@ -15,8 +14,8 @@ const ReservationSchema = z.object({ Id: z.string(), RoomNumber: z.string(), GuestEmail: z.string().email(), - Start: z.string(), - End: z.string(), + Start: z.string().nullable(), + End: z.string().nullable(), }); type Reservation = z.infer; @@ -25,12 +24,11 @@ export function bookRoom(booking: NewReservation) { // unwrap branded types const newReservation = { ...booking, - Start: toIsoStr(booking.Start), - End: toIsoStr(booking.End), + Start: booking.Start ? toDateStr(booking.Start) : null, + End: booking.End ? toDateStr(booking.End) : null, }; - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); + return ky.post("api/reservation", { json: newReservation }).json(); } const RoomSchema = z.object({ @@ -38,6 +36,16 @@ const RoomSchema = z.object({ state: z.number(), }); +function toDateStr(date: Date | string): string { + const d = new Date(date); + + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + const RoomListSchema = RoomSchema.array(); export function useGetRooms() { diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index d3358f7..26c7dc0 100644 --- a/ui/src/utils/toasts.tsx +++ b/ui/src/utils/toasts.tsx @@ -2,6 +2,7 @@ import { SuccessToast } from "../components/SuccessToast"; import { InfoToast } from "../components/InfoToast"; import { ExternalToast, toast } from "sonner"; import { useCallback } from "react"; +import { ErrorToast } from "../components/ErrorToast"; const DEFAULT_TOAST_DURATION_MS = 2_250; @@ -30,3 +31,14 @@ export function useShowInfoToast(message: string) { [message], ); } + +export function useShowErrorToast() { + return useCallback( + (message: string) => + toast.custom( + (t) => , + DEFAULT_TOAST_OPTIONS, + ), + [], + ); +} From ee81201dde3099c0dea88fe229e3a9b547d6f7a5 Mon Sep 17 00:00:00 2001 From: Jack O'Reilly <38988042+samuraijack16@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:16:19 +0100 Subject: [PATCH 2/5] refactor: Update repository interfaces in Guest, Reservation, and Room controllers Co-authored-by: Copilot --- api/Controllers/GuestController.cs | 5 +++-- api/Controllers/ReservationController.cs | 12 ------------ api/Controllers/RoomController.cs | 6 +++--- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index 095d570..56f57ed 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -1,15 +1,16 @@ using Microsoft.AspNetCore.Mvc; using Models; using Repositories; +using Repositories.Interfaces; namespace Controllers { [Tags("Guests"), Route("guest")] public class GuestController : Controller { - private GuestRepository _repo; + private IGuestRepository _repo; - public GuestController(GuestRepository guestRepository) + public GuestController(IGuestRepository guestRepository) { _repo = guestRepository; } diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 8d1a3f4..b13908f 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -1,10 +1,7 @@ -using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; -using Repositories; using Repositories.Interfaces; -using Validators; using Validators.Interfaces; namespace Controllers @@ -17,15 +14,6 @@ public class ReservationController : Controller private readonly IRoomRepository _roomRepo; private readonly IGuestRepository _guestRepo; private readonly IReservationValidator _reservationValidator; - - private static readonly Regex EmailRegex = new Regex( - @"^[^@\s]+@[^@\s]+\.[^@\s]+$", - RegexOptions.Compiled - ); - private static readonly Regex RoomNumberRegex = new Regex( - @"^[0-9](0[1-9]|[1-9][0-9])$", - RegexOptions.Compiled - ); private static readonly string[] error = new[] { "Request payload is missing or invalid." }; public ReservationController( diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 6e97650..3e1a202 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -1,16 +1,16 @@ using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; -using Repositories; +using Repositories.Interfaces; namespace Controllers { [Tags("Rooms"), Route("room")] public class RoomController : Controller { - private RoomRepository _repo { get; set; } + private IRoomRepository _repo { get; set; } - public RoomController(RoomRepository roomRepository) + public RoomController(IRoomRepository roomRepository) { _repo = roomRepository; } From 4f10c3cb391456622836c4a659930b94463a63b4 Mon Sep 17 00:00:00 2001 From: Jack O'Reilly <38988042+samuraijack16@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:23:22 +0100 Subject: [PATCH 3/5] feat: RE-003 Implement staff authentication and staff dashboard page. Added authorization policy on api. Removed IsNotStaff as it can be handled by the authorize attribute. Standardized schema casing. Added unit tests. Co-authored-by: Copilot --- api.tests/Controllers/StaffControllerTests.cs | 81 +++++++++++++++++++ api/Controllers/GuestController.cs | 1 + api/Controllers/RoomController.cs | 1 + api/Controllers/StaffController.cs | 75 ++++++++--------- api/Helpers/GuidTypeHandler.cs | 18 +++++ api/Program.cs | 38 ++++++++- .../Interfaces/IReservationRepository.cs | 1 + api/Repositories/ReservationRepository.cs | 6 ++ ui/src/LandingPage.tsx | 38 +++++---- ui/src/reservations/api.ts | 31 ++++++- ui/src/router.tsx | 6 ++ ui/src/staff/StaffPage.tsx | 34 ++++++++ 12 files changed, 266 insertions(+), 64 deletions(-) create mode 100644 api.tests/Controllers/StaffControllerTests.cs create mode 100644 api/Helpers/GuidTypeHandler.cs create mode 100644 ui/src/staff/StaffPage.tsx diff --git a/api.tests/Controllers/StaffControllerTests.cs b/api.tests/Controllers/StaffControllerTests.cs new file mode 100644 index 0000000..0da34de --- /dev/null +++ b/api.tests/Controllers/StaffControllerTests.cs @@ -0,0 +1,81 @@ +using System.Security.Claims; +using Controllers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Models; +using Moq; +using Repositories.Interfaces; +using Xunit; + +namespace api.tests.Controllers +{ + public class StaffControllerTests + { + private readonly Mock _configMock; + private readonly Mock _repoMock; + private readonly StaffController _controller; + + public StaffControllerTests() + { + _configMock = new Mock(); + _repoMock = new Mock(); + + _controller = new StaffController(_configMock.Object, _repoMock.Object); + + // Mocking HttpContext for Authentication methods + var httpContext = new DefaultHttpContext(); + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + } + + [Fact] + public async Task Login_WithCorrectCode_ReturnsOkAndSignsIn() + { + // Arrange + _configMock.Setup(c => c.GetSection("staffAccessCode").Value).Returns("pass"); + + var authServiceMock = new Mock(); + var serviceProviderMock = new Mock(); + serviceProviderMock + .Setup(s => s.GetService(typeof(IAuthenticationService))) + .Returns(authServiceMock.Object); + _controller.ControllerContext.HttpContext.RequestServices = serviceProviderMock.Object; + + // Act + var result = await _controller.CheckCode("pass"); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task GetStaffReservations_ReturnsUpcomingReservations() + { + // Arrange + var expectedReservations = new List + { + new Reservation + { + Id = Guid.NewGuid(), + GuestEmail = "staff_view@test.com", + RoomNumber = "101", + Start = DateTime.Now.AddDays(1), + End = DateTime.Now.AddDays(2), + }, + }; + _repoMock.Setup(r => r.GetUpcomingReservations()).ReturnsAsync(expectedReservations); + + // Act + var result = await _controller.GetStaffReservations(); + + // Assert + var okResult = Assert.IsType(result); + var returnedReservations = Assert.IsAssignableFrom>( + okResult.Value + ); + Assert.Single(returnedReservations); + Assert.Contains(returnedReservations, r => r.GuestEmail == "staff_view@test.com"); + } + } +} diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index 56f57ed..cb91a9e 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -6,6 +6,7 @@ namespace Controllers { [Tags("Guests"), Route("guest")] + [ApiController] public class GuestController : Controller { private IGuestRepository _repo; diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 3e1a202..19b6417 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -6,6 +6,7 @@ namespace Controllers { [Tags("Rooms"), Route("room")] + [ApiController] public class RoomController : Controller { private IRoomRepository _repo { get; set; } diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..b37fa13 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,69 +1,60 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Repositories.Interfaces; namespace Controllers { - [Route("staff")] + [ApiController, Route("staff")] public class StaffController : Controller { private IConfiguration Config { get; set; } + private readonly IReservationRepository _reservationRepo; - public StaffController(IConfiguration config) + public StaffController(IConfiguration config, IReservationRepository reservationRepo) { Config = config; + _reservationRepo = reservationRepo; } - /// - /// 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) + [HttpGet, Route("login")] + public async Task CheckCode( + [FromHeader(Name = "X-Staff-Code")] string accessCode + ) { - // TODO explore UseAuthentication - request.Cookies.TryGetValue("access", out string? accessValue); + Response.Cookies.Delete("access"); + await HttpContext.SignOutAsync("StaffAuth"); - if (accessValue == null || accessValue == "0") + var configuredSecret = Config.GetValue("staffAccessCode"); + if (string.IsNullOrEmpty(configuredSecret) || configuredSecret != accessCode) { - result = StatusCode(403); - return true; + return Unauthorized("Invalid access code."); } - result = null; - return false; - } + var claims = new List { new Claim(ClaimTypes.Role, "Staff") }; + var identity = new ClaimsIdentity(claims, "StaffAuth"); + var principal = new ClaimsPrincipal(identity); - [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 - } - ); - return NoContent(); + await HttpContext.SignInAsync("StaffAuth", principal); + + return Ok("Authenticated"); } + [Authorize(AuthenticationSchemes = "StaffAuth")] [HttpGet, Route("check")] public IActionResult CheckCookie() { - if (IsNotStaff(Request, out IActionResult? result)) - { - return result!; - } - return Ok("Authorized"); } + + [Authorize(AuthenticationSchemes = "StaffAuth")] + [HttpGet, Route("reservations")] + public async Task GetStaffReservations() + { + // Requirement: Today and future reservations + var reservations = await _reservationRepo.GetUpcomingReservations(); + return Ok(reservations); + } } } diff --git a/api/Helpers/GuidTypeHandler.cs b/api/Helpers/GuidTypeHandler.cs new file mode 100644 index 0000000..777555f --- /dev/null +++ b/api/Helpers/GuidTypeHandler.cs @@ -0,0 +1,18 @@ +using System.Data; +using Dapper; + +namespace Helpers +{ + public class GuidTypeHandler : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, Guid value) + { + parameter.Value = value.ToString(); + } + + public override Guid Parse(object value) + { + return Guid.Parse((string)value); + } + } +} diff --git a/api/Program.cs b/api/Program.cs index 8fed936..b6bd679 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,5 +1,7 @@ using System.Data; +using Dapper; using Db; +using Helpers; using Microsoft.Data.Sqlite; using Repositories; using Repositories.Interfaces; @@ -14,19 +16,47 @@ builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; + SqlMapper.AddTypeHandler(new GuidTypeHandler()); + Services.AddSingleton(_ => new SqliteConnection(connectionString)); Services.AddSingleton(sp => sp.GetRequiredService()); Services.AddScoped(); Services.AddScoped(); Services.AddScoped(); Services.AddSingleton(); - Services.AddMvc(opt => - { - opt.EnableEndpointRouting = false; - }); + Services + .AddMvc(opt => + { + opt.EnableEndpointRouting = false; + }) + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = null; + }); Services.AddCors(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); + + Services + .AddAuthentication("StaffAuth") + .AddCookie( + "StaffAuth", + options => + { + options.Cookie.Name = "StaffAccess"; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Strict; + options.LoginPath = "/staff/login"; + options.Events.OnRedirectToLogin = context => + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }; + } + ); + + Services.AddAuthorization(); } var app = builder.Build(); diff --git a/api/Repositories/Interfaces/IReservationRepository.cs b/api/Repositories/Interfaces/IReservationRepository.cs index 3d3dbc6..46dc075 100644 --- a/api/Repositories/Interfaces/IReservationRepository.cs +++ b/api/Repositories/Interfaces/IReservationRepository.cs @@ -9,5 +9,6 @@ public interface IReservationRepository Task CreateReservation(Reservation newReservation); Task HasConflict(Reservation reservation); Task DeleteReservation(Guid reservationId); + Task> GetUpcomingReservations(); } } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 7027f8b..276f1ba 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -101,6 +101,12 @@ public async Task DeleteReservation(Guid reservationId) return deleted > 0; } + public async Task> GetUpcomingReservations() + { + var sql = "SELECT * FROM Reservations WHERE End >= date('now') ORDER BY Start ASC"; + return await _db.QueryAsync(sql); + } + private class ReservationDb { public string Id { get; set; } diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..5003c16 100644 --- a/ui/src/LandingPage.tsx +++ b/ui/src/LandingPage.tsx @@ -1,12 +1,23 @@ -import { Box, Card, Flex, Heading, Inset } from "@radix-ui/themes"; -import { Link } from "@tanstack/react-router"; - -function handleLogin() { - // TODO have a staff view - alert("Not implemented"); -} +import { Card, Flex, Heading, Inset } from "@radix-ui/themes"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { staffLogin } from "./reservations/api"; export function LandingPage() { + const navigate = useNavigate(); + + async function handleLogin(evt: React.MouseEvent) { + evt.preventDefault(); + const code = prompt("Enter Staff Access Code:"); + if (!code) return; + + try { + await staffLogin(code); + navigate({ to: "/staff" }); + } catch (err) { + alert("Invalid Access Code"); + } + } + return ( @@ -15,10 +26,8 @@ export function LandingPage() { Key on wood board Login @@ -30,10 +39,7 @@ export function LandingPage() { Clean Bed Reserve @@ -41,4 +47,4 @@ export function LandingPage() { ); -} +} \ No newline at end of file diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 6548846..4d18a4c 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -18,7 +18,34 @@ const ReservationSchema = z.object({ End: z.string().nullable(), }); +const StaffReservationSchema = z.object({ + Id: z.string(), + RoomNumber: z.string(), + GuestEmail: z.string().email(), + Start: z.string(), + End: z.string(), + CheckedIn: z.boolean(), + CheckedOut: z.boolean(), +}); + type Reservation = z.infer; +const StaffReservationListSchema = StaffReservationSchema.array(); + +export async function staffLogin(code: string) { + return ky.get("api/staff/login", { + headers: { "X-Staff-Code": code } + }); +} + +export function useGetStaffReservations() { + return useQuery({ + queryKey: ["staff-reservations"], + queryFn: () => + ky.get("api/staff/reservations") + .json() + .then(StaffReservationListSchema.parseAsync), + }); +} export function bookRoom(booking: NewReservation) { // unwrap branded types @@ -32,8 +59,8 @@ export function bookRoom(booking: NewReservation) { } const RoomSchema = z.object({ - number: z.string(), - state: z.number(), + Number: z.string(), + State: z.number(), }); function toDateStr(date: Date | string): string { diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e3020bd..836802c 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 { StaffPage } from "./staff/StaffPage"; const rootRoute = createRootRoute({ component: Layout, @@ -26,6 +27,11 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + createRoute({ + path: "/staff", + getParentRoute: getRootRoute, + component: StaffPage, + }), ]; const routeTree = rootRoute.addChildren(ROUTES); diff --git a/ui/src/staff/StaffPage.tsx b/ui/src/staff/StaffPage.tsx new file mode 100644 index 0000000..c6c0ba9 --- /dev/null +++ b/ui/src/staff/StaffPage.tsx @@ -0,0 +1,34 @@ +import { Box, Card, Flex, Heading, Section, Text } from "@radix-ui/themes"; +import { useGetStaffReservations } from "../reservations/api"; + +export function StaffPage() { + const { data: reservations, isLoading, error } = useGetStaffReservations(); + + if (isLoading) return Loading upcoming reservations...; + if (error) return Access Denied. Please log in again.; + + return ( +
+ Staff Dashboard + Upcoming Reservations + + + {reservations?.map((res) => ( + + + + Room #{res.RoomNumber} + Guest: {res.GuestEmail} + + + + {new Date(res.Start).toLocaleDateString()} to {new Date(res.End).toLocaleDateString()} + + + + + ))} + +
+ ); +} \ No newline at end of file From 715d1f594141d35e1cebc8b1f2832791c3b3c0dc Mon Sep 17 00:00:00 2001 From: Jack O'Reilly <38988042+samuraijack16@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:45:12 +0100 Subject: [PATCH 4/5] feat: RE-003, RE-004, RE-006 Implement check-in functionality with validation and service integration. Added new helper for sqllite boolean conversion Added a service layer for checkin as it touches both reservation and room logic. Keep controller clean. Added logic for already checked in rooms. Updated backend unit tests Co-authored-by: Copilot --- api.tests/Controllers/StaffControllerTests.cs | 38 +++++++- api.tests/Services/CheckInServiceTests.cs | 81 +++++++++++++++++ api/Controllers/StaffController.cs | 26 +++++- api/Db/Setup.cs | 4 +- api/Helpers/SqlLiteBooleanHandler.cs | 20 +++++ api/Program.cs | 3 + .../Interfaces/IReservationRepository.cs | 1 + api/Repositories/ReservationRepository.cs | 38 ++++++++ api/Services/CheckInService.cs | 44 +++++++++ api/Services/ICheckInService.cs | 10 +++ changelog.md | 23 ++++- ui/src/reservations/ReservationPage.tsx | 6 +- ui/src/reservations/api.ts | 4 + ui/src/staff/StaffPage.tsx | 90 ++++++++++++++----- 14 files changed, 359 insertions(+), 29 deletions(-) create mode 100644 api.tests/Services/CheckInServiceTests.cs create mode 100644 api/Helpers/SqlLiteBooleanHandler.cs create mode 100644 api/Services/CheckInService.cs create mode 100644 api/Services/ICheckInService.cs diff --git a/api.tests/Controllers/StaffControllerTests.cs b/api.tests/Controllers/StaffControllerTests.cs index 0da34de..010c357 100644 --- a/api.tests/Controllers/StaffControllerTests.cs +++ b/api.tests/Controllers/StaffControllerTests.cs @@ -7,6 +7,7 @@ using Models; using Moq; using Repositories.Interfaces; +using Services; using Xunit; namespace api.tests.Controllers @@ -15,14 +16,17 @@ public class StaffControllerTests { private readonly Mock _configMock; private readonly Mock _repoMock; + private readonly Mock _checkInServiceMock; private readonly StaffController _controller; public StaffControllerTests() { _configMock = new Mock(); _repoMock = new Mock(); + _checkInServiceMock = new Mock(); - _controller = new StaffController(_configMock.Object, _repoMock.Object); + _controller = new StaffController( + _configMock.Object, _repoMock.Object, checkInService: _checkInServiceMock.Object); // Mocking HttpContext for Authentication methods var httpContext = new DefaultHttpContext(); @@ -77,5 +81,37 @@ public async Task GetStaffReservations_ReturnsUpcomingReservations() Assert.Single(returnedReservations); Assert.Contains(returnedReservations, r => r.GuestEmail == "staff_view@test.com"); } + + [Fact] + public async Task CheckIn_ServiceReturnsSuccess_ReturnsOk() + { + // Arrange + var resId = Guid.NewGuid(); + _checkInServiceMock + .Setup(s => s.ProcessCheckIn(resId, "test@test.com")) + .ReturnsAsync((true, string.Empty)); + + // Act + var result = await _controller.CheckIn(resId, "test@test.com"); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task CheckIn_ServiceReturnsNotFoundError_ReturnsNotFound() + { + // Arrange + var resId = Guid.NewGuid(); + _checkInServiceMock + .Setup(s => s.ProcessCheckIn(resId, "test@test.com")) + .ReturnsAsync((false, "Reservation not found.")); + + // Act + var result = await _controller.CheckIn(resId, "test@test.com"); + + // Assert + Assert.IsType(result); + } } } diff --git a/api.tests/Services/CheckInServiceTests.cs b/api.tests/Services/CheckInServiceTests.cs new file mode 100644 index 0000000..1187c79 --- /dev/null +++ b/api.tests/Services/CheckInServiceTests.cs @@ -0,0 +1,81 @@ +using Models; +using Moq; +using Repositories.Interfaces; +using Services; +using Xunit; + +namespace api.tests.Services +{ + public class CheckInServiceTests + { + private readonly Mock _resRepoMock; + private readonly Mock _roomRepoMock; + private readonly CheckInService _service; + + public CheckInServiceTests() + { + _resRepoMock = new Mock(); + _roomRepoMock = new Mock(); + _service = new CheckInService(_resRepoMock.Object, _roomRepoMock.Object); + } + + [Fact] + public async Task ProcessCheckIn_WrongEmail_ReturnsError() + { + // Arrange + var resId = Guid.NewGuid(); + var reservation = new Reservation + { + GuestEmail = "correct@test.com", + RoomNumber = "101", + }; + _resRepoMock.Setup(r => r.GetReservation(resId)).ReturnsAsync(reservation); + + // Act + var (success, error) = await _service.ProcessCheckIn(resId, "wrong@test.com"); + + // Assert + Assert.False(success); + Assert.Equal("Email confirmation does not match the reservation.", error); // + } + + [Fact] + public async Task ProcessCheckIn_DirtyRoom_ReturnsError() + { + // Arrange + var resId = Guid.NewGuid(); + var reservation = new Reservation { GuestEmail = "test@test.com", RoomNumber = "101" }; + var room = new Room { Number = "101", State = State.Dirty }; + + _resRepoMock.Setup(r => r.GetReservation(resId)).ReturnsAsync(reservation); + _roomRepoMock.Setup(r => r.GetRoom("101")).ReturnsAsync(room); + + // Act + var (success, error) = await _service.ProcessCheckIn(resId, "test@test.com"); + + // Assert + Assert.False(success); + Assert.Equal("Staff cannot check in a guest to a dirty room.", error); // + } + + [Fact] + public async Task ProcessCheckIn_Valid_ExecutesTransaction() + { + // Arrange + var resId = Guid.NewGuid(); + var reservation = new Reservation { GuestEmail = "test@test.com", RoomNumber = "101" }; + var room = new Room { Number = "101", State = State.Ready }; + + _resRepoMock.Setup(r => r.GetReservation(resId)).ReturnsAsync(reservation); + _roomRepoMock.Setup(r => r.GetRoom("101")).ReturnsAsync(room); + _resRepoMock.Setup(r => r.ExecuteCheckInTransaction(resId, "101")).ReturnsAsync(true); + + // Act + var (success, _) = await _service.ProcessCheckIn(resId, "test@test.com"); + + // Assert + Assert.True(success); + _resRepoMock.Verify(r => r.ExecuteCheckInTransaction(resId, "101"), Times.Once); // + } + } +} diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index b37fa13..fc27430 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Repositories.Interfaces; +using Services; namespace Controllers { @@ -11,11 +12,17 @@ public class StaffController : Controller { private IConfiguration Config { get; set; } private readonly IReservationRepository _reservationRepo; + private readonly ICheckInService _checkInService; - public StaffController(IConfiguration config, IReservationRepository reservationRepo) + public StaffController( + IConfiguration config, + IReservationRepository reservationRepo, + ICheckInService checkInService + ) { Config = config; _reservationRepo = reservationRepo; + _checkInService = checkInService; } [HttpGet, Route("login")] @@ -52,9 +59,24 @@ public IActionResult CheckCookie() [HttpGet, Route("reservations")] public async Task GetStaffReservations() { - // Requirement: Today and future reservations var reservations = await _reservationRepo.GetUpcomingReservations(); return Ok(reservations); } + + [Authorize(AuthenticationSchemes = "StaffAuth")] + [HttpPost, Route("checkin/{id}")] + public async Task CheckIn(Guid id, [FromBody] string emailConfirmation) + { + var (success, error) = await _checkInService.ProcessCheckIn(id, emailConfirmation); + + if (success) + return Ok(); + + return error switch + { + "Reservation not found." => NotFound(), + _ => BadRequest(error), + }; + } } } diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..dbfcdd8 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -44,8 +44,8 @@ CREATE TABLE IF NOT EXISTS Reservations ( {nameof(Reservation.RoomNumber)} INT NOT NULL, {nameof(Reservation.Start)} INT NOT NULL, {nameof(Reservation.End)} INT NOT NULL, - {nameof(Reservation.CheckedIn)} INT NOT NULL DEFAULT FALSE, - {nameof(Reservation.CheckedOut)} INT NOT NULL DEFAULT FALSE, + {nameof(Reservation.CheckedIn)} INTEGER NOT NULL DEFAULT FALSE, + {nameof(Reservation.CheckedOut)} INTEGER NOT NULL DEFAULT FALSE, FOREIGN KEY ({nameof(Reservation.GuestEmail)}) REFERENCES Guests ({nameof(Guest.Email)}), FOREIGN KEY ({nameof(Reservation.RoomNumber)}) diff --git a/api/Helpers/SqlLiteBooleanHandler.cs b/api/Helpers/SqlLiteBooleanHandler.cs new file mode 100644 index 0000000..38e687a --- /dev/null +++ b/api/Helpers/SqlLiteBooleanHandler.cs @@ -0,0 +1,20 @@ +using System.Data; +using Dapper; + +namespace Helpers +{ + public class SqliteBooleanHandler : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, bool value) + { + parameter.Value = value ? 1 : 0; + } + + public override bool Parse(object value) + { + if (value == null || value is DBNull) + return false; + return Convert.ToInt32(value) == 1; + } + } +} diff --git a/api/Program.cs b/api/Program.cs index b6bd679..ea3f0ae 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Data.Sqlite; using Repositories; using Repositories.Interfaces; +using Services; using Validators; using Validators.Interfaces; @@ -17,12 +18,14 @@ ?? "Data Source=reservations.db;Cache=Shared"; SqlMapper.AddTypeHandler(new GuidTypeHandler()); + SqlMapper.AddTypeHandler(new SqliteBooleanHandler()); Services.AddSingleton(_ => new SqliteConnection(connectionString)); Services.AddSingleton(sp => sp.GetRequiredService()); Services.AddScoped(); Services.AddScoped(); Services.AddScoped(); + builder.Services.AddScoped(); Services.AddSingleton(); Services .AddMvc(opt => diff --git a/api/Repositories/Interfaces/IReservationRepository.cs b/api/Repositories/Interfaces/IReservationRepository.cs index 46dc075..81e5bd2 100644 --- a/api/Repositories/Interfaces/IReservationRepository.cs +++ b/api/Repositories/Interfaces/IReservationRepository.cs @@ -10,5 +10,6 @@ public interface IReservationRepository Task HasConflict(Reservation reservation); Task DeleteReservation(Guid reservationId); Task> GetUpcomingReservations(); + Task ExecuteCheckInTransaction(Guid reservationId, string roomNumber); } } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 276f1ba..4ec23c2 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -107,6 +107,44 @@ public async Task> GetUpcomingReservations() return await _db.QueryAsync(sql); } + public async Task ExecuteCheckInTransaction(Guid reservationId, string roomNumber) + { + var connection = _db; + if (connection.State != ConnectionState.Open) + connection.Open(); + + using var transaction = connection.BeginTransaction(); + try + { + var idStr = reservationId.ToString(); + + var checkInSql = "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @id"; + var affectedRows = await connection.ExecuteAsync( + checkInSql, + new { id = idStr }, + transaction + ); + + if (affectedRows == 0) + { + transaction.Rollback(); + return false; + } + + var roomSql = "UPDATE Rooms SET State = 2 WHERE Number = @roomNumber"; + await connection.ExecuteAsync(roomSql, new { roomNumber }, transaction); + + transaction.Commit(); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Database Error: {ex.Message}"); + transaction.Rollback(); + return false; + } + } + private class ReservationDb { public string Id { get; set; } diff --git a/api/Services/CheckInService.cs b/api/Services/CheckInService.cs new file mode 100644 index 0000000..5524044 --- /dev/null +++ b/api/Services/CheckInService.cs @@ -0,0 +1,44 @@ +using Models; +using Repositories.Interfaces; +using Validators.Interfaces; + +namespace Services +{ + public class CheckInService : ICheckInService + { + private readonly IReservationRepository _reservationRepo; + private readonly IRoomRepository _roomRepo; + + public CheckInService(IReservationRepository reservationRepo, IRoomRepository roomRepo) + { + _reservationRepo = reservationRepo; + _roomRepo = roomRepo; + } + + public async Task<(bool Success, string Error)> ProcessCheckIn( + Guid reservationId, + string emailConfirmation + ) + { + var reservation = await _reservationRepo.GetReservation(reservationId); + if (reservation == null) + return (false, "Reservation not found."); + + if (reservation.GuestEmail != emailConfirmation) + return (false, "Email confirmation does not match the reservation."); + + var room = await _roomRepo.GetRoom(reservation.RoomNumber); + if (room?.State == State.Dirty) + return (false, "Staff cannot check in a guest to a dirty room."); + + var success = await _reservationRepo.ExecuteCheckInTransaction( + reservationId, + reservation.RoomNumber + ); + + return success + ? (true, string.Empty) + : (false, "An error occurred during database update."); + } + } +} diff --git a/api/Services/ICheckInService.cs b/api/Services/ICheckInService.cs new file mode 100644 index 0000000..f735d39 --- /dev/null +++ b/api/Services/ICheckInService.cs @@ -0,0 +1,10 @@ +namespace Services +{ + public interface ICheckInService + { + Task<(bool Success, string Error)> ProcessCheckIn( + Guid reservationId, + string emailConfirmation + ); + } +} diff --git a/changelog.md b/changelog.md index 1ec677f..eb37d46 100644 --- a/changelog.md +++ b/changelog.md @@ -2,7 +2,28 @@ All notable changes to this project will be documented in this file. -## [Unreleased] +## [2026-04-25] + +### Added +- **Staff Authentication System:** Implemented a secure authentication flow using ASP.NET Core Identity/Cookie middleware, replacing basic `HttpOnly` flags with signed, encrypted authentication tickets. +- **Staff Dashboard (RE-003):** Created a protected frontend view that displays upcoming reservations, including guest email visibility as required for staff operations. +- **One-Click Check-In (RE-004):** Added a "Check In" feature on the staff dashboard that triggers an atomic database transaction to update guest status and room state. +- **Room Status Management (RE-006):** Implemented logic to automatically mark a room as "Dirty" upon guest check-in. +- **Email Confirmation Workflow:** Added a mandatory email verification prompt during the check-in process to ensure data accuracy. +- **Check-In Service Layer:** Introduced `CheckInService` to handle domain logic, decoupling business rules from the `StaffController` for better maintainability. + +### Changed +- **Frontend Routing:** Integrated `useNavigate` within the `LandingPage` to provide a seamless transition between the guest landing area and the staff dashboard. +- **Error Feedback:** Enhanced error handling to display specific server-side validation messages (e.g., "Room is dirty") via existing toast components. +- **Test Coverage:** Added unit tests for the `StaffController` and `CheckInService` using Moq to verify authentication logic and transaction integrity. + +### Fixed +- **Dapper Type Mapping:** Resolved a `System.InvalidCastException` by implementing custom `TypeHandler`s for `Guid` and `Boolean` types to bridge the gap between SQLite storage formats and C# models. +- **Security Vulnerabilities:** Patched a "TODO" regarding weak authentication by enforcing the `[Authorize]` attribute on staff endpoints and ensuring proper middleware ordering in `Program.cs`. +- **Casing Mismatches:** Aligned Zod frontend schemas with backend JSON serialization to correctly parse camelCase properties returned by the API. +- **Connection Lifecycle:** Fixed a bug where the singleton database connection was being prematurely disposed of during transactions, causing subsequent query failures. + +## [Previous] ### Added - **Guest Booking Validation (RE-001):** diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 1a9c01c..e5a10f5 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -66,10 +66,10 @@ async function onSubmit(booking: NewReservation) { {isLoading && } {rooms?.map((room) => ( ))} diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 4d18a4c..3773b3b 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -58,6 +58,10 @@ export function bookRoom(booking: NewReservation) { return ky.post("api/reservation", { json: newReservation }).json(); } +export async function checkInGuest(id: string, email: string) { + return ky.post(`api/staff/checkin/${id}`, { json: email }).json(); +} + const RoomSchema = z.object({ Number: z.string(), State: z.number(), diff --git a/ui/src/staff/StaffPage.tsx b/ui/src/staff/StaffPage.tsx index c6c0ba9..3da6dc1 100644 --- a/ui/src/staff/StaffPage.tsx +++ b/ui/src/staff/StaffPage.tsx @@ -1,33 +1,83 @@ -import { Box, Card, Flex, Heading, Section, Text } from "@radix-ui/themes"; -import { useGetStaffReservations } from "../reservations/api"; +import { Box, Card, Flex, Heading, Section, Text, Button, Badge } from "@radix-ui/themes"; +import { useState } from "react"; +import { useGetStaffReservations, checkInGuest } from "../reservations/api"; +import { useShowSuccessToast, useShowErrorToast } from "../utils/toasts"; export function StaffPage() { - const { data: reservations, isLoading, error } = useGetStaffReservations(); + const [filterToday, setFilterToday] = useState(false); + const { data: reservations, isLoading, refetch } = useGetStaffReservations(); + + const showSuccess = useShowSuccessToast("Guest checked in!"); + const showError = useShowErrorToast(); - if (isLoading) return Loading upcoming reservations...; - if (error) return Access Denied. Please log in again.; + // Get current date in YYYY-MM-DD format for filtering + const todayIso = new Date().toISOString().split('T')[0]; + + const filteredReservations = filterToday + ? reservations?.filter(res => res.Start.startsWith(todayIso)) + : reservations; + + async function handleCheckIn(id: string, correctEmail: string) { + const confirmation = prompt(`Please enter the guest email (${correctEmail}) to confirm check-in:`); + + if (!confirmation) return; + + try { + await checkInGuest(id, confirmation); + showSuccess(); + refetch(); + } catch (err: any) { + const msg = await err.response?.text() || "Check-in failed."; + showError(msg); + } + } + + if (isLoading) return
Loading...
; return (
- Staff Dashboard - Upcoming Reservations - + + Staff Dashboard + + + - {reservations?.map((res) => ( - + {filteredReservations?.map((res) => { + const isToday = res.Start.startsWith(todayIso); + + return ( + - - Room #{res.RoomNumber} + + Room #{res.RoomNumber} Guest: {res.GuestEmail} - - - - {new Date(res.Start).toLocaleDateString()} to {new Date(res.End).toLocaleDateString()} - - + + {new Date(res.Start).toLocaleDateString()} - {new Date(res.End).toLocaleDateString()} + + + + + {res.CheckedIn ? ( + + Checked In + + ) : ( + isToday && ( + + ) + )} + - - ))} + + ); + })}
); From b5cbcfc2ffbc5b76f398442e405da4dff2299231 Mon Sep 17 00:00:00 2001 From: Jack O'Reilly <38988042+samuraijack16@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:55:28 +0100 Subject: [PATCH 5/5] feat: remove extra logging --- api.tests/Repositories/ReservationRepositoryTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/api.tests/Repositories/ReservationRepositoryTests.cs b/api.tests/Repositories/ReservationRepositoryTests.cs index b118a78..a5b322d 100644 --- a/api.tests/Repositories/ReservationRepositoryTests.cs +++ b/api.tests/Repositories/ReservationRepositoryTests.cs @@ -18,7 +18,6 @@ public ReservationRepositoryTests() _db.Open(); // Initialize the schema (Tables: Guests, Rooms, Reservations) - // You can use your existing Setup.cs logic or a simplified script InitializeSchema(); _repo = new ReservationRepository(_db);