From 6f055563e20195e7cd8124388b307969c0ccd9a9 Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 10:54:41 +0000 Subject: [PATCH 01/15] fix(backend): Change DB services to scoped lifetime and improve error handling - Change database services from Singleton to Scoped lifetime for proper connection management - Make EnsureDb async Task instead of async void - Await EnsureDb call with proper scope disposal - Add global exception handler for production environments --- api/Db/Setup.cs | 2 +- api/Program.cs | 34 ++++++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..41bdbbf 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -9,7 +9,7 @@ public static class Setup /// /// Ensures the DB is available and the requried tables are made /// - public static async void EnsureDb(IServiceScope scope) + public static async Task EnsureDb(IServiceScope scope) { using var db = scope.ServiceProvider.GetRequiredService(); diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..e8ac024 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -12,11 +12,11 @@ builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; - Services.AddSingleton(_ => new SqliteConnection(connectionString)); - Services.AddSingleton(sp => sp.GetRequiredService()); - Services.AddSingleton(); - Services.AddSingleton(); - Services.AddSingleton(); + Services.AddScoped(_ => new SqliteConnection(connectionString)); + Services.AddScoped(sp => sp.GetRequiredService()); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); Services.AddMvc(opt => { opt.EnableEndpointRouting = false; @@ -32,7 +32,8 @@ { try { - Setup.EnsureDb(app.Services.CreateScope()); + using var scope = app.Services.CreateScope(); + await Setup.EnsureDb(scope); } catch (Exception ex) { @@ -42,9 +43,26 @@ return; } - app.UsePathBase("/api") + app.UsePathBase("/api"); + + app.UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); + + 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 .UseMvc() - .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) .UseSwagger() .UseSwaggerUI(); } From b2f41586561b4abc94de0780458f1ba819743793 Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 12:25:04 +0000 Subject: [PATCH 02/15] fix(api): change Reservation date types and add missing Surname field to Guest --- api/Db/Setup.cs | 7 ++++--- api/Repositories/GuestRepository.cs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 41bdbbf..3e88ea3 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -22,7 +22,8 @@ await db.ExecuteAsync( $@" CREATE TABLE IF NOT EXISTS Guests ( {nameof(Guest.Email)} TEXT PRIMARY KEY NOT NULL, - {nameof(Guest.Name)} TEXT NOT NULL + {nameof(Guest.Name)} TEXT NOT NULL, + {nameof(Guest.Surname)} TEXT ); " ); @@ -42,8 +43,8 @@ CREATE TABLE IF NOT EXISTS Reservations ( {nameof(Reservation.Id)} TEXT PRIMARY KEY NOT NULL, {nameof(Reservation.GuestEmail)} TEXT NOT NULL, {nameof(Reservation.RoomNumber)} INT NOT NULL, - {nameof(Reservation.Start)} INT NOT NULL, - {nameof(Reservation.End)} INT NOT NULL, + {nameof(Reservation.Start)} TEXT NOT NULL, + {nameof(Reservation.End)} TEXT NOT NULL, {nameof(Reservation.CheckedIn)} INT NOT NULL DEFAULT FALSE, {nameof(Reservation.CheckedOut)} INT NOT NULL DEFAULT FALSE, FOREIGN KEY ({nameof(Reservation.GuestEmail)}) diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 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 ); } From c971d69758b392998fdc665afeb9e18e4d6cd5c2 Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 12:26:28 +0000 Subject: [PATCH 03/15] feat(api): implement booking reservation with validation for reservations and rooms - Add ValidationException for structured error handling - Create ReservationExtensions with validation for booking rules (RE-001): - Room number format validation - Email domain validation - Start date must not be in past - Start date must be before end date - Duration constraints (1-30 days) - Create RoomExtensions with validation and move static methods from Room model - Update ReservationController to validate reserv --- api/Controllers/ReservationController.cs | 51 ++++++++++++++---- api/Controllers/RoomController.cs | 11 +++- api/Extensions/ReservationExtensions.cs | 61 ++++++++++++++++++++++ api/Extensions/RoomExtensions.cs | 63 +++++++++++++++++++++++ api/Models/Errors/ValidationException.cs | 19 +++++++ api/Models/Room.cs | 23 --------- api/Repositories/ReservationRepository.cs | 18 ++++--- api/Repositories/RoomRepository.cs | 9 ++-- ui/src/components/ErrorToast.tsx | 28 ++++++++++ ui/src/reservations/ReservationPage.tsx | 24 +++++++-- ui/src/reservations/api.ts | 29 ++++++----- ui/src/utils/datetime.ts | 8 ++- ui/src/utils/toasts.tsx | 9 ++++ 13 files changed, 290 insertions(+), 63 deletions(-) create mode 100644 api/Extensions/ReservationExtensions.cs create mode 100644 api/Extensions/RoomExtensions.cs create mode 100644 api/Models/Errors/ValidationException.cs create mode 100644 ui/src/components/ErrorToast.tsx diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..2bdab30 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -2,6 +2,7 @@ using Models; using Models.Errors; using Repositories; +using Extensions; namespace Controllers { @@ -9,10 +10,18 @@ namespace Controllers public class ReservationController : Controller { private ReservationRepository _repo { get; set; } + private RoomRepository _roomRepo { get; set; } + private GuestRepository _guestRepo { get; set; } - public ReservationController(ReservationRepository reservationRepository) + public ReservationController( + ReservationRepository reservationRepository, + RoomRepository roomRepository, + GuestRepository guestRepository + ) { _repo = reservationRepository; + _roomRepo = roomRepository; + _guestRepo = guestRepository; } [HttpGet, Produces("application/json"), Route("")] @@ -47,24 +56,46 @@ public async Task> BookReservation( [FromBody] Reservation newBooking ) { - // Provide a real ID if one is not provided - if (newBooking.Id == Guid.Empty) + // Validate the reservation + try { - newBooking.Id = Guid.NewGuid(); + newBooking.Validate(); + } + catch (ValidationException ex) + { + return BadRequest(new { errors = ex.Errors }); } + // Verify the room exists try { - var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); + await _roomRepo.GetRoom(newBooking.RoomNumber); } - catch (Exception ex) + catch (NotFoundException) { - Console.WriteLine("An error occured when trying to book a reservation:"); - Console.WriteLine(ex.ToString()); + return BadRequest(new { errors = new[] { $"Room {newBooking.RoomNumber} does not exist." } }); + } - return BadRequest("Invalid reservation"); + // 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(); + } + + var createdReservation = await _repo.CreateReservation(newBooking); + return Created($"/reservation/{createdReservation.Id}", createdReservation); } [HttpDelete, Produces("application/json"), Route("{reservationId}")] diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 6e97650..d9a8348 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -2,6 +2,7 @@ using Models; using Models.Errors; using Repositories; +using Extensions; namespace Controllers { @@ -33,7 +34,7 @@ public async Task> GetRoom(string roomNumber) { if (roomNumber.Length != 3) { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + return BadRequest(new { errors = new[] { "Invalid room ID - format is ###, ex 001 / 002 / 101" } }); } try @@ -51,6 +52,12 @@ public async Task> GetRoom(string roomNumber) [HttpPost, Produces("application/json"), Route("")] public async Task> CreateRoom([FromBody] Room newRoom) { + var errors = newRoom.Validate(); + if (errors.Count > 0) + { + return BadRequest(new { errors }); + } + var createdRoom = await _repo.CreateRoom(newRoom); if (createdRoom == null) @@ -66,7 +73,7 @@ public async Task DeleteRoom(string roomNumber) { if (roomNumber.Length != 3) { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + return BadRequest(new { errors = new[] { "Invalid room ID - format is ###, ex 001 / 002 / 101" } }); } var deleted = await _repo.DeleteRoom(roomNumber); 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/Room.cs b/api/Models/Room.cs index cbd6536..2b9db5d 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -1,5 +1,3 @@ -using Models.Errors; - namespace Models { /// @@ -18,27 +16,6 @@ public class Room /// Whether the room is available for reservation /// public State State { get; set; } = State.Ready; - - /// - /// 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; - } } public enum State diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..3f50c89 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 { @@ -41,7 +42,7 @@ public async Task GetReservation(Guid reservationId) if (reservation == null) { - throw new NotFoundException($"Room {reservationId} not found"); + throw new NotFoundException($"Reservation {reservationId} not found"); } return reservation.ToDomain(); @@ -49,10 +50,15 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } + var dbModel = new ReservationDb(newReservation); + var created = await _db.QuerySingleAsync( + @"INSERT INTO Reservations(Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) + VALUES(@Id, @GuestEmail, @RoomNumber, @Start, @End, @CheckedIn, @CheckedOut) + RETURNING *", + dbModel ); + + return created.ToDomain(); } public async Task DeleteReservation(Guid reservationId) @@ -87,7 +93,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 +106,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..b02a637 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;", @@ -61,7 +62,7 @@ public async Task CreateRoom(Room newRoom) 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;", @@ -88,13 +89,13 @@ public RoomDb() { } public RoomDb(Room room) { - Number = Room.ConvertRoomNumberToInt(room.Number); + Number = RoomExtensions.ConvertRoomNumberToInt(room.Number); State = room.State; } public Room ToDomain() { - return new Room { Number = Room.FormatRoomNumber(Number), State = State }; + return new Room { Number = RoomExtensions.FormatRoomNumber(Number), State = State }; } } } 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/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 06a0036..3f461d5 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; -import { useShowSuccessToast } from "../utils/toasts"; +import { useShowSuccessToast, showErrorToast } from "../utils/toasts"; import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; import { bookRoom, NewReservation, useGetRooms } from "./api"; import { LoadingCard } from "../components/LoadingCard"; import { BookingDetailsModal } from "./BookingDetailsModal"; +import { HTTPError } from "ky"; const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { sm: "1", @@ -24,8 +25,25 @@ 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) { + 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; + } + } catch { + // response wasn't JSON — fall through to generic error + } + } + showErrorToast("An unexpected error occurred."); + } } const createClickHandler = (roomNumber: string) => () => { diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..b1cbe55 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -10,27 +10,28 @@ 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({ @@ -43,6 +44,6 @@ const RoomListSchema = RoomSchema.array(); export function useGetRooms() { return useQuery({ queryKey: ["rooms"], - queryFn: () => ky.get("api/room").json().then(RoomListSchema.parseAsync), + queryFn: () => ky.get("/api/room").json().then(RoomListSchema.parseAsync), }); } diff --git a/ui/src/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..eee810d 100644 --- a/ui/src/utils/toasts.tsx +++ b/ui/src/utils/toasts.tsx @@ -1,5 +1,6 @@ import { SuccessToast } from "../components/SuccessToast"; import { InfoToast } from "../components/InfoToast"; +import { ErrorToast } from "../components/ErrorToast"; import { ExternalToast, toast } from "sonner"; import { useCallback } from "react"; @@ -30,3 +31,11 @@ export function useShowInfoToast(message: string) { [message], ); } + +/** Non-hook version for use in catch blocks / imperative code */ +export function showErrorToast(message: string) { + toast.custom( + (t) => , + DEFAULT_TOAST_OPTIONS, + ); +} From 16a257f899e22aa45690c3e096da9aaec110891c Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 12:38:22 +0000 Subject: [PATCH 04/15] feat(api): add atomic date overlap check for reservation creation - Wrap overlap check and INSERT in a single transaction for atomicity - Query for existing reservations with date range overlap before inserting - Throw ValidationException if room is already booked for selected dates - Handle ValidationException in controller and return 409 Conflict - Use strict inequality for date comparison to allow same-day checkout/checkin - Ensure database connection is open before starting transaction --- api/Controllers/ReservationController.cs | 12 ++++++-- api/Repositories/ReservationRepository.cs | 34 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 2bdab30..7974c97 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -94,8 +94,16 @@ await _guestRepo.CreateGuest( newBooking.Id = Guid.NewGuid(); } - var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/{createdReservation.Id}", createdReservation); + // Create reservation (overlap check + INSERT are atomic inside a transaction) + try + { + var createdReservation = await _repo.CreateReservation(newBooking); + return Created($"/reservation/{createdReservation.Id}", createdReservation); + } + catch (ValidationException ex) + { + return Conflict(new { errors = ex.Errors }); + } } [HttpDelete, Produces("application/json"), Route("{reservationId}")] diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 3f50c89..e4c1d1b 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -48,16 +48,48 @@ public async Task GetReservation(Guid reservationId) 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) { + 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 + dbModel, + transaction: txn ); + txn.Commit(); return created.ToDomain(); } From dd1840936078e2b71b0c2687dec7fc51042de727 Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 13:06:06 +0000 Subject: [PATCH 05/15] refactor(api): implement cookie-based authentication with role-based authorization - Replace manual cookie checking with ASP.NET Core Cookie Authentication - Add [Authorize] and [AllowAnonymous] attributes to controller endpoints - Configure authentication middleware with secure cookie settings - Update StaffController to use SignInAsync/SignOutAsync for login/logout - Return 401/403 status codes instead of redirecting for API endpoints - Set cookie security based on environment (secure in production --- api/Controllers/GuestController.cs | 2 + api/Controllers/ReservationController.cs | 5 ++ api/Controllers/RoomController.cs | 5 ++ api/Controllers/StaffController.cs | 72 ++++++++++-------------- api/Program.cs | 27 +++++++++ 5 files changed, 70 insertions(+), 41 deletions(-) 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 7974c97..ad8289c 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; @@ -25,6 +26,7 @@ GuestRepository guestRepository } [HttpGet, Produces("application/json"), Route("")] + [Authorize] public async Task> GetReservations() { var reservations = await _repo.GetReservations(); @@ -33,6 +35,7 @@ public async Task> GetReservations() } [HttpGet, Produces("application/json"), Route("{reservationId}")] + [AllowAnonymous] public async Task> GetRoom(Guid reservationId) { try @@ -52,6 +55,7 @@ public async Task> GetRoom(Guid reservationId) /// /// [HttpPost, Produces("application/json"), Route("")] + [AllowAnonymous] public async Task> BookReservation( [FromBody] Reservation newBooking ) @@ -107,6 +111,7 @@ await _guestRepo.CreateGuest( } [HttpDelete, Produces("application/json"), Route("{reservationId}")] + [Authorize] public async Task DeleteReservation(Guid reservationId) { var result = await _repo.DeleteReservation(reservationId); diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index d9a8348..8b2f140 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; @@ -17,6 +18,7 @@ public RoomController(RoomRepository roomRepository) } [HttpGet, Produces("application/json"), Route("")] + [AllowAnonymous] public async Task> GetRooms() { var rooms = await _repo.GetRooms(); @@ -30,6 +32,7 @@ public async Task> GetRooms() } [HttpGet, Produces("application/json"), Route("{roomNumber}")] + [AllowAnonymous] public async Task> GetRoom(string roomNumber) { if (roomNumber.Length != 3) @@ -50,6 +53,7 @@ public async Task> GetRoom(string roomNumber) } [HttpPost, Produces("application/json"), Route("")] + [Authorize] public async Task> CreateRoom([FromBody] Room newRoom) { var errors = newRoom.Validate(); @@ -69,6 +73,7 @@ public async Task> CreateRoom([FromBody] Room newRoom) } [HttpDelete, Produces("application/json"), Route("{roomNumber}")] + [Authorize] public async Task DeleteRoom(string roomNumber) { if (roomNumber.Length != 3) 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/Program.cs b/api/Program.cs index e8ac024..1c3c89c 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,5 +1,6 @@ using System.Data; using Db; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Data.Sqlite; using Repositories; @@ -22,6 +23,29 @@ opt.EnableEndpointRouting = false; }); 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(); } @@ -61,6 +85,9 @@ await context.Response.WriteAsJsonAsync( ); } + app.UseAuthentication(); + app.UseAuthorization(); + app .UseMvc() .UseSwagger() From 4f8dff0b145db80d3b6b1910a69d176dbd756384 Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 13:40:22 +0000 Subject: [PATCH 06/15] feat(api,ui): add pagination and date filtering for reservations with staff dashboard - Add pagination support to GetReservations endpoint with from date filter - Return pagination metadata in response headers (X-Total-Count, X-Page, X-Page-Size) - Create database indexes on Reservations table for query performance - Expose pagination headers in CORS configuration - Implement offset-based pagination in ReservationRepository with configurable page size (1-100) - Add staff login page with access code authentication --- api/Controllers/ReservationController.cs | 11 +- api/Db/Setup.cs | 7 ++ api/Program.cs | 3 +- api/Repositories/ReservationRepository.cs | 29 +++-- ui/src/LandingPage.tsx | 11 +- ui/src/reservations/api.ts | 49 ++++++++ ui/src/router.tsx | 12 ++ ui/src/staff/StaffDashboardPage.tsx | 137 ++++++++++++++++++++++ ui/src/staff/StaffLoginPage.tsx | 90 ++++++++++++++ ui/src/staff/api.ts | 23 ++++ 10 files changed, 353 insertions(+), 19 deletions(-) create mode 100644 ui/src/staff/StaffDashboardPage.tsx create mode 100644 ui/src/staff/StaffLoginPage.tsx create mode 100644 ui/src/staff/api.ts diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index ad8289c..66430e7 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -27,11 +27,16 @@ GuestRepository guestRepository [HttpGet, Produces("application/json"), Route("")] [Authorize] - public async Task> GetReservations() + public async Task GetReservations( + [FromQuery] DateTime? from, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - var reservations = await _repo.GetReservations(); + var (items, totalCount) = await _repo.GetReservations(from, page, pageSize); - return Json(reservations); + Response.Headers["X-Total-Count"] = totalCount.ToString(); + Response.Headers["X-Page"] = page.ToString(); + Response.Headers["X-Page-Size"] = pageSize.ToString(); + + return Json(items); } [HttpGet, Produces("application/json"), Route("{reservationId}")] diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 3e88ea3..f0a087c 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -54,6 +54,13 @@ REFERENCES Rooms ({nameof(Room.Number)}) ); " ); + + 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]);" + ); } } } diff --git a/api/Program.cs b/api/Program.cs index 1c3c89c..51fb8bb 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -69,7 +69,8 @@ app.UsePathBase("/api"); - app.UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); + app.UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() + .WithExposedHeaders("X-Total-Count", "X-Page", "X-Page-Size")); if (!app.Environment.IsDevelopment()) { diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index e4c1d1b..5a9acb9 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -15,16 +15,31 @@ 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, 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); - if (reservations == null) - { - return []; - } + var whereClause = from.HasValue ? "WHERE [End] >= @from" : ""; + var orderClause = from.HasValue ? "ORDER BY [Start] ASC" : ""; + var offset = (page - 1) * pageSize; + + var totalCount = await _db.ExecuteScalarAsync( + $"SELECT COUNT(*) FROM Reservations {whereClause}", + new { from } + ); + + var reservations = await _db.QueryAsync( + $"SELECT * FROM Reservations {whereClause} {orderClause} LIMIT @pageSize OFFSET @offset", + new { from, pageSize, offset } + ); - return reservations.Select(r => r.ToDomain()); + return (reservations?.Select(r => r.ToDomain()) ?? [], totalCount); } /// 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/reservations/api.ts b/ui/src/reservations/api.ts index b1cbe55..22d6ffd 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -47,3 +47,52 @@ export function useGetRooms() { 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) { + const from = toLocalDateStr(new Date()); + + return useQuery({ + queryKey: ["reservations", "upcoming", from, page, pageSize], + queryFn: async (): Promise => { + const response = await ky.get("/api/reservation", { + searchParams: { from, page, pageSize }, + }); + 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, + }); +} 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/StaffDashboardPage.tsx b/ui/src/staff/StaffDashboardPage.tsx new file mode 100644 index 0000000..0177200 --- /dev/null +++ b/ui/src/staff/StaffDashboardPage.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "@tanstack/react-router"; +import { + Box, + Button, + Flex, + Heading, + Section, + Separator, + Table, + Text, +} from "@radix-ui/themes"; +import { checkAuth, logout } from "./api"; +import { useGetUpcomingReservations } from "../reservations/api"; + +const PAGE_SIZE = 20; + +export function StaffDashboardPage() { + const router = useRouter(); + const [page, setPage] = useState(1); + const { data, isLoading, isError } = useGetUpcomingReservations(page, PAGE_SIZE); + + useEffect(() => { + checkAuth().then((authed) => { + if (!authed) { + router.navigate({ to: "/staff/login" }); + } + }); + }, [router]); + + async function handleLogout() { + try { + await logout(); + } catch { + // sign-out failures are non-critical, still navigate away + } + router.navigate({ to: "/" }); + } + + const items = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + + return ( +
+ + + Upcoming Reservations + + + + + + {isLoading && ( + + Loading reservations... + + )} + + {isError && ( + + Failed to load reservations. + + )} + + {!isLoading && !isError && data && ( + <> + + + + + Room + Guest Email + Start + End + Status + + + + {items.length === 0 && ( + + + No upcoming reservations. + + + )} + {items.map((r) => ( + + + #{r.roomNumber} + + {r.guestEmail} + {r.start} + {r.end} + + {r.checkedOut + ? "Checked out" + : r.checkedIn + ? "Checked in" + : "Upcoming"} + + + ))} + + + + + + + {totalCount} reservation{totalCount !== 1 ? "s" : ""} — page {page} of {totalPages} + + + + + + + + )} +
+ ); +} diff --git a/ui/src/staff/StaffLoginPage.tsx b/ui/src/staff/StaffLoginPage.tsx new file mode 100644 index 0000000..5a8dce6 --- /dev/null +++ b/ui/src/staff/StaffLoginPage.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { useRouter } from "@tanstack/react-router"; +import { + Box, + Button, + Card, + Flex, + Heading, + Separator, + TextField, +} from "@radix-ui/themes"; +import { login } from "./api"; +import { showErrorToast } from "../utils/toasts"; +import { HTTPError } from "ky"; +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 [accessCode, setAccessCode] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + 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) { + 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; + } + } catch { + // response wasn't JSON + } + } + showErrorToast("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..39f1ad4 --- /dev/null +++ b/ui/src/staff/api.ts @@ -0,0 +1,23 @@ +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) { + return false; + } + throw err; + } +} From 02fe6fc2778e9b1bd4748c9add0de335ea2b79ba Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 15:24:44 +0000 Subject: [PATCH 07/15] feat(api,ui): add check-in with email confirmation --- api/Controllers/ReservationController.cs | 84 +++++++++++++- api/Program.cs | 2 + api/Repositories/ReservationRepository.cs | 52 ++++++++- api/Repositories/RoomRepository.cs | 12 ++ api/Services/VerificationCodeService.cs | 74 +++++++++++++ ui/src/components/CheckInDialog.tsx | 129 ++++++++++++++++++++++ ui/src/reservations/api.ts | 33 +++++- ui/src/staff/StaffDashboardPage.tsx | 88 ++++++++++++--- ui/src/utils/toasts.tsx | 8 ++ 9 files changed, 455 insertions(+), 27 deletions(-) create mode 100644 api/Services/VerificationCodeService.cs create mode 100644 ui/src/components/CheckInDialog.tsx diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 66430e7..8a2af95 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -3,6 +3,7 @@ using Models; using Models.Errors; using Repositories; +using Services; using Extensions; namespace Controllers @@ -13,24 +14,28 @@ 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, RoomRepository roomRepository, - GuestRepository guestRepository + GuestRepository guestRepository, + VerificationCodeService verificationCodeService ) { _repo = reservationRepository; _roomRepo = roomRepository; _guestRepo = guestRepository; + _verificationCodeService = verificationCodeService; } [HttpGet, Produces("application/json"), Route("")] [Authorize] public async Task GetReservations( - [FromQuery] DateTime? from, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) + [FromQuery] DateTime? from, [FromQuery] DateTime? to, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - var (items, totalCount) = await _repo.GetReservations(from, page, pageSize); + var (items, totalCount) = await _repo.GetReservations(from, to, page, pageSize); Response.Headers["X-Total-Count"] = totalCount.ToString(); Response.Headers["X-Page"] = page.ToString(); @@ -115,6 +120,74 @@ await _guestRepo.CreateGuest( } } + [HttpPost, Produces("application/json"), Route("{reservationId}/checkin")] + [Authorize] + public async Task InitiateCheckIn(Guid reservationId) + { + 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 (reservation.Start.Date != DateTime.Today) + { + return BadRequest(new { errors = new[] { "Check-in is only allowed on the reservation start date." } }); + } + + 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) @@ -124,4 +197,9 @@ public async Task DeleteReservation(Guid reservationId) return result ? NoContent() : NotFound(); } } + + public class CheckInConfirmRequest + { + public string Code { get; set; } = ""; + } } diff --git a/api/Program.cs b/api/Program.cs index 51fb8bb..47900a2 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Data.Sqlite; using Repositories; +using Services; var builder = WebApplication.CreateBuilder(args); @@ -18,6 +19,7 @@ Services.AddScoped(); Services.AddScoped(); Services.AddScoped(); + Services.AddSingleton(); Services.AddMvc(opt => { opt.EnableEndpointRouting = false; diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5a9acb9..6f6e31d 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -20,23 +20,26 @@ public ReservationRepository(IDbConnection db) /// 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, int page = 1, int pageSize = 20) + DateTime? from = null, DateTime? to = null, int page = 1, int pageSize = 20) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); - var whereClause = from.HasValue ? "WHERE [End] >= @from" : ""; - var orderClause = from.HasValue ? "ORDER BY [Start] ASC" : ""; + 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 } + new { from, to } ); var reservations = await _db.QueryAsync( $"SELECT * FROM Reservations {whereClause} {orderClause} LIMIT @pageSize OFFSET @offset", - new { from, pageSize, offset } + new { from, to, pageSize, offset } ); return (reservations?.Select(r => r.ToDomain()) ?? [], totalCount); @@ -108,6 +111,45 @@ LIMIT 1 return created.ToDomain(); } + /// + /// Atomically sets CheckedIn = 1 and room State = Occupied 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 WHERE Number = @roomNumber;", + new { state = (int)State.Occupied, roomNumber }, + transaction: txn + ); + + txn.Commit(); + return true; + } + public async Task DeleteReservation(Guid reservationId) { var deleted = await _db.ExecuteAsync( diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index b02a637..cf67f2d 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -60,6 +60,18 @@ public async Task CreateRoom(Room newRoom) return createdRoom.ToDomain(); } + 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; + } + public async Task DeleteRoom(string roomNumber) { var roomNumberInt = RoomExtensions.ConvertRoomNumberToInt(roomNumber); 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/ui/src/components/CheckInDialog.tsx b/ui/src/components/CheckInDialog.tsx new file mode 100644 index 0000000..17d3edf --- /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 { 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(() => { + showErrorToast("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 { + showErrorToast("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/reservations/api.ts b/ui/src/reservations/api.ts index 22d6ffd..e7b1ec9 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -76,15 +76,19 @@ function toLocalDateStr(date: Date): string { return `${yyyy}-${mm}-${dd}`; } -export function useGetUpcomingReservations(page = 1, pageSize = 20) { +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], + queryKey: ["reservations", "upcoming", from, page, pageSize, todayOnly], queryFn: async (): Promise => { - const response = await ky.get("/api/reservation", { - searchParams: { from, page, pageSize }, - }); + const response = await ky.get("/api/reservation", { searchParams }); const items = ReservationListSchema.parse(await response.json()); return { items, @@ -96,3 +100,22 @@ export function useGetUpcomingReservations(page = 1, pageSize = 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 }, + }); +} diff --git a/ui/src/staff/StaffDashboardPage.tsx b/ui/src/staff/StaffDashboardPage.tsx index 0177200..e94e912 100644 --- a/ui/src/staff/StaffDashboardPage.tsx +++ b/ui/src/staff/StaffDashboardPage.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import { useRouter } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { + Badge, Box, Button, Flex, @@ -11,14 +13,28 @@ import { Text, } from "@radix-ui/themes"; import { checkAuth, logout } from "./api"; -import { useGetUpcomingReservations } from "../reservations/api"; +import { + useGetUpcomingReservations, + type ReservationDetail, +} from "../reservations/api"; +import { CheckInDialog } from "../components/CheckInDialog"; const PAGE_SIZE = 20; export function StaffDashboardPage() { const router = useRouter(); + const queryClient = useQueryClient(); const [page, setPage] = useState(1); - const { data, isLoading, isError } = useGetUpcomingReservations(page, PAGE_SIZE); + const [todayOnly, setTodayOnly] = useState(false); + const { data, isLoading, isError } = useGetUpcomingReservations( + page, + PAGE_SIZE, + todayOnly, + ); + + const [checkInTarget, setCheckInTarget] = useState( + null, + ); useEffect(() => { checkAuth().then((authed) => { @@ -41,15 +57,34 @@ export function StaffDashboardPage() { 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 (
- Upcoming Reservations + {todayOnly ? "Today's Reservations" : "Upcoming Reservations"} - + + + + @@ -76,13 +111,14 @@ export function StaffDashboardPage() { Start End Status + Actions {items.length === 0 && ( - - No upcoming reservations. + + No reservations found. )} @@ -95,11 +131,25 @@ export function StaffDashboardPage() { {r.start} {r.end} - {r.checkedOut - ? "Checked out" - : r.checkedIn - ? "Checked in" - : "Upcoming"} + {r.checkedOut ? ( + Checked out + ) : r.checkedIn ? ( + Checked in + ) : ( + Upcoming + )} + + + {!r.checkedIn && !r.checkedOut && isToday(r.start) && ( + + )} ))} @@ -109,7 +159,8 @@ export function StaffDashboardPage() { - {totalCount} reservation{totalCount !== 1 ? "s" : ""} — page {page} of {totalPages} + {totalCount} reservation{totalCount !== 1 ? "s" : ""} — page{" "} + {page} of {totalPages}
); } diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index eee810d..47577c6 100644 --- a/ui/src/utils/toasts.tsx +++ b/ui/src/utils/toasts.tsx @@ -32,6 +32,14 @@ export function useShowInfoToast(message: string) { ); } +/** 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( From 98772c13960d6a1f3a71191a80cddd0887544904 Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 16:55:52 +0000 Subject: [PATCH 08/15] feat(api,ui): add room dirty state tracking with PATCH endpoint and migration system - Implement versioned database migrations using PRAGMA user_version - Add IsDirty boolean column to Rooms table in migration v3 - Create PATCH endpoint for Room with JsonPatchDocument support - Add RoomPatch model with IsDirty field and whitelist allowed patch paths - Block check-in if room is dirty with validation in ReservationController - Set room to dirty automatically on check-in in ReservationRepository - Add Set --- api/Controllers/ReservationController.cs | 14 +++ api/Controllers/RoomController.cs | 52 +++++++++++ api/Db/Setup.cs | 109 ++++++++++++++-------- api/Models/Room.cs | 13 ++- api/Program.cs | 2 +- api/Repositories/ReservationRepository.cs | 4 +- api/Repositories/RoomRepository.cs | 19 +++- api/api.csproj | 2 + ui/src/components/CheckInDialog.tsx | 6 +- ui/src/reservations/ReservationCard.tsx | 6 +- ui/src/reservations/ReservationPage.tsx | 17 +--- ui/src/reservations/api.ts | 13 +++ ui/src/staff/StaffDashboardPage.tsx | 81 ++++++++++++++++ ui/src/utils/toasts.tsx | 25 +++++ 14 files changed, 297 insertions(+), 66 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 8a2af95..8487ddc 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -144,6 +144,20 @@ public async Task InitiateCheckIn(Guid reservationId) 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." } }); diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 8b2f140..d1d2de7 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; @@ -72,6 +73,57 @@ public async Task> CreateRoom([FromBody] Room newRoom) 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) + { + 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); + } + var updated = await _repo.GetRoom(roomNumber); + return Ok(updated); + } + [HttpDelete, Produces("application/json"), Route("{roomNumber}")] [Authorize] public async Task DeleteRoom(string roomNumber) diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index f0a087c..b8719a9 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -7,7 +7,9 @@ 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 Task EnsureDb(IServiceScope scope) { @@ -18,49 +20,74 @@ public static async Task 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, - {nameof(Guest.Surname)} TEXT - ); - " - ); + var version = await db.ExecuteScalarAsync("PRAGMA user_version;"); - await db.ExecuteAsync( - $@" - CREATE TABLE IF NOT Exists Rooms ( - {nameof(Room.Number)} INT PRIMARY KEY NOT NULL, - {nameof(Room.State)} INT NOT NULL - ); - " - ); + // ── 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 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( + $@" + CREATE TABLE IF NOT EXISTS Rooms ( + {nameof(Room.Number)} INT PRIMARY KEY NOT NULL, + {nameof(Room.State)} INT NOT NULL + ); + " + ); - 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( + $@" + 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/Models/Room.cs b/api/Models/Room.cs index 2b9db5d..a97377e 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -16,12 +16,21 @@ public class Room /// Whether the room is available for reservation /// public State State { get; set; } = State.Ready; + + /// + /// Whether the room needs cleaning + /// + 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 47900a2..bf71fdb 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -23,7 +23,7 @@ Services.AddMvc(opt => { opt.EnableEndpointRouting = false; - }); + }).AddNewtonsoftJson(); Services.AddCors(); Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 6f6e31d..7ba237a 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -112,7 +112,7 @@ LIMIT 1 } /// - /// Atomically sets CheckedIn = 1 and room State = Occupied inside a transaction. + /// 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). /// @@ -141,7 +141,7 @@ public async Task CheckIn(Guid reservationId) ); await _db.ExecuteAsync( - "UPDATE Rooms SET State = @state WHERE Number = @roomNumber;", + "UPDATE Rooms SET State = @state, IsDirty = 1 WHERE Number = @roomNumber;", new { state = (int)State.Occupied, roomNumber }, transaction: txn ); diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index cf67f2d..30cebda 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -53,13 +53,25 @@ 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); @@ -97,17 +109,20 @@ private class RoomDb /// public State State { get; set; } = State.Ready; + public bool IsDirty { get; set; } = false; + public RoomDb() { } public RoomDb(Room room) { Number = RoomExtensions.ConvertRoomNumberToInt(room.Number); State = room.State; + IsDirty = room.IsDirty; } public Room ToDomain() { - return new Room { Number = RoomExtensions.FormatRoomNumber(Number), State = State }; + return new Room { Number = RoomExtensions.FormatRoomNumber(Number), State = State, IsDirty = IsDirty }; } } } diff --git a/api/api.csproj b/api/api.csproj index ef55adc..a3b48a1 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -8,6 +8,8 @@ + + diff --git a/ui/src/components/CheckInDialog.tsx b/ui/src/components/CheckInDialog.tsx index 17d3edf..557a4c6 100644 --- a/ui/src/components/CheckInDialog.tsx +++ b/ui/src/components/CheckInDialog.tsx @@ -12,7 +12,7 @@ import { confirmCheckIn, type ReservationDetail, } from "../reservations/api"; -import { showErrorToast, showSuccessToast } from "../utils/toasts"; +import { handleApiError, showErrorToast, showSuccessToast } from "../utils/toasts"; interface CheckInDialogProps { reservation: ReservationDetail | null; @@ -38,8 +38,8 @@ export function CheckInDialog({ initiateCheckIn(reservation.id) .then(setGeneratedCode) - .catch(() => { - showErrorToast("Failed to initiate check-in."); + .catch(async (err) => { + await handleApiError(err, "Failed to initiate check-in."); onClose(); }) .finally(() => setLoading(false)); 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 3f461d5..d3b45cc 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,11 +1,10 @@ import { useState } from "react"; -import { useShowSuccessToast, showErrorToast } 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"; import { LoadingCard } from "../components/LoadingCard"; import { BookingDetailsModal } from "./BookingDetailsModal"; -import { HTTPError } from "ky"; const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { sm: "1", @@ -31,18 +30,7 @@ export function ReservationPage() { onClose(); showToast(); } catch (err) { - 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; - } - } catch { - // response wasn't JSON — fall through to generic error - } - } - showErrorToast("An unexpected error occurred."); + await handleApiError(err, "An unexpected error occurred."); } } @@ -64,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 e7b1ec9..44e10ae 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -37,8 +37,11 @@ export async function bookRoom(booking: NewReservation): Promise { 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() { @@ -119,3 +122,13 @@ export async function confirmCheckIn( json: { code }, }); } + +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/staff/StaffDashboardPage.tsx b/ui/src/staff/StaffDashboardPage.tsx index e94e912..9528eda 100644 --- a/ui/src/staff/StaffDashboardPage.tsx +++ b/ui/src/staff/StaffDashboardPage.tsx @@ -15,9 +15,12 @@ import { import { checkAuth, logout } from "./api"; import { useGetUpcomingReservations, + useGetRooms, + updateRoomDirtyState, type ReservationDetail, } from "../reservations/api"; import { CheckInDialog } from "../components/CheckInDialog"; +import { handleApiError, showSuccessToast } from "../utils/toasts"; const PAGE_SIZE = 20; @@ -31,6 +34,7 @@ export function StaffDashboardPage() { PAGE_SIZE, todayOnly, ); + const { data: rooms, isLoading: roomsLoading } = useGetRooms(); const [checkInTarget, setCheckInTarget] = useState( null, @@ -190,8 +194,85 @@ export function StaffDashboardPage() { onConfirmed={() => { setCheckInTarget(null); queryClient.invalidateQueries({ queryKey: ["reservations"] }); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); }} /> + + + Housekeeping + + + + {roomsLoading && ( + + Loading rooms... + + )} + + {rooms && rooms.length > 0 && ( + + + + + Room + Occupancy + Cleanliness + Actions + + + + {rooms.map((room) => ( + + + #{room.number} + + + {room.state === 1 ? ( + Occupied + ) : ( + Ready + )} + + + {room.isDirty ? ( + Dirty + ) : ( + Clean + )} + + + + + + ))} + + + + )} ); } diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index 47577c6..cddfb7c 100644 --- a/ui/src/utils/toasts.tsx +++ b/ui/src/utils/toasts.tsx @@ -3,6 +3,7 @@ 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; @@ -47,3 +48,27 @@ export function showErrorToast(message: string) { 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; +} From 0cddc404a5df5298e16758f6e1fafde1376f1a76 Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 18:09:26 +0000 Subject: [PATCH 09/15] feat(api,ui): add CSV import for bulk room creation with validation and error reporting - Add ImportOptions configuration model with MaxFileSizeBytes and MaxRows limits - Create POST /api/rooms/import endpoint with multipart/form-data support - Implement streaming CSV parser with header detection and row limit enforcement - Validate file size, extension, room number format, state, and IsDirty fields - Check for duplicates against existing rooms and within CSV batch - Add BulkCreateRooms method in --- api/Controllers/RoomController.cs | 143 +++++++++++++++- api/Models/ImportOptions.cs | 9 ++ api/Program.cs | 2 + api/Repositories/RoomRepository.cs | 42 +++++ api/appsettings.json | 6 +- api/test-rooms.csv | 206 ++++++++++++++++++++++++ ui/src/components/ImportRoomsDialog.tsx | 171 ++++++++++++++++++++ ui/src/reservations/api.ts | 12 ++ ui/src/staff/StaffDashboardPage.tsx | 62 ++++++- 9 files changed, 644 insertions(+), 9 deletions(-) create mode 100644 api/Models/ImportOptions.cs create mode 100644 api/test-rooms.csv create mode 100644 ui/src/components/ImportRoomsDialog.tsx diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index d1d2de7..91b612d 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using Models; using Models.Errors; using Repositories; @@ -12,10 +13,12 @@ namespace Controllers public class RoomController : Controller { private RoomRepository _repo { get; set; } + private ImportOptions _importOptions { get; set; } - public RoomController(RoomRepository roomRepository) + public RoomController(RoomRepository roomRepository, IOptions importOptions) { _repo = roomRepository; + _importOptions = importOptions.Value; } [HttpGet, Produces("application/json"), Route("")] @@ -124,6 +127,144 @@ public async Task PatchRoom( 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 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." } }); + } + } + } + + 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; + + 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); + } + + var imported = 0; + if (roomsToInsert.Count > 0) + { + imported = await _repo.BulkCreateRooms(roomsToInsert, ct); + } + + return Ok(new { imported, errors }); + } + [HttpDelete, Produces("application/json"), Route("{roomNumber}")] [Authorize] public async Task DeleteRoom(string roomNumber) 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/Program.cs b/api/Program.cs index bf71fdb..ee60a79 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -2,6 +2,7 @@ using Db; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Data.Sqlite; +using Models; using Repositories; using Services; @@ -20,6 +21,7 @@ Services.AddScoped(); Services.AddScoped(); Services.AddSingleton(); + Services.Configure(builder.Configuration.GetSection("Import")); Services.AddMvc(opt => { opt.EnableEndpointRouting = false; diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 30cebda..7a43c6b 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -84,6 +84,48 @@ public async Task UpdateRoomState(string roomNumber, State state) 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 = RoomExtensions.ConvertRoomNumberToInt(roomNumber); 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/api/test-rooms.csv b/api/test-rooms.csv new file mode 100644 index 0000000..6a73bd8 --- /dev/null +++ b/api/test-rooms.csv @@ -0,0 +1,206 @@ +Number,State,IsDirty +101,Ready,false +102,Occupied,true +103,Ready,true +104,Occupied,false +105,, +106,Ready, +107,,false +108,,true +109,Ready,false +110,Occupied,true +201,Ready,false +202,Occupied,true +203,Ready,false +204,Occupied,true +205,Ready,false +206,Ready,false +207,Ready,false +208,Ready,false +209,Ready,false +210,Ready,false +301,Ready,false +302,Occupied,true +303,Ready,false +304,Ready,false +305,Ready,false +306,Ready,false +307,Ready,false +308,Ready,false +309,Ready,false +310,Ready,false +101,Ready,false +102,Occupied,true +103,Ready,false +201,Ready,false +202,Occupied,true +301,Ready,false +101,Occupied,false +101,Ready,true +101,Ready,false +202,Ready,false +202,Occupied,true +000,Ready,false +00,Ready,false +0,Ready,false +abc,Ready,false +xyz,Occupied,true +!!,Ready,false +-1,Ready,false +999999,Ready,false +12345,Ready,false +,Ready,false +"101",Ready,false +#$%,Ready,false +@@@,Ready,false + 101 ,Ready,false +001,Ready,false +002,Occupied,true +003,Ready,false +010,Ready,false +020,Ready,false +030,Ready,false +100,Ready,false +200,Ready,false +300,Ready,false +400,Ready,false +401,Occupied,true +402,Ready,false +403,Ready,true +404,Occupied,false +405,Ready,false +406,Ready,false +407,Ready,false +408,Ready,false +409,Ready,false +410,Ready,false +411,Ready,false +412,Ready,false +413,Occupied,true +414,Ready,false +415,Ready,false +416,Ready,false +417,Ready,false +418,Ready,false +419,Ready,false +420,Ready,false +421,Occupied,true +422,Ready,false +423,Ready,false +424,Ready,false +425,Ready,false +426,Ready,false +427,Ready,false +428,Ready,false +429,Ready,false +430,Ready,false +431,Ready,false +432,Ready,false +433,Ready,false +434,Ready,false +435,Ready,false +436,Ready,false +437,Ready,false +438,Ready,false +439,Ready,false +440,Ready,false +441,Ready,false +442,Ready,false +443,Ready,false +444,Ready,false +445,Ready,false +446,Ready,false +447,Ready,false +448,Ready,false +449,Ready,false +450,Occupied,true +400,Ready,false +401,Occupied,true +450,Ready,false +450,Occupied,true +101,Ready,false +102,Occupied,true +201,Ready,false +301,Ready,false +400,Ready,false +401,Ready,false +101,InvalidState,false +102,Ready,maybe +103,BadState,notbool +104,0,1 +105,1,0 +106,occupied,TRUE +107,READY,FALSE +108,rEaDy,True +109,Ready,True +110,Occupied,False +501,Ready,false +502,Occupied,true +503,Ready,true +504,Occupied,false +505,, +506,Ready, +507,,false +508,,true +509,Ready,false +510,Occupied,true +511,Ready,false +512,Ready,false +513,Ready,false +514,Ready,false +515,Ready,false +516,Ready,false +517,Ready,false +518,Ready,false +519,Ready,false +520,Ready,false +050,Ready,false +051,Ready,false +052,Occupied,true +053,Ready,false +054,Ready,false +055,Ready,false +056,Ready,false +057,Ready,false +058,Ready,false +059,Ready,false +060,Ready,false +061,Occupied,true +062,Ready,false +063,Ready,false +064,Ready,false +065,Ready,false +066,Ready,false +067,Ready,false +068,Ready,false +069,Ready,false +070,Ready,false +071,Ready,false +072,Occupied,true +073,Ready,false +074,Ready,false +075,Ready,false +076,Ready,false +077,Ready,false +078,Ready,false +079,Ready,false +080,Ready,false +081,Ready,false +082,Occupied,true +083,Ready,false +084,Ready,false +085,Ready,false +086,Ready,false +087,Ready,false +088,Ready,false +089,Ready,false +090,Ready,false +091,Ready,false +092,Ready,false +093,Occupied,true +094,Ready,false +095,Ready,false +096,Ready,false +097,Ready,false +098,Ready,false +099,Ready,false 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/reservations/api.ts b/ui/src/reservations/api.ts index 44e10ae..424e9c4 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -123,6 +123,18 @@ export async function confirmCheckIn( }); } +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, diff --git a/ui/src/staff/StaffDashboardPage.tsx b/ui/src/staff/StaffDashboardPage.tsx index 9528eda..55dadd7 100644 --- a/ui/src/staff/StaffDashboardPage.tsx +++ b/ui/src/staff/StaffDashboardPage.tsx @@ -20,9 +20,10 @@ import { type ReservationDetail, } from "../reservations/api"; import { CheckInDialog } from "../components/CheckInDialog"; +import { ImportRoomsDialog } from "../components/ImportRoomsDialog"; import { handleApiError, showSuccessToast } from "../utils/toasts"; -const PAGE_SIZE = 20; +const PAGE_SIZE = 5; export function StaffDashboardPage() { const router = useRouter(); @@ -39,6 +40,8 @@ export function StaffDashboardPage() { const [checkInTarget, setCheckInTarget] = useState( null, ); + const [importOpen, setImportOpen] = useState(false); + const [roomPage, setRoomPage] = useState(1); useEffect(() => { checkAuth().then((authed) => { @@ -198,18 +201,36 @@ export function StaffDashboardPage() { }} /> - - Housekeeping - + + + Housekeeping + + + + setImportOpen(false)} + onImported={() => { + setRoomPage(1); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + }} + /> + {roomsLoading && ( Loading rooms... )} - {rooms && rooms.length > 0 && ( + {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 ( + <> @@ -221,7 +242,7 @@ export function StaffDashboardPage() { - {rooms.map((room) => ( + {pagedRooms.map((room) => ( #{room.number} @@ -272,7 +293,34 @@ export function StaffDashboardPage() { - )} + + + + {rooms.length} room{rooms.length !== 1 ? "s" : ""} — page{" "} + {roomPage} of {roomTotalPages} + + + + + + + + ); + })()} ); } From acefe00a98da54c1ec6c14850054995443aae4e1 Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 18:20:06 +0000 Subject: [PATCH 10/15] chore(api): remove test.csv file from repo --- api/test-rooms.csv | 206 --------------------------------------------- 1 file changed, 206 deletions(-) delete mode 100644 api/test-rooms.csv diff --git a/api/test-rooms.csv b/api/test-rooms.csv deleted file mode 100644 index 6a73bd8..0000000 --- a/api/test-rooms.csv +++ /dev/null @@ -1,206 +0,0 @@ -Number,State,IsDirty -101,Ready,false -102,Occupied,true -103,Ready,true -104,Occupied,false -105,, -106,Ready, -107,,false -108,,true -109,Ready,false -110,Occupied,true -201,Ready,false -202,Occupied,true -203,Ready,false -204,Occupied,true -205,Ready,false -206,Ready,false -207,Ready,false -208,Ready,false -209,Ready,false -210,Ready,false -301,Ready,false -302,Occupied,true -303,Ready,false -304,Ready,false -305,Ready,false -306,Ready,false -307,Ready,false -308,Ready,false -309,Ready,false -310,Ready,false -101,Ready,false -102,Occupied,true -103,Ready,false -201,Ready,false -202,Occupied,true -301,Ready,false -101,Occupied,false -101,Ready,true -101,Ready,false -202,Ready,false -202,Occupied,true -000,Ready,false -00,Ready,false -0,Ready,false -abc,Ready,false -xyz,Occupied,true -!!,Ready,false --1,Ready,false -999999,Ready,false -12345,Ready,false -,Ready,false -"101",Ready,false -#$%,Ready,false -@@@,Ready,false - 101 ,Ready,false -001,Ready,false -002,Occupied,true -003,Ready,false -010,Ready,false -020,Ready,false -030,Ready,false -100,Ready,false -200,Ready,false -300,Ready,false -400,Ready,false -401,Occupied,true -402,Ready,false -403,Ready,true -404,Occupied,false -405,Ready,false -406,Ready,false -407,Ready,false -408,Ready,false -409,Ready,false -410,Ready,false -411,Ready,false -412,Ready,false -413,Occupied,true -414,Ready,false -415,Ready,false -416,Ready,false -417,Ready,false -418,Ready,false -419,Ready,false -420,Ready,false -421,Occupied,true -422,Ready,false -423,Ready,false -424,Ready,false -425,Ready,false -426,Ready,false -427,Ready,false -428,Ready,false -429,Ready,false -430,Ready,false -431,Ready,false -432,Ready,false -433,Ready,false -434,Ready,false -435,Ready,false -436,Ready,false -437,Ready,false -438,Ready,false -439,Ready,false -440,Ready,false -441,Ready,false -442,Ready,false -443,Ready,false -444,Ready,false -445,Ready,false -446,Ready,false -447,Ready,false -448,Ready,false -449,Ready,false -450,Occupied,true -400,Ready,false -401,Occupied,true -450,Ready,false -450,Occupied,true -101,Ready,false -102,Occupied,true -201,Ready,false -301,Ready,false -400,Ready,false -401,Ready,false -101,InvalidState,false -102,Ready,maybe -103,BadState,notbool -104,0,1 -105,1,0 -106,occupied,TRUE -107,READY,FALSE -108,rEaDy,True -109,Ready,True -110,Occupied,False -501,Ready,false -502,Occupied,true -503,Ready,true -504,Occupied,false -505,, -506,Ready, -507,,false -508,,true -509,Ready,false -510,Occupied,true -511,Ready,false -512,Ready,false -513,Ready,false -514,Ready,false -515,Ready,false -516,Ready,false -517,Ready,false -518,Ready,false -519,Ready,false -520,Ready,false -050,Ready,false -051,Ready,false -052,Occupied,true -053,Ready,false -054,Ready,false -055,Ready,false -056,Ready,false -057,Ready,false -058,Ready,false -059,Ready,false -060,Ready,false -061,Occupied,true -062,Ready,false -063,Ready,false -064,Ready,false -065,Ready,false -066,Ready,false -067,Ready,false -068,Ready,false -069,Ready,false -070,Ready,false -071,Ready,false -072,Occupied,true -073,Ready,false -074,Ready,false -075,Ready,false -076,Ready,false -077,Ready,false -078,Ready,false -079,Ready,false -080,Ready,false -081,Ready,false -082,Occupied,true -083,Ready,false -084,Ready,false -085,Ready,false -086,Ready,false -087,Ready,false -088,Ready,false -089,Ready,false -090,Ready,false -091,Ready,false -092,Ready,false -093,Occupied,true -094,Ready,false -095,Ready,false -096,Ready,false -097,Ready,false -098,Ready,false -099,Ready,false From 418b91065bd4ef3ff73029dcf3a606cc77804bab Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 18:54:31 +0000 Subject: [PATCH 11/15] fix(api,ui): centralize auth with AuthProvider resolve no redirect after expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AuthContext.tsx with AuthProvider and useAuth hook - Mount AuthProvider in index.tsx wrapping the app - Move Logout button to Layout.tsx top bar (visible when authenticated) - Auto-redirect /staff/login → /staff if already authed - Call logout() on 401 in checkAuth to clear expired HttpOnly cookie - Remove per-page checkAuth/logout calls in favor of shared context --- ui/src/Layout.tsx | 22 +++++++++++--- ui/src/index.tsx | 7 +++-- ui/src/staff/AuthContext.tsx | 44 +++++++++++++++++++++++++++ ui/src/staff/StaffDashboardPage.tsx | 47 ++++++++++------------------- ui/src/staff/StaffLoginPage.tsx | 11 +++++-- ui/src/staff/api.ts | 1 + 6 files changed, 93 insertions(+), 39 deletions(-) create mode 100644 ui/src/staff/AuthContext.tsx 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/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/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 index 55dadd7..627b60c 100644 --- a/ui/src/staff/StaffDashboardPage.tsx +++ b/ui/src/staff/StaffDashboardPage.tsx @@ -12,7 +12,7 @@ import { Table, Text, } from "@radix-ui/themes"; -import { checkAuth, logout } from "./api"; +import { useAuth } from "./AuthContext"; import { useGetUpcomingReservations, useGetRooms, @@ -28,6 +28,7 @@ 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( @@ -44,21 +45,10 @@ export function StaffDashboardPage() { const [roomPage, setRoomPage] = useState(1); useEffect(() => { - checkAuth().then((authed) => { - if (!authed) { - router.navigate({ to: "/staff/login" }); - } - }); - }, [router]); - - async function handleLogout() { - try { - await logout(); - } catch { - // sign-out failures are non-critical, still navigate away + if (isAuthed === false) { + router.navigate({ to: "/staff/login" }); } - router.navigate({ to: "/" }); - } + }, [isAuthed, router]); const items = data?.items ?? []; const totalCount = data?.totalCount ?? 0; @@ -76,22 +66,17 @@ export function StaffDashboardPage() { {todayOnly ? "Today's Reservations" : "Upcoming Reservations"} - - - - + diff --git a/ui/src/staff/StaffLoginPage.tsx b/ui/src/staff/StaffLoginPage.tsx index 5a8dce6..b7f4816 100644 --- a/ui/src/staff/StaffLoginPage.tsx +++ b/ui/src/staff/StaffLoginPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "@tanstack/react-router"; import { Box, @@ -9,7 +9,7 @@ import { Separator, TextField, } from "@radix-ui/themes"; -import { login } from "./api"; +import { useAuth } from "./AuthContext"; import { showErrorToast } from "../utils/toasts"; import { HTTPError } from "ky"; import styled from "styled-components"; @@ -21,9 +21,16 @@ const DimSlot = styled(TextField.Slot)` 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()) { diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts index 39f1ad4..b00b6bb 100644 --- a/ui/src/staff/api.ts +++ b/ui/src/staff/api.ts @@ -16,6 +16,7 @@ export async function checkAuth(): Promise { return true; } catch (err) { if (err instanceof HTTPError && err.response.status === 401) { + try { await logout(); } catch { /* already unauthenticated */ } return false; } throw err; From 0f1609fc2fa276acce36f8fdd9aa879e7817ea2c Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 19:40:57 +0000 Subject: [PATCH 12/15] test(api): add unit tests for reservation and room validation rules - Add xUnit test project with coverlet and test SDK packages - Create ReservationValidationTests covering email, date, duration, and room number validation - Create RoomValidationTests covering room number format, length, and door number rules - Add .gitignore for test project bin/obj/user files - Reference main api project for testing Extensions and Models --- api.tests/.gitignore | 5 ++ api.tests/ReservationValidationTests.cs | 83 +++++++++++++++++++++++++ api.tests/RoomValidationTests.cs | 76 ++++++++++++++++++++++ api.tests/api.tests.csproj | 27 ++++++++ 4 files changed, 191 insertions(+) create mode 100644 api.tests/.gitignore create mode 100644 api.tests/ReservationValidationTests.cs create mode 100644 api.tests/RoomValidationTests.cs create mode 100644 api.tests/api.tests.csproj 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 + + + + + + + + + + + + + + + + + + From 799f2e1c31731e4b0730b370e33fda8e1c61725e Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 19:41:50 +0000 Subject: [PATCH 13/15] feat(api): add structured logging and distributed tracing for room operations - Add Serilog with console sink configured from appsettings - Replace Console.WriteLine with Log.Fatal for startup errors - Add UseSerilogRequestLogging middleware for HTTP request logging - Inject ILogger and add structured logging for all room operations - Log warnings for validation failures, not found errors, and invalid formats - Log information for successful creates, updates, deletes with structured --- api/Controllers/RoomController.cs | 39 +++++++++++++++++++++++++++++-- api/Program.cs | 9 +++++-- api/api.csproj | 1 + 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 91b612d..e3c7188 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; @@ -12,13 +13,17 @@ 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, IOptions importOptions) + public RoomController(RoomRepository roomRepository, IOptions importOptions, ILogger log) { _repo = roomRepository; _importOptions = importOptions.Value; + _log = log; } [HttpGet, Produces("application/json"), Route("")] @@ -41,17 +46,18 @@ public async Task> GetRoom(string roomNumber) { if (roomNumber.Length != 3) { + _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(); } } @@ -63,6 +69,7 @@ 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 }); } @@ -70,9 +77,12 @@ public async Task> CreateRoom([FromBody] Room 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); } @@ -86,6 +96,7 @@ public async Task PatchRoom( { 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" } }); } @@ -122,6 +133,7 @@ public async Task PatchRoom( 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); @@ -156,6 +168,7 @@ public async Task ImportRooms(IFormFile file, CancellationToken c 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) @@ -178,6 +191,8 @@ public async Task ImportRooms(IFormFile file, CancellationToken c } } } + parseSpan?.SetTag("import.parsed_rows", rows.Count); + parseSpan?.Stop(); if (rows.Count == 0) { @@ -194,6 +209,7 @@ public async Task ImportRooms(IFormFile file, CancellationToken c // 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; @@ -255,13 +271,26 @@ public async Task ImportRooms(IFormFile file, CancellationToken c 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 }); } @@ -271,11 +300,17 @@ public async Task DeleteRoom(string roomNumber) { if (roomNumber.Length != 3) { + _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/Program.cs b/api/Program.cs index ee60a79..90bbc5e 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -4,10 +4,15 @@ 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; @@ -65,13 +70,13 @@ } 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.UseSerilogRequestLogging(); app.UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() .WithExposedHeaders("X-Total-Count", "X-Page", "X-Page-Size")); diff --git a/api/api.csproj b/api/api.csproj index a3b48a1..c82b015 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -11,6 +11,7 @@ + From b6fc1049562976cbae0d22ee503a39aa9f6eae99 Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 21:11:28 +0000 Subject: [PATCH 14/15] refactor(ui): centralize API error handling in CheckInDialog and StaffLoginPage per copilot comment - Replace inline HTTPError handling with shared handleApiError utility - Use handleApiError in CheckInDialog catch block for check-in failures - Use handleApiError in StaffLoginPage catch block for login failures - Remove duplicate error parsing logic and HTTPError import from StaffLoginPage --- ui/src/components/CheckInDialog.tsx | 4 ++-- ui/src/staff/StaffLoginPage.tsx | 16 ++-------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/ui/src/components/CheckInDialog.tsx b/ui/src/components/CheckInDialog.tsx index 557a4c6..f593133 100644 --- a/ui/src/components/CheckInDialog.tsx +++ b/ui/src/components/CheckInDialog.tsx @@ -54,8 +54,8 @@ export function CheckInDialog({ `Checked in reservation for #${reservation.roomNumber}.`, ); onConfirmed(); - } catch { - showErrorToast("Invalid code or check-in failed."); + } catch (err) { + await handleApiError(err, "Invalid code or check-in failed."); } finally { setLoading(false); } diff --git a/ui/src/staff/StaffLoginPage.tsx b/ui/src/staff/StaffLoginPage.tsx index b7f4816..436c321 100644 --- a/ui/src/staff/StaffLoginPage.tsx +++ b/ui/src/staff/StaffLoginPage.tsx @@ -10,8 +10,7 @@ import { TextField, } from "@radix-ui/themes"; import { useAuth } from "./AuthContext"; -import { showErrorToast } from "../utils/toasts"; -import { HTTPError } from "ky"; +import { handleApiError, showErrorToast } from "../utils/toasts"; import styled from "styled-components"; const DimSlot = styled(TextField.Slot)` @@ -43,18 +42,7 @@ export function StaffLoginPage() { await login(accessCode); router.navigate({ to: "/staff" }); } catch (err) { - 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; - } - } catch { - // response wasn't JSON - } - } - showErrorToast("Login failed. Please try again."); + await handleApiError(err, "Login failed. Please try again."); } finally { setIsLoading(false); } From 130d077d84c2bc923a9f0924293f85b1826e681d Mon Sep 17 00:00:00 2001 From: raddadz Date: Thu, 2 Apr 2026 21:12:02 +0000 Subject: [PATCH 15/15] docs: add DECISIONS.md documenting technical choices and production follow-ups - Document initial polish decisions (scoped DI, DB seeding, exception middleware) - Record guest booking implementation (validation, Zod parsing, date handling) - Capture double-booking prevention with transaction-based overlap checks - Detail auth framework refactor to cookie authentication with role-based authorization - Document staff dashboard with pagination, filtering, and auth context - Record check-in flow with verification codes --- docs/DECISIONS.md | 113 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/DECISIONS.md 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