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/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/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)); + } + } +} 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/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..48dbfba --- /dev/null +++ b/api.Tests/ReservationRepositoryTests.cs @@ -0,0 +1,210 @@ +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), + // 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 + }); + 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 ────────────────────────────────────────────── + // + // 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) + { + 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.Count(), Is.EqualTo(expectedCount)); + } + + // 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) + { + 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(futureCount)); + Assert.That(result.All(r => r.RoomNumber == "202"), Is.True); + } + + // 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) + { + var today = DateTime.UtcNow.Date; + + 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")); + + 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_EmptyTable_ReturnsEmpty() + { + var result = await _repo.GetUpcomingReservations(); + Assert.That(result, Is.Empty); + } + + // ── 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)); + } + } +} diff --git a/api.Tests/api.Tests.csproj b/api.Tests/api.Tests.csproj new file mode 100644 index 0000000..a85cba9 --- /dev/null +++ b/api.Tests/api.Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/api/Authorization/NoOpAuthenticationHandler.cs b/api/Authorization/NoOpAuthenticationHandler.cs new file mode 100644 index 0000000..b5f01f7 --- /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 = 401; + 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..1d3f28f --- /dev/null +++ b/api/Authorization/StaffAuthorizationHandler.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Authorization +{ + 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 && + httpContext.Request.Cookies.TryGetValue("access", out string? token) && + token != null) + { + try + { + // Throws CryptographicException if the value was tampered with or forged. + var payload = _protector.Unprotect(token); + if (payload == "staff-authenticated") + { + context.Succeed(requirement); + } + } + catch + { + // Invalid or forged token — leave requirement unsatisfied. + } + } + + return Task.CompletedTask; + } + } +} diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index 095d570..a04c2fa 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -1,10 +1,12 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; +using Models.Errors; using Repositories; namespace Controllers { - [Tags("Guests"), Route("guest")] + [Tags("Guests"), Route("guest"), Authorize(Policy = "StaffOnly")] public class GuestController : Controller { private GuestRepository _repo; @@ -21,5 +23,62 @@ 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); + var location = $"{Request.PathBase}/guest/{System.Uri.EscapeDataString(created.Email)}"; + return Created(location, 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..caf3230 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -1,7 +1,9 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; using Repositories; +using Validators; namespace Controllers { @@ -9,13 +11,17 @@ 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("")] + [HttpGet, Produces("application/json"), Route(""), Authorize(Policy = "StaffOnly")] public async Task> GetReservations() { var reservations = await _repo.GetReservations(); @@ -23,8 +29,8 @@ public async Task> GetReservations() return Json(reservations); } - [HttpGet, Produces("application/json"), Route("{reservationId}")] - public async Task> GetRoom(Guid reservationId) + [HttpGet, Produces("application/json"), Route("{reservationId}"), Authorize(Policy = "StaffOnly")] + public async Task> GetReservation(Guid reservationId) { try { @@ -42,11 +48,16 @@ public async Task> GetRoom(Guid reservationId) /// /// /// - [HttpPost, Produces("application/json"), Route("")] + [HttpPost, Produces("application/json"), Route(""), AllowAnonymous] 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,24 +66,86 @@ [FromBody] Reservation newBooking try { + BookingValidator.Validate(newBooking); + + // 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); + + // 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) + { + return Conflict(ex.Message); + } + catch (InvalidBooking ex) + { + return BadRequest(ex.Message); + } + catch (NotFoundException ex) + { + return NotFound(ex.Message); } catch (Exception ex) { 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."); } } - [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); return result ? NoContent() : NotFound(); } + + [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)) + { + return BadRequest("Guest email is required."); + } + + try + { + var reservation = await _repo.GetReservation(reservationId); + var room = await _roomRepo.GetRoom(reservation.RoomNumber); + + if (room.State != Models.State.Ready) + { + return BadRequest("Cannot check in: room is not ready for check-in."); + } + + // 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) + { + return NotFound(); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } } + + public record CheckInRequest(string GuestEmail); } diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 6e97650..69e6547 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; } @@ -31,9 +32,9 @@ 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"); + return BadRequest("Invalid room ID - must be exactly 3 digits and the last two digits cannot be 00 (e.g. 101, 202)."); } try @@ -51,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 - must be exactly 3 digits and the last two digits cannot be 00 (e.g. 101, 202)."); + } + var createdRoom = await _repo.CreateRoom(newRoom); if (createdRoom == null) @@ -64,14 +70,40 @@ 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"); + 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); return deleted ? NoContent() : NotFound(); } + + [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 - must be exactly 3 digits and the last two digits cannot be 00 (e.g. 101, 202)."); + } + + 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/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..fcbda6c 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,69 +1,67 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; 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; } + private IDataProtector _protector { get; set; } - public StaffController(IConfiguration config) + public StaffController(IConfiguration config, ReservationRepository reservations, IDataProtectionProvider dataProtection) { Config = config; + _reservations = reservations; + _protector = dataProtection.CreateProtector("StaffAccess.v1"); } - /// - /// Checks if the request is from a staff member, if not returns true and a 403 result - /// - /// - private bool IsNotStaff(HttpRequest request, out IActionResult? result) + [HttpGet, Route("login")] + public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) { - // TODO explore UseAuthentication - request.Cookies.TryGetValue("access", out string? accessValue); + var configuredSecret = Config.GetValue("staffAccessCode"); - if (accessValue == null || accessValue == "0") + if (string.IsNullOrEmpty(configuredSecret)) { - result = StatusCode(403); - return true; + // 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."); } - result = null; - return false; - } - - [HttpGet, Route("login")] - public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) - { - var configuredSecret = Config.GetValue("staffAccessCode"); if (configuredSecret != accessCode) { - // don't set cookie, don't indicate anything - return NoContent(); + 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 - // TODO evaluate cookie options & auth mechanism for best security practices { IsEssential = true, SameSite = SameSiteMode.Strict, HttpOnly = true, - Secure = true + Secure = Request.IsHttps, + Path = "/api" } ); return NoContent(); } - [HttpGet, Route("check")] - public IActionResult CheckCookie() + [HttpGet, Produces("application/json"), Route("reservations"), Authorize(Policy = "StaffOnly")] + public async Task GetReservations() { - if (IsNotStaff(Request, out IActionResult? result)) - { - return result!; - } - - return Ok("Authorized"); + var reservations = await _reservations.GetUpcomingReservations(); + return Json(reservations); } } } diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..afae0d0 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -7,9 +7,9 @@ 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) + public static async Task EnsureDb(IServiceScope scope) { using var db = scope.ServiceProvider.GetRequiredService(); @@ -22,14 +22,23 @@ 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 ); " ); + 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;" + ); + } + 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 ); @@ -42,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)}) @@ -53,6 +62,56 @@ 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;"); + // 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 ( + {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)}) + ); + " + ); + // 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 + SELECT + {nameof(Reservation.Id)}, + {nameof(Reservation.GuestEmail)}, + {nameof(Reservation.RoomNumber)}, + 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; + " + ); + 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/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/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/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/Program.cs b/api/Program.cs index 52dc5a2..f985581 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,5 +1,9 @@ using System.Data; +using Authorization; using Db; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; using Repositories; @@ -7,23 +11,51 @@ { - var Services = builder.Services; + var services = builder.Services; var connectionString = 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.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.AddEndpointsApiExplorer(); - Services.AddSwaggerGen(); + services.AddCors(); + services.AddAuthentication("NoOp") + .AddScheme("NoOp", _ => { }); + services.AddAuthorization(options => + { + options.AddPolicy("StaffOnly", policy => + policy.AddRequirements(new StaffRequirement())); + }); + 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). + // 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 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 + }); } var app = builder.Build(); @@ -32,7 +64,8 @@ { try { - Setup.EnsureDb(app.Services.CreateScope()); + using var scope = app.Services.CreateScope(); + await Setup.EnsureDb(scope); } catch (Exception ex) { @@ -42,9 +75,12 @@ return; } + app.UseForwardedHeaders(); app.UsePathBase("/api") - .UseMvc() .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) + .UseAuthentication() + .UseAuthorization() + .UseMvc() .UseSwagger() .UseSwaggerUI(); } diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 54182bc..19eb260 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -41,14 +41,64 @@ public async Task GetGuestByEmail(string guestEmail) return guest; } - public Task CreateGuest(Guest newGuest) + public async Task GuestExists(string guestEmail) { - return _db.QuerySingleAsync( - "INSERT INTO Guests(Email, Name) Values(@Email, @Name) RETURNING *", + var count = await _db.ExecuteScalarAsync( + "SELECT COUNT(1) FROM Guests WHERE Email = @guestEmail;", + new { guestEmail } + ); + + return count > 0; + } + + public async Task CreateGuest(Guest newGuest) + { + 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 *", 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 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 5e0dd1c..f532523 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 { @@ -26,6 +27,18 @@ public async Task> GetReservations() return reservations.Select(r => r.ToDomain()); } + public async Task> GetUpcomingReservations() + { + var today = DateTime.UtcNow.Date; + + 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 /// @@ -41,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(); @@ -49,10 +62,37 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } + await CheckForConflict(newReservation); + + 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(); + } + + 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) @@ -65,6 +105,89 @@ 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.CheckedOut) + { + throw new InvalidOperationException("Reservation has already been checked out."); + } + + 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(); + } + + /// + /// 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."); + } + + if (_db.State != System.Data.ConnectionState.Open) + { + _db.Open(); + } + + using var tx = _db.BeginTransaction(); + + 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 + { + tx.Rollback(); + throw; + } + } + private class ReservationDb { public string Id { get; set; } diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 2b9f904..9909094 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -71,6 +71,20 @@ public async Task DeleteRoom(string roomNumber) return deleted > 0; } + public async Task SetRoomState(string roomNumber, State state) + { + var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + 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 private class RoomDb { diff --git a/api/Validators/BookingValidator.cs b/api/Validators/BookingValidator.cs new file mode 100644 index 0000000..7c5fb30 --- /dev/null +++ b/api/Validators/BookingValidator.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; +using Models; +using Models.Errors; + +namespace Validators +{ + 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)."); + } + + 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/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}."); + } + } + } +} 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..10f68b8 100644 --- a/api/appsettings.Development.json +++ b/api/appsettings.Development.json @@ -5,5 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "staffAccessCode": "pass" + "AllowedHosts": "*" } diff --git a/api/appsettings.json b/api/appsettings.json index c06ebf6..10f68b8 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -5,6 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", - "staffAccessCode": "pass" + "AllowedHosts": "*" } 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). 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/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/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/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..26472a3 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({ @@ -43,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), }); } diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e3020bd..98c4af7 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -6,15 +6,43 @@ import { import { Layout } from "./Layout"; 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) => { + 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 ; + }, +}); + const ROUTES = [ createRoute({ path: "/", @@ -26,6 +54,17 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + createRoute({ + path: "/staff/login", + getParentRoute: getRootRoute, + component: StaffLoginPage, + }), + createRoute({ + path: "/staff/reservations", + getParentRoute: getRootRoute, + component: StaffReservationsPage, + }), + errorRoute, ]; 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..fd3ac85 --- /dev/null +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -0,0 +1,306 @@ +import { useState } from "react"; +import { + Badge, + Box, + Button, + Dialog, + Flex, + Heading, + Section, + Separator, + Switch, + Table, + Text, + TextField, +} 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, + 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, +}: { + 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 (_e: any) { + const message = await _e?.response?.text().catch(() => null); + setError(message?.replace(/^"|"$/g, "") || "Check-in failed. Please 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 { 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; + + 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 ( +
+ + + 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 && ( + { if (!open) setSelectedReservation(null); }} + > + + + + + + + + + + + + + {sorted.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)} + /> + )} + + )} + + + + + 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 new file mode 100644 index 0000000..45f9308 --- /dev/null +++ b/ui/src/staff/api.ts @@ -0,0 +1,84 @@ +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"] }); + 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"] }); + }, + }); +} diff --git a/ui/src/utils/useSortableTable.ts b/ui/src/utils/useSortableTable.ts new file mode 100644 index 0000000..41e6229 --- /dev/null +++ b/ui/src/utils/useSortableTable.ts @@ -0,0 +1,81 @@ +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; +} + +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 defaultKey The column key to sort by on first render. + * @param defaultDir Initial sort direction (defaults to "asc"). + * @param getValue Optional accessor: (row, key) => Comparable value. + * Defaults to `row[key]` coerced to Comparable. + */ +export function useSortableTable< + T extends Record, + K extends keyof T & string +>( + rows: T[] | undefined, + defaultKey: K, + defaultDir: SortDirection = "asc", + getValue?: (row: T, key: K) => Comparable | null | undefined +): 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] 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 multiplier; + if (bVal == null) return -multiplier; + + return compareValues(aVal, bVal) * multiplier; + }); + }, [rows, sortState, getValue]); + + return { sorted, sortState, toggleSort }; +}