From f7b0fa6fec3712e311f3e229eaab1c6bf5dbd19f Mon Sep 17 00:00:00 2001 From: "arkadii.ushakov" Date: Thu, 2 Apr 2026 20:05:59 +0200 Subject: [PATCH 1/6] RE-001: Implemented reservation booking with validation and guest handling --- api/Controllers/ReservationController.cs | 80 ++++++++++++--------- api/Controllers/RoomController.cs | 33 ++++++--- api/Db/Setup.cs | 2 +- api/Models/Errors/InvalidRoomNumber.cs | 2 +- api/Models/Guest.cs | 4 +- api/Repositories/GuestRepository.cs | 15 ++-- api/Repositories/ReservationRepository.cs | 17 +++-- api/Repositories/RoomRepository.cs | 20 +++++- api/Validators/EmailValidator.cs | 30 ++++++++ api/Validators/ReservationValidator.cs | 23 ++++++ api/Validators/RoomValidator.cs | 21 ++++++ ui/src/reservations/BookingDetailsModal.tsx | 12 ++-- ui/src/reservations/ReservationPage.tsx | 25 +++++-- ui/src/reservations/api.ts | 25 ++++--- ui/src/utils/toasts.tsx | 14 ++-- 15 files changed, 237 insertions(+), 86 deletions(-) create mode 100644 api/Validators/EmailValidator.cs create mode 100644 api/Validators/ReservationValidator.cs create mode 100644 api/Validators/RoomValidator.cs diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..8e89c40 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -8,33 +8,30 @@ namespace Controllers [Tags("Reservations"), Route("reservation")] public class ReservationController : Controller { - private ReservationRepository _repo { get; set; } + private readonly ReservationRepository _reservationRepository; + private readonly RoomRepository _roomRepository; + private readonly GuestRepository _guestRepository; - public ReservationController(ReservationRepository reservationRepository) + public ReservationController(ReservationRepository reservationRepository, RoomRepository roomRepository, GuestRepository guestRepository) { - _repo = reservationRepository; + _reservationRepository = reservationRepository; + _roomRepository = roomRepository; + _guestRepository = guestRepository; } - [HttpGet, Produces("application/json"), Route("")] - public async Task> GetReservations() + [HttpGet] + public async Task>> GetReservations() { - var reservations = await _repo.GetReservations(); + var reservations = await _reservationRepository.GetReservations(); return Json(reservations); } - [HttpGet, Produces("application/json"), Route("{reservationId}")] - public async Task> GetRoom(Guid reservationId) + [HttpGet("{reservationId}")] + public async Task> GetReservation(Guid reservationId) { - try - { - var reservation = await _repo.GetReservation(reservationId); - return Json(reservation); - } - catch (NotFoundException) - { - return NotFound(); - } + var reservation = await _reservationRepository.GetReservation(reservationId); + return Json(reservation); } /// @@ -42,35 +39,54 @@ public async Task> GetRoom(Guid reservationId) /// /// /// - [HttpPost, Produces("application/json"), Route("")] - public async Task> BookReservation( - [FromBody] Reservation newBooking - ) + [HttpPost] + public async Task> BookReservation([FromBody] Reservation newBooking) { - // Provide a real ID if one is not provided + if (newBooking == null) + return BadRequest("Request body is required."); + if (newBooking.Id == Guid.Empty) - { newBooking.Id = Guid.NewGuid(); - } try { - var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); + if (!await _roomRepository.RoomExists(newBooking.RoomNumber)) + throw new NotFoundException($"Room {newBooking.RoomNumber} does not exist."); + + var guest = await _guestRepository.GetGuestByEmail(newBooking.GuestEmail); + + if (guest == null) + { + await _guestRepository.CreateGuest(new Guest + { + Email = newBooking.GuestEmail + }); + } + + var createdReservation = await _reservationRepository.CreateReservation(newBooking); + + return Created($"/reservation/{createdReservation.Id}", createdReservation); + } + catch (NotFoundException ex) + { + return NotFound(ex.Message); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); } catch (Exception ex) { - Console.WriteLine("An error occured when trying to book a reservation:"); - Console.WriteLine(ex.ToString()); - - return BadRequest("Invalid reservation"); + //TODO: Proper error logging + Console.WriteLine(ex); + return StatusCode(500, "Internal server error"); } } - [HttpDelete, Produces("application/json"), Route("{reservationId}")] + [HttpDelete("{reservationId}")] public async Task DeleteReservation(Guid reservationId) { - var result = await _repo.DeleteReservation(reservationId); + var result = await _reservationRepository.DeleteReservation(reservationId); return result ? NoContent() : NotFound(); } diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 6e97650..a83e2f4 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -2,6 +2,7 @@ using Models; using Models.Errors; using Repositories; +using Validators; namespace Controllers { @@ -15,7 +16,7 @@ public RoomController(RoomRepository roomRepository) _repo = roomRepository; } - [HttpGet, Produces("application/json"), Route("")] + [HttpGet] public async Task> GetRooms() { var rooms = await _repo.GetRooms(); @@ -28,7 +29,7 @@ public async Task> GetRooms() return Json(rooms); } - [HttpGet, Produces("application/json"), Route("{roomNumber}")] + [HttpGet("{roomNumber}")] public async Task> GetRoom(string roomNumber) { if (roomNumber.Length != 3) @@ -48,17 +49,33 @@ public async Task> GetRoom(string roomNumber) } } - [HttpPost, Produces("application/json"), Route("")] + [HttpPost] + [ProducesResponseType(typeof(Room), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> CreateRoom([FromBody] Room newRoom) { - var createdRoom = await _repo.CreateRoom(newRoom); + if (newRoom == null) + return BadRequest("Request body is required."); - if (createdRoom == null) + try { - return NotFound(); + var createdRoom = await _repo.CreateRoom(newRoom); + return Created($"/room/{createdRoom.Number}", createdRoom); + } + catch (InvalidRoomNumber ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + return Conflict(ex.Message); + } + catch (Exception) + { + return StatusCode(500, "Internal server error"); } - - return Json(createdRoom); } [HttpDelete, Produces("application/json"), Route("{roomNumber}")] diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..743ef6f 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -22,7 +22,7 @@ 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 NULL ); " ); diff --git a/api/Models/Errors/InvalidRoomNumber.cs b/api/Models/Errors/InvalidRoomNumber.cs index 59a690b..cab7b96 100644 --- a/api/Models/Errors/InvalidRoomNumber.cs +++ b/api/Models/Errors/InvalidRoomNumber.cs @@ -3,6 +3,6 @@ namespace Models.Errors public class InvalidRoomNumber : Exception { public InvalidRoomNumber(string invalidRoomNumber) - : base($"The value ${invalidRoomNumber} is not a valid") { } + : base($"The value {invalidRoomNumber} is not a valid") { } } } diff --git a/api/Models/Guest.cs b/api/Models/Guest.cs index 1a52c36..2eea56f 100644 --- a/api/Models/Guest.cs +++ b/api/Models/Guest.cs @@ -11,7 +11,7 @@ public class Guest /// Free form name field to accomodate any and all naming /// cultures the guest may have /// - public required string Name { get; set; } + public string? Name { get; set; } /// /// If there is a clear surname, this can be used @@ -25,7 +25,7 @@ public class Guest /// public string GetLastName() { - return Surname ?? Name; + return Surname ?? Name ?? Email; } } } diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 54182bc..b538e14 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -25,24 +25,17 @@ public async Task> GetGuests() return guests; } - - public async Task GetGuestByEmail(string guestEmail) + + public async Task GetGuestByEmail(string guestEmail) { - var guest = await _db.QueryFirstOrDefaultAsync( + return await _db.QuerySingleOrDefaultAsync( "SELECT * FROM Guests WHERE Email = @guestEmail;", new { guestEmail } ); - - if (guest == null) - { - throw new NotFoundException($"Guest {guestEmail} not found"); - } - - return guest; } public Task CreateGuest(Guest newGuest) - { + { return _db.QuerySingleAsync( "INSERT INTO Guests(Email, Name) Values(@Email, @Name) RETURNING *", newGuest diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..94f2b0a 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -2,6 +2,7 @@ using Dapper; using Models; using Models.Errors; +using Validators; namespace Repositories { @@ -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,18 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } + ReservationValidator.ValidateForCreate(newReservation); + EmailValidator.Validate(newReservation.GuestEmail); + RoomValidator.ValidateRoomNumber(newReservation.RoomNumber); + + var createdReservation = await _db.QuerySingleAsync( + @"INSERT INTO Reservations (Id, RoomNumber, GuestEmail, Start, End, CheckedIn, CheckedOut) + VALUES (@Id, @RoomNumber, @GuestEmail, @Start, @End, @CheckedIn, @CheckedOut) + RETURNING *;", + new ReservationDb(newReservation) ); + + return createdReservation.ToDomain(); } public async Task DeleteReservation(Guid reservationId) diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 2b9f904..d743c55 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -2,6 +2,7 @@ using Dapper; using Models; using Models.Errors; +using Validators; namespace Repositories { @@ -51,8 +52,13 @@ public async Task> GetRooms() public async Task CreateRoom(Room newRoom) { + RoomValidator.ValidateRoomNumber(newRoom.Number); + + if (await RoomExists(newRoom.Number)) + throw new InvalidOperationException("Room already exists."); + var createdRoom = await _db.QuerySingleAsync( - "INSERT INTO Rooms(Number, State) Values(@Number, @State) RETURNING *", + "INSERT INTO Rooms(Number, State) VALUES(@Number, @State) RETURNING *", new RoomDb(newRoom) ); @@ -71,6 +77,18 @@ public async Task DeleteRoom(string roomNumber) return deleted > 0; } + public async Task RoomExists(string roomNumber) + { + var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + + var count = await _db.ExecuteScalarAsync( + "SELECT COUNT(1) FROM Rooms WHERE Number = @roomNumberInt;", + new { roomNumberInt } + ); + + return count > 0; + } + // Inner class to hide the details of a direct mapping to SQLite private class RoomDb { diff --git a/api/Validators/EmailValidator.cs b/api/Validators/EmailValidator.cs new file mode 100644 index 0000000..05c0528 --- /dev/null +++ b/api/Validators/EmailValidator.cs @@ -0,0 +1,30 @@ +using System.Net.Mail; + +namespace Validators; + +public static class EmailValidator +{ + public static void Validate(string email) + { + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Guest email is required."); + + if (!IsValid(email)) + throw new ArgumentException("Guest email is invalid."); + } + + public static bool IsValid(string email) + { + try + { + var mailAddress = new MailAddress(email); + + var domain = email.Split('@').Last(); + return mailAddress.Address == email && domain.Contains('.'); + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/api/Validators/ReservationValidator.cs b/api/Validators/ReservationValidator.cs new file mode 100644 index 0000000..df9335e --- /dev/null +++ b/api/Validators/ReservationValidator.cs @@ -0,0 +1,23 @@ +using Models; + +namespace Validators; + +public static class ReservationValidator +{ + public static void ValidateForCreate(Reservation reservation) + { + var startDate = reservation.Start.Date; + var endDate = reservation.End.Date; + + if (endDate < startDate) + throw new ArgumentException("End date must be after start date."); + + if (endDate == startDate) + throw new ArgumentException("Reservation must be at least 1 day long."); + + var duration = endDate - startDate; + + if (duration.TotalDays > 30) + throw new ArgumentException("Reservation cannot exceed 30 days."); + } +} \ No newline at end of file diff --git a/api/Validators/RoomValidator.cs b/api/Validators/RoomValidator.cs new file mode 100644 index 0000000..4c765be --- /dev/null +++ b/api/Validators/RoomValidator.cs @@ -0,0 +1,21 @@ +using Models.Errors; + +namespace Validators; + +public static class RoomValidator +{ + public static void ValidateRoomNumber(string roomNumber) + { + if (string.IsNullOrWhiteSpace(roomNumber)) + throw new InvalidRoomNumber(roomNumber ?? ""); + + if (roomNumber.Length != 3) + throw new InvalidRoomNumber(roomNumber); + + if (!roomNumber.All(char.IsDigit)) + throw new InvalidRoomNumber(roomNumber); + + if (roomNumber[1..] == "00") + throw new InvalidRoomNumber(roomNumber); + } +} \ No newline at end of file diff --git a/ui/src/reservations/BookingDetailsModal.tsx b/ui/src/reservations/BookingDetailsModal.tsx index 6f1edfe..7bee771 100644 --- a/ui/src/reservations/BookingDetailsModal.tsx +++ b/ui/src/reservations/BookingDetailsModal.tsx @@ -55,22 +55,22 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { null, ]); const [focusedInput, setFocusedInput] = useState(null); - const showProcessingToast = useShowInfoToast("Processing booking..."); - const showNoInfoToast = useShowInfoToast("Missing email or dates."); + const showProcessingToast = useShowInfoToast(); + const showInfoToast = useShowInfoToast(); function handleSubmit(evt: React.MouseEvent) { if (!email || !dateRange[0] || !dateRange[1]) { - showNoInfoToast(); + showInfoToast("Missing email or dates."); evt.preventDefault(); return false; } - showProcessingToast(); + showProcessingToast("Processing booking..."); onSubmit({ RoomNumber: roomNumber, GuestEmail: email, - Start: fromDateStringToIso(dateRange[0]), - End: fromDateStringToIso(dateRange[1]), + Start: dateRange[0], + End: dateRange[1], }); return true; } diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 06a0036..b88e745 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, useShowInfoToast } 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", @@ -17,15 +18,29 @@ export function ReservationPage() { const [selectedRoomNumber, setSelectedRoomNumber] = useState(""); const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); - - const showToast = useShowSuccessToast("We have received your booking!"); + const showSuccessToast = useShowSuccessToast(); + const showInfoToast = useShowInfoToast(); function onClose() { setSelectedRoomNumber(""); } - function onSubmit(booking: NewReservation) { - bookRoom(booking).then(onClose).then(showToast); + async function onSubmit(booking: NewReservation) { + try { + await bookRoom(booking); + onClose(); + showSuccessToast("We have received your booking!"); + } catch (error) { + console.error("Booking failed", error); + + let message = "Booking failed."; + + if (error instanceof HTTPError) { + message = await error.response.text().catch(() => message); + } + + showInfoToast(message); + } } const createClickHandler = (roomNumber: string) => () => { diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..02ee9d0 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -1,13 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import { ISO8601String, toIsoStr } from "../utils/datetime"; import ky from "ky"; import { z } from "zod"; export interface NewReservation { RoomNumber: string; GuestEmail: string; - Start: ISO8601String; - End: ISO8601String; + Start: Date; + End: Date; } /** The schema the API returns */ @@ -22,15 +21,17 @@ const ReservationSchema = z.object({ type Reservation = z.infer; export function bookRoom(booking: NewReservation) { - // unwrap branded types const newReservation = { ...booking, - Start: toIsoStr(booking.Start), - End: toIsoStr(booking.End), + Start: toDateOnlyStr(booking.Start), + End: toDateOnlyStr(booking.End), }; - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); + return ky + .post("api/reservation", { + json: newReservation, + }) + .json(); } const RoomSchema = z.object({ @@ -46,3 +47,11 @@ export function useGetRooms() { queryFn: () => ky.get("api/room").json().then(RoomListSchema.parseAsync), }); } + +function toDateOnlyStr(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index d3358f7..caa3b54 100644 --- a/ui/src/utils/toasts.tsx +++ b/ui/src/utils/toasts.tsx @@ -9,24 +9,24 @@ const DEFAULT_TOAST_OPTIONS: ExternalToast = { duration: DEFAULT_TOAST_DURATION_MS, }; -export function useShowSuccessToast(message: string) { +export function useShowSuccessToast() { return useCallback( - () => + (message: string) => toast.custom( (t) => , DEFAULT_TOAST_OPTIONS, ), - [message], + [], ); } -export function useShowInfoToast(message: string) { +export function useShowInfoToast() { return useCallback( - () => + (message: string) => toast.custom( (t) => , DEFAULT_TOAST_OPTIONS, ), - [message], + [], ); -} +} \ No newline at end of file From a626ee615b32ba4a968993c860d74f42aa17a493 Mon Sep 17 00:00:00 2001 From: "arkadii.ushakov" Date: Thu, 2 Apr 2026 20:51:31 +0200 Subject: [PATCH 2/6] RE-001: Added sln file and unit tests for validators --- Reservations.Tests/EmailValidatorTests.cs | 82 +++++++++++++++++ .../ReservationValidatorTests.cs | 90 +++++++++++++++++++ Reservations.Tests/Reservations.Tests.csproj | 25 ++++++ Reservations.Tests/RoomValidatorTests.cs | 75 ++++++++++++++++ Reservations.sln | 48 ++++++++++ 5 files changed, 320 insertions(+) create mode 100644 Reservations.Tests/EmailValidatorTests.cs create mode 100644 Reservations.Tests/ReservationValidatorTests.cs create mode 100644 Reservations.Tests/Reservations.Tests.csproj create mode 100644 Reservations.Tests/RoomValidatorTests.cs create mode 100644 Reservations.sln diff --git a/Reservations.Tests/EmailValidatorTests.cs b/Reservations.Tests/EmailValidatorTests.cs new file mode 100644 index 0000000..d59c786 --- /dev/null +++ b/Reservations.Tests/EmailValidatorTests.cs @@ -0,0 +1,82 @@ +using Validators; + +namespace Reservations.Tests; + +public class EmailValidatorTests +{ + //Validate tests + [Fact] + public void Validate_ShouldThrow_WhenEmailIsNull() + { + Assert.Throws(() => + EmailValidator.Validate(null)); + } + + [Fact] + public void Validate_ShouldThrow_WhenEmailIsEmpty() + { + Assert.Throws(() => + EmailValidator.Validate("")); + } + + [Fact] + public void Validate_ShouldThrow_WhenEmailIsWhitespace() + { + Assert.Throws(() => + EmailValidator.Validate(" ")); + } + + [Fact] + public void Validate_ShouldThrow_WhenEmailIsInvalid() + { + Assert.Throws(() => + EmailValidator.Validate("invalid-email")); + } + + [Fact] + public void Validate_ShouldPass_WhenEmailIsValid() + { + EmailValidator.Validate("test@example.com"); + } + + // IsValid tests + [Fact] + public void IsValid_ShouldReturnTrue_ForValidEmail() + { + var result = EmailValidator.IsValid("test@example.com"); + + Assert.True(result); + } + + [Fact] + public void IsValid_ShouldReturnFalse_WhenMissingAt() + { + var result = EmailValidator.IsValid("testexample.com"); + + Assert.False(result); + } + + [Fact] + public void IsValid_ShouldReturnFalse_WhenMissingDomainDot() + { + var result = EmailValidator.IsValid("test@example"); + + Assert.False(result); + } + + [Fact] + public void IsValid_ShouldReturnFalse_ForInvalidFormat() + { + var result = EmailValidator.IsValid("test@.com"); + + Assert.False(result); + } + + [Fact] + public void IsValid_ShouldReturnFalse_WhenNull() + { + var result = EmailValidator.IsValid(null); + + Assert.False(result); + } +} \ No newline at end of file diff --git a/Reservations.Tests/ReservationValidatorTests.cs b/Reservations.Tests/ReservationValidatorTests.cs new file mode 100644 index 0000000..5b70487 --- /dev/null +++ b/Reservations.Tests/ReservationValidatorTests.cs @@ -0,0 +1,90 @@ +using Validators; +using Models; + +namespace Reservations.Tests; + +public class ReservationValidatorTests +{ + [Fact] + public void ValidateForCreate_ShouldThrow_WhenEndDateIsBeforeStartDate() + { + var reservation = CreateReservation( + start: new DateTime(2026, 4, 10), + end: new DateTime(2026, 4, 9)); + + var ex = Assert.Throws(() => + ReservationValidator.ValidateForCreate(reservation)); + + Assert.Equal("End date must be after start date.", ex.Message); + } + + [Fact] + public void ValidateForCreate_ShouldThrow_WhenReservationIsLessThanOneDay() + { + var reservation = CreateReservation( + start: new DateTime(2026, 4, 10), + end: new DateTime(2026, 4, 10)); + + var ex = Assert.Throws(() => + ReservationValidator.ValidateForCreate(reservation)); + + Assert.Equal("Reservation must be at least 1 day long.", ex.Message); + } + + [Fact] + public void ValidateForCreate_ShouldThrow_WhenReservationExceedsThirtyDays() + { + var reservation = CreateReservation( + start: new DateTime(2026, 4, 1), + end: new DateTime(2026, 5, 2)); // 31 days + + var ex = Assert.Throws(() => + ReservationValidator.ValidateForCreate(reservation)); + + Assert.Equal("Reservation cannot exceed 30 days.", ex.Message); + } + + [Fact] + public void ValidateForCreate_ShouldPass_WhenReservationIsOneDayLong() + { + var reservation = CreateReservation( + start: new DateTime(2026, 4, 10), + end: new DateTime(2026, 4, 11)); + + ReservationValidator.ValidateForCreate(reservation); + } + + [Fact] + public void ValidateForCreate_ShouldPass_WhenReservationIsThirtyDaysLong() + { + var reservation = CreateReservation( + start: new DateTime(2026, 4, 1), + end: new DateTime(2026, 5, 1)); // 30 days + + ReservationValidator.ValidateForCreate(reservation); + } + + [Fact] + public void ValidateForCreate_ShouldIgnoreTimePart_WhenComparingDates() + { + var reservation = CreateReservation( + start: new DateTime(2026, 4, 10, 23, 0, 0), + end: new DateTime(2026, 4, 11, 1, 0, 0)); + + ReservationValidator.ValidateForCreate(reservation); + } + + private static Reservation CreateReservation(DateTime start, DateTime end) + { + return new Reservation + { + Id = Guid.NewGuid(), + RoomNumber = "101", + GuestEmail = "test@example.com", + Start = start, + End = end, + CheckedIn = false, + CheckedOut = false + }; + } +} \ No newline at end of file diff --git a/Reservations.Tests/Reservations.Tests.csproj b/Reservations.Tests/Reservations.Tests.csproj new file mode 100644 index 0000000..0a24d3f --- /dev/null +++ b/Reservations.Tests/Reservations.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/Reservations.Tests/RoomValidatorTests.cs b/Reservations.Tests/RoomValidatorTests.cs new file mode 100644 index 0000000..869d4dc --- /dev/null +++ b/Reservations.Tests/RoomValidatorTests.cs @@ -0,0 +1,75 @@ +using Models.Errors; +using Validators; + +namespace Reservations.Tests; + +public class RoomValidatorTests +{ + [Fact] + public void ValidateRoomNumber_ShouldThrow_WhenRoomNumberIsNull() + { + Assert.Throws(() => + RoomValidator.ValidateRoomNumber(null)); + } + + [Fact] + public void ValidateRoomNumber_ShouldThrow_WhenRoomNumberIsEmpty() + { + Assert.Throws(() => + RoomValidator.ValidateRoomNumber("")); + } + + [Fact] + public void ValidateRoomNumber_ShouldThrow_WhenRoomNumberIsWhitespace() + { + Assert.Throws(() => + RoomValidator.ValidateRoomNumber(" ")); + } + + [Theory] + [InlineData("0")] + [InlineData("1")] + [InlineData("12")] + [InlineData("2020")] + public void ValidateRoomNumber_ShouldThrow_WhenLengthIsNotThree(string roomNumber) + { + Assert.Throws(() => + RoomValidator.ValidateRoomNumber(roomNumber)); + } + + [Theory] + [InlineData("-101")] + [InlineData("10a")] + [InlineData("A01")] + [InlineData("1 1")] + [InlineData("1-1")] + public void ValidateRoomNumber_ShouldThrow_WhenContainsNonDigits(string roomNumber) + { + Assert.Throws(() => + RoomValidator.ValidateRoomNumber(roomNumber)); + } + + [Theory] + [InlineData("000")] + [InlineData("100")] + [InlineData("200")] + [InlineData("900")] + public void ValidateRoomNumber_ShouldThrow_WhenDoorNumberIs00(string roomNumber) + { + Assert.Throws(() => + RoomValidator.ValidateRoomNumber(roomNumber)); + } + + [Theory] + [InlineData("001")] + [InlineData("010")] + [InlineData("101")] + [InlineData("105")] + [InlineData("201")] + [InlineData("203")] + [InlineData("999")] + public void ValidateRoomNumber_ShouldPass_ForValidRoomNumbers(string roomNumber) + { + RoomValidator.ValidateRoomNumber(roomNumber); + } +} \ No newline at end of file diff --git a/Reservations.sln b/Reservations.sln new file mode 100644 index 0000000..8ec302b --- /dev/null +++ b/Reservations.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api", "api\api.csproj", "{10F44563-94BF-4783-88EF-FBD7B34B85F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reservations.Tests", "Reservations.Tests\Reservations.Tests.csproj", "{764FADC1-93C5-4B23-B68D-0B3EA8C162B1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Debug|x64.Build.0 = Debug|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Debug|x86.Build.0 = Debug|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Release|Any CPU.Build.0 = Release|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Release|x64.ActiveCfg = Release|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Release|x64.Build.0 = Release|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Release|x86.ActiveCfg = Release|Any CPU + {10F44563-94BF-4783-88EF-FBD7B34B85F6}.Release|x86.Build.0 = Release|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Debug|x64.ActiveCfg = Debug|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Debug|x64.Build.0 = Debug|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Debug|x86.ActiveCfg = Debug|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Debug|x86.Build.0 = Debug|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Release|Any CPU.Build.0 = Release|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Release|x64.ActiveCfg = Release|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Release|x64.Build.0 = Release|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Release|x86.ActiveCfg = Release|Any CPU + {764FADC1-93C5-4B23-B68D-0B3EA8C162B1}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal From 1ddf7386c6945bfb64254a18384ed84ca3a4a573 Mon Sep 17 00:00:00 2001 From: "arkadii.ushakov" Date: Thu, 2 Apr 2026 22:07:11 +0200 Subject: [PATCH 3/6] RE-002: Added conflict detection for reservations and integration tests --- .../ReservationRepositoryIntegrationTests.cs | 144 ++++++++++++++++++ .../Validators}/EmailValidatorTests.cs | 2 +- .../Validators}/ReservationValidatorTests.cs | 2 +- .../Validators}/RoomValidatorTests.cs | 2 +- api/Controllers/ReservationController.cs | 8 +- api/Repositories/ReservationRepository.cs | 28 ++++ 6 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 Reservations.Tests/Integration/Repositories/ReservationRepositoryIntegrationTests.cs rename Reservations.Tests/{ => Unit/Validators}/EmailValidatorTests.cs (97%) rename Reservations.Tests/{ => Unit/Validators}/ReservationValidatorTests.cs (98%) rename Reservations.Tests/{ => Unit/Validators}/RoomValidatorTests.cs (97%) diff --git a/Reservations.Tests/Integration/Repositories/ReservationRepositoryIntegrationTests.cs b/Reservations.Tests/Integration/Repositories/ReservationRepositoryIntegrationTests.cs new file mode 100644 index 0000000..dffac93 --- /dev/null +++ b/Reservations.Tests/Integration/Repositories/ReservationRepositoryIntegrationTests.cs @@ -0,0 +1,144 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Repositories; + +namespace Tests.Integration.Repositories; + +public class ReservationRepositoryIntegrationTests : IDisposable +{ + private readonly SqliteConnection _connection; + + public ReservationRepositoryIntegrationTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + CreateSchema(); + } + + private void CreateSchema() + { + _connection.Execute(@" + CREATE TABLE Reservations ( + Id TEXT PRIMARY KEY NOT NULL, + GuestEmail TEXT NOT NULL, + RoomNumber INT NOT NULL, + Start INT NOT NULL, + End INT NOT NULL, + CheckedIn INT NOT NULL, + CheckedOut INT NOT NULL + ); + "); + } + + public void Dispose() + { + _connection.Dispose(); + } + + private async Task InsertReservation(int roomNumber, DateTime start, DateTime end) + { + await _connection.ExecuteAsync(@" + INSERT INTO Reservations (Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) + VALUES (@Id, @GuestEmail, @RoomNumber, @Start, @End, 0, 0); + ", + new + { + Id = Guid.NewGuid().ToString(), + GuestEmail = "test@test.com", + RoomNumber = roomNumber, + Start = start.Date.Ticks, + End = end.Date.Ticks + }); + } + + private ReservationRepository CreateRepo() + { + return new ReservationRepository(_connection); + } + + [Fact] + public async Task Should_Return_True_When_Dates_Overlap() + { + // Arrange + await InsertReservation(101, new DateTime(2026, 4, 10), new DateTime(2026, 4, 12)); + var repo = CreateRepo(); + + // Act + var result = await repo.HasConflictingReservation( + "101", + new DateTime(2026, 4, 11), + new DateTime(2026, 4, 13)); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task Should_Return_False_When_No_Overlap_Right_Side() + { + // Arrange + await InsertReservation(101, new DateTime(2026, 4, 10), new DateTime(2026, 4, 12)); + var repo = CreateRepo(); + + // Act + var result = await repo.HasConflictingReservation( + "101", + new DateTime(2026, 4, 12), + new DateTime(2026, 4, 14)); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task Should_Return_False_When_No_Overlap_Left_Side() + { + // Arrange + await InsertReservation(101, new DateTime(2026, 4, 10), new DateTime(2026, 4, 12)); + var repo = CreateRepo(); + + // Act + var result = await repo.HasConflictingReservation( + "101", + new DateTime(2026, 4, 8), + new DateTime(2026, 4, 10)); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task Should_Return_True_When_New_Reservation_Fully_Covers_Existing() + { + // Arrange + await InsertReservation(101, new DateTime(2026, 4, 10), new DateTime(2026, 4, 12)); + var repo = CreateRepo(); + + // Act + var result = await repo.HasConflictingReservation( + "101", + new DateTime(2026, 4, 9), + new DateTime(2026, 4, 13)); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task Should_Return_False_For_Different_Room() + { + // Arrange + await InsertReservation(101, new DateTime(2026, 4, 10), new DateTime(2026, 4, 12)); + var repo = CreateRepo(); + + // Act + var result = await repo.HasConflictingReservation( + "102", + new DateTime(2026, 4, 11), + new DateTime(2026, 4, 13)); + + // Assert + Assert.False(result); + } +} \ No newline at end of file diff --git a/Reservations.Tests/EmailValidatorTests.cs b/Reservations.Tests/Unit/Validators/EmailValidatorTests.cs similarity index 97% rename from Reservations.Tests/EmailValidatorTests.cs rename to Reservations.Tests/Unit/Validators/EmailValidatorTests.cs index d59c786..ca0e54d 100644 --- a/Reservations.Tests/EmailValidatorTests.cs +++ b/Reservations.Tests/Unit/Validators/EmailValidatorTests.cs @@ -1,6 +1,6 @@ using Validators; -namespace Reservations.Tests; +namespace Reservations.Tests.Unit.Validators; public class EmailValidatorTests { diff --git a/Reservations.Tests/ReservationValidatorTests.cs b/Reservations.Tests/Unit/Validators/ReservationValidatorTests.cs similarity index 98% rename from Reservations.Tests/ReservationValidatorTests.cs rename to Reservations.Tests/Unit/Validators/ReservationValidatorTests.cs index 5b70487..f013b96 100644 --- a/Reservations.Tests/ReservationValidatorTests.cs +++ b/Reservations.Tests/Unit/Validators/ReservationValidatorTests.cs @@ -1,7 +1,7 @@ using Validators; using Models; -namespace Reservations.Tests; +namespace Reservations.Tests.Unit.Validators; public class ReservationValidatorTests { diff --git a/Reservations.Tests/RoomValidatorTests.cs b/Reservations.Tests/Unit/Validators/RoomValidatorTests.cs similarity index 97% rename from Reservations.Tests/RoomValidatorTests.cs rename to Reservations.Tests/Unit/Validators/RoomValidatorTests.cs index 869d4dc..7fe496c 100644 --- a/Reservations.Tests/RoomValidatorTests.cs +++ b/Reservations.Tests/Unit/Validators/RoomValidatorTests.cs @@ -1,7 +1,7 @@ using Models.Errors; using Validators; -namespace Reservations.Tests; +namespace Reservations.Tests.Unit.Validators; public class RoomValidatorTests { diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 8e89c40..9f36d62 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -67,13 +67,17 @@ await _guestRepository.CreateGuest(new Guest return Created($"/reservation/{createdReservation.Id}", createdReservation); } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } catch (NotFoundException ex) { return NotFound(ex.Message); } - catch (ArgumentException ex) + catch (InvalidOperationException ex) { - return BadRequest(ex.Message); + return Conflict(ex.Message); } catch (Exception ex) { diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 94f2b0a..54c95a0 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -54,6 +54,14 @@ public async Task CreateReservation(Reservation newReservation) EmailValidator.Validate(newReservation.GuestEmail); RoomValidator.ValidateRoomNumber(newReservation.RoomNumber); + var hasConflict = await HasConflictingReservation( + newReservation.RoomNumber, + newReservation.Start, + newReservation.End); + + if (hasConflict) + throw new InvalidOperationException("Room is already booked for the selected dates."); + var createdReservation = await _db.QuerySingleAsync( @"INSERT INTO Reservations (Id, RoomNumber, GuestEmail, Start, End, CheckedIn, CheckedOut) VALUES (@Id, @RoomNumber, @GuestEmail, @Start, @End, @CheckedIn, @CheckedOut) @@ -73,6 +81,26 @@ public async Task DeleteReservation(Guid reservationId) return deleted > 0; } + + public async Task HasConflictingReservation(string roomNumber, DateTime start, DateTime end) + { + var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + + var count = await _db.ExecuteScalarAsync( + @"SELECT COUNT(1) + FROM Reservations + WHERE RoomNumber = @RoomNumber + AND @Start < End + AND @End > Start;", + new + { + RoomNumber = roomNumberInt, + Start = start.Date.Ticks, + End = end.Date.Ticks + }); + + return count > 0; + } private class ReservationDb { From 78bb5062341c12999e1889e3d00d95c4e99c0c47 Mon Sep 17 00:00:00 2001 From: "arkadii.ushakov" Date: Fri, 3 Apr 2026 01:00:16 +0200 Subject: [PATCH 4/6] RE-003: Implemented staff authentication and reservations view --- api/Controllers/StaffController.cs | 54 +++++++++++++++++------ api/Program.cs | 15 ++++--- api/Repositories/ReservationRepository.cs | 19 +++++++- ui/src/LandingPage.tsx | 27 ++++++++++-- ui/src/router.tsx | 6 +++ ui/src/staff/StaffPage.tsx | 52 ++++++++++++++++++++++ 6 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 ui/src/staff/StaffPage.tsx diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..f047697 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,29 +1,33 @@ using Microsoft.AspNetCore.Mvc; +using Models; +using Repositories; namespace Controllers { [Route("staff")] public class StaffController : Controller { - private IConfiguration Config { get; set; } + private readonly ReservationRepository _reservationRepository; + private readonly IConfiguration _сonfig; - public StaffController(IConfiguration config) + public StaffController(ReservationRepository reservationRepository, IConfiguration config) { - Config = config; + _reservationRepository = reservationRepository; + _сonfig = 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) + private bool IsNotStaff(HttpRequest request, out ActionResult? result) { // TODO explore UseAuthentication request.Cookies.TryGetValue("access", out string? accessValue); - if (accessValue == null || accessValue == "0") + if (accessValue != "1") { - result = StatusCode(403); + result = Unauthorized(); return true; } @@ -31,15 +35,16 @@ private bool IsNotStaff(HttpRequest request, out IActionResult? result) return false; } - [HttpGet, Route("login")] - public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) + [HttpPost("login")] + public IActionResult Login([FromHeader(Name = "X-Staff-Code")] string accessCode) { - var configuredSecret = Config.GetValue("staffAccessCode"); - if (configuredSecret != accessCode) + var configuredSecret = _сonfig.GetValue("staffAccessCode"); + + if (string.IsNullOrWhiteSpace(accessCode) || configuredSecret != accessCode) { - // don't set cookie, don't indicate anything - return NoContent(); + return Unauthorized(); } + Response.Cookies.Append( "access", "1", @@ -52,18 +57,39 @@ public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string access Secure = true } ); + return NoContent(); } - [HttpGet, Route("check")] + [HttpGet("check")] public IActionResult CheckCookie() { - if (IsNotStaff(Request, out IActionResult? result)) + if (IsNotStaff(Request, out ActionResult? result)) { return result!; } return Ok("Authorized"); } + + [HttpGet("reservations")] + public async Task>> GetUpcomingReservations() + { + try + { + if (IsNotStaff(Request, out ActionResult? result)) + { + return result!; + } + + var reservations = await _reservationRepository.GetTodayAndUpcomingReservations(); + + return Ok(reservations); + } + catch (Exception ex) + { + return StatusCode(500, "Internal server error"); + } + } } } diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..ae7676a 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -12,11 +12,16 @@ 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(_ => + { + var connection = new SqliteConnection(connectionString); + connection.Open(); + return connection; + }); + Services.AddScoped(sp => sp.GetRequiredService()); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); Services.AddMvc(opt => { opt.EnableEndpointRouting = false; diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 54c95a0..70fa947 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -95,13 +95,28 @@ AND @Start < End new { RoomNumber = roomNumberInt, - Start = start.Date.Ticks, - End = end.Date.Ticks + Start = start.Date, + End = end.Date }); return count > 0; } + public async Task> GetTodayAndUpcomingReservations() + { + var reservations = await _db.QueryAsync( + @"SELECT * + FROM Reservations + WHERE End >= @Today + ORDER BY Start;", + new + { + DateTime.Today + }); + + return reservations.Select(r => r.ToDomain()); + } + private class ReservationDb { public string Id { get; set; } diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..2a139ed 100644 --- a/ui/src/LandingPage.tsx +++ b/ui/src/LandingPage.tsx @@ -1,9 +1,30 @@ import { Box, Card, Flex, Heading, Inset } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; -function handleLogin() { - // TODO have a staff view - alert("Not implemented"); +async function handleLogin() { + const code = prompt("Enter staff access code"); + + if (!code) return; + + try { + const response = await fetch("api/staff/login", { + method: "POST", + headers: { + "X-Staff-Code": code, + }, + credentials: "include", + }); + + if (!response.ok) { + alert("Invalid access code"); + return; + } + + window.location.href = "/staff"; + } catch (e) { + console.error(e); + alert("Login failed"); + } } export function LandingPage() { diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e3020bd..836802c 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -6,6 +6,7 @@ import { import { Layout } from "./Layout"; import { LandingPage } from "./LandingPage"; import { ReservationPage } from "./reservations/ReservationPage"; +import { StaffPage } from "./staff/StaffPage"; const rootRoute = createRootRoute({ component: Layout, @@ -26,6 +27,11 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + createRoute({ + path: "/staff", + getParentRoute: getRootRoute, + component: StaffPage, + }), ]; const routeTree = rootRoute.addChildren(ROUTES); diff --git a/ui/src/staff/StaffPage.tsx b/ui/src/staff/StaffPage.tsx new file mode 100644 index 0000000..eed01ce --- /dev/null +++ b/ui/src/staff/StaffPage.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; + +type Reservation = { + id: string; + roomNumber: string; + guestEmail: string; + start: string; + end: string; +}; + +export function StaffPage() { + const [reservations, setReservations] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/staff/reservations", { + credentials: "include", + }) + .then(async (res) => { + if (res.status === 401) { + setError("Not authorized"); + return; + } + + const data = await res.json(); + setReservations(data); + }) + .catch(() => setError("Failed to load reservations")); + }, []); + + if (error) { + return
{error}
; + } + + return ( +
+

Upcoming Reservations

+ + {reservations.length === 0 &&
No reservations
} + +
    + {reservations.map((r) => ( +
  • + Room: {r.roomNumber}
    + Email: {r.guestEmail}
    + From: {r.start} → To: {r.end} +
  • + ))} +
+
+ ); +} \ No newline at end of file From 576b2ae92ea37ebac89588ee3a792b2d4bad3ed2 Mon Sep 17 00:00:00 2001 From: "arkadii.ushakov" Date: Fri, 3 Apr 2026 02:12:30 +0200 Subject: [PATCH 5/6] Code cleanup --- .../ReservationRepositoryIntegrationTests.cs | 4 ++-- api/Controllers/StaffController.cs | 23 +++++++------------ api/Repositories/ReservationRepository.cs | 2 +- ui/src/staff/StaffPage.tsx | 2 +- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/Reservations.Tests/Integration/Repositories/ReservationRepositoryIntegrationTests.cs b/Reservations.Tests/Integration/Repositories/ReservationRepositoryIntegrationTests.cs index dffac93..0291e0d 100644 --- a/Reservations.Tests/Integration/Repositories/ReservationRepositoryIntegrationTests.cs +++ b/Reservations.Tests/Integration/Repositories/ReservationRepositoryIntegrationTests.cs @@ -47,8 +47,8 @@ INSERT INTO Reservations (Id, GuestEmail, RoomNumber, Start, End, CheckedIn, Che Id = Guid.NewGuid().ToString(), GuestEmail = "test@test.com", RoomNumber = roomNumber, - Start = start.Date.Ticks, - End = end.Date.Ticks + Start = start.Date, + End = end.Date }); } diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index f047697..437c708 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -8,12 +8,12 @@ namespace Controllers public class StaffController : Controller { private readonly ReservationRepository _reservationRepository; - private readonly IConfiguration _сonfig; + private readonly IConfiguration _config; public StaffController(ReservationRepository reservationRepository, IConfiguration config) { _reservationRepository = reservationRepository; - _сonfig = config; + _config = config; } /// @@ -38,7 +38,7 @@ private bool IsNotStaff(HttpRequest request, out ActionResult? result) [HttpPost("login")] public IActionResult Login([FromHeader(Name = "X-Staff-Code")] string accessCode) { - var configuredSecret = _сonfig.GetValue("staffAccessCode"); + var configuredSecret = _config.GetValue("staffAccessCode"); if (string.IsNullOrWhiteSpace(accessCode) || configuredSecret != accessCode) { @@ -75,21 +75,14 @@ public IActionResult CheckCookie() [HttpGet("reservations")] public async Task>> GetUpcomingReservations() { - try + if (IsNotStaff(Request, out ActionResult? result)) { - if (IsNotStaff(Request, out ActionResult? result)) - { - return result!; - } + return result!; + } - var reservations = await _reservationRepository.GetTodayAndUpcomingReservations(); + var reservations = await _reservationRepository.GetTodayAndUpcomingReservations(); - return Ok(reservations); - } - catch (Exception ex) - { - return StatusCode(500, "Internal server error"); - } + return Ok(reservations); } } } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 70fa947..d53ae26 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -111,7 +111,7 @@ FROM Reservations ORDER BY Start;", new { - DateTime.Today + Today = DateTime.Today }); return reservations.Select(r => r.ToDomain()); diff --git a/ui/src/staff/StaffPage.tsx b/ui/src/staff/StaffPage.tsx index eed01ce..91600ad 100644 --- a/ui/src/staff/StaffPage.tsx +++ b/ui/src/staff/StaffPage.tsx @@ -13,7 +13,7 @@ export function StaffPage() { const [error, setError] = useState(null); useEffect(() => { - fetch("/api/staff/reservations", { + fetch("api/staff/reservations", { credentials: "include", }) .then(async (res) => { From 5e45d2eab619954c413d31d33996351986a6de79 Mon Sep 17 00:00:00 2001 From: "arkadii.ushakov" Date: Fri, 3 Apr 2026 10:55:04 +0200 Subject: [PATCH 6/6] Small refactoring to move exception handling to middleware --- api/Controllers/ReservationController.cs | 43 ++++++------------------ api/Controllers/RoomController.cs | 20 ++--------- api/Program.cs | 36 +++++++++++++++++++- 3 files changed, 49 insertions(+), 50 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 9f36d62..074225f 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -48,43 +48,22 @@ public async Task> BookReservation([FromBody] Reservat if (newBooking.Id == Guid.Empty) newBooking.Id = Guid.NewGuid(); - try - { - if (!await _roomRepository.RoomExists(newBooking.RoomNumber)) - throw new NotFoundException($"Room {newBooking.RoomNumber} does not exist."); + if (!await _roomRepository.RoomExists(newBooking.RoomNumber)) + throw new NotFoundException($"Room {newBooking.RoomNumber} does not exist."); - var guest = await _guestRepository.GetGuestByEmail(newBooking.GuestEmail); + var guest = await _guestRepository.GetGuestByEmail(newBooking.GuestEmail); - if (guest == null) + if (guest == null) + { + await _guestRepository.CreateGuest(new Guest { - await _guestRepository.CreateGuest(new Guest - { - Email = newBooking.GuestEmail - }); - } + Email = newBooking.GuestEmail + }); + } - var createdReservation = await _reservationRepository.CreateReservation(newBooking); + var createdReservation = await _reservationRepository.CreateReservation(newBooking); - return Created($"/reservation/{createdReservation.Id}", createdReservation); - } - catch (ArgumentException ex) - { - return BadRequest(ex.Message); - } - catch (NotFoundException ex) - { - return NotFound(ex.Message); - } - catch (InvalidOperationException ex) - { - return Conflict(ex.Message); - } - catch (Exception ex) - { - //TODO: Proper error logging - Console.WriteLine(ex); - return StatusCode(500, "Internal server error"); - } + return Created($"/reservation/{createdReservation.Id}", createdReservation); } [HttpDelete("{reservationId}")] diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index a83e2f4..f00186e 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -59,23 +59,9 @@ public async Task> CreateRoom([FromBody] Room newRoom) if (newRoom == null) return BadRequest("Request body is required."); - try - { - var createdRoom = await _repo.CreateRoom(newRoom); - return Created($"/room/{createdRoom.Number}", createdRoom); - } - catch (InvalidRoomNumber ex) - { - return BadRequest(ex.Message); - } - catch (InvalidOperationException ex) - { - return Conflict(ex.Message); - } - catch (Exception) - { - return StatusCode(500, "Internal server error"); - } + var createdRoom = await _repo.CreateRoom(newRoom); + + return Created($"/room/{createdRoom.Number}", createdRoom); } [HttpDelete, Produces("application/json"), Route("{roomNumber}")] diff --git a/api/Program.cs b/api/Program.cs index ae7676a..b751b87 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,6 +1,7 @@ using System.Data; using Db; using Microsoft.Data.Sqlite; +using Models.Errors; using Repositories; var builder = WebApplication.CreateBuilder(args); @@ -46,7 +47,40 @@ Environment.Exit(1); return; } - + app.Use(async (context, next) => + { + try + { + await next(); + } + catch (NotFoundException ex) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync(ex.Message); + } + catch (InvalidRoomNumber ex) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync(ex.Message); + } + catch (ArgumentException ex) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync(ex.Message); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = StatusCodes.Status409Conflict; + await context.Response.WriteAsync(ex.Message); + } + catch (Exception ex) + { + Console.WriteLine(ex); + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsync("Internal server error"); + } + }); + app.UsePathBase("/api") .UseMvc() .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())