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