diff --git a/api.tests/.gitignore b/api.tests/.gitignore new file mode 100644 index 0000000..74f9794 --- /dev/null +++ b/api.tests/.gitignore @@ -0,0 +1,5 @@ +# .NET +bin/ +obj/ +*.user +*.suo \ No newline at end of file diff --git a/api.tests/ReservationValidationTests.cs b/api.tests/ReservationValidationTests.cs new file mode 100644 index 0000000..b7d3ee3 --- /dev/null +++ b/api.tests/ReservationValidationTests.cs @@ -0,0 +1,83 @@ +using Extensions; +using Models; +using Models.Errors; + +namespace api.tests; + +public class ReservationValidationTests +{ + private static Reservation MakeValid() => new() + { + RoomNumber = "101", + GuestEmail = "guest@example.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(3), + }; + + [Fact] + public void ValidReservation_DoesNotThrow() + { + var r = MakeValid(); + var ex = Record.Exception(() => r.Validate()); + Assert.Null(ex); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void MissingEmail_Throws(string? email) + { + var r = MakeValid(); + r.GuestEmail = email!; + var ex = Assert.Throws(() => r.Validate()); + Assert.Contains(ex.Errors, e => e.Contains("Email", StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData("noatsign")] + [InlineData("trailing@")] + public void BadEmailFormat_Throws(string email) + { + var r = MakeValid(); + r.GuestEmail = email; + var ex = Assert.Throws(() => r.Validate()); + Assert.Contains(ex.Errors, e => e.Contains("domain", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void StartInPast_Throws() + { + var r = MakeValid(); + r.Start = DateTime.Today.AddDays(-1); + var ex = Assert.Throws(() => r.Validate()); + Assert.Contains(ex.Errors, e => e.Contains("past", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void StartAfterEnd_Throws() + { + var r = MakeValid(); + r.End = r.Start.AddHours(-1); + var ex = Assert.Throws(() => r.Validate()); + Assert.Contains(ex.Errors, e => e.Contains("before the end", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void DurationOver30Days_Throws() + { + var r = MakeValid(); + r.End = r.Start.AddDays(31); + var ex = Assert.Throws(() => r.Validate()); + Assert.Contains(ex.Errors, e => e.Contains("30 days", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void InvalidRoomNumber_Throws() + { + var r = MakeValid(); + r.RoomNumber = "abc"; + var ex = Assert.Throws(() => r.Validate()); + Assert.Contains(ex.Errors, e => e.Contains("digits", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/api.tests/RoomValidationTests.cs b/api.tests/RoomValidationTests.cs new file mode 100644 index 0000000..cde52b2 --- /dev/null +++ b/api.tests/RoomValidationTests.cs @@ -0,0 +1,76 @@ +using Extensions; +using Models; + +namespace api.tests; + +public class RoomValidationTests +{ + [Theory] + [InlineData("101")] + [InlineData("999")] + [InlineData("010")] + [InlineData("001")] + public void ValidRoomNumbers_PassValidation(string number) + { + var room = new Room { Number = number }; + var errors = room.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void NullRoomNumber_ReturnsRequired() + { + var room = new Room { Number = null! }; + var errors = room.Validate(); + Assert.Single(errors); + Assert.Contains("required", errors[0], StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void EmptyOrWhitespace_ReturnsRequired(string number) + { + var room = new Room { Number = number }; + var errors = room.Validate(); + Assert.Single(errors); + Assert.Contains("required", errors[0], StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("1")] + [InlineData("12")] + [InlineData("1234")] + public void WrongLength_ReturnsLengthError(string number) + { + var room = new Room { Number = number }; + var errors = room.Validate(); + Assert.Single(errors); + Assert.Contains("exactly 3 digits", errors[0]); + } + + [Theory] + [InlineData("abc")] + [InlineData("1a2")] + [InlineData("!!1")] + public void NonDigits_ReturnsDigitError(string number) + { + var room = new Room { Number = number }; + var errors = room.Validate(); + Assert.Single(errors); + Assert.Contains("only digits", errors[0]); + } + + [Theory] + [InlineData("100")] + [InlineData("200")] + [InlineData("900")] + [InlineData("000")] + public void DoorZeroZero_ReturnsDoorError(string number) + { + var room = new Room { Number = number }; + var errors = room.Validate(); + Assert.Single(errors); + Assert.Contains("Door number cannot be \"00\"", errors[0]); + } +} diff --git a/api.tests/api.tests.csproj b/api.tests/api.tests.csproj new file mode 100644 index 0000000..1356ec8 --- /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..148bc1b 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; using Repositories; @@ -5,6 +6,7 @@ namespace Controllers { [Tags("Guests"), Route("guest")] + [Authorize] public class GuestController : Controller { private GuestRepository _repo; diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..8487ddc 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -1,7 +1,10 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; using Repositories; +using Services; +using Extensions; namespace Controllers { @@ -9,21 +12,40 @@ namespace Controllers public class ReservationController : Controller { private ReservationRepository _repo { get; set; } + private RoomRepository _roomRepo { get; set; } + private GuestRepository _guestRepo { get; set; } + private VerificationCodeService _verificationCodeService { get; set; } - public ReservationController(ReservationRepository reservationRepository) + public ReservationController( + ReservationRepository reservationRepository, + RoomRepository roomRepository, + GuestRepository guestRepository, + VerificationCodeService verificationCodeService + ) { _repo = reservationRepository; + _roomRepo = roomRepository; + _guestRepo = guestRepository; + _verificationCodeService = verificationCodeService; } [HttpGet, Produces("application/json"), Route("")] - public async Task> GetReservations() + [Authorize] + public async Task GetReservations( + [FromQuery] DateTime? from, [FromQuery] DateTime? to, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - var reservations = await _repo.GetReservations(); + var (items, totalCount) = await _repo.GetReservations(from, to, page, pageSize); + + Response.Headers["X-Total-Count"] = totalCount.ToString(); + Response.Headers["X-Page"] = page.ToString(); + Response.Headers["X-Page-Size"] = pageSize.ToString(); - return Json(reservations); + return Json(items); } [HttpGet, Produces("application/json"), Route("{reservationId}")] + [AllowAnonymous] public async Task> GetRoom(Guid reservationId) { try @@ -43,31 +65,145 @@ public async Task> GetRoom(Guid reservationId) /// /// [HttpPost, Produces("application/json"), Route("")] + [AllowAnonymous] public async Task> BookReservation( [FromBody] Reservation newBooking ) { + // Validate the reservation + try + { + newBooking.Validate(); + } + catch (ValidationException ex) + { + return BadRequest(new { errors = ex.Errors }); + } + + // Verify the room exists + try + { + await _roomRepo.GetRoom(newBooking.RoomNumber); + } + catch (NotFoundException) + { + return BadRequest(new { errors = new[] { $"Room {newBooking.RoomNumber} does not exist." } }); + } + + // Upsert guest by email + try + { + await _guestRepo.GetGuestByEmail(newBooking.GuestEmail); + } + catch (NotFoundException) + { + await _guestRepo.CreateGuest( + new Guest { Email = newBooking.GuestEmail, Name = newBooking.GuestEmail } + ); + } + // Provide a real ID if one is not provided if (newBooking.Id == Guid.Empty) { newBooking.Id = Guid.NewGuid(); } + // Create reservation (overlap check + INSERT are atomic inside a transaction) try { var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); + return Created($"/reservation/{createdReservation.Id}", createdReservation); + } + catch (ValidationException ex) + { + return Conflict(new { errors = ex.Errors }); + } + } + + [HttpPost, Produces("application/json"), Route("{reservationId}/checkin")] + [Authorize] + public async Task InitiateCheckIn(Guid reservationId) + { + Reservation reservation; + try + { + reservation = await _repo.GetReservation(reservationId); } - catch (Exception ex) + catch (NotFoundException) { - Console.WriteLine("An error occured when trying to book a reservation:"); - Console.WriteLine(ex.ToString()); + return NotFound(); + } - return BadRequest("Invalid reservation"); + if (reservation.CheckedIn) + { + return Conflict(new { errors = new[] { "Reservation is already checked in." } }); } + + if (reservation.Start.Date != DateTime.Today) + { + return BadRequest(new { errors = new[] { "Check-in is only allowed on the reservation start date." } }); + } + + // Block check-in if room is dirty + try + { + var room = await _roomRepo.GetRoom(reservation.RoomNumber); + if (room.IsDirty) + { + return BadRequest(new { errors = new[] { "Room must be cleaned before check-in." } }); + } + } + catch (NotFoundException) + { + return BadRequest(new { errors = new[] { $"Room {reservation.RoomNumber} does not exist." } }); + } + + if (_verificationCodeService.HasActiveCode(reservationId)) + { + return Conflict(new { errors = new[] { "A verification code is already active for this reservation. Please wait for it to expire before requesting a new one." } }); + } + + var code = _verificationCodeService.GenerateCode(reservationId); + return Ok(new { code }); + } + + [HttpPut, Produces("application/json"), Route("{reservationId}/checkin")] + [Authorize] + public async Task ConfirmCheckIn( + Guid reservationId, [FromBody] CheckInConfirmRequest request) + { + Reservation reservation; + try + { + reservation = await _repo.GetReservation(reservationId); + } + catch (NotFoundException) + { + return NotFound(); + } + + if (reservation.CheckedIn) + { + return Conflict(new { errors = new[] { "Reservation is already checked in." } }); + } + + if (!_verificationCodeService.ValidateCode(reservationId, request.Code)) + { + return BadRequest(new { errors = new[] { "Invalid verification code." } }); + } + + var checkedIn = await _repo.CheckIn(reservationId); + if (!checkedIn) + { + return Conflict(new { errors = new[] { "Reservation is already checked in." } }); + } + + var updated = await _repo.GetReservation(reservationId); + return Ok(updated); } [HttpDelete, Produces("application/json"), Route("{reservationId}")] + [Authorize] public async Task DeleteReservation(Guid reservationId) { var result = await _repo.DeleteReservation(reservationId); @@ -75,4 +211,9 @@ public async Task DeleteReservation(Guid reservationId) return result ? NoContent() : NotFound(); } } + + public class CheckInConfirmRequest + { + public string Code { get; set; } = ""; + } } diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 6e97650..e3c7188 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -1,21 +1,33 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using Models; using Models.Errors; using Repositories; +using Extensions; namespace Controllers { [Tags("Rooms"), Route("room")] public class RoomController : Controller { + private static readonly ActivitySource _activitySource = new("Reservations.RoomImport"); + private RoomRepository _repo { get; set; } + private ImportOptions _importOptions { get; set; } + private ILogger _log { get; set; } - public RoomController(RoomRepository roomRepository) + public RoomController(RoomRepository roomRepository, IOptions importOptions, ILogger log) { _repo = roomRepository; + _importOptions = importOptions.Value; + _log = log; } [HttpGet, Produces("application/json"), Route("")] + [AllowAnonymous] public async Task> GetRooms() { var rooms = await _repo.GetRooms(); @@ -29,48 +41,276 @@ public async Task> GetRooms() } [HttpGet, Produces("application/json"), Route("{roomNumber}")] + [AllowAnonymous] public async Task> GetRoom(string roomNumber) { if (roomNumber.Length != 3) { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + _log.LogWarning("GetRoom invalid format: {RoomNumber}", roomNumber); + return BadRequest(new { errors = new[] { "Invalid room ID - format is ###, ex 001 / 002 / 101" } }); } try { var room = await _repo.GetRoom(roomNumber); - return Json(room); } catch (NotFoundException) { + _log.LogWarning("GetRoom not found: {RoomNumber}", roomNumber); return NotFound(); } } [HttpPost, Produces("application/json"), Route("")] + [Authorize] public async Task> CreateRoom([FromBody] Room newRoom) { + var errors = newRoom.Validate(); + if (errors.Count > 0) + { + _log.LogWarning("CreateRoom validation failed for {RoomNumber}: {ErrorCount} errors", newRoom.Number, errors.Count); + return BadRequest(new { errors }); + } + var createdRoom = await _repo.CreateRoom(newRoom); if (createdRoom == null) { + _log.LogWarning("CreateRoom failed — room {RoomNumber} not created", newRoom.Number); return NotFound(); } + _log.LogInformation("Created room {RoomNumber} with State={State}, IsDirty={IsDirty}", + createdRoom.Number, createdRoom.State, createdRoom.IsDirty); return Json(createdRoom); } + private static readonly HashSet AllowedPatchPaths = + new(StringComparer.OrdinalIgnoreCase) { $"/{nameof(RoomPatch.IsDirty)}" }; + + [HttpPatch, Produces("application/json"), Route("{roomNumber}")] + [Authorize] + public async Task PatchRoom( + string roomNumber, [FromBody] JsonPatchDocument patchDoc) + { + if (roomNumber.Length != 3) + { + _log.LogWarning("PatchRoom invalid format: {RoomNumber}", roomNumber); + return BadRequest(new { errors = new[] { "Invalid room ID - format is ###, ex 001 / 002 / 101" } }); + } + + // Reject operations on paths we don't support + var disallowed = patchDoc.Operations + .Where(op => !AllowedPatchPaths.Contains(op.path)) + .Select(op => op.path) + .Distinct() + .ToList(); + + if (disallowed.Count > 0) + { + return BadRequest(new { errors = disallowed.Select(p => $"Patching '{p}' is not allowed.").ToArray() }); + } + + Room room; + try + { + room = await _repo.GetRoom(roomNumber); + } + catch (NotFoundException) + { + return NotFound(); + } + + var patchModel = new RoomPatch(); + + patchDoc.ApplyTo(patchModel, ModelState); + if (!ModelState.IsValid) + { + return BadRequest(new { errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToArray() }); + } + + if (patchModel.IsDirty != null) + { + await _repo.SetRoomDirtyState(roomNumber, patchModel.IsDirty.Value); + _log.LogInformation("Patched room {RoomNumber}: IsDirty={IsDirty}", roomNumber, patchModel.IsDirty.Value); + } + var updated = await _repo.GetRoom(roomNumber); + return Ok(updated); + } + + + [HttpPost, Produces("application/json"), Consumes("multipart/form-data"), Route("import")] + [Authorize] + public async Task ImportRooms(IFormFile file, CancellationToken ct) + { + var maxFileSize = _importOptions.MaxFileSizeBytes; + var maxRows = _importOptions.MaxRows; + + if (file == null || file.Length == 0) + { + return BadRequest(new { errors = new[] { "A CSV file is required." } }); + } + + if (file.Length > maxFileSize) + { + return BadRequest(new { errors = new[] { $"File exceeds the maximum size of {maxFileSize / 1024} KB." } }); + } + + // Server-side file type check: extension + var ext = Path.GetExtension(file.FileName); + if (!string.Equals(ext, ".csv", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(new { errors = new[] { "Only .csv files are accepted." } }); + } + + // Stream-read lines with early bail at row limit + var rows = new List(); + var hasHeader = false; + using var stream = file.OpenReadStream(); + using var parseSpan = _activitySource.StartActivity("ImportRooms.Parse"); + using (var reader = new StreamReader(stream)) + { + while (await reader.ReadLineAsync(ct) is { } line) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed)) continue; + + // Detect and skip header row (matches "Number" alone or "Number,State,IsDirty" pattern) + if (rows.Count == 0 && trimmed.Split(',')[0].Trim().Equals("Number", StringComparison.OrdinalIgnoreCase)) + { + hasHeader = true; + continue; + } + + rows.Add(trimmed); + + if (rows.Count > maxRows) + { + return BadRequest(new { errors = new[] { $"CSV exceeds the maximum of {maxRows} data rows." } }); + } + } + } + parseSpan?.SetTag("import.parsed_rows", rows.Count); + parseSpan?.Stop(); + + if (rows.Count == 0) + { + return BadRequest(new { errors = new[] { "CSV file contains no data rows." } }); + } + + // Fetch existing rooms once for O(1) duplicate check + var existingNumbers = await _repo.GetExistingRoomNumbers(); + + var roomsToInsert = new List(); + var errors = new List(); + var seenInBatch = new HashSet(); + + // Row numbers are 1-indexed relative to the original file + var rowOffset = hasHeader ? 2 : 1; + + using var validateSpan = _activitySource.StartActivity("ImportRooms.Validate"); + for (int i = 0; i < rows.Count; i++) + { + var rowNumber = i + rowOffset; + var columns = rows[i].Split(','); + + // Column 1: Number (required) + var rawNumber = columns[0].Trim().TrimStart('0').PadLeft(3, '0'); + + // Column 2: State (optional, defaults to Ready) + var state = State.Ready; + if (columns.Length > 1) + { + var stateVal = columns[1].Trim(); + if (!string.IsNullOrEmpty(stateVal) && !Enum.TryParse(stateVal, ignoreCase: true, out state)) + { + errors.Add(new { row = rowNumber, message = $"Invalid State '{stateVal}'. Must be Ready or Occupied." }); + continue; + } + } + + // Column 3: IsDirty (optional, defaults to false) + var isDirty = false; + if (columns.Length > 2) + { + var dirtyVal = columns[2].Trim(); + if (!string.IsNullOrEmpty(dirtyVal) && !bool.TryParse(dirtyVal, out isDirty)) + { + errors.Add(new { row = rowNumber, message = $"Invalid IsDirty '{dirtyVal}'. Must be true or false." }); + continue; + } + } + + var room = new Room { Number = rawNumber, State = state, IsDirty = isDirty }; + var validationErrors = room.Validate(); + + if (validationErrors.Count > 0) + { + foreach (var err in validationErrors) + { + errors.Add(new { row = rowNumber, message = err }); + } + continue; + } + + // Duplicate in DB + var roomInt = RoomExtensions.ConvertRoomNumberToInt(rawNumber); + if (existingNumbers.Contains(roomInt)) + { + errors.Add(new { row = rowNumber, message = $"Room #{rawNumber} already exists." }); + continue; + } + + // Duplicate within same CSV + if (!seenInBatch.Add(rawNumber)) + { + errors.Add(new { row = rowNumber, message = $"Room #{rawNumber} is a duplicate in this file." }); + continue; + } + + roomsToInsert.Add(room); + } + validateSpan?.SetTag("import.valid", roomsToInsert.Count); + validateSpan?.SetTag("import.invalid", errors.Count); + validateSpan?.Stop(); + + var imported = 0; + if (roomsToInsert.Count > 0) + { + using var insertSpan = _activitySource.StartActivity("ImportRooms.BulkInsert"); + imported = await _repo.BulkCreateRooms(roomsToInsert, ct); + insertSpan?.SetTag("import.inserted", imported); + } + + Activity.Current?.SetTag("import.imported", imported); + Activity.Current?.SetTag("import.errors", errors.Count); + Activity.Current?.SetTag("import.total_rows", rows.Count); + Activity.Current?.SetTag("import.file_name", file.FileName); + + _log.LogInformation("Room import completed: {Imported} imported, {Errors} errors, {TotalRows} rows parsed from {FileName}", + imported, errors.Count, rows.Count, file.FileName); + + return Ok(new { imported, errors }); + } + [HttpDelete, Produces("application/json"), Route("{roomNumber}")] + [Authorize] public async Task DeleteRoom(string roomNumber) { if (roomNumber.Length != 3) { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + _log.LogWarning("DeleteRoom invalid format: {RoomNumber}", roomNumber); + return BadRequest(new { errors = new[] { "Invalid room ID - format is ###, ex 001 / 002 / 101" } }); } var deleted = await _repo.DeleteRoom(roomNumber); + if (deleted) + _log.LogInformation("Deleted room {RoomNumber}", roomNumber); + else + _log.LogWarning("DeleteRoom not found: {RoomNumber}", roomNumber); + return deleted ? NoContent() : NotFound(); } } diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..e5d4c70 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,3 +1,7 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Controllers @@ -12,58 +16,44 @@ public StaffController(IConfiguration config) Config = config; } - /// - /// Checks if the request is from a staff member, if not returns true and a 403 result - /// - /// - private bool IsNotStaff(HttpRequest request, out IActionResult? result) + [HttpPost, Route("login")] + [AllowAnonymous] + public async Task Login([FromHeader(Name = "X-Staff-Code")] string accessCode) { - // TODO explore UseAuthentication - request.Cookies.TryGetValue("access", out string? accessValue); - - if (accessValue == null || accessValue == "0") + var configuredSecret = Config.GetValue("staffAccessCode"); + if (configuredSecret != accessCode) { - result = StatusCode(403); - return true; + return Unauthorized(new { errors = new[] { "Invalid access code." } }); } - result = null; - return false; + var claims = new List + { + new(ClaimTypes.Role, "Staff"), + }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + principal + ); + + return Ok(new { message = "Logged in." }); } - [HttpGet, Route("login")] - public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) + [HttpPost, Route("logout")] + [Authorize] + public async Task Logout() { - 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.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Ok(new { message = "Logged out." }); } [HttpGet, Route("check")] - public IActionResult CheckCookie() + [Authorize] + public IActionResult CheckAuth() { - if (IsNotStaff(Request, out IActionResult? result)) - { - return result!; - } - - return Ok("Authorized"); + return Ok(new { message = "Authorized." }); } } } diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..b8719a9 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -7,9 +7,11 @@ namespace Db public static class Setup { /// - /// Ensures the DB is available and the requried tables are made + /// Versioned migration system using PRAGMA user_version. + /// Each migration block runs exactly once; the version number is + /// persisted in the SQLite file itself. /// - public static async void EnsureDb(IServiceScope scope) + public static async Task EnsureDb(IServiceScope scope) { using var db = scope.ServiceProvider.GetRequiredService(); @@ -18,41 +20,74 @@ public static async void EnsureDb(IServiceScope scope) // SQLite does not enforce FKs by default await db.ExecuteAsync("PRAGMA foreign_keys = ON;"); - await db.ExecuteAsync( - $@" - CREATE TABLE IF NOT EXISTS Guests ( - {nameof(Guest.Email)} TEXT PRIMARY KEY NOT NULL, - {nameof(Guest.Name)} TEXT NOT NULL - ); - " - ); - - await db.ExecuteAsync( - $@" - CREATE TABLE IF NOT Exists Rooms ( - {nameof(Room.Number)} INT PRIMARY KEY NOT NULL, - {nameof(Room.State)} INT NOT NULL - ); - " - ); - - await db.ExecuteAsync( - $@" - CREATE TABLE IF NOT EXISTS Reservations ( - {nameof(Reservation.Id)} TEXT PRIMARY KEY NOT NULL, - {nameof(Reservation.GuestEmail)} TEXT NOT NULL, - {nameof(Reservation.RoomNumber)} INT NOT NULL, - {nameof(Reservation.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, - FOREIGN KEY ({nameof(Reservation.GuestEmail)}) - REFERENCES Guests ({nameof(Guest.Email)}), - FOREIGN KEY ({nameof(Reservation.RoomNumber)}) - REFERENCES Rooms ({nameof(Room.Number)}) - ); - " - ); + var version = await db.ExecuteScalarAsync("PRAGMA user_version;"); + + // ── v1: baseline tables ───────────────────────────────────── + if (version < 1) + { + 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.Surname)} TEXT + ); + " + ); + + await db.ExecuteAsync( + $@" + CREATE TABLE IF NOT EXISTS Rooms ( + {nameof(Room.Number)} INT PRIMARY KEY NOT NULL, + {nameof(Room.State)} INT NOT NULL + ); + " + ); + + await db.ExecuteAsync( + $@" + CREATE TABLE IF NOT EXISTS Reservations ( + {nameof(Reservation.Id)} TEXT PRIMARY KEY NOT NULL, + {nameof(Reservation.GuestEmail)} TEXT NOT NULL, + {nameof(Reservation.RoomNumber)} INT NOT NULL, + {nameof(Reservation.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)}) + ); + " + ); + + await db.ExecuteAsync("PRAGMA user_version = 1;"); + version = 1; + } + + // ── v2: indexes ──────────────────────────────────────────────── + if (version < 2) + { + await db.ExecuteAsync( + "CREATE INDEX IF NOT EXISTS IX_Reservations_End ON Reservations([End]);" + ); + await db.ExecuteAsync( + "CREATE INDEX IF NOT EXISTS IX_Reservations_RoomNumber_Start_End ON Reservations(RoomNumber, [Start], [End]);" + ); + + await db.ExecuteAsync("PRAGMA user_version = 2;"); + version = 2; + } + + // ── v3: add IsDirty column to Rooms ──────────────────────────── + if (version < 3) + { + await db.ExecuteAsync($"ALTER TABLE Rooms ADD COLUMN {nameof(Room.IsDirty)} INT NOT NULL DEFAULT 0;"); + + await db.ExecuteAsync("PRAGMA user_version = 3;"); + version = 3; + } } } } diff --git a/api/Extensions/ReservationExtensions.cs b/api/Extensions/ReservationExtensions.cs new file mode 100644 index 0000000..3dd1bce --- /dev/null +++ b/api/Extensions/ReservationExtensions.cs @@ -0,0 +1,61 @@ +using Models; +using Models.Errors; + +namespace Extensions +{ + public static class ReservationExtensions + { + /// + /// Validates a reservation against RE-001 booking rules. + /// Throws if any rules are violated. + /// + public static void Validate(this Reservation reservation) + { + var errors = new List(); + + // Room number validation + errors.AddRange(new Room { Number = reservation.RoomNumber }.Validate()); + + // Email must include a domain + if (string.IsNullOrWhiteSpace(reservation.GuestEmail)) + { + errors.Add("Email is required."); + } + else if ( + !reservation.GuestEmail.Contains('@') + || reservation.GuestEmail.IndexOf('@') == reservation.GuestEmail.Length - 1 + ) + { + errors.Add("Email must include a domain (e.g. user@example.com)."); + } + + // Start date must not be in the past (compare local server date — dates are calendar dates at the hotel) + if (reservation.Start.Date < DateTime.Today) + { + errors.Add("Start date cannot be in the past."); + } + + // Start date must be before End date + if (reservation.Start >= reservation.End) + { + errors.Add("Start date must be before the end date."); + } + + // Duration constraints + var duration = (reservation.End - reservation.Start).TotalDays; + if (duration < 1) + { + errors.Add("Reservation must be at least 1 day."); + } + else if (duration > 30) + { + errors.Add("Reservation cannot exceed 30 days."); + } + + if (errors.Count > 0) + { + throw new ValidationException(errors); + } + } + } +} diff --git a/api/Extensions/RoomExtensions.cs b/api/Extensions/RoomExtensions.cs new file mode 100644 index 0000000..eb1c2c4 --- /dev/null +++ b/api/Extensions/RoomExtensions.cs @@ -0,0 +1,63 @@ +using Models; +using Models.Errors; + +namespace Extensions +{ + public static class RoomExtensions + { + /// + /// Formats the room number filling it with 0s + /// to get a three digit string + /// + public static string FormatRoomNumber(int number) + { + return number.ToString().PadLeft(3, '0'); + } + + public static int ConvertRoomNumberToInt(string roomNumber) + { + var success = int.TryParse(roomNumber, out int roomNumberInt); + if (!success) + { + throw new InvalidRoomNumber(roomNumber); + } + + return roomNumberInt; + } + + /// + /// Validates a room against RE-001 rules. + /// Returns a list of validation errors (empty if valid). + /// + public static List Validate(this Room room) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(room.Number)) + { + errors.Add("Room number is required."); + return errors; + } + + if (room.Number.Length != 3) + { + errors.Add("Room number must be exactly 3 digits in the format \"###\"."); + return errors; + } + + if (!room.Number.All(char.IsDigit)) + { + errors.Add("Room number must contain only digits 0-9."); + return errors; + } + + var door = room.Number.Substring(1, 2); + if (door == "00") + { + errors.Add("Door number cannot be \"00\"."); + } + + return errors; + } + } +} diff --git a/api/Models/Errors/ValidationException.cs b/api/Models/Errors/ValidationException.cs new file mode 100644 index 0000000..2b86f1f --- /dev/null +++ b/api/Models/Errors/ValidationException.cs @@ -0,0 +1,19 @@ +namespace Models.Errors +{ + public class ValidationException : Exception + { + public List Errors { get; } + + public ValidationException(List errors) + : base("Validation failed") + { + Errors = errors; + } + + public ValidationException(string error) + : base("Validation failed") + { + Errors = [error]; + } + } +} diff --git a/api/Models/ImportOptions.cs b/api/Models/ImportOptions.cs new file mode 100644 index 0000000..7de36bf --- /dev/null +++ b/api/Models/ImportOptions.cs @@ -0,0 +1,9 @@ +namespace Models +{ + public class ImportOptions + { + public long MaxFileSizeBytes { get; set; } = 102_400; + + public long MaxRows { get; set; } = 500; + } +} diff --git a/api/Models/Room.cs b/api/Models/Room.cs index cbd6536..a97377e 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -1,5 +1,3 @@ -using Models.Errors; - namespace Models { /// @@ -20,31 +18,19 @@ public class Room public State State { get; set; } = State.Ready; /// - /// Formats the room number filling it with 0s - /// to get a three digit string + /// Whether the room needs cleaning /// - /// - public static string FormatRoomNumber(int number) - { - return number.ToString().PadLeft(3, '0'); - } - - public static int ConvertRoomNumberToInt(string roomNumber) - { - var success = int.TryParse(roomNumber, out int roomNumberInt); - if (!success) - { - throw new InvalidRoomNumber(roomNumber); - } - - return roomNumberInt; - } + public bool IsDirty { get; set; } = false; } public enum State { Ready = 0, - Occupied = 1, - Dirty = 2 + Occupied = 1 + } + + public class RoomPatch + { + public bool? IsDirty { get; set; } } } diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..90bbc5e 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,10 +1,18 @@ using System.Data; using Db; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Data.Sqlite; +using Models; using Repositories; +using Serilog; +using Services; var builder = WebApplication.CreateBuilder(args); +builder.Host.UseSerilog((ctx, config) => config + .ReadFrom.Configuration(ctx.Configuration) + .WriteTo.Console()); + { var Services = builder.Services; @@ -12,16 +20,41 @@ builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; - Services.AddSingleton(_ => new SqliteConnection(connectionString)); - Services.AddSingleton(sp => sp.GetRequiredService()); - Services.AddSingleton(); - Services.AddSingleton(); - Services.AddSingleton(); + Services.AddScoped(_ => new SqliteConnection(connectionString)); + Services.AddScoped(sp => sp.GetRequiredService()); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddSingleton(); + Services.Configure(builder.Configuration.GetSection("Import")); Services.AddMvc(opt => { opt.EnableEndpointRouting = false; - }); + }).AddNewtonsoftJson(); Services.AddCors(); + Services + .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() + ? CookieSecurePolicy.None + : CookieSecurePolicy.Always; + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromMinutes(30); + options.Events.OnRedirectToLogin = context => + { + context.Response.StatusCode = 401; + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + context.Response.StatusCode = 403; + return Task.CompletedTask; + }; + }); + Services.AddAuthorization(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); } @@ -32,19 +65,41 @@ { try { - Setup.EnsureDb(app.Services.CreateScope()); + using var scope = app.Services.CreateScope(); + await Setup.EnsureDb(scope); } catch (Exception ex) { - Console.WriteLine("Failed to setup the database, aborting"); - Console.WriteLine(ex.ToString()); + Log.Fatal(ex, "Failed to setup the database, aborting"); Environment.Exit(1); return; } - app.UsePathBase("/api") + app.UsePathBase("/api"); + app.UseSerilogRequestLogging(); + + app.UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() + .WithExposedHeaders("X-Total-Count", "X-Page", "X-Page-Size")); + + if (!app.Environment.IsDevelopment()) + { + app.UseExceptionHandler(err => + err.Run(async context => + { + context.Response.StatusCode = 500; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + new { errors = new[] { "An unexpected error occurred." } } + ); + }) + ); + } + + app.UseAuthentication(); + app.UseAuthorization(); + + app .UseMvc() - .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) .UseSwagger() .UseSwaggerUI(); } diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 54182bc..22aaa2b 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -44,7 +44,7 @@ public async Task GetGuestByEmail(string guestEmail) public Task CreateGuest(Guest newGuest) { return _db.QuerySingleAsync( - "INSERT INTO Guests(Email, Name) Values(@Email, @Name) RETURNING *", + "INSERT INTO Guests(Email, Name, Surname) Values(@Email, @Name, @Surname) RETURNING *", newGuest ); } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..7ba237a 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -2,6 +2,7 @@ using Dapper; using Models; using Models.Errors; +using Extensions; namespace Repositories { @@ -14,16 +15,34 @@ public ReservationRepository(IDbConnection db) _db = db; } - public async Task> GetReservations() + /// + /// Returns reservations with optional date filter and offset-based pagination. + /// When is provided, only reservations whose End >= from are returned, ordered by Start ASC. + /// + public async Task<(IEnumerable Items, int TotalCount)> GetReservations( + DateTime? from = null, DateTime? to = null, int page = 1, int pageSize = 20) { - var reservations = await _db.QueryAsync("SELECT * FROM Reservations"); + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 1, 100); + + var conditions = new List(); + if (from.HasValue) conditions.Add("[End] >= @from"); + if (to.HasValue) conditions.Add("[Start] <= @to"); + var whereClause = conditions.Count > 0 ? "WHERE " + string.Join(" AND ", conditions) : ""; + var orderClause = from.HasValue || to.HasValue ? "ORDER BY [Start] ASC" : ""; + var offset = (page - 1) * pageSize; + + var totalCount = await _db.ExecuteScalarAsync( + $"SELECT COUNT(*) FROM Reservations {whereClause}", + new { from, to } + ); - if (reservations == null) - { - return []; - } + var reservations = await _db.QueryAsync( + $"SELECT * FROM Reservations {whereClause} {orderClause} LIMIT @pageSize OFFSET @offset", + new { from, to, pageSize, offset } + ); - return reservations.Select(r => r.ToDomain()); + return (reservations?.Select(r => r.ToDomain()) ?? [], totalCount); } /// @@ -41,18 +60,94 @@ 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(); } + /// + /// Atomically checks for overlapping reservations and inserts a new one inside a transaction. + /// Throws if the room is already booked for the selected dates. + /// Uses strict inequality so same-day checkout/checkin is allowed. + /// public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } + if (_db.State != ConnectionState.Open) _db.Open(); + using var txn = _db.BeginTransaction(); + + var dbModel = new ReservationDb(newReservation); + + // Check for overlap inside the transaction + var hasOverlap = await _db.ExecuteScalarAsync( + @"SELECT EXISTS( + SELECT 1 FROM Reservations + WHERE RoomNumber = @RoomNumber + AND [Start] < @End + AND [End] > @Start + LIMIT 1 + )", + new { dbModel.RoomNumber, dbModel.Start, dbModel.End }, + transaction: txn ); + + if (hasOverlap) + { + txn.Rollback(); + throw new ValidationException( + $"Room {newReservation.RoomNumber} is already booked for the selected dates." + ); + } + + var created = await _db.QuerySingleAsync( + @"INSERT INTO Reservations(Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) + VALUES(@Id, @GuestEmail, @RoomNumber, @Start, @End, @CheckedIn, @CheckedOut) + RETURNING *", + dbModel, + transaction: txn + ); + + txn.Commit(); + return created.ToDomain(); + } + + /// + /// Atomically sets CheckedIn = 1, room State = Occupied, and IsDirty = 1 inside a transaction. + /// The UPDATE uses WHERE CheckedIn = 0 as a guard against concurrent check-ins. + /// Returns false if the reservation was already checked in (no rows updated). + /// + public async Task CheckIn(Guid reservationId) + { + if (_db.State != ConnectionState.Open) _db.Open(); + using var txn = _db.BeginTransaction(); + + var updated = await _db.ExecuteAsync( + "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @id AND CheckedIn = 0;", + new { id = reservationId.ToString() }, + transaction: txn + ); + + if (updated == 0) + { + txn.Rollback(); + return false; + } + + // Get room number to update its state + var roomNumber = await _db.ExecuteScalarAsync( + "SELECT RoomNumber FROM Reservations WHERE Id = @id;", + new { id = reservationId.ToString() }, + transaction: txn + ); + + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @state, IsDirty = 1 WHERE Number = @roomNumber;", + new { state = (int)State.Occupied, roomNumber }, + transaction: txn + ); + + txn.Commit(); + return true; } public async Task DeleteReservation(Guid reservationId) @@ -87,7 +182,7 @@ public ReservationDb() public ReservationDb(Reservation reservation) { Id = reservation.Id.ToString(); - RoomNumber = Room.ConvertRoomNumberToInt(reservation.RoomNumber); + RoomNumber = RoomExtensions.ConvertRoomNumberToInt(reservation.RoomNumber); GuestEmail = reservation.GuestEmail; Start = reservation.Start; End = reservation.End; @@ -100,7 +195,7 @@ public Reservation ToDomain() return new Reservation { Id = Guid.Parse(Id), - RoomNumber = Room.FormatRoomNumber(RoomNumber), + RoomNumber = RoomExtensions.FormatRoomNumber(RoomNumber), GuestEmail = GuestEmail, Start = Start, End = End, diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 2b9f904..7a43c6b 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -2,6 +2,7 @@ using Dapper; using Models; using Models.Errors; +using Extensions; namespace Repositories { @@ -22,7 +23,7 @@ public RoomRepository(IDbConnection db) /// public async Task GetRoom(string roomNumber) { - var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + var roomNumberInt = RoomExtensions.ConvertRoomNumberToInt(roomNumber); var room = await _db.QueryFirstOrDefaultAsync( "SELECT * FROM Rooms WHERE Number = @roomNumberInt;", @@ -52,16 +53,82 @@ public async Task> GetRooms() public async Task CreateRoom(Room newRoom) { var createdRoom = await _db.QuerySingleAsync( - "INSERT INTO Rooms(Number, State) Values(@Number, @State) RETURNING *", + "INSERT INTO Rooms(Number, State, IsDirty) Values(@Number, @State, @IsDirty) RETURNING *", new RoomDb(newRoom) ); return createdRoom.ToDomain(); } + public async Task SetRoomDirtyState(string roomNumber, bool isDirty) + { + var roomNumberInt = RoomExtensions.ConvertRoomNumberToInt(roomNumber); + + var updated = await _db.ExecuteAsync( + "UPDATE Rooms SET IsDirty = @isDirty WHERE Number = @roomNumberInt;", + new { isDirty, roomNumberInt } + ); + + return updated > 0; + } + + public async Task UpdateRoomState(string roomNumber, State state) + { + var roomNumberInt = RoomExtensions.ConvertRoomNumberToInt(roomNumber); + + var updated = await _db.ExecuteAsync( + "UPDATE Rooms SET State = @state WHERE Number = @roomNumberInt;", + new { state = (int)state, roomNumberInt } + ); + + return updated > 0; + } + + /// + /// Returns the set of all room numbers currently in the database (as ints) for O(1) duplicate checking. + /// + public async Task> GetExistingRoomNumbers() + { + var numbers = await _db.QueryAsync("SELECT Number FROM Rooms;"); + return new HashSet(numbers); + } + + /// + /// Batch-inserts rooms in a single transaction for performance. + /// Callers are responsible for pre-filtering duplicates and invalid rooms. + /// + public async Task BulkCreateRooms(IEnumerable rooms, CancellationToken ct = default) + { + if (_db.State != ConnectionState.Open) _db.Open(); + + using var txn = _db.BeginTransaction(); + + try + { + var dbRooms = rooms.Select(r => new RoomDb(r)).ToList(); + var sql = "INSERT INTO Rooms(Number, State, IsDirty) VALUES(@Number, @State, @IsDirty)"; + + var cmd = new CommandDefinition( + sql, + dbRooms, + transaction: txn, + cancellationToken: ct + ); + + var affected = await _db.ExecuteAsync(cmd); + txn.Commit(); + return affected; + } + catch + { + txn.Rollback(); + throw; + } + } + public async Task DeleteRoom(string roomNumber) { - var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + var roomNumberInt = RoomExtensions.ConvertRoomNumberToInt(roomNumber); var deleted = await _db.ExecuteAsync( "DELETE FROM Rooms WHERE Number = @roomNumberInt;", @@ -84,17 +151,20 @@ private class RoomDb /// public State State { get; set; } = State.Ready; + public bool IsDirty { get; set; } = false; + public RoomDb() { } public RoomDb(Room room) { - Number = Room.ConvertRoomNumberToInt(room.Number); + Number = RoomExtensions.ConvertRoomNumberToInt(room.Number); State = room.State; + IsDirty = room.IsDirty; } public Room ToDomain() { - return new Room { Number = Room.FormatRoomNumber(Number), State = State }; + return new Room { Number = RoomExtensions.FormatRoomNumber(Number), State = State, IsDirty = IsDirty }; } } } diff --git a/api/Services/VerificationCodeService.cs b/api/Services/VerificationCodeService.cs new file mode 100644 index 0000000..c61a731 --- /dev/null +++ b/api/Services/VerificationCodeService.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; + +namespace Services +{ + /// + /// Generic in-memory verification code store with TTL-based expiry. + /// Registered as a singleton. Codes expire after and are + /// lazily cleaned on every / call. + /// + public class VerificationCodeService + { + private static readonly TimeSpan CodeTtl = TimeSpan.FromSeconds(30); + + private readonly ConcurrentDictionary _codes = new(); + + /// + /// Returns true if a non-expired code already exists for this key. + /// + public bool HasActiveCode(Guid key) + { + return _codes.TryGetValue(key, out var entry) + && DateTime.UtcNow - entry.CreatedAt <= CodeTtl; + } + + /// + /// Generates a 6-character alphanumeric verification code for a key (e.g. reservation ID). + /// Overwrites any existing code for the same key. + /// + public string GenerateCode(Guid key) + { + Cleanup(); + var code = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant(); + _codes[key] = (code, DateTime.UtcNow); + return code; + } + + /// + /// Validates the code for a key. Removes the code on success. + /// Returns false if the code is wrong, missing, or expired. + /// + public bool ValidateCode(Guid key, string code) + { + Cleanup(); + + if (!_codes.TryRemove(key, out var entry)) + return false; + + if (DateTime.UtcNow - entry.CreatedAt > CodeTtl) + return false; // expired — don't put it back + + if (!string.Equals(entry.Code, code, StringComparison.OrdinalIgnoreCase)) + { + // Put it back if the code was wrong — don't consume the token on failure + _codes.TryAdd(key, entry); + return false; + } + + return true; + } + + /// + /// Lazily removes expired entries from the store. + /// + private void Cleanup() + { + var now = DateTime.UtcNow; + foreach (var kvp in _codes) + { + if (now - kvp.Value.CreatedAt > CodeTtl) + _codes.TryRemove(kvp.Key, out _); + } + } + } +} diff --git a/api/api.csproj b/api/api.csproj index ef55adc..c82b015 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -8,7 +8,10 @@ + + + diff --git a/api/appsettings.json b/api/appsettings.json index c06ebf6..3088b72 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -6,5 +6,9 @@ } }, "AllowedHosts": "*", - "staffAccessCode": "pass" + "staffAccessCode": "pass", + "Import": { + "MaxFileSizeBytes": 102400, + "MaxRows": 500 + } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..43b4180 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,113 @@ +# DECISIONS + +This file records the main technical and product decisions for the reservations app, plus known follow-ups and production hardening tasks. + +--- + +## Initial polish + +- Scoped DI for SQL connections and repositories is used, +- DB seeding is awaited on startup. +- and a simple global exception-handling middleware is run. + +Follow-ups: +- CORS will be restricted to the UI origin before production (no `AllowAnyOrigin`) +- exception-based control flow may be replaced with result types +- API namespaces/controllers will be cleaned up (e.g., `Api.*`, plural controller names) + +--- + +## RE-001 Guest booking + +- Guest booking is implemented end-to-end: server-side validation (dates, email, room), room existence check, guest upsert, and reservation insert, with consistent `{ errors: [...] }` 400 responses. +- The UI calls the API via `ky.post`, parses responses with Zod, and surfaces validation errors via a shared `ErrorToast`. +- DB Schema fixes are in place: `Start`/`End` stored as text dates, `Guest.Surname` added, dates sent as `YYYY-MM-DD` (local) to avoid UTC drift, past dates checked against server-local `DateTime.Today`. + +Follow-ups: + +- FluentValidation may be migrated to for declarative, testable validation. + +--- + +## RE-002 Prevent double bookings + +- Double-booking prevention is implemented inside a single DB transaction in `CreateReservation`, wrapping the overlap check and insert to avoid TOCTOU issues. +- Overlaps are detected via `SELECT EXISTS` with `[Start] < @End AND [End] > @Start`, allowing same-day checkout/check-in, and conflicts return `409 Conflict`. + +--- + +## Auth framework refactor + +- Manual cookie handling has been replaced with ASP.NET Core cookie authentication (`AddAuthentication().AddCookie()`, `SignInAsync`/`SignOutAsync` with `ClaimsPrincipal`). +- Login is `POST /staff/login` (401 on wrong codes); logout is POST; cookies are HttpOnly with `Secure` in production only and 30-minute sliding expiration. +- `[AllowAnonymous]` is applied to public GETs and guest booking; `[Authorize]` is used for staff-only actions (room create/delete, reservation delete, guest list). + +Follow-ups: + +- Rate limiting will be added for `POST /staff/login` to mitigate brute force. +- `staffAccessCode` will be moved from `appsettings.json` into vaults or a secrets manager. +- A proper `Staff` user model (per-user credentials, claims, audit logging) will be added long-term. + +--- + +## RE-003 Staff login and dashboard + +- A single paginated endpoint `GET /reservation?from=&page=&pageSize=` (authorized) returns upcoming reservations with optional `from` filtering and clamped `page`/`pageSize` (1–100). DB indexes added for performance. +- Pagination metadata is exposed via headers (`X-Total-Count`, `X-Page`, `X-Page-Size`); CORS exposes these to the UI. +- Staff UI (`staff/`) includes `AuthContext` for state management, login page with auto-redirects, dashboard with paginated table, logout button in layout, and reservations hook reading headers; landing page links to `/staff/login`; 401 triggers server-side logout. + +Follow-ups: + +- Cursor-based pagination will be considered for better scaling. +- Some count and data queries will be combined using for better performance. + +--- + +## RE-004 Check-in flow + +- Check-in with Email confirmation is a two-step staff-only flow: `POST /reservation/{id}/checkin` generates a 6-char code; `PUT` validates it, sets `CheckedIn = true` and room `State = Occupied`. +- `VerificationCodeService` is an in-memory TTL store (10 min); check-in is transactional with atomic guards; validation requires today’s start date; `GET /reservation` supports `to` param for “today only”. +- UI includes “Today only” filter, Check-In button/dialog/toasts, and table refresh. + +Follow-ups: + +- Verification codes will be moved to durable storage (Redis/DB TTL). +- Email integration (SendGrid/SES) will deliver codes to guests. +- Rate limiting will be added to `PUT /checkin` for brute-force protection. + +--- + +## RE-005 Room CSV import + +- `POST /room/import` parses CSV (`Number,State,IsDirty`), validates rows, checks duplicates, and batch-inserts in a transaction with configurable limits (`IOptions`). +- Response includes `{ imported, errors: [{ row, message }] }`; `CancellationToken` is supported. +- UI provides drag-and-drop dialog, size warnings, error list, and paginated rooms table. + +Follow-ups: + +- Binary sniffing will harden file type checks. +- UI limits will be fetched from server config. +- Undo/rollback will be considered for imports. + +--- + +## RE-006 Housekeeping and DB migrations + +- DB migrations use `PRAGMA user_version` (V1 tables, V2 indexes, V3 `IsDirty` column); `IsDirty` boolean replaces old enum. +- `PATCH /room/{roomNumber}` supports RFC 6902 JSON Patch (whitelisted paths); check-in sets `IsDirty = 1` transactionally. +- Staff dashboard shows room badges/toggles; guest UI shows “Dirty” badge; errors normalized via `handleApiError`. + +Follow-ups: + +- A dedicated Housekeeping page with filtering (dirty-only, floors) will be added. +- Audit trail for cleanliness changes will be introduced. + +--- + +## Cross-cutting and operational concerns + +- DTOs will be extracted to a folder; down-migration added. +- CORS will be locked to frontend origins before production. +- Global request validation middleware will unify invalid-body responses to `{ errors: [...] }`. +- A health check endpoint will be added for load balancers. +- SQLite will be migrated to PostgreSQL for production concurrency. \ No newline at end of file diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..40b723e 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 { 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/Layout.tsx b/ui/src/Layout.tsx index c32639c..a123e4c 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -1,6 +1,7 @@ -import { Box, Text } from "@radix-ui/themes"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { Link, Outlet, useRouter } from "@tanstack/react-router"; import React from "react"; +import { useAuth } from "./staff/AuthContext"; const TOP_BAR_ACCENT_BACKGROUND: React.CSSProperties = { backgroundColor: "var(--accent-10)", @@ -12,15 +13,28 @@ const UNDERLINE_HEADING: React.CSSProperties = { }; export const Layout = () => { + const router = useRouter(); + const { isAuthed, logout } = useAuth(); + + async function handleLogout() { + await logout(); + router.navigate({ to: "/" }); + } + return ( - + Reservations @ Mewstel - + {isAuthed && ( + + )} + ); diff --git a/ui/src/components/CheckInDialog.tsx b/ui/src/components/CheckInDialog.tsx new file mode 100644 index 0000000..f593133 --- /dev/null +++ b/ui/src/components/CheckInDialog.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect } from "react"; +import { + Box, + Button, + Dialog, + Flex, + Text, + TextField, +} from "@radix-ui/themes"; +import { + initiateCheckIn, + confirmCheckIn, + type ReservationDetail, +} from "../reservations/api"; +import { handleApiError, showErrorToast, showSuccessToast } from "../utils/toasts"; + +interface CheckInDialogProps { + reservation: ReservationDetail | null; + onClose: () => void; + onConfirmed: () => void; +} + +export function CheckInDialog({ + reservation, + onClose, + onConfirmed, +}: CheckInDialogProps) { + const [generatedCode, setGeneratedCode] = useState(null); + const [codeInput, setCodeInput] = useState(""); + const [loading, setLoading] = useState(false); + + // Initiate check-in when a reservation is selected + useEffect(() => { + if (!reservation) return; + setGeneratedCode(null); + setCodeInput(""); + setLoading(true); + + initiateCheckIn(reservation.id) + .then(setGeneratedCode) + .catch(async (err) => { + await handleApiError(err, "Failed to initiate check-in."); + onClose(); + }) + .finally(() => setLoading(false)); + }, [reservation, onClose]); + + async function handleConfirm() { + if (!reservation) return; + setLoading(true); + try { + await confirmCheckIn(reservation.id, codeInput); + showSuccessToast( + `Checked in reservation for #${reservation.roomNumber}.`, + ); + onConfirmed(); + } catch (err) { + await handleApiError(err, "Invalid code or check-in failed."); + } finally { + setLoading(false); + } + } + + return ( + { + if (!open) onClose(); + }} + > + + Check In — #{reservation?.roomNumber} + + + {reservation?.guestEmail} + + + {loading && !generatedCode && ( + + Sending verification code... + + )} + + {generatedCode && ( + <> + + A verification code has been sent to the guest's email. Enter the + code below to confirm check-in. + + + + + Dev mock — code not emailed: {generatedCode} + + + + setCodeInput(e.target.value)} + /> + + + + + + + + )} + + + ); +} diff --git a/ui/src/components/ErrorToast.tsx b/ui/src/components/ErrorToast.tsx new file mode 100644 index 0000000..6eaf015 --- /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(--red-9); +`; + +/** An error toast */ +export function ErrorToast({ toastId, message }: ErrorToastProps) { + const closeToast = useCallback(() => toast.dismiss(toastId), [toastId]); + + return ( + + + {message} + + + ); +} diff --git a/ui/src/components/ImportRoomsDialog.tsx b/ui/src/components/ImportRoomsDialog.tsx new file mode 100644 index 0000000..1561623 --- /dev/null +++ b/ui/src/components/ImportRoomsDialog.tsx @@ -0,0 +1,171 @@ +import { useState, useRef, useCallback } from "react"; +import { Box, Button, Dialog, Flex, Text, Badge } from "@radix-ui/themes"; +import { importRoomsCsv, type ImportResult } from "../reservations/api"; +import { handleApiError, showSuccessToast } from "../utils/toasts"; + +interface ImportRoomsDialogProps { + open: boolean; + onClose: () => void; + onImported: () => void; +} + +type Phase = "pick" | "uploading" | "done"; + +export function ImportRoomsDialog({ + open, + onClose, + onImported, +}: ImportRoomsDialogProps) { + const [file, setFile] = useState(null); + const [phase, setPhase] = useState("pick"); + const [result, setResult] = useState(null); + const [dragOver, setDragOver] = useState(false); + const inputRef = useRef(null); + + const reset = useCallback(() => { + setFile(null); + setPhase("pick"); + setResult(null); + setDragOver(false); + }, []); + + function handleClose() { + reset(); + onClose(); + } + + const MAX_FILE_SIZE = 102_400; // 100 KB — matches server limit + + function handleFile(f: File | undefined) { + if (!f) return; + if (!f.name.endsWith(".csv")) return; + setFile(f); + } + + async function handleUpload() { + if (!file) return; + setPhase("uploading"); + try { + const res = await importRoomsCsv(file); + setResult(res); + setPhase("done"); + if (res.imported > 0) { + showSuccessToast(`Imported ${res.imported} room${res.imported !== 1 ? "s" : ""}.`); + onImported(); + } + } catch (err) { + await handleApiError(err, "Failed to import rooms."); + setPhase("pick"); + } + } + + return ( + { if (!o) handleClose(); }}> + + Import Rooms from CSV + + {phase === "pick" && ( + <> + + Upload a CSV with columns: Number, State, IsDirty (max 500 rows). + + + { e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + handleFile(e.dataTransfer.files[0]); + }} + onClick={() => inputRef.current?.click()} + style={{ + border: `2px dashed var(${dragOver ? "--mint-9" : "--gray-6"})`, + borderRadius: "var(--radius-3)", + padding: "32px", + textAlign: "center", + cursor: "pointer", + background: dragOver ? "var(--mint-a2)" : "var(--gray-a2)", + transition: "all 150ms", + }} + > + handleFile(e.target.files?.[0])} + /> + {file ? ( + <> + {file.name} + MAX_FILE_SIZE ? "red" : "gray"} as="p"> + {(file.size / 1024).toFixed(1)} KB + {file.size > MAX_FILE_SIZE && " — exceeds 100 KB limit"} + + + ) : ( + + Drag & drop a .csv file here, or click to browse + + )} + + + + + + + + + + )} + + {phase === "uploading" && ( + Uploading and processing... + )} + + {phase === "done" && result && ( + <> + + {result.imported} imported + {result.errors.length > 0 && ( + {result.errors.length} error{result.errors.length !== 1 ? "s" : ""} + )} + + + {result.errors.length > 0 && ( + + {result.errors.map((e, i) => ( + + Row {e.row}: {e.message} + + ))} + + )} + + + + + + )} + + + ); +} diff --git a/ui/src/index.tsx b/ui/src/index.tsx index b2b6941..b379ebe 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -5,6 +5,7 @@ import { Toaster } from "sonner"; import { Theme } from "@radix-ui/themes"; import "@radix-ui/themes/styles.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { AuthProvider } from "./staff/AuthContext"; declare var root: HTMLDivElement; const queryClient = new QueryClient(); @@ -14,8 +15,10 @@ reactRoot.render( - - + + + + , diff --git a/ui/src/reservations/ReservationCard.tsx b/ui/src/reservations/ReservationCard.tsx index d2c8f52..2ccd302 100644 --- a/ui/src/reservations/ReservationCard.tsx +++ b/ui/src/reservations/ReservationCard.tsx @@ -1,4 +1,4 @@ -import { Text, Card, Inset, Dialog } from "@radix-ui/themes"; +import { Text, Card, Inset, Dialog, Badge } from "@radix-ui/themes"; import { PropsWithChildren } from "react"; import styled from "styled-components"; @@ -14,6 +14,7 @@ export type ReservationCardProps = PropsWithChildren<{ onClick: () => void; imgSrc: string; roomNumber: string; + isDirty?: boolean; }>; /** A Card wrapped in a Dialog.Trigger */ @@ -27,6 +28,9 @@ export function ReservationCard(props: ReservationCardProps) { Room #{props.roomNumber} + {props.isDirty && ( + Dirty + )} diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 06a0036..d3b45cc 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 { useShowSuccessToast, handleApiError } from "../utils/toasts"; import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; import { bookRoom, NewReservation, useGetRooms } from "./api"; @@ -24,8 +24,14 @@ export function ReservationPage() { setSelectedRoomNumber(""); } - function onSubmit(booking: NewReservation) { - bookRoom(booking).then(onClose).then(showToast); + async function onSubmit(booking: NewReservation) { + try { + await bookRoom(booking); + onClose(); + showToast(); + } catch (err) { + await handleApiError(err, "An unexpected error occurred."); + } } const createClickHandler = (roomNumber: string) => () => { @@ -46,6 +52,7 @@ export function ReservationPage() { key={room.number} imgSrc="/bed.png" roomNumber={room.number} + isDirty={room.isDirty} onClick={createClickHandler(room.number)} /> ))} diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..424e9c4 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -10,39 +10,137 @@ export interface NewReservation { End: ISO8601String; } -/** The schema the API returns */ +/** The schema the API returns (camelCase — ASP.NET Core default) */ const ReservationSchema = z.object({ - Id: z.string(), - RoomNumber: z.string(), - GuestEmail: z.string().email(), - Start: z.string(), - End: z.string(), + id: z.string(), + roomNumber: z.string(), + guestEmail: z.string(), + start: z.string(), + end: z.string(), }); type Reservation = z.infer; -export function bookRoom(booking: NewReservation) { - // unwrap branded types +export async function bookRoom(booking: NewReservation): Promise { const newReservation = { - ...booking, - Start: toIsoStr(booking.Start), - End: toIsoStr(booking.End), + roomNumber: booking.RoomNumber, + guestEmail: booking.GuestEmail, + start: toIsoStr(booking.Start), + end: toIsoStr(booking.End), }; - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); + const response = await ky.post("/api/reservation", { json: newReservation }); + const data = await response.json(); + return ReservationSchema.parse(data); } const RoomSchema = z.object({ number: z.string(), state: z.number(), + isDirty: z.boolean(), }); +export type Room = z.infer; + 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), + }); +} + +const ReservationDetailSchema = 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 ReservationDetail = z.infer; + +const ReservationListSchema = ReservationDetailSchema.array(); + +export interface PaginatedReservations { + items: ReservationDetail[]; + totalCount: number; + page: number; + pageSize: number; +} + +function toLocalDateStr(date: Date): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +export function useGetUpcomingReservations( + page = 1, + pageSize = 20, + todayOnly = false, +) { + const from = toLocalDateStr(new Date()); + const searchParams: Record = { from, page, pageSize }; + if (todayOnly) searchParams.to = from; + + return useQuery({ + queryKey: ["reservations", "upcoming", from, page, pageSize, todayOnly], + queryFn: async (): Promise => { + const response = await ky.get("/api/reservation", { searchParams }); + const items = ReservationListSchema.parse(await response.json()); + return { + items, + totalCount: Number(response.headers.get("X-Total-Count") ?? "0"), + page: Number(response.headers.get("X-Page") ?? "1"), + pageSize: Number(response.headers.get("X-Page-Size") ?? "20"), + }; + }, + retry: false, + }); +} + +const InitiateCheckInResponseSchema = z.object({ + code: z.string(), +}); + +export async function initiateCheckIn(reservationId: string): Promise { + const response = await ky.post(`/api/reservation/${reservationId}/checkin`); + const data = InitiateCheckInResponseSchema.parse(await response.json()); + return data.code; +} + +export async function confirmCheckIn( + reservationId: string, + code: string, +): Promise { + await ky.put(`/api/reservation/${reservationId}/checkin`, { + json: { code }, + }); +} + +export interface ImportResult { + imported: number; + errors: { row: number; message: string }[]; +} + +export async function importRoomsCsv(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + const response = await ky.post("/api/room/import", { body: formData }); + return (await response.json()) as ImportResult; +} + +export async function updateRoomDirtyState( + roomNumber: string, + isDirty: boolean, +): Promise { + const response = await ky.patch(`/api/room/${roomNumber}`, { + json: [{ op: "replace", path: "/isDirty", value: isDirty }], }); + return RoomSchema.parse(await response.json()); } diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e3020bd..07388de 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -6,6 +6,8 @@ import { import { Layout } from "./Layout"; import { LandingPage } from "./LandingPage"; import { ReservationPage } from "./reservations/ReservationPage"; +import { StaffLoginPage } from "./staff/StaffLoginPage"; +import { StaffDashboardPage } from "./staff/StaffDashboardPage"; const rootRoute = createRootRoute({ component: Layout, @@ -26,6 +28,16 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + createRoute({ + path: "/staff/login", + getParentRoute: getRootRoute, + component: StaffLoginPage, + }), + createRoute({ + path: "/staff", + getParentRoute: getRootRoute, + component: StaffDashboardPage, + }), ]; const routeTree = rootRoute.addChildren(ROUTES); diff --git a/ui/src/staff/AuthContext.tsx b/ui/src/staff/AuthContext.tsx new file mode 100644 index 0000000..dcd2ca5 --- /dev/null +++ b/ui/src/staff/AuthContext.tsx @@ -0,0 +1,44 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { checkAuth as apiCheckAuth, login as apiLogin, logout as apiLogout } from "./api"; + +interface AuthContextValue { + isAuthed: boolean | null; + login: (code: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthed, setIsAuthed] = useState(null); + + useEffect(() => { + apiCheckAuth().then(setIsAuthed); + }, []); + + const login = useCallback(async (code: string) => { + await apiLogin(code); + setIsAuthed(true); + }, []); + + const logout = useCallback(async () => { + try { + await apiLogout(); + } catch { + // sign-out failures are non-critical + } + setIsAuthed(false); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/ui/src/staff/StaffDashboardPage.tsx b/ui/src/staff/StaffDashboardPage.tsx new file mode 100644 index 0000000..627b60c --- /dev/null +++ b/ui/src/staff/StaffDashboardPage.tsx @@ -0,0 +1,311 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Badge, + Box, + Button, + Flex, + Heading, + Section, + Separator, + Table, + Text, +} from "@radix-ui/themes"; +import { useAuth } from "./AuthContext"; +import { + useGetUpcomingReservations, + useGetRooms, + updateRoomDirtyState, + type ReservationDetail, +} from "../reservations/api"; +import { CheckInDialog } from "../components/CheckInDialog"; +import { ImportRoomsDialog } from "../components/ImportRoomsDialog"; +import { handleApiError, showSuccessToast } from "../utils/toasts"; + +const PAGE_SIZE = 5; + +export function StaffDashboardPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const { isAuthed } = useAuth(); + const [page, setPage] = useState(1); + const [todayOnly, setTodayOnly] = useState(false); + const { data, isLoading, isError } = useGetUpcomingReservations( + page, + PAGE_SIZE, + todayOnly, + ); + const { data: rooms, isLoading: roomsLoading } = useGetRooms(); + + const [checkInTarget, setCheckInTarget] = useState( + null, + ); + const [importOpen, setImportOpen] = useState(false); + const [roomPage, setRoomPage] = useState(1); + + useEffect(() => { + if (isAuthed === false) { + router.navigate({ to: "/staff/login" }); + } + }, [isAuthed, router]); + + const items = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + + const today = new Date().toISOString().split("T")[0]; + + function isToday(dateStr: string) { + return dateStr.split("T")[0] === today; + } + + return ( +
+ + + {todayOnly ? "Today's Reservations" : "Upcoming Reservations"} + + + + + + {isLoading && ( + + Loading reservations... + + )} + + {isError && ( + + Failed to load reservations. + + )} + + {!isLoading && !isError && data && ( + <> + + + + + Room + Guest Email + Start + End + Status + Actions + + + + {items.length === 0 && ( + + + No reservations found. + + + )} + {items.map((r) => ( + + + #{r.roomNumber} + + {r.guestEmail} + {r.start} + {r.end} + + {r.checkedOut ? ( + Checked out + ) : r.checkedIn ? ( + Checked in + ) : ( + Upcoming + )} + + + {!r.checkedIn && !r.checkedOut && isToday(r.start) && ( + + )} + + + ))} + + + + + + + {totalCount} reservation{totalCount !== 1 ? "s" : ""} — page{" "} + {page} of {totalPages} + + + + + + + + )} + + setCheckInTarget(null)} + onConfirmed={() => { + setCheckInTarget(null); + queryClient.invalidateQueries({ queryKey: ["reservations"] }); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + }} + /> + + + + Housekeeping + + + + + + setImportOpen(false)} + onImported={() => { + setRoomPage(1); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + }} + /> + + {roomsLoading && ( + + Loading rooms... + + )} + + {rooms && rooms.length > 0 && (() => { + const roomTotalPages = Math.max(1, Math.ceil(rooms.length / PAGE_SIZE)); + const pagedRooms = rooms.slice((roomPage - 1) * PAGE_SIZE, roomPage * PAGE_SIZE); + return ( + <> + + + + + Room + Occupancy + Cleanliness + Actions + + + + {pagedRooms.map((room) => ( + + + #{room.number} + + + {room.state === 1 ? ( + Occupied + ) : ( + Ready + )} + + + {room.isDirty ? ( + Dirty + ) : ( + Clean + )} + + + + + + ))} + + + + + + + {rooms.length} room{rooms.length !== 1 ? "s" : ""} — page{" "} + {roomPage} of {roomTotalPages} + + + + + + + + ); + })()} +
+ ); +} diff --git a/ui/src/staff/StaffLoginPage.tsx b/ui/src/staff/StaffLoginPage.tsx new file mode 100644 index 0000000..436c321 --- /dev/null +++ b/ui/src/staff/StaffLoginPage.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "@tanstack/react-router"; +import { + Box, + Button, + Card, + Flex, + Heading, + Separator, + TextField, +} from "@radix-ui/themes"; +import { useAuth } from "./AuthContext"; +import { handleApiError, showErrorToast } from "../utils/toasts"; +import styled from "styled-components"; + +const DimSlot = styled(TextField.Slot)` + background-color: var(--gray-4); + margin-right: 8px; +`; + +export function StaffLoginPage() { + const router = useRouter(); + const { isAuthed, login } = useAuth(); + const [accessCode, setAccessCode] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (isAuthed === true) { + router.navigate({ to: "/staff" }); + } + }, [isAuthed, router]); + + async function handleSubmit(evt: React.FormEvent) { + evt.preventDefault(); + if (!accessCode.trim()) { + showErrorToast("Access code is required."); + return; + } + + setIsLoading(true); + try { + await login(accessCode); + router.navigate({ to: "/staff" }); + } catch (err) { + await handleApiError(err, "Login failed. Please try again."); + } finally { + setIsLoading(false); + } + } + + return ( + + + + Staff Login + + +
+ + setAccessCode(e.target.value)} + autoFocus + > + Code + + + + + +
+
+
+ ); +} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts new file mode 100644 index 0000000..b00b6bb --- /dev/null +++ b/ui/src/staff/api.ts @@ -0,0 +1,24 @@ +import ky, { HTTPError } from "ky"; + +export async function login(accessCode: string): Promise { + await ky.post("/api/staff/login", { + headers: { "X-Staff-Code": accessCode }, + }); +} + +export async function logout(): Promise { + await ky.post("/api/staff/logout"); +} + +export async function checkAuth(): Promise { + try { + await ky.get("/api/staff/check"); + return true; + } catch (err) { + if (err instanceof HTTPError && err.response.status === 401) { + try { await logout(); } catch { /* already unauthenticated */ } + return false; + } + throw err; + } +} diff --git a/ui/src/utils/datetime.ts b/ui/src/utils/datetime.ts index 17d55a7..bfaf67d 100644 --- a/ui/src/utils/datetime.ts +++ b/ui/src/utils/datetime.ts @@ -34,6 +34,12 @@ export function getNowIso(): ISO8601String { return fromDate(new Date()); } +/** Returns a date-only string (YYYY-MM-DD) using the local date parts. + * Hotel reservations are calendar dates — timezone of the booker is irrelevant. */ export function toIsoStr(branded: ISO8601String): string { - return branded._value; + const d = branded._dateValue; + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; } diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index d3358f7..cddfb7c 100644 --- a/ui/src/utils/toasts.tsx +++ b/ui/src/utils/toasts.tsx @@ -1,7 +1,9 @@ import { SuccessToast } from "../components/SuccessToast"; import { InfoToast } from "../components/InfoToast"; +import { ErrorToast } from "../components/ErrorToast"; import { ExternalToast, toast } from "sonner"; import { useCallback } from "react"; +import { HTTPError } from "ky"; const DEFAULT_TOAST_DURATION_MS = 2_250; @@ -30,3 +32,43 @@ export function useShowInfoToast(message: string) { [message], ); } + +/** Non-hook version for use in catch blocks / imperative code */ +export function showSuccessToast(message: string) { + toast.custom( + (t) => , + DEFAULT_TOAST_OPTIONS, + ); +} + +/** Non-hook version for use in catch blocks / imperative code */ +export function showErrorToast(message: string) { + toast.custom( + (t) => , + DEFAULT_TOAST_OPTIONS, + ); +} + +/** + * Parse structured `{ errors: string[] }` from an API error response and show + * each error as a toast. Falls back to a generic message for non-HTTP or + * unparseable errors. Returns true if structured errors were shown. + */ +export async function handleApiError( + err: unknown, + fallbackMessage: string, +): Promise { + if (err instanceof HTTPError) { + try { + const body = await err.response.json(); + if (body?.errors && Array.isArray(body.errors)) { + body.errors.forEach((msg: string) => showErrorToast(msg)); + return true; + } + } catch { + // response wasn't JSON — fall through + } + } + showErrorToast(fallbackMessage); + return false; +}