From 175b9a893289c461116cf511c76334f438786c37 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Sun, 26 Apr 2026 16:55:56 +0200 Subject: [PATCH 01/36] R001 Add guest CRUD, booking validation, and tests - Implemented create, update, and delete endpoints for guests with validation and error handling. - Added booking validation logic and exception classes. - Updated reservation creation to validate input and check guest/room existence. - Modified database schema to include guest surname. - Enhanced Room model with room number validation. - Updated repositories for new guest and reservation logic. - Switched frontend booking to real API call. - Added comprehensive unit tests for booking validation. - Bumped package version and improved .gitignore. --- .gitignore | 12 ++- api.Tests/BookingValidatorTests.cs | 120 ++++++++++++++++++++++ api.Tests/api.Tests.csproj | 20 ++++ api/Controllers/GuestController.cs | 57 ++++++++++ api/Controllers/ReservationController.cs | 33 +++++- api/Db/Setup.cs | 18 +++- api/Models/Errors/ConflictException.cs | 8 ++ api/Models/Errors/InvalidBooking.cs | 8 ++ api/Models/Room.cs | 11 ++ api/Repositories/GuestRepository.cs | 20 +++- api/Repositories/ReservationRepository.cs | 12 ++- api/Validators/BookingValidator.cs | 42 ++++++++ ui/package-lock.json | 4 +- ui/src/reservations/api.ts | 3 +- 14 files changed, 351 insertions(+), 17 deletions(-) create mode 100644 api.Tests/BookingValidatorTests.cs create mode 100644 api.Tests/api.Tests.csproj create mode 100644 api/Models/Errors/ConflictException.cs create mode 100644 api/Models/Errors/InvalidBooking.cs create mode 100644 api/Validators/BookingValidator.cs diff --git a/.gitignore b/.gitignore index 496ee2c..70b4e93 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,11 @@ -.DS_Store \ No newline at end of file +.DS_Store + +# Copilot plan snapshots +.copilot/ +.vs/CopilotSnapshots + +# .NET build outputs +*.dll +bin/ +obj/ +/.vs diff --git a/api.Tests/BookingValidatorTests.cs b/api.Tests/BookingValidatorTests.cs new file mode 100644 index 0000000..aabb034 --- /dev/null +++ b/api.Tests/BookingValidatorTests.cs @@ -0,0 +1,120 @@ +using Models; +using Models.Errors; +using NUnit.Framework; +using Validators; + +namespace api.Tests +{ + [TestFixture] + public class BookingValidatorTests + { + // ── helpers ───────────────────────────────────────────────────────────── + + private static Reservation ValidBooking( + string roomNumber = "101", + string email = "guest@example.com", + DateTime? start = null, + DateTime? end = null + ) + { + var s = start ?? DateTime.Today; + var e = end ?? DateTime.Today.AddDays(3); + return new Reservation + { + Id = Guid.NewGuid(), + RoomNumber = roomNumber, + GuestEmail = email, + Start = s, + End = e, + }; + } + + // ── valid room numbers ─────────────────────────────────────────────────── + + [TestCase("101")] + [TestCase("102")] + [TestCase("103")] + [TestCase("104")] + [TestCase("105")] + [TestCase("201")] + [TestCase("202")] + [TestCase("203")] + [TestCase("901")] + [TestCase("910")] + public void Validate_ValidRoomNumber_DoesNotThrow(string roomNumber) + { + var booking = ValidBooking(roomNumber: roomNumber); + Assert.DoesNotThrow(() => BookingValidator.Validate(booking)); + } + + // ── invalid room numbers ───────────────────────────────────────────────── + + [TestCase("000", TestName = "RoomNumber_000_AllZeros")] + [TestCase("100", TestName = "RoomNumber_100_DoorIsZeroZero")] + [TestCase("200", TestName = "RoomNumber_200_DoorIsZeroZero")] + [TestCase("900", TestName = "RoomNumber_900_DoorIsZeroZero")] + [TestCase("0", TestName = "RoomNumber_SingleDigit")] + [TestCase("1", TestName = "RoomNumber_SingleDigit_1")] + [TestCase("2020",TestName = "RoomNumber_FourDigits")] + [TestCase("-101",TestName = "RoomNumber_Negative")] + [TestCase("abc", TestName = "RoomNumber_NonNumeric")] + [TestCase("", TestName = "RoomNumber_Empty")] + public void Validate_InvalidRoomNumber_ThrowsInvalidBooking(string roomNumber) + { + var booking = ValidBooking(roomNumber: roomNumber); + Assert.Throws(() => BookingValidator.Validate(booking)); + } + + // ── email validation ───────────────────────────────────────────────────── + + [TestCase("guest@example.com")] + [TestCase("a@b.io")] + [TestCase("first.last+tag@sub.domain.org")] + public void Validate_ValidEmail_DoesNotThrow(string email) + { + var booking = ValidBooking(email: email); + Assert.DoesNotThrow(() => BookingValidator.Validate(booking)); + } + + [TestCase("notanemail", TestName = "Email_NoAtSign")] + [TestCase("missing@domain", TestName = "Email_NoDotInDomain")] + [TestCase("@nodomain.com", TestName = "Email_EmptyLocalPart")] + [TestCase("", TestName = "Email_Empty")] + [TestCase("spaces @a.com", TestName = "Email_SpaceInLocal")] + public void Validate_InvalidEmail_ThrowsInvalidBooking(string email) + { + var booking = ValidBooking(email: email); + Assert.Throws(() => BookingValidator.Validate(booking)); + } + + // ── date range validation ──────────────────────────────────────────────── + + [TestCase(1, TestName = "Duration_1Day_IsValid")] + [TestCase(15, TestName = "Duration_15Days_IsValid")] + [TestCase(30, TestName = "Duration_30Days_IsValid")] + public void Validate_ValidDuration_DoesNotThrow(int days) + { + var start = DateTime.Today; + var booking = ValidBooking(start: start, end: start.AddDays(days)); + Assert.DoesNotThrow(() => BookingValidator.Validate(booking)); + } + + [TestCase(0, TestName = "Duration_SameDay_StartEqualsEnd")] + [TestCase(-1, TestName = "Duration_EndBeforeStart")] + public void Validate_StartNotBeforeEnd_ThrowsInvalidBooking(int daysOffset) + { + var start = DateTime.Today; + var booking = ValidBooking(start: start, end: start.AddDays(daysOffset)); + Assert.Throws(() => BookingValidator.Validate(booking)); + } + + [TestCase(31, TestName = "Duration_31Days_ExceedsMaximum")] + [TestCase(60, TestName = "Duration_60Days_ExceedsMaximum")] + public void Validate_DurationExceedsMaximum_ThrowsInvalidBooking(int days) + { + var start = DateTime.Today; + var booking = ValidBooking(start: start, end: start.AddDays(days)); + Assert.Throws(() => BookingValidator.Validate(booking)); + } + } +} diff --git a/api.Tests/api.Tests.csproj b/api.Tests/api.Tests.csproj new file mode 100644 index 0000000..be1195f --- /dev/null +++ b/api.Tests/api.Tests.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index 095d570..b0c9e59 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Models; +using Models.Errors; using Repositories; namespace Controllers @@ -21,5 +22,61 @@ public async Task> GetGuests() return Json(guests); } + + [HttpDelete, Route("{email}")] + public async Task DeleteGuest(string email) + { + var deleted = await _repo.DeleteGuestByEmail(email); + + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpPost, Produces("application/json"), Route("")] + public async Task> CreateGuest([FromBody] Guest? newGuest) + { + if (newGuest is null) + { + return BadRequest("Invalid guest payload."); + } + + if (!System.Net.Mail.MailAddress.TryCreate(newGuest.Email, out _)) + { + return BadRequest("Invalid email address."); + } + + try + { + var created = await _repo.CreateGuest(newGuest); + return Created($"/guest/{created.Email}", created); + } + catch (ConflictException) + { + return Conflict($"Guest {newGuest.Email} already exists."); + } + } + + [HttpPut, Produces("application/json"), Route("{email}")] + public async Task> UpdateGuest(string email, [FromBody] Guest? updatedGuest) + { + if (updatedGuest is null) + { + return BadRequest("Invalid guest payload."); + } + + try + { + var updated = await _repo.UpdateGuest(email, updatedGuest); + return Json(updated); + } + catch (NotFoundException) + { + return NotFound(); + } + } } } diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..f8afb46 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -2,6 +2,7 @@ using Models; using Models.Errors; using Repositories; +using Validators; namespace Controllers { @@ -9,10 +10,14 @@ namespace Controllers public class ReservationController : Controller { private ReservationRepository _repo { get; set; } + private RoomRepository _roomRepo { get; set; } + private GuestRepository _guestRepo { get; set; } - public ReservationController(ReservationRepository reservationRepository) + public ReservationController(ReservationRepository reservationRepository, RoomRepository roomRepository, GuestRepository guestRepository) { _repo = reservationRepository; + _roomRepo = roomRepository; + _guestRepo = guestRepository; } [HttpGet, Produces("application/json"), Route("")] @@ -44,9 +49,14 @@ public async Task> GetRoom(Guid reservationId) /// [HttpPost, Produces("application/json"), Route("")] public async Task> BookReservation( - [FromBody] Reservation newBooking + [FromBody] Reservation? newBooking ) { + if (newBooking is null) + { + return BadRequest("Invalid reservation payload."); + } + // Provide a real ID if one is not provided if (newBooking.Id == Guid.Empty) { @@ -55,8 +65,24 @@ [FromBody] Reservation newBooking try { + BookingValidator.Validate(newBooking); + + // Verify the guest exists + await _guestRepo.GetGuestByEmail(newBooking.GuestEmail); + + // Verify the room exists + await _roomRepo.GetRoom(newBooking.RoomNumber); + var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); + return Created($"/reservation/{createdReservation.Id}", createdReservation); + } + catch (InvalidBooking ex) + { + return BadRequest(ex.Message); + } + catch (NotFoundException ex) + { + return BadRequest(ex.Message); } catch (Exception ex) { @@ -67,6 +93,7 @@ [FromBody] Reservation newBooking } } + [HttpDelete, Produces("application/json"), Route("{reservationId}")] public async Task DeleteReservation(Guid reservationId) { diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..153b4f2 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -7,7 +7,7 @@ namespace Db public static class Setup { /// - /// Ensures the DB is available and the requried tables are made + /// Ensures the DB is available and the required tables are made /// public static async void EnsureDb(IServiceScope scope) { @@ -22,14 +22,26 @@ await db.ExecuteAsync( $@" CREATE TABLE IF NOT EXISTS Guests ( {nameof(Guest.Email)} TEXT PRIMARY KEY NOT NULL, - {nameof(Guest.Name)} TEXT NOT NULL + {nameof(Guest.Name)} TEXT NOT NULL, + {nameof(Guest.Surname)} TEXT ); " ); + try + { + await db.ExecuteAsync( + $"ALTER TABLE Guests ADD COLUMN {nameof(Guest.Surname)} TEXT;" + ); + } + catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.Message.Contains("duplicate column")) + { + // column already exists, nothing to do + } + await db.ExecuteAsync( $@" - CREATE TABLE IF NOT Exists Rooms ( + CREATE TABLE IF NOT EXISTS Rooms ( {nameof(Room.Number)} INT PRIMARY KEY NOT NULL, {nameof(Room.State)} INT NOT NULL ); diff --git a/api/Models/Errors/ConflictException.cs b/api/Models/Errors/ConflictException.cs new file mode 100644 index 0000000..bd42a19 --- /dev/null +++ b/api/Models/Errors/ConflictException.cs @@ -0,0 +1,8 @@ +namespace Models.Errors +{ + public class ConflictException : Exception + { + public ConflictException(string message) + : base(message) { } + } +} diff --git a/api/Models/Errors/InvalidBooking.cs b/api/Models/Errors/InvalidBooking.cs new file mode 100644 index 0000000..cc388e5 --- /dev/null +++ b/api/Models/Errors/InvalidBooking.cs @@ -0,0 +1,8 @@ +namespace Models.Errors +{ + public class InvalidBooking : Exception + { + public InvalidBooking(string reason) + : base(reason) { } + } +} diff --git a/api/Models/Room.cs b/api/Models/Room.cs index cbd6536..65c2e36 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Models.Errors; namespace Models @@ -29,6 +30,16 @@ public static string FormatRoomNumber(int number) return number.ToString().PadLeft(3, '0'); } + /// + /// Validates that a room number string matches the "###" format: + /// - Exactly three digits + /// - Door portion (last two digits) cannot be "00" + /// + public static bool IsValidRoomNumber(string roomNumber) + { + return Regex.IsMatch(roomNumber, @"^\d{3}$") && !roomNumber.EndsWith("00"); + } + public static int ConvertRoomNumberToInt(string roomNumber) { var success = int.TryParse(roomNumber, out int roomNumberInt); diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 54182bc..eb7b460 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -41,14 +41,28 @@ public async Task GetGuestByEmail(string guestEmail) return guest; } - public Task CreateGuest(Guest newGuest) + public async Task CreateGuest(Guest newGuest) { - return _db.QuerySingleAsync( - "INSERT INTO Guests(Email, Name) Values(@Email, @Name) RETURNING *", + var existing = await GetGuestByEmail(newGuest.Email); + + return await _db.QuerySingleAsync( + "INSERT INTO Guests(Email, Name, Surname) Values(@Email, @Name, @Surname) RETURNING *", newGuest ); } + public async Task UpdateGuest(string email, Guest updatedGuest) + { + var existing = await GetGuestByEmail(email); + + var updated = await _db.QuerySingleAsync( + "UPDATE Guests SET Name = @Name, Surname = @Surname WHERE Email = @Email RETURNING *", + new { existing.Email, updatedGuest.Name, updatedGuest.Surname } + ); + + return updated; + } + public async Task DeleteGuestByEmail(string guestEmail) { var count = await _db.ExecuteAsync( diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..377d2ae 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -49,10 +49,16 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } + var db = new ReservationDb(newReservation); + + var created = await _db.QuerySingleAsync( + @"INSERT INTO Reservations (Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) + VALUES (@Id, @GuestEmail, @RoomNumber, @Start, @End, @CheckedIn, @CheckedOut) + RETURNING *;", + db ); + + return created.ToDomain(); } public async Task DeleteReservation(Guid reservationId) diff --git a/api/Validators/BookingValidator.cs b/api/Validators/BookingValidator.cs new file mode 100644 index 0000000..59262be --- /dev/null +++ b/api/Validators/BookingValidator.cs @@ -0,0 +1,42 @@ +using System.Text.RegularExpressions; +using Models; +using Models.Errors; + +namespace Validators +{ + public static class BookingValidator + { + public static void Validate(Reservation booking) + { + if (!Room.IsValidRoomNumber(booking.RoomNumber)) + { + throw new InvalidBooking($"'{booking.RoomNumber}' is not a valid room number."); + } + + if (!Regex.IsMatch(booking.GuestEmail, @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) + { + throw new InvalidBooking("Email must include a domain."); + } + + var start = booking.Start.Date; + var end = booking.End.Date; + + if (start >= end) + { + throw new InvalidBooking("Start date must be before end date."); + } + + var duration = (end - start).Days; + + if (duration < 1) + { + throw new InvalidBooking("Minimum booking duration is 1 day."); + } + + if (duration > 30) + { + throw new InvalidBooking("Maximum booking duration is 30 days."); + } + } + } +} 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/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..f67f17a 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -29,8 +29,7 @@ export function bookRoom(booking: NewReservation) { End: toIsoStr(booking.End), }; - // 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({ From 4cb938141fb21cc0a0c25aa599d7b282cc4aa432 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Sun, 26 Apr 2026 17:02:38 +0200 Subject: [PATCH 02/36] R002 Add reservation conflict detection and handling Implement conflict detection for overlapping room reservations. Return HTTP 409 Conflict when a reservation overlaps. Add `ReservationConflictValidator` for validation logic and refactor `ReservationDb` for clarity. --- api/Controllers/ReservationController.cs | 4 + api/Repositories/ReservationRepository.cs | 94 ++++++++++++------- .../ReservationConflictValidator.cs | 16 ++++ 3 files changed, 78 insertions(+), 36 deletions(-) create mode 100644 api/Validators/ReservationConflictValidator.cs diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f8afb46..b690783 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -76,6 +76,10 @@ [FromBody] Reservation? newBooking var createdReservation = await _repo.CreateReservation(newBooking); return Created($"/reservation/{createdReservation.Id}", createdReservation); } + catch (ConflictException ex) + { + return Conflict(ex.Message); + } catch (InvalidBooking ex) { return BadRequest(ex.Message); diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 377d2ae..eeb6f16 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -2,6 +2,7 @@ using Dapper; using Models; using Models.Errors; +using Validators; namespace Repositories { @@ -49,6 +50,8 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { + await CheckForConflict(newReservation); + var db = new ReservationDb(newReservation); var created = await _db.QuerySingleAsync( @@ -61,6 +64,25 @@ public async Task CreateReservation(Reservation newReservation) return created.ToDomain(); } + private async Task CheckForConflict(Reservation newReservation) + { + var conflict = await _db.QueryFirstOrDefaultAsync( + @"SELECT * FROM Reservations + WHERE RoomNumber = @RoomNumber + AND Start < @End + AND End > @Start + LIMIT 1;", + new + { + RoomNumber = Room.ConvertRoomNumberToInt(newReservation.RoomNumber), + newReservation.Start, + newReservation.End + } + ); + + ReservationConflictValidator.ValidateNoConflict(newReservation, conflict != null); + } + public async Task DeleteReservation(Guid reservationId) { var deleted = await _db.ExecuteAsync( @@ -72,48 +94,48 @@ public async Task DeleteReservation(Guid reservationId) } private class ReservationDb - { - public string Id { get; set; } - public int RoomNumber { get; set; } + { + public string Id { get; set; } + public int RoomNumber { get; set; } - public string GuestEmail { 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 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() + { + 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; - } + 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; + } - public Reservation ToDomain() + public Reservation ToDomain() + { + return new Reservation { - return new Reservation - { - Id = Guid.Parse(Id), - RoomNumber = Room.FormatRoomNumber(RoomNumber), - GuestEmail = GuestEmail, - Start = Start, - End = End, - CheckedIn = CheckedIn, - CheckedOut = CheckedOut - }; - } + Id = Guid.Parse(Id), + RoomNumber = Room.FormatRoomNumber(RoomNumber), + GuestEmail = GuestEmail, + Start = Start, + End = End, + CheckedIn = CheckedIn, + CheckedOut = CheckedOut + }; } } } +} diff --git a/api/Validators/ReservationConflictValidator.cs b/api/Validators/ReservationConflictValidator.cs new file mode 100644 index 0000000..4004f50 --- /dev/null +++ b/api/Validators/ReservationConflictValidator.cs @@ -0,0 +1,16 @@ +using Models; +using Models.Errors; + +namespace Validators +{ + public static class ReservationConflictValidator + { + public static void ValidateNoConflict(Reservation newReservation, bool hasConflict) + { + if (hasConflict) + { + throw new ConflictException($"Room {newReservation.RoomNumber} is already booked between {newReservation.Start:d} and {newReservation.End:d}."); + } + } + } +} From 423fc8a2b4cd740a5a3f6508ff91520fb2b6b967 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Sun, 26 Apr 2026 17:15:20 +0200 Subject: [PATCH 03/36] R002 Add CreateReservationTests with in-memory SQLite Introduce CreateReservationTests to verify ReservationRepository's CreateReservation logic, including conflict detection and edge cases. Add Microsoft.Data.Sqlite package for in-memory database testing. --- api.Tests/CreateReservationTests.cs | 121 ++++++++++++++++++++++++++++ api.Tests/api.Tests.csproj | 1 + 2 files changed, 122 insertions(+) create mode 100644 api.Tests/CreateReservationTests.cs diff --git a/api.Tests/CreateReservationTests.cs b/api.Tests/CreateReservationTests.cs new file mode 100644 index 0000000..0b76ae7 --- /dev/null +++ b/api.Tests/CreateReservationTests.cs @@ -0,0 +1,121 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Models; +using Models.Errors; +using NUnit.Framework; +using Repositories; + +namespace api.Tests +{ + [TestFixture] + public class CreateReservationTests + { + private SqliteConnection _db = null!; + private ReservationRepository _repo = null!; + + [SetUp] + public async Task SetUp() + { + _db = new SqliteConnection("Data Source=:memory:"); + await _db.OpenAsync(); + + await _db.ExecuteAsync(@" + CREATE TABLE Guests ( + Email TEXT PRIMARY KEY NOT NULL, + Name TEXT NOT NULL, + Surname TEXT + ); + CREATE TABLE Rooms ( + Number INT PRIMARY KEY NOT NULL, + State INT NOT NULL + ); + CREATE TABLE Reservations ( + Id TEXT PRIMARY KEY NOT NULL, + GuestEmail TEXT NOT NULL, + RoomNumber INT NOT NULL, + Start TEXT NOT NULL, + End TEXT NOT NULL, + CheckedIn INT NOT NULL DEFAULT 0, + CheckedOut INT NOT NULL DEFAULT 0 + ); + INSERT INTO Guests (Email, Name) VALUES ('guest@example.com', 'Test Guest'); + INSERT INTO Rooms (Number, State) VALUES (101, 0); + "); + + _repo = new ReservationRepository(_db); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + private static Reservation Make(string room, string start, string end) => new() + { + Id = Guid.NewGuid(), + RoomNumber = room, + GuestEmail = "guest@example.com", + Start = DateTime.Parse(start), + End = DateTime.Parse(end), + }; + + // -- successful creation --------------------------------------------- + + [TestCase("101", "2025-06-01", "2025-06-05", TestName = "Create_ValidReservation_ReturnsCreated")] + [TestCase("101", "2025-07-01", "2025-07-10", TestName = "Create_DifferentDates_ReturnsCreated")] + public async Task CreateReservation_NoConflict_ReturnsReservation(string room, string start, string end) + { + var reservation = Make(room, start, end); + var result = await _repo.CreateReservation(reservation); + + Assert.That(result.Id, Is.EqualTo(reservation.Id)); + Assert.That(result.RoomNumber, Is.EqualTo(room)); + Assert.That(result.Start.Date, Is.EqualTo(DateTime.Parse(start).Date)); + Assert.That(result.End.Date, Is.EqualTo(DateTime.Parse(end).Date)); + } + + // -- conflict detection ---------------------------------------------- + + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-05", "2025-06-15", TestName = "Conflict_PartialOverlapAfter_ThrowsConflict")] + [TestCase("101", "2025-06-05", "2025-06-15", "2025-06-01", "2025-06-10", TestName = "Conflict_PartialOverlapBefore_ThrowsConflict")] + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-01", "2025-06-10", TestName = "Conflict_ExactMatch_ThrowsConflict")] + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-03", "2025-06-07", TestName = "Conflict_NewInsideExisting_ThrowsConflict")] + [TestCase("101", "2025-06-03", "2025-06-07", "2025-06-01", "2025-06-10", TestName = "Conflict_ExistingInsideNew_ThrowsConflict")] + public async Task CreateReservation_ConflictingReservation_ThrowsConflictException( + string room, string existStart, string existEnd, + string newStart, string newEnd) + { + await _repo.CreateReservation(Make(room, existStart, existEnd)); + + var conflicting = Make(room, newStart, newEnd); + Assert.ThrowsAsync(() => _repo.CreateReservation(conflicting)); + } + + // -- no conflict different room -------------------------------------- + + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-01", "2025-06-10", TestName = "NoConflict_DifferentRoom_BothCreated")] + public async Task CreateReservation_DifferentRoom_DoesNotThrow( + string room1, string existStart, string existEnd, + string newStart, string newEnd) + { + // Insert a second room so the FK constraint is satisfied + await _db.ExecuteAsync("INSERT INTO Rooms (Number, State) VALUES (202, 0);"); + + await _repo.CreateReservation(Make(room1, existStart, existEnd)); + + var other = Make("202", newStart, newEnd); + Assert.DoesNotThrowAsync(() => _repo.CreateReservation(other)); + } + + // -- adjacent (touching) boundaries --------------------------------- + + [TestCase("101", "2025-06-01", "2025-06-05", "2025-06-05", "2025-06-10", TestName = "NoConflict_AdjacentCheckoutCheckin_DoesNotThrow")] + public async Task CreateReservation_AdjacentReservations_DoesNotThrow( + string room, string existStart, string existEnd, + string newStart, string newEnd) + { + await _repo.CreateReservation(Make(room, existStart, existEnd)); + + var adjacent = Make(room, newStart, newEnd); + Assert.DoesNotThrowAsync(() => _repo.CreateReservation(adjacent)); + } + } +} diff --git a/api.Tests/api.Tests.csproj b/api.Tests/api.Tests.csproj index be1195f..a85cba9 100644 --- a/api.Tests/api.Tests.csproj +++ b/api.Tests/api.Tests.csproj @@ -11,6 +11,7 @@ + From 96a6dddaf2760733cd49815352a49924e0ac6523 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Sun, 26 Apr 2026 17:37:41 +0200 Subject: [PATCH 04/36] R003 Add staff login and reservations management UI/API Implement staff login and upcoming reservations endpoints on the backend. Add StaffLoginPage and StaffReservationsPage with routing and API hooks on the frontend. Update LandingPage to link to staff login. Enable staff to view and filter upcoming reservations. --- api/Controllers/StaffController.cs | 26 ++++++-- api/Repositories/ReservationRepository.cs | 12 ++++ ui/src/LandingPage.tsx | 9 +-- ui/src/router.tsx | 12 ++++ ui/src/staff/StaffLoginPage.tsx | 62 ++++++++++++++++++ ui/src/staff/StaffReservationsPage.tsx | 78 +++++++++++++++++++++++ ui/src/staff/api.ts | 50 +++++++++++++++ 7 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 ui/src/staff/StaffLoginPage.tsx create mode 100644 ui/src/staff/StaffReservationsPage.tsx create mode 100644 ui/src/staff/api.ts diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..f919451 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,15 +1,19 @@ using Microsoft.AspNetCore.Mvc; +using Models; +using Repositories; namespace Controllers { - [Route("staff")] + [Tags("Staff"), Route("staff")] public class StaffController : Controller { private IConfiguration Config { get; set; } + private ReservationRepository _reservations { get; set; } - public StaffController(IConfiguration config) + public StaffController(IConfiguration config, ReservationRepository reservations) { Config = config; + _reservations = reservations; } /// @@ -37,19 +41,17 @@ public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string access var configuredSecret = Config.GetValue("staffAccessCode"); if (configuredSecret != accessCode) { - // don't set cookie, don't indicate anything - return NoContent(); + return StatusCode(403); } 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 + Secure = false } ); return NoContent(); @@ -65,5 +67,17 @@ public IActionResult CheckCookie() return Ok("Authorized"); } + + [HttpGet, Produces("application/json"), Route("reservations")] + public async Task GetReservations() + { + if (IsNotStaff(Request, out IActionResult? result)) + { + return result!; + } + + var reservations = await _reservations.GetUpcomingReservations(); + return Json(reservations); + } } } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index eeb6f16..3c259e4 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -27,6 +27,18 @@ public async Task> GetReservations() return reservations.Select(r => r.ToDomain()); } + public async Task> GetUpcomingReservations() + { + var today = DateTime.UtcNow.Date.ToString("yyyy-MM-dd"); + + var reservations = await _db.QueryAsync( + "SELECT * FROM Reservations WHERE End >= @today ORDER BY Start ASC;", + new { today } + ); + + return reservations?.Select(r => r.ToDomain()) ?? []; + } + /// /// Find a reservation by its Guid ID, throwing if not found /// diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..57c5127 100644 --- a/ui/src/LandingPage.tsx +++ b/ui/src/LandingPage.tsx @@ -1,16 +1,11 @@ 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"); -} - export function LandingPage() { return ( - + Login - + diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e3020bd..caeb9e9 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -6,6 +6,8 @@ import { import { Layout } from "./Layout"; import { LandingPage } from "./LandingPage"; import { ReservationPage } from "./reservations/ReservationPage"; +import { StaffLoginPage } from "./staff/StaffLoginPage"; +import { StaffReservationsPage } from "./staff/StaffReservationsPage"; const rootRoute = createRootRoute({ component: Layout, @@ -26,6 +28,16 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + createRoute({ + path: "/staff/login", + getParentRoute: getRootRoute, + component: StaffLoginPage, + }), + createRoute({ + path: "/staff/reservations", + getParentRoute: getRootRoute, + component: StaffReservationsPage, + }), ]; const routeTree = rootRoute.addChildren(ROUTES); diff --git a/ui/src/staff/StaffLoginPage.tsx b/ui/src/staff/StaffLoginPage.tsx new file mode 100644 index 0000000..bd022b7 --- /dev/null +++ b/ui/src/staff/StaffLoginPage.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { Box, Button, Card, Flex, Heading, Text, TextField } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; +import { staffLogin } from "./api"; + +export function StaffLoginPage() { + const [accessCode, setAccessCode] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + const success = await staffLogin(accessCode); + + setLoading(false); + + if (success) { + navigate({ to: "/staff/reservations" }); + } else { + setError("Invalid access code. Please try again."); + } + } + + return ( + + + + Staff Login + +
+ + + + Access Code + + setAccessCode(e.target.value)} + mt="1" + /> + + {error && ( + + {error} + + )} + + +
+
+
+ ); +} diff --git a/ui/src/staff/StaffReservationsPage.tsx b/ui/src/staff/StaffReservationsPage.tsx new file mode 100644 index 0000000..27adbc8 --- /dev/null +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { + Badge, + Flex, + Heading, + Section, + Switch, + Table, + Text, +} from "@radix-ui/themes"; +import { useGetStaffReservations } from "./api"; +import { LoadingCard } from "../components/LoadingCard"; + +export function StaffReservationsPage() { + const { isLoading, data: reservations, isError } = useGetStaffReservations(); + const [todayOnly, setTodayOnly] = useState(false); + + const today = new Date().toLocaleDateString(); + + const filtered = reservations?.filter((r) => + todayOnly ? new Date(r.start).toLocaleDateString() === today : true + ); + + return ( +
+ + + Upcoming Reservations + + + Today only + + + + + {isLoading && } + + {isError && ( + Failed to load reservations. Please log in again. + )} + + {filtered && filtered.length === 0 && ( + No reservations found. + )} + + {filtered && filtered.length > 0 && ( + + + + Room + Guest Email + Start + End + Status + + + + {filtered.map((r) => ( + + #{r.roomNumber} + {r.guestEmail} + {new Date(r.start).toLocaleDateString()} + {new Date(r.end).toLocaleDateString()} + + + {r.checkedIn && Checked In} + {r.checkedOut && Checked Out} + {!r.checkedIn && !r.checkedOut && Upcoming} + + + + ))} + + + )} +
+ ); +} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts new file mode 100644 index 0000000..8eaf8f2 --- /dev/null +++ b/ui/src/staff/api.ts @@ -0,0 +1,50 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import ky from "ky"; +import { z } from "zod"; + +export async function staffLogin(accessCode: string): Promise { + try { + await ky.get("/api/staff/login", { + headers: { "X-Staff-Code": accessCode }, + }); + return true; + } catch (err) { + return false; + } +} + +const ReservationSchema = z.object({ + id: z.string(), + roomNumber: z.string(), + guestEmail: z.string(), + start: z.string(), + end: z.string(), + checkedIn: z.boolean(), + checkedOut: z.boolean(), +}); + +export type StaffReservation = z.infer; + +const ReservationListSchema = ReservationSchema.array(); + +export function useGetStaffReservations() { + return useQuery({ + queryKey: ["staff", "reservations"], + queryFn: () => + ky.get("/api/staff/reservations").json().then(ReservationListSchema.parseAsync), + retry: false, + }); +} + +export function useCheckIn() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ reservationId, guestEmail }: { reservationId: string; guestEmail: string }) => + ky.post(`/api/reservation/${reservationId}/checkin`, { + json: { guestEmail }, + }).json(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["staff", "reservations"] }); + }, + }); +} From efc0668a90abfd70cea0e8545e2f6d447d2f3777 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Sun, 26 Apr 2026 17:53:19 +0200 Subject: [PATCH 05/36] R003 Use .NET user-secrets for staff access code config Replaced staffAccessCode in config files with a placeholder and added UserSecretsId to the project. Updated README with instructions for setting the access code using dotnet user-secrets. Explicitly set AllowedHosts in development settings. --- api/api.csproj | 3 ++- api/appsettings.Development.json | 3 ++- api/appsettings.json | 2 +- readme.md | 6 ++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/api/api.csproj b/api/api.csproj index ef55adc..588921f 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -1,9 +1,10 @@ - + net8.0 enable enable + 68596633-f7d8-4748-97aa-90fcce77de4c diff --git a/api/appsettings.Development.json b/api/appsettings.Development.json index ea7f768..3be6213 100644 --- a/api/appsettings.Development.json +++ b/api/appsettings.Development.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "staffAccessCode": "pass" + "AllowedHosts": "*", + "staffAccessCode": "Replaced by CI/CD" } diff --git a/api/appsettings.json b/api/appsettings.json index c06ebf6..3be6213 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "staffAccessCode": "pass" + "staffAccessCode": "Replaced by CI/CD" } diff --git a/readme.md b/readme.md index d93119e..eb60c4d 100644 --- a/readme.md +++ b/readme.md @@ -101,6 +101,12 @@ caddy adapt Caddy's doc say to run `caddy adapt`, so that was included in the above code block. +### Local Secrets + +The app requires a staff access code to be configured locally. Go to api and run the following command in the terminal to set it: +dotnet user-secrets init +dotnet user-secrets set "staffAccessCode" "[YOUR-PASS]" + With all that done, run the start shell script at the root of this repository to initiate the api (dotnet), the router (caddy), and the ui (rspack dev server). From d35d58a3ae886c74e992ef01dfbbc6b03b53d94f Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Sun, 26 Apr 2026 18:07:12 +0200 Subject: [PATCH 06/36] R004 Add staff check-in feature for reservations Implemented backend and frontend support for staff to check in guests. Added a new API endpoint to handle check-in with guest email validation, updated reservation and room state logic, and introduced a dialog in the staff UI for confirming check-in. --- api/Controllers/ReservationController.cs | 26 ++++ api/Repositories/ReservationRepository.cs | 22 ++++ api/Repositories/RoomRepository.cs | 9 ++ ui/src/staff/StaffReservationsPage.tsx | 148 +++++++++++++++++----- 4 files changed, 175 insertions(+), 30 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index b690783..0af6b45 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -105,5 +105,31 @@ public async Task DeleteReservation(Guid reservationId) return result ? NoContent() : NotFound(); } + + [HttpPost, Produces("application/json"), Route("{reservationId}/checkin")] + public async Task> CheckIn(Guid reservationId, [FromBody] CheckInRequest? request) + { + if (request is null || string.IsNullOrEmpty(request.GuestEmail)) + { + return BadRequest("Guest email is required."); + } + + try + { + var reservation = await _repo.CheckIn(reservationId, request.GuestEmail); + await _roomRepo.SetRoomState(reservation.RoomNumber, Models.State.Occupied); + return Json(reservation); + } + catch (NotFoundException) + { + return NotFound(); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } } + + public record CheckInRequest(string GuestEmail); } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 3c259e4..64be08a 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -105,6 +105,28 @@ public async Task DeleteReservation(Guid reservationId) return deleted > 0; } + public async Task CheckIn(Guid reservationId, string guestEmail) + { + var reservation = await GetReservation(reservationId); + + if (!string.Equals(reservation.GuestEmail, guestEmail, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Email does not match reservation."); + } + + if (reservation.CheckedIn) + { + throw new InvalidOperationException("Reservation is already checked in."); + } + + var updated = await _db.QuerySingleAsync( + "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @id RETURNING *;", + new { id = reservationId.ToString() } + ); + + return updated.ToDomain(); + } + private class ReservationDb { public string Id { get; set; } diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 2b9f904..050f131 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -71,6 +71,15 @@ public async Task DeleteRoom(string roomNumber) return deleted > 0; } + public async Task SetRoomState(string roomNumber, State state) + { + var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @state WHERE Number = @roomNumberInt;", + new { state, roomNumberInt } + ); + } + // Inner class to hide the details of a direct mapping to SQLite private class RoomDb { diff --git a/ui/src/staff/StaffReservationsPage.tsx b/ui/src/staff/StaffReservationsPage.tsx index 27adbc8..d1c763a 100644 --- a/ui/src/staff/StaffReservationsPage.tsx +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -1,24 +1,92 @@ import { useState } from "react"; import { Badge, + Box, + Button, + Dialog, Flex, Heading, Section, Switch, Table, Text, + TextField, } from "@radix-ui/themes"; -import { useGetStaffReservations } from "./api"; +import { useGetStaffReservations, useCheckIn, StaffReservation } from "./api"; import { LoadingCard } from "../components/LoadingCard"; +function CheckInDialog({ + reservation, + onClose, +}: { + reservation: StaffReservation; + onClose: () => void; +}) { + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + const checkIn = useCheckIn(); + + async function handleConfirm() { + setError(""); + try { + await checkIn.mutateAsync({ reservationId: reservation.id, guestEmail: email }); + onClose(); + } catch { + setError("Check-in failed. Please verify the email and try again."); + } + } + + return ( + + Check In - Room #{reservation.roomNumber} + + Enter the guest email address to confirm check-in. + + + + + Guest Email + + setEmail(e.target.value)} + mt="1" + /> + + {error && ( + + {error} + + )} + + + + + + + + + ); +} + export function StaffReservationsPage() { const { isLoading, data: reservations, isError } = useGetStaffReservations(); const [todayOnly, setTodayOnly] = useState(false); + const [selectedReservation, setSelectedReservation] = useState(null); const today = new Date().toLocaleDateString(); + const isToday = (dateStr: string) => new Date(dateStr).toLocaleDateString() === today; + const filtered = reservations?.filter((r) => - todayOnly ? new Date(r.start).toLocaleDateString() === today : true + todayOnly ? isToday(r.start) : true ); return ( @@ -44,35 +112,55 @@ export function StaffReservationsPage() { )} {filtered && filtered.length > 0 && ( - - - - Room - Guest Email - Start - End - Status - - - - {filtered.map((r) => ( - - #{r.roomNumber} - {r.guestEmail} - {new Date(r.start).toLocaleDateString()} - {new Date(r.end).toLocaleDateString()} - - - {r.checkedIn && Checked In} - {r.checkedOut && Checked Out} - {!r.checkedIn && !r.checkedOut && Upcoming} - - + { if (!open) setSelectedReservation(null); }} + > + + + + Room + Guest Email + Start + End + Status + - ))} - - - )} + + + {filtered.map((r) => ( + + #{r.roomNumber} + {r.guestEmail} + {new Date(r.start).toLocaleDateString()} + {new Date(r.end).toLocaleDateString()} + + + {r.checkedIn && Checked In} + {r.checkedOut && Checked Out} + {!r.checkedIn && !r.checkedOut && Upcoming} + + + + {!r.checkedIn && isToday(r.start) && ( + + )} + + + ))} + + + + {selectedReservation && ( + setSelectedReservation(null)} + /> + )} + + )} ); } From d3600d89efca63c9774125742a90862e21131331 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Sun, 26 Apr 2026 18:30:34 +0200 Subject: [PATCH 07/36] R006 Add housekeeping UI and room state management Introduce housekeeping section for staff to view and update room states (Ready, Occupied, Dirty). Add RoomStateDialog for state changes. Backend now prevents check-in to dirty rooms and provides an endpoint to set room state. Improve guest and room repository validation. Change repository DI from singleton to scoped. Update frontend to fetch and manage room states. --- api/Controllers/ReservationController.cs | 14 ++- api/Controllers/RoomController.cs | 21 ++++ api/Program.cs | 10 +- api/Repositories/GuestRepository.cs | 15 ++- ui/src/staff/StaffReservationsPage.tsx | 121 ++++++++++++++++++++++- ui/src/staff/api.ts | 34 +++++++ 6 files changed, 202 insertions(+), 13 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 0af6b45..e173766 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -116,9 +116,17 @@ public async Task> CheckIn(Guid reservationId, [FromBo try { - var reservation = await _repo.CheckIn(reservationId, request.GuestEmail); - await _roomRepo.SetRoomState(reservation.RoomNumber, Models.State.Occupied); - return Json(reservation); + var reservation = await _repo.GetReservation(reservationId); + var room = await _roomRepo.GetRoom(reservation.RoomNumber); + + if (room.State == Models.State.Dirty) + { + return BadRequest("Cannot check in: room is dirty and has not been cleaned yet."); + } + + var checkedIn = await _repo.CheckIn(reservationId, request.GuestEmail); + await _roomRepo.SetRoomState(checkedIn.RoomNumber, Models.State.Dirty); + return Json(checkedIn); } catch (NotFoundException) { diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 6e97650..cfe49ab 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -73,5 +73,26 @@ public async Task DeleteRoom(string roomNumber) return deleted ? NoContent() : NotFound(); } + + [HttpPut, Produces("application/json"), Route("{roomNumber}/state")] + public async Task SetRoomState(string roomNumber, [FromBody] SetRoomStateRequest? request) + { + if (request is null) + { + return BadRequest("Invalid payload."); + } + + try + { + await _repo.SetRoomState(roomNumber, request.State); + return NoContent(); + } + catch (NotFoundException) + { + return NotFound(); + } + } } + + public record SetRoomStateRequest(Models.State State); } diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..152e129 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -12,11 +12,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/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index eb7b460..2393ff0 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -41,9 +41,22 @@ public async Task GetGuestByEmail(string guestEmail) return guest; } + public async Task GuestExists(string guestEmail) + { + var guest = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Guests WHERE Email = @guestEmail;", + new { guestEmail } + ); + + return guest != null; + } + public async Task CreateGuest(Guest newGuest) { - var existing = await GetGuestByEmail(newGuest.Email); + if (await GuestExists(newGuest.Email)) + { + throw new ConflictException($"Guest {newGuest.Email} already exists"); + } return await _db.QuerySingleAsync( "INSERT INTO Guests(Email, Name, Surname) Values(@Email, @Name, @Surname) RETURNING *", diff --git a/ui/src/staff/StaffReservationsPage.tsx b/ui/src/staff/StaffReservationsPage.tsx index d1c763a..d69c1f7 100644 --- a/ui/src/staff/StaffReservationsPage.tsx +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -7,14 +7,56 @@ import { Flex, Heading, Section, + Separator, Switch, Table, Text, TextField, } from "@radix-ui/themes"; -import { useGetStaffReservations, useCheckIn, StaffReservation } from "./api"; +import { useGetStaffReservations, useCheckIn, useGetRoomsState, useSetRoomState, RoomState, StaffReservation, StaffRoom } from "./api"; import { LoadingCard } from "../components/LoadingCard"; +function RoomStateDialog({ + room, + targetState, + onClose, +}: { + room: StaffRoom; + targetState: RoomState; + onClose: () => void; +}) { + const setRoomState = useSetRoomState(); + const label = targetState === RoomState.Ready ? "Clean" : "Dirty"; + + async function handleConfirm() { + await setRoomState.mutateAsync({ roomNumber: room.number, state: targetState }); + onClose(); + } + + return ( + + Mark Room #{room.number} as {label} + + Are you sure you want to mark room #{room.number} as {label}? + + + + + + + + + ); +} + function CheckInDialog({ reservation, onClose, @@ -31,8 +73,9 @@ function CheckInDialog({ try { await checkIn.mutateAsync({ reservationId: reservation.id, guestEmail: email }); onClose(); - } catch { - setError("Check-in failed. Please verify the email and try again."); + } catch (_e: any) { + const message = await _e?.response?.text().catch(() => null); + setError(message?.replace(/^"|"$/g, "") || "Check-in failed. Please try again."); } } @@ -81,6 +124,9 @@ export function StaffReservationsPage() { const [todayOnly, setTodayOnly] = useState(false); const [selectedReservation, setSelectedReservation] = useState(null); + const { data: rooms } = useGetRoomsState(); + const [roomStateDialog, setRoomStateDialog] = useState<{ room: StaffRoom; targetState: RoomState } | null>(null); + const today = new Date().toLocaleDateString(); const isToday = (dateStr: string) => new Date(dateStr).toLocaleDateString() === today; @@ -160,7 +206,74 @@ export function StaffReservationsPage() { /> )} - )} + )} + + + + + Housekeeping + + + {rooms && rooms.length > 0 && ( + { if (!open) setRoomStateDialog(null); }} + > + + + + Room + State + Actions + + + + {rooms.map((room) => ( + + #{room.number} + + {room.state === RoomState.Ready && Clean} + {room.state === RoomState.Dirty && Dirty} + {room.state === RoomState.Occupied && Occupied} + + + + {room.state !== RoomState.Ready && ( + + )} + {room.state !== RoomState.Dirty && ( + + )} + + + + ))} + + + + {roomStateDialog && ( + setRoomStateDialog(null)} + /> + )} + + )} ); } diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts index 8eaf8f2..45f9308 100644 --- a/ui/src/staff/api.ts +++ b/ui/src/staff/api.ts @@ -45,6 +45,40 @@ export function useCheckIn() { }).json(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["staff", "reservations"] }); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + }, + }); +} + +// 0 = Ready, 1 = Occupied, 2 = Dirty +export enum RoomState { + Ready = 0, + Occupied = 1, + Dirty = 2, +} + +const RoomSchema = z.object({ + number: z.string(), + state: z.number(), +}); + +const RoomListSchema = RoomSchema.array(); +export type StaffRoom = z.infer; + +export function useGetRoomsState() { + return useQuery({ + queryKey: ["rooms"], + queryFn: () => ky.get("/api/room").json().then(RoomListSchema.parseAsync), + }); +} + +export function useSetRoomState() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ roomNumber, state }: { roomNumber: string; state: RoomState }) => + ky.put(`/api/room/${roomNumber}/state`, { json: { state } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["rooms"] }); }, }); } From de2851354ce28dfa14ad961ce823da7ba0dbb710 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 00:14:08 +0200 Subject: [PATCH 08/36] R003 Update room state to Occupied on check-in Previously, rooms were marked as Dirty after check-in. Now, the room state is set to Occupied to better reflect its current status during the check-in process. --- api/Controllers/ReservationController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index e173766..769475b 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -125,7 +125,7 @@ public async Task> CheckIn(Guid reservationId, [FromBo } var checkedIn = await _repo.CheckIn(reservationId, request.GuestEmail); - await _roomRepo.SetRoomState(checkedIn.RoomNumber, Models.State.Dirty); + await _roomRepo.SetRoomState(checkedIn.RoomNumber, Models.State.Occupied); return Json(checkedIn); } catch (NotFoundException) From 60fc314924f8c01477b12ac1afcd35911315ca6f Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 00:16:32 +0200 Subject: [PATCH 09/36] R001 Update ReservationController to return 404 on NotFoundException Changed response from BadRequest (400) to NotFound (404) when a NotFoundException is caught, ensuring the API returns a more accurate HTTP status for missing resources. --- api/Controllers/ReservationController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 769475b..3b505a1 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -86,7 +86,7 @@ [FromBody] Reservation? newBooking } catch (NotFoundException ex) { - return BadRequest(ex.Message); + return NotFound(ex.Message); } catch (Exception ex) { From 6473657f673acf292d721f4829a39e3a9dfc0324 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 00:22:18 +0200 Subject: [PATCH 10/36] R003 Refactor staff auth logic into StaffAuth static class Move IsNotStaff method to new StaffAuth static class for centralized staff authentication. Update RoomController and StaffController to use StaffAuth.IsNotStaff for consistent access checks based on the "access" cookie. --- api/Controllers/RoomController.cs | 5 +++++ api/Controllers/StaffAuth.cs | 24 ++++++++++++++++++++++++ api/Controllers/StaffController.cs | 16 ++-------------- 3 files changed, 31 insertions(+), 14 deletions(-) create mode 100644 api/Controllers/StaffAuth.cs diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index cfe49ab..e218755 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -77,6 +77,11 @@ public async Task DeleteRoom(string roomNumber) [HttpPut, Produces("application/json"), Route("{roomNumber}/state")] public async Task SetRoomState(string roomNumber, [FromBody] SetRoomStateRequest? request) { + if (StaffAuth.IsNotStaff(Request, out IActionResult? authResult)) + { + return authResult!; + } + if (request is null) { return BadRequest("Invalid payload."); diff --git a/api/Controllers/StaffAuth.cs b/api/Controllers/StaffAuth.cs new file mode 100644 index 0000000..e7cc7da --- /dev/null +++ b/api/Controllers/StaffAuth.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Controllers +{ + internal static class StaffAuth + { + /// + /// Checks if the request is from a staff member, if not returns true and a 403 result + /// + internal static bool IsNotStaff(HttpRequest request, out IActionResult? result) + { + request.Cookies.TryGetValue("access", out string? accessValue); + + if (accessValue == null || accessValue == "0") + { + result = new StatusCodeResult(403); + return true; + } + + result = null; + return false; + } + } +} diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index f919451..64f8688 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -20,20 +20,8 @@ public StaffController(IConfiguration config, ReservationRepository reservations /// 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) - { - // TODO explore UseAuthentication - request.Cookies.TryGetValue("access", out string? accessValue); - - if (accessValue == null || accessValue == "0") - { - result = StatusCode(403); - return true; - } - - result = null; - return false; - } + private static bool IsNotStaff(HttpRequest request, out IActionResult? result) + => StaffAuth.IsNotStaff(request, out result); [HttpGet, Route("login")] public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) From dcd461c57a9234a60e916bb99035917f75ee6b98 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 00:26:02 +0200 Subject: [PATCH 11/36] R003 Improve SetRoomState error handling for missing rooms Throw NotFoundException in SetRoomState if no rows are updated, ensuring callers are notified when a specified room does not exist. This enhances error reporting and robustness. --- api/Repositories/RoomRepository.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 050f131..9909094 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -74,10 +74,15 @@ public async Task DeleteRoom(string roomNumber) public async Task SetRoomState(string roomNumber, State state) { var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); - await _db.ExecuteAsync( + var updated = await _db.ExecuteAsync( "UPDATE Rooms SET State = @state WHERE Number = @roomNumberInt;", new { state, roomNumberInt } ); + + if (updated == 0) + { + throw new NotFoundException($"Room {roomNumber} not found"); + } } // Inner class to hide the details of a direct mapping to SQLite From 885b212e8922af98d6a48d2fc315966740a36fe1 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 00:42:27 +0200 Subject: [PATCH 12/36] R003 Add staff-only auth policy and secure API endpoints Implemented authentication and "StaffOnly" authorization policy using a custom handler that checks the "access" cookie. Applied [Authorize(Policy = "StaffOnly")] to Guest, Reservation, and Room controllers, and the StaffController's /reservations endpoint. Removed manual staff checks and updated Program.cs to configure authentication/authorization middleware. Improved cookie security by setting the Secure flag based on HTTPS. --- .../NoOpAuthenticationHandler.cs | 30 +++++++++++++++++ .../StaffAuthorizationHandler.cs | 27 +++++++++++++++ api/Controllers/GuestController.cs | 3 +- api/Controllers/ReservationController.cs | 3 +- api/Controllers/RoomController.cs | 8 ++--- api/Controllers/StaffController.cs | 33 ++++--------------- api/Program.cs | 13 ++++++++ 7 files changed, 82 insertions(+), 35 deletions(-) create mode 100644 api/Authorization/NoOpAuthenticationHandler.cs create mode 100644 api/Authorization/StaffAuthorizationHandler.cs diff --git a/api/Authorization/NoOpAuthenticationHandler.cs b/api/Authorization/NoOpAuthenticationHandler.cs new file mode 100644 index 0000000..5bc3606 --- /dev/null +++ b/api/Authorization/NoOpAuthenticationHandler.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using System.Text.Encodings.Web; + +namespace Authorization +{ + public class NoOpAuthenticationHandler : AuthenticationHandler + { + public NoOpAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) { } + + protected override Task HandleAuthenticateAsync() + => Task.FromResult(AuthenticateResult.NoResult()); + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = 403; + return Task.CompletedTask; + } + + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + Response.StatusCode = 403; + return Task.CompletedTask; + } + } +} diff --git a/api/Authorization/StaffAuthorizationHandler.cs b/api/Authorization/StaffAuthorizationHandler.cs new file mode 100644 index 0000000..e054c0a --- /dev/null +++ b/api/Authorization/StaffAuthorizationHandler.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Authorization +{ + public class StaffRequirement : IAuthorizationRequirement { } + + public class StaffAuthorizationHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + StaffRequirement requirement) + { + var httpContext = (context.Resource as AuthorizationFilterContext)?.HttpContext; + if (httpContext != null) + { + httpContext.Request.Cookies.TryGetValue("access", out string? accessValue); + if (accessValue == "1") + { + context.Succeed(requirement); + } + } + + return Task.CompletedTask; + } + } +} diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index b0c9e59..3a4088d 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; @@ -5,7 +6,7 @@ namespace Controllers { - [Tags("Guests"), Route("guest")] + [Tags("Guests"), Route("guest"), Authorize(Policy = "StaffOnly")] public class GuestController : Controller { private GuestRepository _repo; diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 3b505a1..90debd9 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; @@ -6,7 +7,7 @@ namespace Controllers { - [Tags("Reservations"), Route("reservation")] + [Tags("Reservations"), Route("reservation"), Authorize(Policy = "StaffOnly")] public class ReservationController : Controller { private ReservationRepository _repo { get; set; } diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index e218755..e121341 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; @@ -5,7 +6,7 @@ namespace Controllers { - [Tags("Rooms"), Route("room")] + [Tags("Rooms"), Route("room"), Authorize(Policy = "StaffOnly")] public class RoomController : Controller { private RoomRepository _repo { get; set; } @@ -77,11 +78,6 @@ public async Task DeleteRoom(string roomNumber) [HttpPut, Produces("application/json"), Route("{roomNumber}/state")] public async Task SetRoomState(string roomNumber, [FromBody] SetRoomStateRequest? request) { - if (StaffAuth.IsNotStaff(Request, out IActionResult? authResult)) - { - return authResult!; - } - if (request is null) { return BadRequest("Invalid payload."); diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 64f8688..a5772fd 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; using Repositories; @@ -14,14 +15,7 @@ public StaffController(IConfiguration config, ReservationRepository reservations { Config = config; _reservations = reservations; - } - - /// - /// Checks if the request is from a staff member, if not returns true and a 403 result - /// - /// - private static bool IsNotStaff(HttpRequest request, out IActionResult? result) - => StaffAuth.IsNotStaff(request, out result); + } [HttpGet, Route("login")] public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) @@ -39,31 +33,16 @@ public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string access IsEssential = true, SameSite = SameSiteMode.Strict, HttpOnly = true, - Secure = false + Secure = Request.IsHttps } ); return NoContent(); } - [HttpGet, Route("check")] - public IActionResult CheckCookie() - { - if (IsNotStaff(Request, out IActionResult? result)) - { - return result!; - } - - return Ok("Authorized"); - } - - [HttpGet, Produces("application/json"), Route("reservations")] + + [HttpGet, Produces("application/json"), Route("reservations"), Authorize(Policy = "StaffOnly")] public async Task GetReservations() - { - if (IsNotStaff(Request, out IActionResult? result)) - { - return result!; - } - + { var reservations = await _reservations.GetUpcomingReservations(); return Json(reservations); } diff --git a/api/Program.cs b/api/Program.cs index 152e129..919b5da 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,5 +1,8 @@ using System.Data; +using Authorization; using Db; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.Data.Sqlite; using Repositories; @@ -22,6 +25,14 @@ opt.EnableEndpointRouting = false; }); Services.AddCors(); + Services.AddAuthentication("NoOp") + .AddScheme("NoOp", _ => { }); + Services.AddAuthorization(options => + { + options.AddPolicy("StaffOnly", policy => + policy.AddRequirements(new StaffRequirement())); + }); + Services.AddSingleton(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); } @@ -43,6 +54,8 @@ } app.UsePathBase("/api") + .UseAuthentication() + .UseAuthorization() .UseMvc() .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) .UseSwagger() From 1800c6ba8a5969c7288f8f58e551ad5807e69bc3 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 00:47:46 +0200 Subject: [PATCH 13/36] R004 Prevent check-in if reservation is already checked out Added a validation in ReservationRepository.cs to throw an InvalidOperationException when attempting to check in a reservation that has already been checked out, ensuring reservation state consistency. --- api/Repositories/ReservationRepository.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 64be08a..2e8ee78 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -114,6 +114,11 @@ public async Task CheckIn(Guid reservationId, string guestEmail) throw new InvalidOperationException("Email does not match reservation."); } + if (reservation.CheckedOut) + { + throw new InvalidOperationException("Reservation has already been checked out."); + } + if (reservation.CheckedIn) { throw new InvalidOperationException("Reservation is already checked in."); From 5a1582fc00ed39f0d2c97a6adb8498e83d900dda Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 00:53:47 +0200 Subject: [PATCH 14/36] R004 Add CheckInTests for reservation and room state logic Introduced CheckInTests to validate reservation check-in behavior and room state persistence using an in-memory SQLite database. Tests cover successful check-ins (with case-insensitive email), error scenarios, unknown reservation handling, and room state updates. Setup and teardown ensure isolated test environments. --- api.Tests/CheckInTests.cs | 121 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 api.Tests/CheckInTests.cs diff --git a/api.Tests/CheckInTests.cs b/api.Tests/CheckInTests.cs new file mode 100644 index 0000000..42c5917 --- /dev/null +++ b/api.Tests/CheckInTests.cs @@ -0,0 +1,121 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Models; +using Models.Errors; +using NUnit.Framework; +using Repositories; + +namespace api.Tests +{ + [TestFixture] + public class CheckInTests + { + private SqliteConnection _db = null!; + private ReservationRepository _reservationRepo = null!; + private RoomRepository _roomRepo = null!; + + [SetUp] + public async Task SetUp() + { + _db = new SqliteConnection("Data Source=:memory:"); + await _db.OpenAsync(); + + await _db.ExecuteAsync(@" + CREATE TABLE Guests ( + Email TEXT PRIMARY KEY NOT NULL, + Name TEXT NOT NULL, + Surname TEXT + ); + CREATE TABLE Rooms ( + Number INT PRIMARY KEY NOT NULL, + State INT NOT NULL + ); + CREATE TABLE Reservations ( + Id TEXT PRIMARY KEY NOT NULL, + GuestEmail TEXT NOT NULL, + RoomNumber INT NOT NULL, + Start TEXT NOT NULL, + End TEXT NOT NULL, + CheckedIn INT NOT NULL DEFAULT 0, + CheckedOut INT NOT NULL DEFAULT 0 + ); + INSERT INTO Guests (Email, Name) VALUES ('guest@example.com', 'Test Guest'); + INSERT INTO Rooms (Number, State) VALUES (101, 0); + "); + + _reservationRepo = new ReservationRepository(_db); + _roomRepo = new RoomRepository(_db); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + private async Task CreateReservation( + string email = "guest@example.com", + bool checkedIn = false, + bool checkedOut = false) + { + var id = Guid.NewGuid(); + await _db.ExecuteAsync( + @"INSERT INTO Reservations (Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) + VALUES (@Id, @GuestEmail, 101, '2025-06-01', '2025-06-05', @CheckedIn, @CheckedOut);", + new { Id = id.ToString(), GuestEmail = email, CheckedIn = checkedIn ? 1 : 0, CheckedOut = checkedOut ? 1 : 0 } + ); + return await _reservationRepo.GetReservation(id); + } + + // -- successful check-in --------------------------------------------- + + [TestCase("guest@example.com", TestName = "CheckIn_ExactEmail_ReturnsCheckedInReservation")] + [TestCase("GUEST@EXAMPLE.COM", TestName = "CheckIn_UpperCaseEmail_Succeeds")] + [TestCase("Guest@Example.Com", TestName = "CheckIn_MixedCaseEmail_Succeeds")] + public async Task CheckIn_ValidEmail_ReturnsCheckedInReservation(string email) + { + var reservation = await CreateReservation(); + + var result = await _reservationRepo.CheckIn(reservation.Id, email); + + Assert.That(result.CheckedIn, Is.True); + Assert.That(result.Id, Is.EqualTo(reservation.Id)); + } + + // -- error cases ----------------------------------------------------- + + [TestCase(false, false, "wrong@example.com", "Email does not match", TestName = "CheckIn_WrongEmail_ThrowsInvalidOperationException")] + [TestCase(true, false, "guest@example.com", "already checked in", TestName = "CheckIn_AlreadyCheckedIn_ThrowsInvalidOperationException")] + [TestCase(true, true, "guest@example.com", "already been checked out", TestName = "CheckIn_AlreadyCheckedOut_ThrowsInvalidOperationException")] + public async Task CheckIn_InvalidState_ThrowsInvalidOperationException( + bool checkedIn, bool checkedOut, string email, string expectedMessage) + { + var reservation = await CreateReservation(checkedIn: checkedIn, checkedOut: checkedOut); + + var ex = Assert.ThrowsAsync(() => + _reservationRepo.CheckIn(reservation.Id, email)); + + Assert.That(ex!.Message, Does.Contain(expectedMessage)); + } + + // -- reservation not found ------------------------------------------- + + [Test] + public void CheckIn_UnknownReservationId_ThrowsNotFoundException() + { + Assert.ThrowsAsync(() => + _reservationRepo.CheckIn(Guid.NewGuid(), "guest@example.com")); + } + + // -- room state persistence ------------------------------------------ + + [TestCase(State.Dirty, TestName = "RoomState_SetDirty_Persists")] + [TestCase(State.Occupied, TestName = "RoomState_SetOccupied_Persists")] + [TestCase(State.Ready, TestName = "RoomState_SetReady_Persists")] + public async Task SetRoomState_Persists(State state) + { + await _roomRepo.SetRoomState("101", state); + + var room = await _roomRepo.GetRoom("101"); + + Assert.That(room.State, Is.EqualTo(state)); + } + } +} From 58b3f4be5f07a19582efaeb3e4a975775cb7f22a Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 00:55:13 +0200 Subject: [PATCH 15/36] R004 Update check-in logic to require room is 'Ready' Expanded the check-in condition to block check-in for any room state other than 'Ready', not just 'Dirty'. Updated the error message to match the new logic. --- api/Controllers/ReservationController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 90debd9..b670dd4 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -120,9 +120,9 @@ public async Task> CheckIn(Guid reservationId, [FromBo var reservation = await _repo.GetReservation(reservationId); var room = await _roomRepo.GetRoom(reservation.RoomNumber); - if (room.State == Models.State.Dirty) + if (room.State != Models.State.Ready) { - return BadRequest("Cannot check in: room is dirty and has not been cleaned yet."); + return BadRequest("Cannot check in: room is not ready for check-in."); } var checkedIn = await _repo.CheckIn(reservationId, request.GuestEmail); From a89114aa03b693ab5ff6c5fe5884cd1bfa5663b6 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 01:01:20 +0200 Subject: [PATCH 16/36] R004 Update API endpoints to use absolute paths Changed API URLs in api.ts to include a leading slash, ensuring requests are sent to the correct absolute server paths for reservations and rooms. --- ui/src/reservations/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index f67f17a..26472a3 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -29,7 +29,7 @@ export function bookRoom(booking: NewReservation) { End: toIsoStr(booking.End), }; - return ky.post("api/reservation", { json: newReservation }).json(); + return ky.post("/api/reservation", { json: newReservation }).json(); } const RoomSchema = z.object({ @@ -42,6 +42,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/room").json().then(RoomListSchema.parseAsync), }); } From c0c89acd24b3b41060c516cd2046a92baea37ca9 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 01:04:42 +0200 Subject: [PATCH 17/36] R004 Refactor room number validation in controllers Replaced direct length checks with Room.IsValidRoomNumber in GetRoom, CreateRoom, DeleteRoom, and SetRoomState actions. This centralizes and standardizes room number validation logic across endpoints, and adds validation to CreateRoom. --- api/Controllers/RoomController.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index e121341..df6e2df 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -32,7 +32,7 @@ public async Task> GetRooms() [HttpGet, Produces("application/json"), Route("{roomNumber}")] public async Task> GetRoom(string roomNumber) { - if (roomNumber.Length != 3) + if (!Room.IsValidRoomNumber(roomNumber)) { return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); } @@ -52,6 +52,11 @@ public async Task> GetRoom(string roomNumber) [HttpPost, Produces("application/json"), Route("")] public async Task> CreateRoom([FromBody] Room newRoom) { + if (!Room.IsValidRoomNumber(newRoom.Number)) + { + return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + } + var createdRoom = await _repo.CreateRoom(newRoom); if (createdRoom == null) @@ -65,7 +70,7 @@ public async Task> CreateRoom([FromBody] Room newRoom) [HttpDelete, Produces("application/json"), Route("{roomNumber}")] public async Task DeleteRoom(string roomNumber) { - if (roomNumber.Length != 3) + if (!Room.IsValidRoomNumber(roomNumber)) { return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); } @@ -78,6 +83,11 @@ public async Task DeleteRoom(string roomNumber) [HttpPut, Produces("application/json"), Route("{roomNumber}/state")] public async Task SetRoomState(string roomNumber, [FromBody] SetRoomStateRequest? request) { + if (!Room.IsValidRoomNumber(roomNumber)) + { + return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + } + if (request is null) { return BadRequest("Invalid payload."); From ed1d134c8c420ce906d8e7812f3c836e5b4e343f Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 01:08:41 +0200 Subject: [PATCH 18/36] R004 Set cookie Path to /api in StaffController Updated CookieOptions in StaffController.cs to specify the Path property as "/api", restricting the cookie to API requests only. --- api/Controllers/StaffController.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index a5772fd..0383671 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -33,7 +33,8 @@ public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string access IsEssential = true, SameSite = SameSiteMode.Strict, HttpOnly = true, - Secure = Request.IsHttps + Secure = Request.IsHttps, + Path = "/api" } ); return NoContent(); From c55bb1fc509296e69217f40863138d038edcbb57 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Mon, 27 Apr 2026 01:10:04 +0200 Subject: [PATCH 19/36] R001 Check for column before ALTER TABLE in Guests Replaced exception-based check for existing "Surname" column in the "Guests" table with a schema query using `pragma_table_info`. Now, the column is only added if it does not already exist. --- api/Db/Setup.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 153b4f2..49b86cd 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -28,16 +28,13 @@ CREATE TABLE IF NOT EXISTS Guests ( " ); - try + var columns = await db.QueryAsync("SELECT name FROM pragma_table_info('Guests');"); + if (!columns.Any(c => c == nameof(Guest.Surname))) { await db.ExecuteAsync( $"ALTER TABLE Guests ADD COLUMN {nameof(Guest.Surname)} TEXT;" ); } - catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.Message.Contains("duplicate column")) - { - // column already exists, nothing to do - } await db.ExecuteAsync( $@" From 5a2a7b840f2f42b5bf4a3971bcb902b3781d5d4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:58:20 +0000 Subject: [PATCH 20/36] test: add comprehensive tests for repositories, validators, and models Agent-Logs-Url: https://github.com/apkouk/reservations-interview/sessions/da694a2d-2187-4e80-96cd-1ce6ac7a7535 Co-authored-by: apkouk <9098732+apkouk@users.noreply.github.com> --- api.Tests/GuestModelTests.cs | 36 ++++ api.Tests/GuestRepositoryTests.cs | 195 ++++++++++++++++++ .../ReservationConflictValidatorTests.cs | 43 ++++ api.Tests/ReservationRepositoryTests.cs | 168 +++++++++++++++ api.Tests/RoomModelTests.cs | 55 +++++ api.Tests/RoomRepositoryTests.cs | 137 ++++++++++++ 6 files changed, 634 insertions(+) create mode 100644 api.Tests/GuestModelTests.cs create mode 100644 api.Tests/GuestRepositoryTests.cs create mode 100644 api.Tests/ReservationConflictValidatorTests.cs create mode 100644 api.Tests/ReservationRepositoryTests.cs create mode 100644 api.Tests/RoomModelTests.cs create mode 100644 api.Tests/RoomRepositoryTests.cs diff --git a/api.Tests/GuestModelTests.cs b/api.Tests/GuestModelTests.cs new file mode 100644 index 0000000..4133ad8 --- /dev/null +++ b/api.Tests/GuestModelTests.cs @@ -0,0 +1,36 @@ +using Models; +using NUnit.Framework; + +namespace api.Tests +{ + [TestFixture] + public class GuestModelTests + { + // ── GetLastName ────────────────────────────────────────────────────────── + + [Test] + public void GetLastName_WithSurname_ReturnsSurname() + { + var guest = new Guest { Email = "a@b.com", Name = "Alice Smith", Surname = "Smith" }; + + Assert.That(guest.GetLastName(), Is.EqualTo("Smith")); + } + + [Test] + public void GetLastName_NullSurname_ReturnsFullName() + { + var guest = new Guest { Email = "a@b.com", Name = "Alice Smith", Surname = null }; + + Assert.That(guest.GetLastName(), Is.EqualTo("Alice Smith")); + } + + [Test] + public void GetLastName_EmptySurname_ReturnsEmptySurname() + { + var guest = new Guest { Email = "a@b.com", Name = "Alice", Surname = "" }; + + // Empty string is not null, so it should be returned as-is + Assert.That(guest.GetLastName(), Is.EqualTo("")); + } + } +} diff --git a/api.Tests/GuestRepositoryTests.cs b/api.Tests/GuestRepositoryTests.cs new file mode 100644 index 0000000..f642e37 --- /dev/null +++ b/api.Tests/GuestRepositoryTests.cs @@ -0,0 +1,195 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Models; +using Models.Errors; +using NUnit.Framework; +using Repositories; + +namespace api.Tests +{ + [TestFixture] + public class GuestRepositoryTests + { + private SqliteConnection _db = null!; + private GuestRepository _repo = null!; + + [SetUp] + public async Task SetUp() + { + _db = new SqliteConnection("Data Source=:memory:"); + await _db.OpenAsync(); + + await _db.ExecuteAsync(@" + CREATE TABLE Guests ( + Email TEXT PRIMARY KEY NOT NULL, + Name TEXT NOT NULL, + Surname TEXT + ); + "); + + _repo = new GuestRepository(_db); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + // ── GetGuests ──────────────────────────────────────────────────────────── + + [Test] + public async Task GetGuests_EmptyTable_ReturnsEmpty() + { + var result = await _repo.GetGuests(); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task GetGuests_MultipleGuests_ReturnsAll() + { + await _db.ExecuteAsync("INSERT INTO Guests (Email, Name) VALUES ('a@a.com', 'Alice');"); + await _db.ExecuteAsync("INSERT INTO Guests (Email, Name) VALUES ('b@b.com', 'Bob');"); + + var result = await _repo.GetGuests(); + + Assert.That(result.Count(), Is.EqualTo(2)); + } + + // ── GetGuestByEmail ────────────────────────────────────────────────────── + + [Test] + public async Task GetGuestByEmail_ExistingGuest_ReturnsGuest() + { + await _db.ExecuteAsync("INSERT INTO Guests (Email, Name, Surname) VALUES ('alice@example.com', 'Alice', 'Smith');"); + + var guest = await _repo.GetGuestByEmail("alice@example.com"); + + Assert.That(guest.Email, Is.EqualTo("alice@example.com")); + Assert.That(guest.Name, Is.EqualTo("Alice")); + Assert.That(guest.Surname, Is.EqualTo("Smith")); + } + + [Test] + public void GetGuestByEmail_UnknownEmail_ThrowsNotFoundException() + { + Assert.ThrowsAsync(() => + _repo.GetGuestByEmail("nobody@example.com")); + } + + // ── GuestExists ────────────────────────────────────────────────────────── + + [Test] + public async Task GuestExists_ExistingGuest_ReturnsTrue() + { + await _db.ExecuteAsync("INSERT INTO Guests (Email, Name) VALUES ('alice@example.com', 'Alice');"); + + var exists = await _repo.GuestExists("alice@example.com"); + + Assert.That(exists, Is.True); + } + + [Test] + public async Task GuestExists_UnknownEmail_ReturnsFalse() + { + var exists = await _repo.GuestExists("nobody@example.com"); + + Assert.That(exists, Is.False); + } + + // ── CreateGuest ────────────────────────────────────────────────────────── + + [Test] + public async Task CreateGuest_NewGuest_ReturnsCreated() + { + var newGuest = new Guest { Email = "new@example.com", Name = "New", Surname = "Guest" }; + + var created = await _repo.CreateGuest(newGuest); + + Assert.That(created.Email, Is.EqualTo("new@example.com")); + Assert.That(created.Name, Is.EqualTo("New")); + Assert.That(created.Surname, Is.EqualTo("Guest")); + } + + [Test] + public async Task CreateGuest_NewGuestNoSurname_ReturnsCreated() + { + var newGuest = new Guest { Email = "nosurname@example.com", Name = "NoSurname" }; + + var created = await _repo.CreateGuest(newGuest); + + Assert.That(created.Email, Is.EqualTo("nosurname@example.com")); + Assert.That(created.Surname, Is.Null); + } + + [Test] + public async Task CreateGuest_DuplicateEmail_ThrowsConflictException() + { + var guest = new Guest { Email = "dup@example.com", Name = "Dup" }; + await _repo.CreateGuest(guest); + + Assert.ThrowsAsync(() => + _repo.CreateGuest(new Guest { Email = "dup@example.com", Name = "Other" })); + } + + // ── UpdateGuest ────────────────────────────────────────────────────────── + + [Test] + public async Task UpdateGuest_ExistingGuest_ReturnsUpdated() + { + await _db.ExecuteAsync("INSERT INTO Guests (Email, Name, Surname) VALUES ('alice@example.com', 'Alice', 'Old');"); + + var updated = await _repo.UpdateGuest("alice@example.com", + new Guest { Email = "alice@example.com", Name = "Alice", Surname = "New" }); + + Assert.That(updated.Surname, Is.EqualTo("New")); + } + + [Test] + public async Task UpdateGuest_ExistingGuest_CanClearSurname() + { + await _db.ExecuteAsync("INSERT INTO Guests (Email, Name, Surname) VALUES ('alice@example.com', 'Alice', 'Smith');"); + + var updated = await _repo.UpdateGuest("alice@example.com", + new Guest { Email = "alice@example.com", Name = "Alice Updated", Surname = null }); + + Assert.That(updated.Name, Is.EqualTo("Alice Updated")); + Assert.That(updated.Surname, Is.Null); + } + + [Test] + public void UpdateGuest_UnknownEmail_ThrowsNotFoundException() + { + Assert.ThrowsAsync(() => + _repo.UpdateGuest("nobody@example.com", + new Guest { Email = "nobody@example.com", Name = "Nobody" })); + } + + // ── DeleteGuestByEmail ─────────────────────────────────────────────────── + + [Test] + public async Task DeleteGuestByEmail_ExistingGuest_ReturnsTrue() + { + await _db.ExecuteAsync("INSERT INTO Guests (Email, Name) VALUES ('alice@example.com', 'Alice');"); + + var result = await _repo.DeleteGuestByEmail("alice@example.com"); + + Assert.That(result, Is.True); + } + + [Test] + public async Task DeleteGuestByEmail_ExistingGuest_IsActuallyDeleted() + { + await _db.ExecuteAsync("INSERT INTO Guests (Email, Name) VALUES ('alice@example.com', 'Alice');"); + await _repo.DeleteGuestByEmail("alice@example.com"); + + var exists = await _repo.GuestExists("alice@example.com"); + Assert.That(exists, Is.False); + } + + [Test] + public async Task DeleteGuestByEmail_UnknownEmail_ReturnsFalse() + { + var result = await _repo.DeleteGuestByEmail("nobody@example.com"); + + Assert.That(result, Is.False); + } + } +} diff --git a/api.Tests/ReservationConflictValidatorTests.cs b/api.Tests/ReservationConflictValidatorTests.cs new file mode 100644 index 0000000..ffe5cbe --- /dev/null +++ b/api.Tests/ReservationConflictValidatorTests.cs @@ -0,0 +1,43 @@ +using Models; +using Models.Errors; +using NUnit.Framework; +using Validators; + +namespace api.Tests +{ + [TestFixture] + public class ReservationConflictValidatorTests + { + private static Reservation MakeReservation() => new() + { + Id = Guid.NewGuid(), + RoomNumber = "101", + GuestEmail = "guest@example.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(3), + }; + + [Test] + public void ValidateNoConflict_NoConflict_DoesNotThrow() + { + Assert.DoesNotThrow(() => + ReservationConflictValidator.ValidateNoConflict(MakeReservation(), hasConflict: false)); + } + + [Test] + public void ValidateNoConflict_HasConflict_ThrowsConflictException() + { + Assert.Throws(() => + ReservationConflictValidator.ValidateNoConflict(MakeReservation(), hasConflict: true)); + } + + [Test] + public void ValidateNoConflict_HasConflict_MessageContainsRoomNumber() + { + var ex = Assert.Throws(() => + ReservationConflictValidator.ValidateNoConflict(MakeReservation(), hasConflict: true)); + + Assert.That(ex!.Message, Does.Contain("101")); + } + } +} diff --git a/api.Tests/ReservationRepositoryTests.cs b/api.Tests/ReservationRepositoryTests.cs new file mode 100644 index 0000000..be7abf6 --- /dev/null +++ b/api.Tests/ReservationRepositoryTests.cs @@ -0,0 +1,168 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Models; +using NUnit.Framework; +using Repositories; + +namespace api.Tests +{ + [TestFixture] + public class ReservationRepositoryTests + { + private SqliteConnection _db = null!; + private ReservationRepository _repo = null!; + + [SetUp] + public async Task SetUp() + { + _db = new SqliteConnection("Data Source=:memory:"); + await _db.OpenAsync(); + + await _db.ExecuteAsync(@" + CREATE TABLE Guests ( + Email TEXT PRIMARY KEY NOT NULL, + Name TEXT NOT NULL, + Surname TEXT + ); + CREATE TABLE Rooms ( + Number INT PRIMARY KEY NOT NULL, + State INT NOT NULL + ); + CREATE TABLE Reservations ( + Id TEXT PRIMARY KEY NOT NULL, + GuestEmail TEXT NOT NULL, + RoomNumber INT NOT NULL, + Start TEXT NOT NULL, + End TEXT NOT NULL, + CheckedIn INT NOT NULL DEFAULT 0, + CheckedOut INT NOT NULL DEFAULT 0 + ); + INSERT INTO Guests (Email, Name) VALUES ('guest@example.com', 'Test Guest'); + INSERT INTO Rooms (Number, State) VALUES (101, 0); + INSERT INTO Rooms (Number, State) VALUES (202, 0); + "); + + _repo = new ReservationRepository(_db); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + private async Task Insert(string room, string start, string end, + bool checkedIn = false, bool checkedOut = false) + { + var id = Guid.NewGuid(); + await _db.ExecuteAsync( + @"INSERT INTO Reservations (Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) + VALUES (@Id, 'guest@example.com', @RoomNumber, @Start, @End, @CheckedIn, @CheckedOut);", + new + { + Id = id.ToString(), + RoomNumber = int.Parse(room), + Start = start, + End = end, + CheckedIn = checkedIn ? 1 : 0, + CheckedOut = checkedOut ? 1 : 0 + }); + return await _repo.GetReservation(id); + } + + // ── GetReservations ────────────────────────────────────────────────────── + + [Test] + public async Task GetReservations_EmptyTable_ReturnsEmpty() + { + var result = await _repo.GetReservations(); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task GetReservations_MultipleReservations_ReturnsAll() + { + await Insert("101", "2025-06-01", "2025-06-05"); + await Insert("202", "2025-07-01", "2025-07-05"); + + var result = await _repo.GetReservations(); + + Assert.That(result.Count(), Is.EqualTo(2)); + } + + // ── GetUpcomingReservations ────────────────────────────────────────────── + + [Test] + public async Task GetUpcomingReservations_PastReservation_IsExcluded() + { + // A reservation that ended well in the past + await Insert("101", "2000-01-01", "2000-01-05"); + + var result = await _repo.GetUpcomingReservations(); + + Assert.That(result, Is.Empty); + } + + [Test] + public async Task GetUpcomingReservations_FutureReservation_IsIncluded() + { + // A reservation ending far in the future + await Insert("101", "2099-01-01", "2099-01-10"); + + var result = await _repo.GetUpcomingReservations(); + + Assert.That(result.Count(), Is.EqualTo(1)); + } + + [Test] + public async Task GetUpcomingReservations_MixedReservations_ReturnsOnlyFuture() + { + await Insert("101", "2000-01-01", "2000-01-05"); // past + await Insert("202", "2099-06-01", "2099-06-10"); // future + + var result = await _repo.GetUpcomingReservations(); + + Assert.That(result.Count(), Is.EqualTo(1)); + Assert.That(result.First().RoomNumber, Is.EqualTo("202")); + } + + [Test] + public async Task GetUpcomingReservations_ReturnsOrderedByStart() + { + await Insert("202", "2099-07-01", "2099-07-10"); + await Insert("101", "2099-06-01", "2099-06-10"); + + var result = (await _repo.GetUpcomingReservations()).ToList(); + + Assert.That(result[0].RoomNumber, Is.EqualTo("101")); + Assert.That(result[1].RoomNumber, Is.EqualTo("202")); + } + + // ── DeleteReservation ──────────────────────────────────────────────────── + + [Test] + public async Task DeleteReservation_ExistingReservation_ReturnsTrue() + { + var reservation = await Insert("101", "2025-06-01", "2025-06-05"); + + var result = await _repo.DeleteReservation(reservation.Id); + + Assert.That(result, Is.True); + } + + [Test] + public async Task DeleteReservation_ExistingReservation_IsActuallyDeleted() + { + var reservation = await Insert("101", "2025-06-01", "2025-06-05"); + await _repo.DeleteReservation(reservation.Id); + + var all = await _repo.GetReservations(); + Assert.That(all, Is.Empty); + } + + [Test] + public async Task DeleteReservation_UnknownId_ReturnsFalse() + { + var result = await _repo.DeleteReservation(Guid.NewGuid()); + + Assert.That(result, Is.False); + } + } +} diff --git a/api.Tests/RoomModelTests.cs b/api.Tests/RoomModelTests.cs new file mode 100644 index 0000000..b85c56d --- /dev/null +++ b/api.Tests/RoomModelTests.cs @@ -0,0 +1,55 @@ +using Models; +using Models.Errors; +using NUnit.Framework; + +namespace api.Tests +{ + [TestFixture] + public class RoomModelTests + { + // ── FormatRoomNumber ───────────────────────────────────────────────────── + + [TestCase(101, "101", TestName = "FormatRoomNumber_101_ReturnsString101")] + [TestCase(1, "001", TestName = "FormatRoomNumber_1_PadsToThreeDigits")] + [TestCase(10, "010", TestName = "FormatRoomNumber_10_PadsToThreeDigits")] + [TestCase(999, "999", TestName = "FormatRoomNumber_999_NoPadNeeded")] + public void FormatRoomNumber_ReturnsZeroPaddedThreeDigitString(int input, string expected) + { + Assert.That(Room.FormatRoomNumber(input), Is.EqualTo(expected)); + } + + // ── IsValidRoomNumber ──────────────────────────────────────────────────── + // (comprehensive cases are in BookingValidatorTests; these cover the + // boundary cases of the static method directly) + + [TestCase("101", true, TestName = "IsValidRoomNumber_101_IsValid")] + [TestCase("100", false, TestName = "IsValidRoomNumber_100_EndsInDoubleZero")] + [TestCase("001", true, TestName = "IsValidRoomNumber_001_IsValid")] + [TestCase("999", true, TestName = "IsValidRoomNumber_999_IsValid")] + [TestCase("1000",false, TestName = "IsValidRoomNumber_FourDigits_IsInvalid")] + [TestCase("10", false, TestName = "IsValidRoomNumber_TwoDigits_IsInvalid")] + [TestCase("abc", false, TestName = "IsValidRoomNumber_NonNumeric_IsInvalid")] + [TestCase("", false, TestName = "IsValidRoomNumber_Empty_IsInvalid")] + public void IsValidRoomNumber_ReturnsExpected(string roomNumber, bool expected) + { + Assert.That(Room.IsValidRoomNumber(roomNumber), Is.EqualTo(expected)); + } + + // ── ConvertRoomNumberToInt ─────────────────────────────────────────────── + + [TestCase("101", 101, TestName = "ConvertRoomNumberToInt_101_Returns101")] + [TestCase("001", 1, TestName = "ConvertRoomNumberToInt_001_Returns1")] + [TestCase("999", 999, TestName = "ConvertRoomNumberToInt_999_Returns999")] + public void ConvertRoomNumberToInt_ValidNumber_ReturnsInt(string input, int expected) + { + Assert.That(Room.ConvertRoomNumberToInt(input), Is.EqualTo(expected)); + } + + [TestCase("abc", TestName = "ConvertRoomNumberToInt_NonNumeric_ThrowsInvalidRoomNumber")] + [TestCase("", TestName = "ConvertRoomNumberToInt_Empty_ThrowsInvalidRoomNumber")] + public void ConvertRoomNumberToInt_InvalidInput_ThrowsInvalidRoomNumber(string input) + { + Assert.Throws(() => Room.ConvertRoomNumberToInt(input)); + } + } +} diff --git a/api.Tests/RoomRepositoryTests.cs b/api.Tests/RoomRepositoryTests.cs new file mode 100644 index 0000000..18e8ef4 --- /dev/null +++ b/api.Tests/RoomRepositoryTests.cs @@ -0,0 +1,137 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Models; +using Models.Errors; +using NUnit.Framework; +using Repositories; + +namespace api.Tests +{ + [TestFixture] + public class RoomRepositoryTests + { + private SqliteConnection _db = null!; + private RoomRepository _repo = null!; + + [SetUp] + public async Task SetUp() + { + _db = new SqliteConnection("Data Source=:memory:"); + await _db.OpenAsync(); + + await _db.ExecuteAsync(@" + CREATE TABLE Rooms ( + Number INT PRIMARY KEY NOT NULL, + State INT NOT NULL + ); + "); + + _repo = new RoomRepository(_db); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + // ── GetRooms ───────────────────────────────────────────────────────────── + + [Test] + public async Task GetRooms_EmptyTable_ReturnsEmpty() + { + var rooms = await _repo.GetRooms(); + Assert.That(rooms, Is.Empty); + } + + [Test] + public async Task GetRooms_MultipleRooms_ReturnsAll() + { + await _db.ExecuteAsync("INSERT INTO Rooms (Number, State) VALUES (101, 0);"); + await _db.ExecuteAsync("INSERT INTO Rooms (Number, State) VALUES (202, 0);"); + + var rooms = await _repo.GetRooms(); + + Assert.That(rooms.Count(), Is.EqualTo(2)); + } + + // ── GetRoom ────────────────────────────────────────────────────────────── + + [Test] + public async Task GetRoom_ExistingRoom_ReturnsRoom() + { + await _db.ExecuteAsync("INSERT INTO Rooms (Number, State) VALUES (101, 0);"); + + var room = await _repo.GetRoom("101"); + + Assert.That(room.Number, Is.EqualTo("101")); + Assert.That(room.State, Is.EqualTo(State.Ready)); + } + + [Test] + public void GetRoom_UnknownRoomNumber_ThrowsNotFoundException() + { + Assert.ThrowsAsync(() => _repo.GetRoom("999")); + } + + // ── CreateRoom ─────────────────────────────────────────────────────────── + + [Test] + public async Task CreateRoom_NewRoom_ReturnsCreated() + { + var newRoom = new Room { Number = "303", State = State.Ready }; + + var created = await _repo.CreateRoom(newRoom); + + Assert.That(created.Number, Is.EqualTo("303")); + Assert.That(created.State, Is.EqualTo(State.Ready)); + } + + [TestCase(State.Ready, TestName = "CreateRoom_StateReady_Persists")] + [TestCase(State.Occupied, TestName = "CreateRoom_StateOccupied_Persists")] + [TestCase(State.Dirty, TestName = "CreateRoom_StateDirty_Persists")] + public async Task CreateRoom_WithState_PersistsState(State state) + { + var newRoom = new Room { Number = "401", State = state }; + + var created = await _repo.CreateRoom(newRoom); + + Assert.That(created.State, Is.EqualTo(state)); + } + + // ── DeleteRoom ─────────────────────────────────────────────────────────── + + [Test] + public async Task DeleteRoom_ExistingRoom_ReturnsTrue() + { + await _db.ExecuteAsync("INSERT INTO Rooms (Number, State) VALUES (101, 0);"); + + var result = await _repo.DeleteRoom("101"); + + Assert.That(result, Is.True); + } + + [Test] + public async Task DeleteRoom_ExistingRoom_IsActuallyDeleted() + { + await _db.ExecuteAsync("INSERT INTO Rooms (Number, State) VALUES (101, 0);"); + await _repo.DeleteRoom("101"); + + Assert.ThrowsAsync(() => _repo.GetRoom("101")); + } + + [Test] + public async Task DeleteRoom_UnknownRoom_ReturnsFalse() + { + var result = await _repo.DeleteRoom("999"); + + Assert.That(result, Is.False); + } + + // ── SetRoomState (not-found path) ──────────────────────────────────────── + + [Test] + public void SetRoomState_UnknownRoom_ThrowsNotFoundException() + { + Assert.ThrowsAsync(() => + _repo.SetRoomState("999", State.Dirty)); + } + } +} From 78c3fe5a42b0907f9d3de87e231b9741adb9757c Mon Sep 17 00:00:00 2001 From: Francisco Rosa Date: Tue, 28 Apr 2026 23:43:21 +0200 Subject: [PATCH 21/36] Update api/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/Program.cs b/api/Program.cs index 919b5da..c91b1e4 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -56,8 +56,8 @@ app.UsePathBase("/api") .UseAuthentication() .UseAuthorization() - .UseMvc() .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) + .UseMvc() .UseSwagger() .UseSwaggerUI(); } From 74641cdf0f54bb3c97da465d62d4e146e55eef46 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 00:13:19 +0200 Subject: [PATCH 22/36] Improve reservation conflict checks and test coverage - Add CheckForConflictTests for comprehensive overlap testing - Refactor ReservationRepositoryTests with parameterized cases - Store reservation dates as ISO TEXT for correct comparisons - Clarify room number validation error messages - Add GuestRepository.GetOrCreateGuest for on-demand guest creation - Use CreatedAtAction in ReservationController for Location header - Remove obsolete StaffAuth.cs; use attribute-based auth - Update authorization: allow anonymous booking, restrict staff actions - Remove unused staffAccessCode from appsettings files --- api.Tests/CheckForConflictTests.cs | 198 ++++++++++++++++++++++ api.Tests/ReservationRepositoryTests.cs | 98 ++++++++--- api/Controllers/ReservationController.cs | 24 ++- api/Controllers/RoomController.cs | 8 +- api/Controllers/StaffAuth.cs | 24 --- api/Db/Setup.cs | 4 +- api/Repositories/GuestRepository.cs | 23 +++ api/Repositories/ReservationRepository.cs | 44 ++++- api/Validators/BookingValidator.cs | 2 +- api/appsettings.Development.json | 3 +- api/appsettings.json | 3 +- 11 files changed, 358 insertions(+), 73 deletions(-) create mode 100644 api.Tests/CheckForConflictTests.cs delete mode 100644 api/Controllers/StaffAuth.cs diff --git a/api.Tests/CheckForConflictTests.cs b/api.Tests/CheckForConflictTests.cs new file mode 100644 index 0000000..629b3b7 --- /dev/null +++ b/api.Tests/CheckForConflictTests.cs @@ -0,0 +1,198 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Models; +using Models.Errors; +using NUnit.Framework; +using Repositories; + +namespace api.Tests +{ + /// + /// Tests for the conflict-detection logic inside ReservationRepository.CheckForConflict, + /// exercised indirectly via CreateReservation (the only public entry point). + /// + /// Interval model: [Start, End) — the standard half-open hotel night convention. + /// Two reservations on the same room conflict when: existingStart < newEnd AND existingEnd > newStart + /// + [TestFixture] + public class CheckForConflictTests + { + private SqliteConnection _db = null!; + private ReservationRepository _repo = null!; + + [SetUp] + public async Task SetUp() + { + _db = new SqliteConnection("Data Source=:memory:"); + await _db.OpenAsync(); + + await _db.ExecuteAsync(@" + CREATE TABLE Guests ( + Email TEXT PRIMARY KEY NOT NULL, + Name TEXT NOT NULL, + Surname TEXT + ); + CREATE TABLE Rooms ( + Number INT PRIMARY KEY NOT NULL, + State INT NOT NULL + ); + CREATE TABLE Reservations ( + Id TEXT PRIMARY KEY NOT NULL, + GuestEmail TEXT NOT NULL, + RoomNumber INT NOT NULL, + Start TEXT NOT NULL, + End TEXT NOT NULL, + CheckedIn INT NOT NULL DEFAULT 0, + CheckedOut INT NOT NULL DEFAULT 0 + ); + INSERT INTO Guests (Email, Name) VALUES ('guest@example.com', 'Test Guest'); + INSERT INTO Rooms (Number, State) VALUES (101, 0); + INSERT INTO Rooms (Number, State) VALUES (102, 0); + "); + + _repo = new ReservationRepository(_db); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + private static Reservation Make(string room, string start, string end) => new() + { + Id = Guid.NewGuid(), + RoomNumber = room, + GuestEmail = "guest@example.com", + Start = DateTime.Parse(start), + End = DateTime.Parse(end), + }; + + // ── Overlapping — must throw ConflictException ──────────────────────────── + + // existing: |-------| + // new: |-------| + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-07", "2025-06-15", + TestName = "Conflict_NewStartsInsideExisting_Throws")] + + // existing: |-------| + // new: |-------| + [TestCase("101", "2025-06-07", "2025-06-15", "2025-06-01", "2025-06-10", + TestName = "Conflict_NewEndsInsideExisting_Throws")] + + // existing: |---------------| + // new: |-------| + [TestCase("101", "2025-06-01", "2025-06-20", "2025-06-05", "2025-06-15", + TestName = "Conflict_NewContainedByExisting_Throws")] + + // existing: |-------| + // new: |---------------| + [TestCase("101", "2025-06-05", "2025-06-15", "2025-06-01", "2025-06-20", + TestName = "Conflict_NewContainsExisting_Throws")] + + // existing: |-------| + // new: |-------| + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-01", "2025-06-10", + TestName = "Conflict_ExactSameDates_Throws")] + + // existing: |-------| + // new: |---| + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-01", "2025-06-05", + TestName = "Conflict_NewSameStartShorter_Throws")] + + // existing: |-------| + // new: |---| + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-05", "2025-06-10", + TestName = "Conflict_NewSameEndLonger_Throws")] + + // existing: |-| + // new: |-------| + [TestCase("101", "2025-06-03", "2025-06-05", "2025-06-01", "2025-06-10", + TestName = "Conflict_ExistingShorterInsideNew_Throws")] + public async Task CheckForConflict_OverlappingRanges_ThrowsConflictException( + string room, + string existStart, string existEnd, + string newStart, string newEnd) + { + await _repo.CreateReservation(Make(room, existStart, existEnd)); + + Assert.ThrowsAsync( + () => _repo.CreateReservation(Make(room, newStart, newEnd))); + } + + // ── Non-overlapping — must NOT throw ───────────────────────────────────── + + // existing: |-------| + // new: |-------| (new starts exactly when existing ends) + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-10", "2025-06-20", + TestName = "NoConflict_AdjacentNewAfter_DoesNotThrow")] + + // existing: |-------| + // new: |-------| (new ends exactly when existing starts) + [TestCase("101", "2025-06-10", "2025-06-20", "2025-06-01", "2025-06-10", + TestName = "NoConflict_AdjacentNewBefore_DoesNotThrow")] + + // existing: |-------| + // new: |--| (gap between) + [TestCase("101", "2025-06-01", "2025-06-05", "2025-06-10", "2025-06-15", + TestName = "NoConflict_NewStartsAfterGap_DoesNotThrow")] + + // existing: |-------| + // new: |--| (gap between) + [TestCase("101", "2025-06-10", "2025-06-15", "2025-06-01", "2025-06-05", + TestName = "NoConflict_NewEndsBeforeGap_DoesNotThrow")] + + // same dates but different room — never a conflict + [TestCase("101", "2025-06-01", "2025-06-10", "2025-06-01", "2025-06-10", + TestName = "NoConflict_SameDatesButDifferentRoom_DoesNotThrow")] + public async Task CheckForConflict_NonOverlappingRanges_DoesNotThrow( + string room, + string existStart, string existEnd, + string newStart, string newEnd) + { + await _repo.CreateReservation(Make(room, existStart, existEnd)); + + // For the different-room case, point the new reservation at room 102 + var newRoom = (room == "101" && newStart == existStart && newEnd == existEnd) ? "102" : room; + + Assert.DoesNotThrowAsync( + () => _repo.CreateReservation(Make(newRoom, newStart, newEnd))); + } + + // ── Multiple existing reservations ─────────────────────────────────────── + + // Ensures the query finds a conflict even when there are multiple rows + // and the conflict is not with the first-inserted one. + [TestCase("101", "2025-06-01", "2025-06-05", + "2025-06-10", "2025-06-15", + "2025-06-12", "2025-06-18", + TestName = "Conflict_SecondOfTwoExistingReservations_Throws")] + public async Task CheckForConflict_ConflictsWithSecondExistingReservation_Throws( + string room, + string exist1Start, string exist1End, + string exist2Start, string exist2End, + string newStart, string newEnd) + { + await _repo.CreateReservation(Make(room, exist1Start, exist1End)); + await _repo.CreateReservation(Make(room, exist2Start, exist2End)); + + Assert.ThrowsAsync( + () => _repo.CreateReservation(Make(room, newStart, newEnd))); + } + + // A reservation that fits cleanly in a gap between two existing ones must succeed. + [TestCase("101", "2025-06-01", "2025-06-05", + "2025-06-10", "2025-06-15", + "2025-06-05", "2025-06-10", + TestName = "NoConflict_FitsInGapBetweenTwoExisting_DoesNotThrow")] + public async Task CheckForConflict_FitsInGapBetweenTwoReservations_DoesNotThrow( + string room, + string exist1Start, string exist1End, + string exist2Start, string exist2End, + string newStart, string newEnd) + { + await _repo.CreateReservation(Make(room, exist1Start, exist1End)); + await _repo.CreateReservation(Make(room, exist2Start, exist2End)); + + Assert.DoesNotThrowAsync( + () => _repo.CreateReservation(Make(room, newStart, newEnd))); + } + } +} diff --git a/api.Tests/ReservationRepositoryTests.cs b/api.Tests/ReservationRepositoryTests.cs index be7abf6..48dbfba 100644 --- a/api.Tests/ReservationRepositoryTests.cs +++ b/api.Tests/ReservationRepositoryTests.cs @@ -59,8 +59,10 @@ await _db.ExecuteAsync( { Id = id.ToString(), RoomNumber = int.Parse(room), - Start = start, - End = end, + // Pass DateTime so Dapper serialises to "yyyy-MM-dd HH:mm:ss", matching + // what CreateReservation stores and what GetUpcomingReservations compares against. + Start = DateTime.Parse(start), + End = DateTime.Parse(end), CheckedIn = checkedIn ? 1 : 0, CheckedOut = checkedOut ? 1 : 0 }); @@ -88,51 +90,91 @@ public async Task GetReservations_MultipleReservations_ReturnsAll() } // ── GetUpcomingReservations ────────────────────────────────────────────── - - [Test] - public async Task GetUpcomingReservations_PastReservation_IsExcluded() + // + // startDaysFromNow / endDaysFromNow are offsets relative to DateTime.UtcNow.Date + // so the tests stay correct regardless of when they are run. + + // Single-reservation inclusion / exclusion + [TestCase(-10, -1, 0, TestName = "Upcoming_EndedYesterday_IsExcluded")] + [TestCase(-5, -5, 0, TestName = "Upcoming_EndedFiveDaysAgo_IsExcluded")] + [TestCase(-1, 0, 1, TestName = "Upcoming_EndsToday_IsIncluded")] + [TestCase( 0, 1, 1, TestName = "Upcoming_StartsTodayEndsTomorrow_IsIncluded")] + [TestCase( 1, 5, 1, TestName = "Upcoming_StartsAndEndsFuture_IsIncluded")] + [TestCase( 0, 30, 1, TestName = "Upcoming_EndsThirtyDaysAhead_IsIncluded")] + public async Task GetUpcomingReservations_SingleReservation_CountMatchesExpected( + int startDaysFromNow, int endDaysFromNow, int expectedCount) { - // A reservation that ended well in the past - await Insert("101", "2000-01-01", "2000-01-05"); + var today = DateTime.UtcNow.Date; + var start = today.AddDays(startDaysFromNow).ToString("yyyy-MM-dd"); + var end = today.AddDays(endDaysFromNow).ToString("yyyy-MM-dd"); + + await Insert("101", start, end); var result = await _repo.GetUpcomingReservations(); - Assert.That(result, Is.Empty); + Assert.That(result.Count(), Is.EqualTo(expectedCount)); } - [Test] - public async Task GetUpcomingReservations_FutureReservation_IsIncluded() + // Mixed past + future — only upcoming ones come back + [TestCase(1, 1, TestName = "Upcoming_OnePastOneFuture_ReturnsOneFuture")] + [TestCase(2, 2, TestName = "Upcoming_TwoPastTwoFuture_ReturnsTwoFuture")] + public async Task GetUpcomingReservations_MixedReservations_ReturnsOnlyUpcoming( + int pastCount, int futureCount) { - // A reservation ending far in the future - await Insert("101", "2099-01-01", "2099-01-10"); + var today = DateTime.UtcNow.Date; + + // Insert past reservations (room 101) + for (int i = 0; i < pastCount; i++) + { + var s = today.AddDays(-20 - i).ToString("yyyy-MM-dd"); + var e = today.AddDays(-10 - i).ToString("yyyy-MM-dd"); + await Insert("101", s, e); + } + + // Insert future reservations (room 202) + for (int i = 0; i < futureCount; i++) + { + var s = today.AddDays(10 + i).ToString("yyyy-MM-dd"); + var e = today.AddDays(20 + i).ToString("yyyy-MM-dd"); + await Insert("202", s, e); + } var result = await _repo.GetUpcomingReservations(); - Assert.That(result.Count(), Is.EqualTo(1)); + Assert.That(result.Count(), Is.EqualTo(futureCount)); + Assert.That(result.All(r => r.RoomNumber == "202"), Is.True); } - [Test] - public async Task GetUpcomingReservations_MixedReservations_ReturnsOnlyFuture() + // Results must be ordered by Start ASC + [TestCase("101", 5, 10, "202", 1, 4, "202", "101", TestName = "Upcoming_OrderedByStart_EarlierStartFirst")] + [TestCase("101", 1, 3, "202", 4, 8, "101", "202", TestName = "Upcoming_OrderedByStart_LaterStartSecond")] + public async Task GetUpcomingReservations_MultipleReservations_ReturnedOrderedByStartAsc( + string room1, int start1, int end1, + string room2, int start2, int end2, + string expectedFirst, string expectedSecond) { - await Insert("101", "2000-01-01", "2000-01-05"); // past - await Insert("202", "2099-06-01", "2099-06-10"); // future + var today = DateTime.UtcNow.Date; - var result = await _repo.GetUpcomingReservations(); + await Insert(room1, + today.AddDays(start1).ToString("yyyy-MM-dd"), + today.AddDays(end1).ToString("yyyy-MM-dd")); + + await Insert(room2, + today.AddDays(start2).ToString("yyyy-MM-dd"), + today.AddDays(end2).ToString("yyyy-MM-dd")); - Assert.That(result.Count(), Is.EqualTo(1)); - Assert.That(result.First().RoomNumber, Is.EqualTo("202")); + var result = (await _repo.GetUpcomingReservations()).ToList(); + + Assert.That(result[0].RoomNumber, Is.EqualTo(expectedFirst)); + Assert.That(result[1].RoomNumber, Is.EqualTo(expectedSecond)); } + // Empty table edge case [Test] - public async Task GetUpcomingReservations_ReturnsOrderedByStart() + public async Task GetUpcomingReservations_EmptyTable_ReturnsEmpty() { - await Insert("202", "2099-07-01", "2099-07-10"); - await Insert("101", "2099-06-01", "2099-06-10"); - - var result = (await _repo.GetUpcomingReservations()).ToList(); - - Assert.That(result[0].RoomNumber, Is.EqualTo("101")); - Assert.That(result[1].RoomNumber, Is.EqualTo("202")); + var result = await _repo.GetUpcomingReservations(); + Assert.That(result, Is.Empty); } // ── DeleteReservation ──────────────────────────────────────────────────── diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index b670dd4..adf8a97 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -7,7 +7,7 @@ namespace Controllers { - [Tags("Reservations"), Route("reservation"), Authorize(Policy = "StaffOnly")] + [Tags("Reservations"), Route("reservation")] public class ReservationController : Controller { private ReservationRepository _repo { get; set; } @@ -21,7 +21,7 @@ public ReservationController(ReservationRepository reservationRepository, RoomRe _guestRepo = guestRepository; } - [HttpGet, Produces("application/json"), Route("")] + [HttpGet, Produces("application/json"), Route(""), Authorize(Policy = "StaffOnly")] public async Task> GetReservations() { var reservations = await _repo.GetReservations(); @@ -29,7 +29,7 @@ public async Task> GetReservations() return Json(reservations); } - [HttpGet, Produces("application/json"), Route("{reservationId}")] + [HttpGet, Produces("application/json"), Route("{reservationId}"), Authorize(Policy = "StaffOnly")] public async Task> GetRoom(Guid reservationId) { try @@ -48,7 +48,7 @@ public async Task> GetRoom(Guid reservationId) /// /// /// - [HttpPost, Produces("application/json"), Route("")] + [HttpPost, Produces("application/json"), Route(""), AllowAnonymous] public async Task> BookReservation( [FromBody] Reservation? newBooking ) @@ -68,14 +68,20 @@ [FromBody] Reservation? newBooking { BookingValidator.Validate(newBooking); - // Verify the guest exists - await _guestRepo.GetGuestByEmail(newBooking.GuestEmail); + // Ensure the guest record exists, creating one on-demand if this is + // their first booking (the UI only supplies an email, not a full profile). + await _guestRepo.GetOrCreateGuest(newBooking.GuestEmail); // Verify the room exists await _roomRepo.GetRoom(newBooking.RoomNumber); var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/{createdReservation.Id}", createdReservation); + + // CreatedAtAction delegates URL generation to the routing infrastructure, + // so it automatically prepends whatever PathBase (e.g. /api) the host or reverse proxy has configured. + // The Location header will now always be a correctly-rooted URL that resolves to GET /reservation/{id} + // regardless of where the app is mounted. + return CreatedAtAction(nameof(GetRoom), new { reservationId = createdReservation.Id }, createdReservation); } catch (ConflictException ex) { @@ -99,7 +105,7 @@ [FromBody] Reservation? newBooking } - [HttpDelete, Produces("application/json"), Route("{reservationId}")] + [HttpDelete, Produces("application/json"), Route("{reservationId}"), Authorize(Policy = "StaffOnly")] public async Task DeleteReservation(Guid reservationId) { var result = await _repo.DeleteReservation(reservationId); @@ -107,7 +113,7 @@ public async Task DeleteReservation(Guid reservationId) return result ? NoContent() : NotFound(); } - [HttpPost, Produces("application/json"), Route("{reservationId}/checkin")] + [HttpPost, Produces("application/json"), Route("{reservationId}/checkin"), Authorize(Policy = "StaffOnly")] public async Task> CheckIn(Guid reservationId, [FromBody] CheckInRequest? request) { if (request is null || string.IsNullOrEmpty(request.GuestEmail)) diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index df6e2df..69e6547 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -34,7 +34,7 @@ public async Task> GetRoom(string roomNumber) { if (!Room.IsValidRoomNumber(roomNumber)) { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + return BadRequest("Invalid room ID - must be exactly 3 digits and the last two digits cannot be 00 (e.g. 101, 202)."); } try @@ -54,7 +54,7 @@ public async Task> CreateRoom([FromBody] Room newRoom) { if (!Room.IsValidRoomNumber(newRoom.Number)) { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + return BadRequest("Invalid room ID - must be exactly 3 digits and the last two digits cannot be 00 (e.g. 101, 202)."); } var createdRoom = await _repo.CreateRoom(newRoom); @@ -72,7 +72,7 @@ public async Task DeleteRoom(string roomNumber) { if (!Room.IsValidRoomNumber(roomNumber)) { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + return BadRequest("Invalid room ID - must be exactly 3 digits and the last two digits cannot be 00 (e.g. 101, 202)."); } var deleted = await _repo.DeleteRoom(roomNumber); @@ -85,7 +85,7 @@ public async Task SetRoomState(string roomNumber, [FromBody] SetR { if (!Room.IsValidRoomNumber(roomNumber)) { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + return BadRequest("Invalid room ID - must be exactly 3 digits and the last two digits cannot be 00 (e.g. 101, 202)."); } if (request is null) diff --git a/api/Controllers/StaffAuth.cs b/api/Controllers/StaffAuth.cs deleted file mode 100644 index e7cc7da..0000000 --- a/api/Controllers/StaffAuth.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Controllers -{ - internal static class StaffAuth - { - /// - /// Checks if the request is from a staff member, if not returns true and a 403 result - /// - internal static bool IsNotStaff(HttpRequest request, out IActionResult? result) - { - request.Cookies.TryGetValue("access", out string? accessValue); - - if (accessValue == null || accessValue == "0") - { - result = new StatusCodeResult(403); - return true; - } - - result = null; - return false; - } - } -} diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 49b86cd..49e1536 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -51,8 +51,8 @@ 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.Start)} INT NOT NULL, - {nameof(Reservation.End)} INT NOT NULL, + {nameof(Reservation.Start)} TEXT NOT NULL, + {nameof(Reservation.End)} TEXT NOT NULL, {nameof(Reservation.CheckedIn)} INT NOT NULL DEFAULT FALSE, {nameof(Reservation.CheckedOut)} INT NOT NULL DEFAULT FALSE, FOREIGN KEY ({nameof(Reservation.GuestEmail)}) diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 2393ff0..5a7c076 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -76,6 +76,29 @@ public async Task UpdateGuest(string email, Guest updatedGuest) return updated; } + public async Task GetOrCreateGuest(string email) + { + var existing = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Guests WHERE Email = @email;", + new { email } + ); + + if (existing != null) + { + return existing; + } + + // Derive a placeholder name from the local part of the email so the + // NOT NULL constraint on Name is satisfied without requiring the UI + // to collect it separately during booking. + var name = email.Split('@')[0]; + + return await _db.QuerySingleAsync( + "INSERT INTO Guests(Email, Name, Surname) VALUES(@Email, @Name, NULL) RETURNING *", + new { Email = email, Name = name } + ); + } + public async Task DeleteGuestByEmail(string guestEmail) { var count = await _db.ExecuteAsync( diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 2e8ee78..8b31350 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -29,7 +29,7 @@ public async Task> GetReservations() public async Task> GetUpcomingReservations() { - var today = DateTime.UtcNow.Date.ToString("yyyy-MM-dd"); + var today = DateTime.UtcNow.Date; var reservations = await _db.QueryAsync( "SELECT * FROM Reservations WHERE End >= @today ORDER BY Start ASC;", @@ -132,6 +132,48 @@ public async Task CheckIn(Guid reservationId, string guestEmail) return updated.ToDomain(); } + /// + /// Validates the check-in conditions against the already-loaded , + /// then atomically marks the reservation as checked-in and sets the room state to Occupied + /// inside a single transaction, avoiding partial-update inconsistencies. + /// + public async Task CheckInWithRoomUpdate(Reservation reservation, string guestEmail) + { + if (!string.Equals(reservation.GuestEmail, guestEmail, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Email does not match reservation."); + } + + if (reservation.CheckedOut) + { + throw new InvalidOperationException("Reservation has already been checked out."); + } + + if (reservation.CheckedIn) + { + throw new InvalidOperationException("Reservation is already checked in."); + } + + using var tx = _db.BeginTransaction(); + + var updated = await _db.QuerySingleAsync( + "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @id RETURNING *;", + new { id = reservation.Id.ToString() }, + tx + ); + + var roomNumberInt = Room.ConvertRoomNumberToInt(reservation.RoomNumber); + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @state WHERE Number = @roomNumberInt;", + new { state = Models.State.Occupied, roomNumberInt }, + tx + ); + + tx.Commit(); + + return updated.ToDomain(); + } + private class ReservationDb { public string Id { get; set; } diff --git a/api/Validators/BookingValidator.cs b/api/Validators/BookingValidator.cs index 59262be..3de0cbf 100644 --- a/api/Validators/BookingValidator.cs +++ b/api/Validators/BookingValidator.cs @@ -10,7 +10,7 @@ public static void Validate(Reservation booking) { if (!Room.IsValidRoomNumber(booking.RoomNumber)) { - throw new InvalidBooking($"'{booking.RoomNumber}' is not a valid room number."); + throw new InvalidBooking($"'{booking.RoomNumber}' is not a valid room number - must be exactly 3 digits and the last two digits cannot be 00 (e.g. 101, 202)."); } if (!Regex.IsMatch(booking.GuestEmail, @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) diff --git a/api/appsettings.Development.json b/api/appsettings.Development.json index 3be6213..10f68b8 100644 --- a/api/appsettings.Development.json +++ b/api/appsettings.Development.json @@ -5,6 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", - "staffAccessCode": "Replaced by CI/CD" + "AllowedHosts": "*" } diff --git a/api/appsettings.json b/api/appsettings.json index 3be6213..6dcc0f3 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -5,6 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", - "staffAccessCode": "Replaced by CI/CD" + "AllowedHosts": "*" } From 8c83c458fc5e3e925e077595ba14f23e2803f83b Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 00:21:55 +0200 Subject: [PATCH 23/36] Atomically check in reservations and improve DB setup Refactor reservation check-in to atomically update both reservation and room state in a single repository call. Change EnsureDb to async Task and properly await it during app startup, ensuring database setup completes before continuing. Adjust middleware order for correct CORS and authentication handling. --- api/Controllers/ReservationController.cs | 6 ++++-- api/Db/Setup.cs | 2 +- api/Program.cs | 7 ++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index adf8a97..431c5ee 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -131,8 +131,10 @@ public async Task> CheckIn(Guid reservationId, [FromBo return BadRequest("Cannot check in: room is not ready for check-in."); } - var checkedIn = await _repo.CheckIn(reservationId, request.GuestEmail); - await _roomRepo.SetRoomState(checkedIn.RoomNumber, Models.State.Occupied); + // Atomically marks the reservation checked-in and sets the room to Occupied + // in a single transaction, reusing the already-loaded reservation to avoid + // an extra round-trip inside the repository. + var checkedIn = await _repo.CheckInWithRoomUpdate(reservation, request.GuestEmail); return Json(checkedIn); } catch (NotFoundException) diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 49e1536..0bb9125 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -9,7 +9,7 @@ public static class Setup /// /// Ensures the DB is available and the required tables are made /// - public static async void EnsureDb(IServiceScope scope) + public static async Task EnsureDb(IServiceScope scope) { using var db = scope.ServiceProvider.GetRequiredService(); diff --git a/api/Program.cs b/api/Program.cs index 919b5da..c408b8e 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -43,7 +43,8 @@ { try { - Setup.EnsureDb(app.Services.CreateScope()); + using var scope = app.Services.CreateScope(); + await Setup.EnsureDb(scope); } catch (Exception ex) { @@ -54,10 +55,10 @@ } app.UsePathBase("/api") + .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) .UseAuthentication() .UseAuthorization() - .UseMvc() - .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) + .UseMvc() .UseSwagger() .UseSwaggerUI(); } From 0b304b58d8f529d3269a31ed0b3070f6bfb1dfd7 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 00:29:05 +0200 Subject: [PATCH 24/36] (feat) Add sortable table hook and improve staff reservations UI Implemented a generic useSortableTable React hook for client-side sorting. Updated StaffReservationsPage to use sortable columns with clickable headers and sort indicators. Ensured stable sorting for reservation status. Added a database connection check before transactions in ReservationRepository.cs. --- api/Repositories/ReservationRepository.cs | 5 ++ ui/src/staff/StaffReservationsPage.tsx | 45 ++++++++++++---- ui/src/utils/useSortableTable.ts | 66 +++++++++++++++++++++++ 3 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 ui/src/utils/useSortableTable.ts diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 8b31350..5c8fd4b 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -154,6 +154,11 @@ public async Task CheckInWithRoomUpdate(Reservation reservation, st throw new InvalidOperationException("Reservation is already checked in."); } + if (_db.State != System.Data.ConnectionState.Open) + { + _db.Open(); + } + using var tx = _db.BeginTransaction(); var updated = await _db.QuerySingleAsync( diff --git a/ui/src/staff/StaffReservationsPage.tsx b/ui/src/staff/StaffReservationsPage.tsx index d69c1f7..fd3ac85 100644 --- a/ui/src/staff/StaffReservationsPage.tsx +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -15,6 +15,7 @@ import { } from "@radix-ui/themes"; import { useGetStaffReservations, useCheckIn, useGetRoomsState, useSetRoomState, RoomState, StaffReservation, StaffRoom } from "./api"; import { LoadingCard } from "../components/LoadingCard"; +import { useSortableTable } from "../utils/useSortableTable"; function RoomStateDialog({ room, @@ -128,11 +129,37 @@ export function StaffReservationsPage() { const [roomStateDialog, setRoomStateDialog] = useState<{ room: StaffRoom; targetState: RoomState } | null>(null); const today = new Date().toLocaleDateString(); - const isToday = (dateStr: string) => new Date(dateStr).toLocaleDateString() === today; - const filtered = reservations?.filter((r) => - todayOnly ? isToday(r.start) : true + const filtered = reservations?.filter((r) => todayOnly ? isToday(r.start) : true); + + type SortKey = "roomNumber" | "guestEmail" | "start" | "end" | "status"; + + // Derive a stable string for the "status" pseudo-column so the hook can sort it. + const getStatus = (r: StaffReservation): string => + r.checkedOut ? "Checked Out" : r.checkedIn ? "Checked In" : "Upcoming"; + + const { sorted, sortState, toggleSort } = useSortableTable< + StaffReservation & { status: string }, + SortKey + >( + filtered?.map((r) => ({ ...r, status: getStatus(r) })), + "start", + "asc" + ); + + // Arrow indicator for active sort column + const arrow = (key: SortKey) => + sortState.key !== key ? " ↕" : sortState.direction === "asc" ? " ↑" : " ↓"; + + // Clickable header cell + const SortHeader = ({ col, label }: { col: SortKey; label: string }) => ( + toggleSort(col)} + style={{ cursor: "pointer", userSelect: "none", whiteSpace: "nowrap" }} + > + {label}{arrow(col)} + ); return ( @@ -165,16 +192,16 @@ export function StaffReservationsPage() { - Room - Guest Email - Start - End - Status + + + + + - {filtered.map((r) => ( + {sorted.map((r) => ( #{r.roomNumber} {r.guestEmail} diff --git a/ui/src/utils/useSortableTable.ts b/ui/src/utils/useSortableTable.ts new file mode 100644 index 0000000..03ef483 --- /dev/null +++ b/ui/src/utils/useSortableTable.ts @@ -0,0 +1,66 @@ +import { useMemo, useState } from "react"; + +export type SortDirection = "asc" | "desc"; + +export interface SortState { + key: K; + direction: SortDirection; +} + +export interface UseSortableTableResult { + sorted: T[]; + sortState: SortState; + toggleSort: (key: K) => void; +} + +/** + * Generic client-side sort hook. + * + * @param rows The array to sort (typically already filtered). + * @param defaultKey The column key to sort by on first render. + * @param defaultDir Initial sort direction (defaults to "asc"). + * @param getValue Optional accessor override: (row, key) => comparable value. + * Defaults to `row[key]`. + */ +export function useSortableTable, K extends keyof T & string>( + rows: T[] | undefined, + defaultKey: K, + defaultDir: SortDirection = "asc", + getValue?: (row: T, key: K) => unknown +): UseSortableTableResult { + const [sortState, setSortState] = useState>({ + key: defaultKey, + direction: defaultDir, + }); + + const toggleSort = (key: K) => { + setSortState((prev) => + prev.key === key + ? { key, direction: prev.direction === "asc" ? "desc" : "asc" } + : { key, direction: "asc" } + ); + }; + + const sorted = useMemo(() => { + if (!rows) return []; + const { key, direction } = sortState; + const multiplier = direction === "asc" ? 1 : -1; + + return [...rows].sort((a, b) => { + const aVal = getValue ? getValue(a, key) : a[key]; + const bVal = getValue ? getValue(b, key) : b[key]; + + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1 * multiplier; + if (bVal == null) return -1 * multiplier; + + if (typeof aVal === "string" && typeof bVal === "string") { + return aVal.localeCompare(bVal) * multiplier; + } + + return (aVal < bVal ? -1 : aVal > bVal ? 1 : 0) * multiplier; + }); + }, [rows, sortState, getValue]); + + return { sorted, sortState, toggleSort }; +} From d1ec5fb0224ed8c4d1bc6354f64cb8c4f5e62a1a Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 00:51:04 +0200 Subject: [PATCH 25/36] (feat) Secure staff auth with data protection, add global error UI Implement signed staff authentication using ASP.NET Core Data Protection to prevent cookie forgery. Add global error handling in the frontend with TanStack Query, a new /error route, and a user-friendly ErrorPage component. Update router for 404 and error states. Add comments to AllowedHosts in appsettings. --- .../StaffAuthorizationHandler.cs | 26 +++++- api/Controllers/StaffController.cs | 17 ++-- api/Program.cs | 1 + api/appsettings.Development.json | 1 + api/appsettings.json | 3 +- ui/src/components/ErrorPage.tsx | 87 +++++++++++++++++++ ui/src/index.tsx | 19 +++- ui/src/router.tsx | 23 +++++ 8 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/ErrorPage.tsx diff --git a/api/Authorization/StaffAuthorizationHandler.cs b/api/Authorization/StaffAuthorizationHandler.cs index e054c0a..1d3f28f 100644 --- a/api/Authorization/StaffAuthorizationHandler.cs +++ b/api/Authorization/StaffAuthorizationHandler.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc.Filters; namespace Authorization @@ -7,17 +8,34 @@ public class StaffRequirement : IAuthorizationRequirement { } public class StaffAuthorizationHandler : AuthorizationHandler { + private readonly IDataProtector _protector; + + public StaffAuthorizationHandler(IDataProtectionProvider dataProtection) + { + _protector = dataProtection.CreateProtector("StaffAccess.v1"); + } + protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, StaffRequirement requirement) { var httpContext = (context.Resource as AuthorizationFilterContext)?.HttpContext; - if (httpContext != null) + if (httpContext != null && + httpContext.Request.Cookies.TryGetValue("access", out string? token) && + token != null) { - httpContext.Request.Cookies.TryGetValue("access", out string? accessValue); - if (accessValue == "1") + try + { + // Throws CryptographicException if the value was tampered with or forged. + var payload = _protector.Unprotect(token); + if (payload == "staff-authenticated") + { + context.Succeed(requirement); + } + } + catch { - context.Succeed(requirement); + // Invalid or forged token — leave requirement unsatisfied. } } diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 0383671..f15de53 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Models; using Repositories; @@ -10,12 +11,14 @@ public class StaffController : Controller { private IConfiguration Config { get; set; } private ReservationRepository _reservations { get; set; } + private IDataProtector _protector { get; set; } - public StaffController(IConfiguration config, ReservationRepository reservations) + public StaffController(IConfiguration config, ReservationRepository reservations, IDataProtectionProvider dataProtection) { Config = config; _reservations = reservations; - } + _protector = dataProtection.CreateProtector("StaffAccess.v1"); + } [HttpGet, Route("login")] public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) @@ -25,9 +28,14 @@ public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string access { return StatusCode(403); } + + // Sign the token so the handler can verify it was issued by this server. + // A client crafting any other cookie value will fail Unprotect() with CryptographicException. + var token = _protector.Protect("staff-authenticated"); + Response.Cookies.Append( "access", - "1", + token, new CookieOptions { IsEssential = true, @@ -40,10 +48,9 @@ public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string access return NoContent(); } - [HttpGet, Produces("application/json"), Route("reservations"), Authorize(Policy = "StaffOnly")] public async Task GetReservations() - { + { var reservations = await _reservations.GetUpcomingReservations(); return Json(reservations); } diff --git a/api/Program.cs b/api/Program.cs index c408b8e..594db8e 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -35,6 +35,7 @@ Services.AddSingleton(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); + Services.AddDataProtection(); } var app = builder.Build(); diff --git a/api/appsettings.Development.json b/api/appsettings.Development.json index 10f68b8..27d5573 100644 --- a/api/appsettings.Development.json +++ b/api/appsettings.Development.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, + //allows arbitrary Host headers, which can enable host-header injection attacks. Should be locked to known hostnames in production. "AllowedHosts": "*" } diff --git a/api/appsettings.json b/api/appsettings.json index 6dcc0f3..27d5573 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + //allows arbitrary Host headers, which can enable host-header injection attacks. Should be locked to known hostnames in production. + "AllowedHosts": "*" } diff --git a/ui/src/components/ErrorPage.tsx b/ui/src/components/ErrorPage.tsx new file mode 100644 index 0000000..d93fb71 --- /dev/null +++ b/ui/src/components/ErrorPage.tsx @@ -0,0 +1,87 @@ +import { Flex, Heading, Text, Button } from "@radix-ui/themes"; +import { useRouter, Link } from "@tanstack/react-router"; + +interface ErrorPageProps { + statusCode?: number; + message?: string; +} + +const CONFIG: Record< + number, + { title: string; description: string; color: "red" | "orange" } +> = { + 400: { + title: "Bad Request", + description: "The request could not be understood by the server.", + color: "orange", + }, + 401: { + title: "Unauthorised", + description: "You need to be logged in to access this page.", + color: "orange", + }, + 403: { + title: "Forbidden", + description: "You don't have permission to access this resource.", + color: "orange", + }, + 404: { + title: "Page Not Found", + description: "The page you're looking for doesn't exist or has been moved.", + color: "orange", + }, + 500: { + title: "Server Error", + description: "Something went wrong on our end. Please try again later.", + color: "red", + }, + 503: { + title: "Service Unavailable", + description: "The service is temporarily unavailable. Please try again later.", + color: "red", + }, +}; + +function getConfig(code: number) { + if (CONFIG[code]) return { ...CONFIG[code], code }; + if (code >= 500) return { ...CONFIG[500], code }; + if (code >= 400) return { ...CONFIG[400], code }; + return { title: "Unexpected Error", description: "An unexpected error occurred.", color: "red" as const, code }; +} + +export function ErrorPage({ statusCode = 500, message }: ErrorPageProps) { + const router = useRouter(); + const { title, description, color, code } = getConfig(statusCode); + + return ( + + + {code} + + + {title} + + + {message ?? description} + + + + + + + + ); +} diff --git a/ui/src/index.tsx b/ui/src/index.tsx index b2b6941..b769b92 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -4,10 +4,25 @@ import { App } from "./App"; import { Toaster } from "sonner"; import { Theme } from "@radix-ui/themes"; import "@radix-ui/themes/styles.css"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from "@tanstack/react-query"; +import { router } from "./router"; declare var root: HTMLDivElement; -const queryClient = new QueryClient(); + +function navigateToError(error: unknown) { + const status: number = (error as any)?.response?.status ?? 500; + if (status >= 400) { + router.navigate({ + to: "/error", + search: { status, message: (error as any)?.message }, + }); + } +} + +const queryClient = new QueryClient({ + queryCache: new QueryCache({ onError: navigateToError }), + mutationCache: new MutationCache({ onError: navigateToError }), +}); const reactRoot = ReactDOM.createRoot(root); reactRoot.render( diff --git a/ui/src/router.tsx b/ui/src/router.tsx index caeb9e9..14d558c 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -8,15 +8,37 @@ import { LandingPage } from "./LandingPage"; import { ReservationPage } from "./reservations/ReservationPage"; import { StaffLoginPage } from "./staff/StaffLoginPage"; import { StaffReservationsPage } from "./staff/StaffReservationsPage"; +import { ErrorPage } from "./components/ErrorPage"; const rootRoute = createRootRoute({ component: Layout, + // Rendered when no route matches (404) + notFoundComponent: () => , + // Rendered when a route component or loader throws (5xx-class) + errorComponent: ({ error }: { error: unknown }) => { + const status = (error as any)?.response?.status ?? 500; + const message = (error as any)?.message; + return ; + }, }); function getRootRoute() { return rootRoute; } +const errorRoute = createRoute({ + path: "/error", + getParentRoute: getRootRoute, + validateSearch: (search: Record) => ({ + status: Number(search.status ?? 500), + message: search.message as string | undefined, + }), + component: function ErrorRoute() { + const { status, message } = errorRoute.useSearch(); + return ; + }, +}); + const ROUTES = [ createRoute({ path: "/", @@ -38,6 +60,7 @@ const ROUTES = [ getParentRoute: getRootRoute, component: StaffReservationsPage, }), + errorRoute, ]; const routeTree = rootRoute.addChildren(ROUTES); From 28a365a8633f72ceb0eca3af757da36ab27ceaad Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 00:56:43 +0200 Subject: [PATCH 26/36] (refactor) Update auth, error handling, and repository logic - Change NoOpAuthenticationHandler challenge status from 403 to 401 - Rename GetRoom to GetReservation in ReservationController - Return 500 with generic message for reservation errors - Clarify InvalidRoomNumber exception message - Optimize GuestExists with COUNT query in GuestRepository - Fix NotFoundException message in ReservationRepository --- api/Authorization/NoOpAuthenticationHandler.cs | 2 +- api/Controllers/ReservationController.cs | 4 ++-- api/Models/Errors/InvalidRoomNumber.cs | 2 +- api/Repositories/GuestRepository.cs | 6 +++--- api/Repositories/ReservationRepository.cs | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/Authorization/NoOpAuthenticationHandler.cs b/api/Authorization/NoOpAuthenticationHandler.cs index 5bc3606..b5f01f7 100644 --- a/api/Authorization/NoOpAuthenticationHandler.cs +++ b/api/Authorization/NoOpAuthenticationHandler.cs @@ -17,7 +17,7 @@ protected override Task HandleAuthenticateAsync() protected override Task HandleChallengeAsync(AuthenticationProperties properties) { - Response.StatusCode = 403; + Response.StatusCode = 401; return Task.CompletedTask; } diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 431c5ee..e6c3a34 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -30,7 +30,7 @@ public async Task> GetReservations() } [HttpGet, Produces("application/json"), Route("{reservationId}"), Authorize(Policy = "StaffOnly")] - public async Task> GetRoom(Guid reservationId) + public async Task> GetReservation(Guid reservationId) { try { @@ -100,7 +100,7 @@ [FromBody] Reservation? newBooking Console.WriteLine("An error occured when trying to book a reservation:"); Console.WriteLine(ex.ToString()); - return BadRequest("Invalid reservation"); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred. Please try again later."); } } diff --git a/api/Models/Errors/InvalidRoomNumber.cs b/api/Models/Errors/InvalidRoomNumber.cs index 59a690b..90f9a5d 100644 --- a/api/Models/Errors/InvalidRoomNumber.cs +++ b/api/Models/Errors/InvalidRoomNumber.cs @@ -3,6 +3,6 @@ namespace Models.Errors public class InvalidRoomNumber : Exception { public InvalidRoomNumber(string invalidRoomNumber) - : base($"The value ${invalidRoomNumber} is not a valid") { } + : base($"The value '{invalidRoomNumber}' is not a valid room number.") { } } } diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 5a7c076..19eb260 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -43,12 +43,12 @@ public async Task GetGuestByEmail(string guestEmail) public async Task GuestExists(string guestEmail) { - var guest = await _db.QueryFirstOrDefaultAsync( - "SELECT * FROM Guests WHERE Email = @guestEmail;", + var count = await _db.ExecuteScalarAsync( + "SELECT COUNT(1) FROM Guests WHERE Email = @guestEmail;", new { guestEmail } ); - return guest != null; + return count > 0; } public async Task CreateGuest(Guest newGuest) diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5c8fd4b..caf06a4 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -54,7 +54,7 @@ public async Task GetReservation(Guid reservationId) if (reservation == null) { - throw new NotFoundException($"Room {reservationId} not found"); + throw new NotFoundException($"Reservation {reservationId} not found"); } return reservation.ToDomain(); From 349eb15f6dab54d62754c779e0a9676f1aa5e2d7 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:00:42 +0200 Subject: [PATCH 27/36] (refactor) Update routing and add support for forwarded headers Correct CreatedAtAction usage in ReservationController to use GetReservation. Add and configure forwarded headers middleware in Program.cs for reverse proxy support. --- api/Controllers/ReservationController.cs | 2 +- api/Program.cs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index e6c3a34..5f5b94c 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -81,7 +81,7 @@ [FromBody] Reservation? newBooking // so it automatically prepends whatever PathBase (e.g. /api) the host or reverse proxy has configured. // The Location header will now always be a correctly-rooted URL that resolves to GET /reservation/{id} // regardless of where the app is mounted. - return CreatedAtAction(nameof(GetRoom), new { reservationId = createdReservation.Id }, createdReservation); + return CreatedAtAction(nameof(GetReservation), new { reservationId = createdReservation.Id }, createdReservation); } catch (ConflictException ex) { diff --git a/api/Program.cs b/api/Program.cs index 594db8e..e788306 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -3,6 +3,7 @@ using Db; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; using Repositories; @@ -36,6 +37,14 @@ Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); Services.AddDataProtection(); + Services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + // Caddy runs as a local reverse proxy; clear the default known-networks + // restriction so the forwarded headers are always accepted. + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + }); } var app = builder.Build(); @@ -55,6 +64,7 @@ return; } + app.UseForwardedHeaders(); app.UsePathBase("/api") .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) .UseAuthentication() From 566d93309bc7498e1ae555e9b3d91d76d2231c81 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:09:34 +0200 Subject: [PATCH 28/36] (refactor) Add required field checks to BookingValidator Added validation to ensure RoomNumber and GuestEmail are not null, empty, or whitespace in BookingValidator.Validate. Throws InvalidBooking with a clear message if validation fails. --- api/Db/Setup.cs | 47 ++++++++++++++++++++++++++++++ api/Validators/BookingValidator.cs | 10 +++++++ 2 files changed, 57 insertions(+) diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 0bb9125..03a8549 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -62,6 +62,53 @@ REFERENCES Rooms ({nameof(Room.Number)}) ); " ); + + // Migration: if Start/End were created as INT (old schema) migrate them to TEXT. + // SQLite does not support ALTER COLUMN, so we use the standard rename-copy-drop pattern. + var reservationColumns = await db.QueryAsync<(string name, string type)>( + "SELECT name, type FROM pragma_table_info('Reservations');" + ); + var colMap = reservationColumns.ToDictionary(c => c.name, c => c.type, StringComparer.OrdinalIgnoreCase); + if (colMap.TryGetValue(nameof(Reservation.Start), out var startType) && startType.Equals("INT", StringComparison.OrdinalIgnoreCase)) + { + await db.ExecuteAsync("PRAGMA foreign_keys = OFF;"); + await db.ExecuteAsync( + $@" + CREATE TABLE Reservations_new ( + {nameof(Reservation.Id)} TEXT PRIMARY KEY NOT NULL, + {nameof(Reservation.GuestEmail)} TEXT NOT NULL, + {nameof(Reservation.RoomNumber)} INT NOT NULL, + {nameof(Reservation.Start)} TEXT NOT NULL, + {nameof(Reservation.End)} TEXT NOT NULL, + {nameof(Reservation.CheckedIn)} INT NOT NULL DEFAULT FALSE, + {nameof(Reservation.CheckedOut)} INT NOT NULL DEFAULT FALSE, + FOREIGN KEY ({nameof(Reservation.GuestEmail)}) + REFERENCES Guests ({nameof(Guest.Email)}), + FOREIGN KEY ({nameof(Reservation.RoomNumber)}) + REFERENCES Rooms ({nameof(Room.Number)}) + ); + " + ); + // Cast existing INT epoch values (seconds since Unix epoch) to ISO-8601 TEXT + // so date comparisons remain correct after migration. + await db.ExecuteAsync( + $@" + INSERT INTO Reservations_new + SELECT + {nameof(Reservation.Id)}, + {nameof(Reservation.GuestEmail)}, + {nameof(Reservation.RoomNumber)}, + datetime({nameof(Reservation.Start)}, 'unixepoch') AS {nameof(Reservation.Start)}, + datetime({nameof(Reservation.End)}, 'unixepoch') AS {nameof(Reservation.End)}, + {nameof(Reservation.CheckedIn)}, + {nameof(Reservation.CheckedOut)} + FROM Reservations; + " + ); + await db.ExecuteAsync("DROP TABLE Reservations;"); + await db.ExecuteAsync("ALTER TABLE Reservations_new RENAME TO Reservations;"); + await db.ExecuteAsync("PRAGMA foreign_keys = ON;"); + } } } } diff --git a/api/Validators/BookingValidator.cs b/api/Validators/BookingValidator.cs index 3de0cbf..7c5fb30 100644 --- a/api/Validators/BookingValidator.cs +++ b/api/Validators/BookingValidator.cs @@ -8,6 +8,16 @@ public static class BookingValidator { public static void Validate(Reservation booking) { + if (string.IsNullOrWhiteSpace(booking.RoomNumber)) + { + throw new InvalidBooking("Room number is required."); + } + + if (string.IsNullOrWhiteSpace(booking.GuestEmail)) + { + throw new InvalidBooking("Guest email is required."); + } + if (!Room.IsValidRoomNumber(booking.RoomNumber)) { throw new InvalidBooking($"'{booking.RoomNumber}' is not a valid room number - must be exactly 3 digits and the last two digits cannot be 00 (e.g. 101, 202)."); From 7745c74718e023f5d188df4bdffa20c6b1b4ce4b Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:13:13 +0200 Subject: [PATCH 29/36] (fix) Drop temp table and fix reservation date migration logic Ensure Reservations_new temp table is dropped before migration to prevent conflicts. Update migration to cast Start/End columns to TEXT directly, since Dapper stored ISO-8601 strings, eliminating the need for epoch conversion. --- api/Db/Setup.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 03a8549..afae0d0 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -72,6 +72,8 @@ REFERENCES Rooms ({nameof(Room.Number)}) if (colMap.TryGetValue(nameof(Reservation.Start), out var startType) && startType.Equals("INT", StringComparison.OrdinalIgnoreCase)) { await db.ExecuteAsync("PRAGMA foreign_keys = OFF;"); + // Drop any leftover temp table from a previous failed migration attempt. + await db.ExecuteAsync("DROP TABLE IF EXISTS Reservations_new;"); await db.ExecuteAsync( $@" CREATE TABLE Reservations_new ( @@ -89,8 +91,9 @@ REFERENCES Rooms ({nameof(Room.Number)}) ); " ); - // Cast existing INT epoch values (seconds since Unix epoch) to ISO-8601 TEXT - // so date comparisons remain correct after migration. + // The old schema declared Start/End as INT but Dapper always stored + // ISO-8601 strings ("yyyy-MM-dd HH:mm:ss") due to DateTime serialisation. + // Cast the existing values to TEXT as-is — no epoch conversion needed. await db.ExecuteAsync( $@" INSERT INTO Reservations_new @@ -98,8 +101,8 @@ INSERT INTO Reservations_new {nameof(Reservation.Id)}, {nameof(Reservation.GuestEmail)}, {nameof(Reservation.RoomNumber)}, - datetime({nameof(Reservation.Start)}, 'unixepoch') AS {nameof(Reservation.Start)}, - datetime({nameof(Reservation.End)}, 'unixepoch') AS {nameof(Reservation.End)}, + CAST({nameof(Reservation.Start)} AS TEXT) AS {nameof(Reservation.Start)}, + CAST({nameof(Reservation.End)} AS TEXT) AS {nameof(Reservation.End)}, {nameof(Reservation.CheckedIn)}, {nameof(Reservation.CheckedOut)} FROM Reservations; From 6f39e959d8a9bbd6c2b31d6ff8aeaaf72c847d6a Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:14:34 +0200 Subject: [PATCH 30/36] (refactor) Improve Location header construction in Created response Updated the GuestController to build the Location header using Request.PathBase and URI-escaped email addresses. This change ensures the generated URLs are correctly formatted and safe for use in HTTP headers. --- api/Controllers/GuestController.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index 3a4088d..a04c2fa 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -53,7 +53,8 @@ public async Task> CreateGuest([FromBody] Guest? newGuest) try { var created = await _repo.CreateGuest(newGuest); - return Created($"/guest/{created.Email}", created); + var location = $"{Request.PathBase}/guest/{System.Uri.EscapeDataString(created.Email)}"; + return Created(location, created); } catch (ConflictException) { From 9caf70bb25df7a73ee81af8507ab707f67bae0ac Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:15:29 +0200 Subject: [PATCH 31/36] (refactor) Remove security comment from AllowedHosts settings Removed the comment warning about arbitrary Host headers from appsettings.Development.json and appsettings.json. The AllowedHosts configuration remains unchanged. --- api/appsettings.Development.json | 1 - api/appsettings.json | 1 - 2 files changed, 2 deletions(-) diff --git a/api/appsettings.Development.json b/api/appsettings.Development.json index 27d5573..10f68b8 100644 --- a/api/appsettings.Development.json +++ b/api/appsettings.Development.json @@ -5,6 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - //allows arbitrary Host headers, which can enable host-header injection attacks. Should be locked to known hostnames in production. "AllowedHosts": "*" } diff --git a/api/appsettings.json b/api/appsettings.json index 27d5573..10f68b8 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -5,6 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - //allows arbitrary Host headers, which can enable host-header injection attacks. Should be locked to known hostnames in production. "AllowedHosts": "*" } From 9218f969b6fdd6db07ec56dd039db40e49ce9995 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:18:10 +0200 Subject: [PATCH 32/36] (refactor) Restrict trusted forwarded headers to loopback only Limit trusted proxies to 127.0.0.1 and ::1, ensuring only local reverse proxy (e.g., Caddy) forwarded headers are accepted. This prevents external spoofing of X-Forwarded-For and X-Forwarded-Proto, improving security. --- api/Program.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/api/Program.cs b/api/Program.cs index e788306..62c6b33 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -40,10 +40,17 @@ Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - // Caddy runs as a local reverse proxy; clear the default known-networks - // restriction so the forwarded headers are always accepted. + // Trust forwarded headers only from loopback addresses (127.0.0.1 / ::1). + // Caddy runs on the same machine, so this is sufficient and prevents external + // clients from spoofing X-Forwarded-For or X-Forwarded-Proto. options.KnownNetworks.Clear(); options.KnownProxies.Clear(); + //The fix pins the trusted proxy list to the loopback addresses(127.0.0.1 and::1) instead of trusting everyone. Since Caddy runs on the same machine, + //all its forwarded headers arrive from loopback and are still honoured.Any request that reaches the ASP.NET Core process from a non-loopback + //address — e.g. if the port is accidentally exposed — will have its X-Forwarded - *headers silently stripped before reaching auth or cookie middleware, + //preventing scheme and IP spoofing. + options.KnownProxies.Add(System.Net.IPAddress.Loopback); // 127.0.0.1 + options.KnownProxies.Add(System.Net.IPAddress.IPv6Loopback); // ::1 }); } From ac9bbb05f28c8fde0fc6520bdf81396b59d53764 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:20:25 +0200 Subject: [PATCH 33/36] (refactor) Improve /error route search param validation Enhanced validateSearch to ensure status is a valid HTTP code (100-599) and message is a string, defaulting to 500 and undefined otherwise. This increases robustness against invalid query parameters. --- ui/src/router.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 14d558c..98c4af7 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -29,10 +29,14 @@ function getRootRoute() { const errorRoute = createRoute({ path: "/error", getParentRoute: getRootRoute, - validateSearch: (search: Record) => ({ - status: Number(search.status ?? 500), - message: search.message as string | undefined, - }), + validateSearch: (search: Record) => { + const parsed = Number(search.status); + const status = Number.isFinite(parsed) && parsed >= 100 && parsed <= 599 ? parsed : 500; + return { + status, + message: typeof search.message === "string" ? search.message : undefined, + }; + }, component: function ErrorRoute() { const { status, message } = errorRoute.useSearch(); return ; From 69611114ef2101833ac8cdd32c365feaff86ebd6 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:23:04 +0200 Subject: [PATCH 34/36] (refactor) Refactor ReservationDb and add check-in error handling Refactored ReservationDb class for clarity and consistency. Added error handling to ensure room state updates during check-in affect exactly one room. Introduced DeleteReservation method to support reservation deletion by ID. --- api/Repositories/ReservationRepository.cs | 90 ++++++++++++----------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index caf06a4..5dcad82 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -93,7 +93,7 @@ AND End > @Start ); ReservationConflictValidator.ValidateNoConflict(newReservation, conflict != null); - } + } public async Task DeleteReservation(Guid reservationId) { @@ -167,12 +167,16 @@ public async Task CheckInWithRoomUpdate(Reservation reservation, st tx ); - var roomNumberInt = Room.ConvertRoomNumberToInt(reservation.RoomNumber); - await _db.ExecuteAsync( - "UPDATE Rooms SET State = @state WHERE Number = @roomNumberInt;", - new { state = Models.State.Occupied, roomNumberInt }, - tx - ); + var updatedRooms = await _db.ExecuteAsync( + "UPDATE Rooms SET State = @state WHERE Number = @roomNumberInt;", + new { state = Models.State.Occupied, roomNumberInt }, + tx + ); + + if (updatedRooms != 1) + { + throw new InvalidOperationException("Room update failed for reservation check-in."); + } tx.Commit(); @@ -180,48 +184,48 @@ await _db.ExecuteAsync( } private class ReservationDb - { - public string Id { get; set; } - public int RoomNumber { get; set; } + { + public string Id { get; set; } + public int RoomNumber { get; set; } - public string GuestEmail { 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 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() + { + 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; - } + 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; + } - public Reservation ToDomain() - { - return new Reservation + public Reservation ToDomain() { - Id = Guid.Parse(Id), - RoomNumber = Room.FormatRoomNumber(RoomNumber), - GuestEmail = GuestEmail, - Start = Start, - End = End, - CheckedIn = CheckedIn, - CheckedOut = CheckedOut - }; + return new Reservation + { + Id = Guid.Parse(Id), + RoomNumber = Room.FormatRoomNumber(RoomNumber), + GuestEmail = GuestEmail, + Start = Start, + End = End, + CheckedIn = CheckedIn, + CheckedOut = CheckedOut + }; + } } } } -} From e58731aa2498d260fed9fbf5c2d7d5855887b866 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:25:06 +0200 Subject: [PATCH 35/36] (fix) Convert room number to int and validate room update Convert reservation room number to integer before updating room state in the database. Add check to ensure exactly one room is updated, throwing an exception if the update fails. --- api/Repositories/ReservationRepository.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5dcad82..28e9ffe 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -167,11 +167,12 @@ public async Task CheckInWithRoomUpdate(Reservation reservation, st tx ); + var roomNumberInt = Room.ConvertRoomNumberToInt(reservation.RoomNumber); var updatedRooms = await _db.ExecuteAsync( "UPDATE Rooms SET State = @state WHERE Number = @roomNumberInt;", new { state = Models.State.Occupied, roomNumberInt }, tx - ); + ); if (updatedRooms != 1) { From 8d78a41c4f8291eac9ca9bf935b6fe3e0a78fb78 Mon Sep 17 00:00:00 2001 From: Paco Rosa Date: Wed, 29 Apr 2026 01:40:14 +0200 Subject: [PATCH 36/36] (refactors) Refactor API responses, error handling, and sorting logic - Return 201 without Location header for anonymous reservations - StaffController returns 500 if access code is misconfigured - Standardize service variable naming and clarify proxy comments - Ensure transaction rollback on check-in failure in repository - Add type-safe compareValues for improved table sorting --- api/Controllers/ReservationController.cs | 9 +++-- api/Controllers/StaffController.cs | 9 +++++ api/Program.cs | 42 ++++++++++++---------- api/Repositories/ReservationRepository.cs | 42 ++++++++++++---------- ui/src/utils/useSortableTable.ts | 43 +++++++++++++++-------- 5 files changed, 88 insertions(+), 57 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 5f5b94c..caf3230 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -77,11 +77,10 @@ [FromBody] Reservation? newBooking var createdReservation = await _repo.CreateReservation(newBooking); - // CreatedAtAction delegates URL generation to the routing infrastructure, - // so it automatically prepends whatever PathBase (e.g. /api) the host or reverse proxy has configured. - // The Location header will now always be a correctly-rooted URL that resolves to GET /reservation/{id} - // regardless of where the app is mounted. - return CreatedAtAction(nameof(GetReservation), new { reservationId = createdReservation.Id }, createdReservation); + // Do not emit a Location header pointing at GetReservation because that + // endpoint is restricted to staff-only callers while this action allows + // anonymous bookings. Return 201 Created with the created reservation body. + return StatusCode(StatusCodes.Status201Created, createdReservation); } catch (ConflictException ex) { diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index f15de53..fcbda6c 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -24,6 +24,15 @@ public StaffController(IConfiguration config, ReservationRepository reservations public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) { var configuredSecret = Config.GetValue("staffAccessCode"); + + if (string.IsNullOrEmpty(configuredSecret)) + { + // staffAccessCode is missing from configuration — this is a server misconfiguration, + // not a wrong credential. Return 500 so it is distinct from a genuine 403. + return StatusCode(StatusCodes.Status500InternalServerError, + "Staff access code is not configured. Contact the system administrator."); + } + if (configuredSecret != accessCode) { return StatusCode(403); diff --git a/api/Program.cs b/api/Program.cs index 62c6b33..f985581 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -11,33 +11,33 @@ { - var Services = builder.Services; + var services = builder.Services; var connectionString = builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; - Services.AddScoped(_ => new SqliteConnection(connectionString)); - Services.AddScoped(sp => sp.GetRequiredService()); - Services.AddScoped(); - Services.AddScoped(); - Services.AddScoped(); - Services.AddMvc(opt => + services.AddScoped(_ => new SqliteConnection(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddMvc(opt => { opt.EnableEndpointRouting = false; }); - Services.AddCors(); - Services.AddAuthentication("NoOp") + services.AddCors(); + services.AddAuthentication("NoOp") .AddScheme("NoOp", _ => { }); - Services.AddAuthorization(options => + services.AddAuthorization(options => { options.AddPolicy("StaffOnly", policy => policy.AddRequirements(new StaffRequirement())); }); - Services.AddSingleton(); - Services.AddEndpointsApiExplorer(); - Services.AddSwaggerGen(); - Services.AddDataProtection(); - Services.Configure(options => + services.AddSingleton(); + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); + services.AddDataProtection(); + services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; // Trust forwarded headers only from loopback addresses (127.0.0.1 / ::1). @@ -45,10 +45,14 @@ // clients from spoofing X-Forwarded-For or X-Forwarded-Proto. options.KnownNetworks.Clear(); options.KnownProxies.Clear(); - //The fix pins the trusted proxy list to the loopback addresses(127.0.0.1 and::1) instead of trusting everyone. Since Caddy runs on the same machine, - //all its forwarded headers arrive from loopback and are still honoured.Any request that reaches the ASP.NET Core process from a non-loopback - //address — e.g. if the port is accidentally exposed — will have its X-Forwarded - *headers silently stripped before reaching auth or cookie middleware, - //preventing scheme and IP spoofing. + // The fix pins the trusted proxy list to the loopback addresses + // (127.0.0.1 and ::1) instead of trusting everyone. Since Caddy runs on + // the same machine, all its forwarded headers arrive from loopback and + // are still honored. Any request that reaches the ASP.NET Core process + // from a non-loopback address, for example if the port is accidentally + // exposed, will have its X-Forwarded-For and X-Forwarded-Proto headers + // silently stripped before reaching auth or cookie middleware, + // preventing scheme and IP spoofing. options.KnownProxies.Add(System.Net.IPAddress.Loopback); // 127.0.0.1 options.KnownProxies.Add(System.Net.IPAddress.IPv6Loopback); // ::1 }); diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 28e9ffe..f532523 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -161,27 +161,31 @@ public async Task CheckInWithRoomUpdate(Reservation reservation, st using var tx = _db.BeginTransaction(); - var updated = await _db.QuerySingleAsync( - "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @id RETURNING *;", - new { id = reservation.Id.ToString() }, - tx - ); - - var roomNumberInt = Room.ConvertRoomNumberToInt(reservation.RoomNumber); - var updatedRooms = await _db.ExecuteAsync( - "UPDATE Rooms SET State = @state WHERE Number = @roomNumberInt;", - new { state = Models.State.Occupied, roomNumberInt }, - tx - ); - - if (updatedRooms != 1) + try + { + var updated = await _db.QuerySingleAsync( + "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @id RETURNING *;", + new { id = reservation.Id.ToString() }, + tx + ); + var roomNumberInt = Room.ConvertRoomNumberToInt(reservation.RoomNumber); + var updatedRooms = await _db.ExecuteAsync( + "UPDATE Rooms SET State = @state WHERE Number = @roomNumberInt;", + new { state = Models.State.Occupied, roomNumberInt }, + tx + ); + if (updatedRooms != 1) + { + throw new InvalidOperationException("Room update failed for reservation check-in."); + } + tx.Commit(); + return updated.ToDomain(); + } + catch { - throw new InvalidOperationException("Room update failed for reservation check-in."); + tx.Rollback(); + throw; } - - tx.Commit(); - - return updated.ToDomain(); } private class ReservationDb diff --git a/ui/src/utils/useSortableTable.ts b/ui/src/utils/useSortableTable.ts index 03ef483..41e6229 100644 --- a/ui/src/utils/useSortableTable.ts +++ b/ui/src/utils/useSortableTable.ts @@ -13,20 +13,39 @@ export interface UseSortableTableResult { toggleSort: (key: K) => void; } +type Comparable = string | number | Date; + +function compareValues(a: Comparable, b: Comparable): number { + if (a instanceof Date && b instanceof Date) { + return a.getTime() - b.getTime(); + } + if (typeof a === "string" && typeof b === "string") { + return a.localeCompare(b); + } + if (typeof a === "number" && typeof b === "number") { + return a - b; + } + // Mixed types: fall back to string comparison + return String(a).localeCompare(String(b)); +} + /** * Generic client-side sort hook. * - * @param rows The array to sort (typically already filtered). + * @param rows The array to sort (typically already filtered). * @param defaultKey The column key to sort by on first render. * @param defaultDir Initial sort direction (defaults to "asc"). - * @param getValue Optional accessor override: (row, key) => comparable value. - * Defaults to `row[key]`. + * @param getValue Optional accessor: (row, key) => Comparable value. + * Defaults to `row[key]` coerced to Comparable. */ -export function useSortableTable, K extends keyof T & string>( +export function useSortableTable< + T extends Record, + K extends keyof T & string +>( rows: T[] | undefined, defaultKey: K, defaultDir: SortDirection = "asc", - getValue?: (row: T, key: K) => unknown + getValue?: (row: T, key: K) => Comparable | null | undefined ): UseSortableTableResult { const [sortState, setSortState] = useState>({ key: defaultKey, @@ -47,18 +66,14 @@ export function useSortableTable, K extends ke const multiplier = direction === "asc" ? 1 : -1; return [...rows].sort((a, b) => { - const aVal = getValue ? getValue(a, key) : a[key]; - const bVal = getValue ? getValue(b, key) : b[key]; + const aVal = getValue ? getValue(a, key) : (a[key] as Comparable | null | undefined); + const bVal = getValue ? getValue(b, key) : (b[key] as Comparable | null | undefined); if (aVal == null && bVal == null) return 0; - if (aVal == null) return 1 * multiplier; - if (bVal == null) return -1 * multiplier; - - if (typeof aVal === "string" && typeof bVal === "string") { - return aVal.localeCompare(bVal) * multiplier; - } + if (aVal == null) return multiplier; + if (bVal == null) return -multiplier; - return (aVal < bVal ? -1 : aVal > bVal ? 1 : 0) * multiplier; + return compareValues(aVal, bVal) * multiplier; }); }, [rows, sortState, getValue]);