From 1698fee5eeab302d0437eb93383f1b854fe349fe Mon Sep 17 00:00:00 2001 From: Nuria Lopez Date: Fri, 17 Apr 2026 21:05:33 +0200 Subject: [PATCH 1/7] RE-001: Guest Booking --- api/.gitignore | 4 +- api/Controllers/GuestController.cs | 17 ++++++++ api/Db/Setup.cs | 3 +- api/Models/Validators/ReservationValidator.cs | 41 +++++++++++++++++++ api/Program.cs | 13 +++++- api/Repositories/GuestRepository.cs | 2 +- api/Repositories/ReservationRepository.cs | 11 +++-- api/Utils/GuidTypeHandler.cs | 15 +++++++ api/api.csproj | 3 ++ ui/package-lock.json | 4 +- ui/src/components/ErrorToast.tsx | 28 +++++++++++++ ui/src/reservations/ReservationPage.tsx | 21 +++++++--- ui/src/reservations/api.ts | 27 ++++++++++-- ui/src/utils/toasts.tsx | 12 ++++++ 14 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 api/Models/Validators/ReservationValidator.cs create mode 100644 api/Utils/GuidTypeHandler.cs create mode 100644 ui/src/components/ErrorToast.tsx diff --git a/api/.gitignore b/api/.gitignore index 6b8f78f..ca2054a 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -2,4 +2,6 @@ bin/ obj/ reservations.db reservations.db-shm -reservations.db-wal \ No newline at end of file +reservations.db-wal +.DS_Store +.vs \ No newline at end of file diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index 095d570..7c753a9 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -21,5 +21,22 @@ public async Task> GetGuests() return Json(guests); } + + [HttpPost, Produces("application/json"), Route("")] + public async Task> AddGuest([FromBody] Guest guest) + { + try + { + var registeredGuest = await _repo.CreateGuest(guest); + return Created($"/guest/{guest.Email}", registeredGuest); + } + catch (Exception ex) + { + Console.WriteLine("An error occured when trying to register a new guest:"); + Console.WriteLine(ex.ToString()); + + return BadRequest("Invalid guest data"); + } + } } } diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..96f98a2 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 NOT NULL ); " ); diff --git a/api/Models/Validators/ReservationValidator.cs b/api/Models/Validators/ReservationValidator.cs new file mode 100644 index 0000000..85999fa --- /dev/null +++ b/api/Models/Validators/ReservationValidator.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using Models; + +namespace api.Models.Validators; + +public class ReservationValidator : AbstractValidator +{ + public ReservationValidator() + { + RuleFor(r => r.Start) + .NotEmpty() + .GreaterThan(DateTime.UtcNow) + .WithMessage("Time travels have not been discovered... yet"); + + RuleFor(r => r.End) + .GreaterThanOrEqualTo(r => r.Start.AddDays(1)) + .WithMessage("Minimum reservation allowed is 1 night") + .LessThanOrEqualTo(r => r.Start.AddDays(30)) + .WithMessage("Maximum reservation allowed is 30 nights") + .When(r => r.Start != default); + + RuleFor(r => r.GuestEmail) + .Cascade(CascadeMode.Stop) + .EmailAddress() + .WithMessage("Email address is missing the domain") + .Matches(@"^[^@]+@[^@]+\.[^@]+$") + .WithMessage("The email domain is incomplete"); + + RuleFor(r => r.RoomNumber) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .Must(r => r[..1] != "-") + .WithMessage("Underground rooms are not allowed") + .Must(r => r[..1] != "0") + .WithMessage("Rooms must be placed on the 1st floor or above") + .Matches(@"^\d{3}$") + .WithMessage("Invalid room number. Plese enter a number between 101 and 999 avoiding 00s") + .Must(r => r[1..] != "00") + .WithMessage("Invalid door '00'"); + } +} diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..26d8b78 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,7 +1,13 @@ using System.Data; +using api.Models.Validators; +using api.Utils; +using Controllers; +using Dapper; using Db; +using FluentValidation; using Microsoft.Data.Sqlite; using Repositories; +using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -12,6 +18,7 @@ builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; + SqlMapper.AddTypeHandler(new GuidTypeHandler()); Services.AddSingleton(_ => new SqliteConnection(connectionString)); Services.AddSingleton(sp => sp.GetRequiredService()); Services.AddSingleton(); @@ -21,6 +28,10 @@ { opt.EnableEndpointRouting = false; }); + Services + .AddFluentValidationAutoValidation() + .AddValidatorsFromAssemblyContaining(); + Services.AddCors(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); @@ -43,8 +54,8 @@ } app.UsePathBase("/api") - .UseMvc() .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) + .UseMvc() .UseSwagger() .UseSwaggerUI(); } diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 54182bc..22aaa2b 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -44,7 +44,7 @@ public async Task GetGuestByEmail(string guestEmail) public Task CreateGuest(Guest newGuest) { return _db.QuerySingleAsync( - "INSERT INTO Guests(Email, Name) Values(@Email, @Name) RETURNING *", + "INSERT INTO Guests(Email, Name, Surname) Values(@Email, @Name, @Surname) RETURNING *", newGuest ); } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..39887c9 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -49,10 +49,13 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } - ); + const string query = """ + INSERT INTO Reservations (Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) + VALUES (@Id, @GuestEmail, @RoomNumber, @Start, @End, @CheckedIn, @CheckedOut) + RETURNING * + """; + + return await _db.QuerySingleAsync(query, newReservation); } public async Task DeleteReservation(Guid reservationId) diff --git a/api/Utils/GuidTypeHandler.cs b/api/Utils/GuidTypeHandler.cs new file mode 100644 index 0000000..fe0b0cb --- /dev/null +++ b/api/Utils/GuidTypeHandler.cs @@ -0,0 +1,15 @@ +using Dapper; +using System.Data; + +namespace api.Utils; + +public class GuidTypeHandler : SqlMapper.TypeHandler +{ + public override Guid Parse(object value) => Guid.Parse(value.ToString()!); + + public override void SetValue(IDbDataParameter parameter, Guid value) + { + parameter.Value = value.ToString(); + parameter.DbType = DbType.String; + } +} diff --git a/api/api.csproj b/api/api.csproj index ef55adc..fc2d64e 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -8,7 +8,10 @@ + + + diff --git a/ui/package-lock.json b/ui/package-lock.json index cfc4957..06c0e87 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "0.0.2", + "version": "0.0.3", "dependencies": { "@datepicker-react/styled": "^2.8.4", "@radix-ui/react-dialog": "^1.1.2", diff --git a/ui/src/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..dd558ba 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; -import { useShowSuccessToast } from "../utils/toasts"; +import { useShowSuccessToast, useShowErrorToast } from "../utils/toasts"; import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; -import { bookRoom, NewReservation, useGetRooms } from "./api"; +import { bookRoom, BookingError, NewReservation, useGetRooms } from "./api"; import { LoadingCard } from "../components/LoadingCard"; import { BookingDetailsModal } from "./BookingDetailsModal"; @@ -18,14 +18,25 @@ export function ReservationPage() { const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); - const showToast = useShowSuccessToast("We have received your booking!"); + const showSuccessToast = useShowSuccessToast("We have received your booking!"); + const showErrorToast = useShowErrorToast(); function onClose() { setSelectedRoomNumber(""); } - function onSubmit(booking: NewReservation) { - bookRoom(booking).then(onClose).then(showToast); + async function onSubmit(booking: NewReservation) { + try { + await bookRoom(booking); + onClose(); + showSuccessToast(); + } catch (error) { + if (error instanceof BookingError) { + error.messages.forEach((msg) => showErrorToast(msg)); + } else { + showErrorToast("Something went wrong, please try again."); + } + } } const createClickHandler = (roomNumber: string) => () => { diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..35ddf43 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { ISO8601String, toIsoStr } from "../utils/datetime"; -import ky from "ky"; +import ky, { HTTPError } from "ky"; import { z } from "zod"; export interface NewReservation { @@ -21,7 +21,13 @@ const ReservationSchema = z.object({ type Reservation = z.infer; -export function bookRoom(booking: NewReservation) { +export class BookingError extends Error { + constructor(public readonly messages: string[]) { + super(messages.join(", ")); + } +} + +export async function bookRoom(booking: NewReservation): Promise { // unwrap branded types const newReservation = { ...booking, @@ -29,8 +35,21 @@ export function bookRoom(booking: NewReservation) { End: toIsoStr(booking.End), }; - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); + try { + return await ky.post("api/reservation", { json: newReservation }).json(); + } catch (error) { + if (error instanceof HTTPError && error.response.status === 400) { + const body = await error.response.json(); + if (body && typeof body === "object" && "errors" in body) { + const messages = Object.values(body.errors as Record).flat(); + throw new BookingError(messages); + } + if (typeof body === "string") { + throw new BookingError([body]); + } + } + throw error; + } } const RoomSchema = z.object({ diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index d3358f7..16a0972 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,14 @@ export function useShowInfoToast(message: string) { [message], ); } + +export function useShowErrorToast() { + return useCallback( + (message: string) => + toast.custom( + (t) => , + DEFAULT_TOAST_OPTIONS, + ), + [], + ); +} From a3684c757df5b9c5d9a9f7ce963a766916d34b24 Mon Sep 17 00:00:00 2001 From: Nuria Lopez Date: Fri, 17 Apr 2026 22:53:14 +0200 Subject: [PATCH 2/7] RE-002:Booking Validations * Block, in the date picker, the dates already booked to prevent the new customer from selecting them * Add double check in the backend booking endpoint, if received dates collide with another reservation of that room the endpoint returns a 400. * Created endpoint to return all the reservations for a given room. --- api/Controllers/ReservationController.cs | 12 ++++++++++ api/Repositories/ReservationRepository.cs | 10 ++++++++ ui/src/reservations/BookingDetailsModal.tsx | 19 ++++++++++++--- ui/src/reservations/ReservationPage.tsx | 4 +++- ui/src/reservations/api.ts | 26 ++++++++++++++++----- 5 files changed, 61 insertions(+), 10 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..26320f1 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -37,6 +37,14 @@ public async Task> GetRoom(Guid reservationId) } } + [HttpGet, Produces("application/json"), Route("room/{roomNumber}")] + public async Task> GetRoomReservations(string roomNumber) + { + var reservations = await _repo.GetRoomReservations(roomNumber); + + return Json(reservations); + } + /// /// Create a new reservation, to generate the GUID ID on the server, send an Empty GUID (all 0s) /// @@ -55,6 +63,10 @@ [FromBody] Reservation newBooking try { + var existingReservations = await _repo.GetRoomReservations(newBooking.RoomNumber); + if(existingReservations.Any(r => newBooking.Start >= r.Start && newBooking.Start < r.End || newBooking.End > r.Start && newBooking.End <= r.End)) + return BadRequest("The room is already booked for the provided time range"); + var createdReservation = await _repo.CreateReservation(newBooking); return Created($"/reservation/${createdReservation.Id}", createdReservation); } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 39887c9..d311977 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -47,6 +47,16 @@ public async Task GetReservation(Guid reservationId) return reservation.ToDomain(); } + public async Task> GetRoomReservations(string roomNumber) + { + var reservations = await _db.QueryAsync("SELECT * FROM Reservations WHERE RoomNumber = @roomNumber", new { roomNumber }); + + if (reservations is null) + return []; + + return reservations.Select(r => r.ToDomain()); + } + public async Task CreateReservation(Reservation newReservation) { const string query = """ diff --git a/ui/src/reservations/BookingDetailsModal.tsx b/ui/src/reservations/BookingDetailsModal.tsx index 6f1edfe..ce04869 100644 --- a/ui/src/reservations/BookingDetailsModal.tsx +++ b/ui/src/reservations/BookingDetailsModal.tsx @@ -6,23 +6,26 @@ import { OnDatesChangeProps, } from "@datepicker-react/styled"; import { Box, Button, Dialog, Separator, TextField } from "@radix-ui/themes"; -import { NewReservation } from "./api"; +import { NewReservation, Reservation } from "./api"; import { useState } from "react"; import styled from "styled-components"; interface BookingDetailsModalProps { roomNumber: string; + reservations: Reservation[] | undefined; onSubmit: (booking: NewReservation) => void; } interface BookingFormProps { roomNumber: string; + reservations: Reservation[] | undefined; onSubmit: (booking: NewReservation) => void; } /** Must be inside a Dialog.Root that container Dialog.Triggers elsewhere */ export function BookingDetailsModal({ roomNumber, + reservations, onSubmit, }: BookingDetailsModalProps) { return ( @@ -32,7 +35,7 @@ export function BookingDetailsModal({ Provide details for your reservation - + ); } @@ -48,7 +51,7 @@ const BottomRightBox = styled(Box)` right: 0; `; -function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { +function BookingForm({ roomNumber, reservations, onSubmit }: BookingFormProps) { const [email, setEmail] = useState(""); const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([ null, @@ -57,6 +60,15 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { const [focusedInput, setFocusedInput] = useState(null); const showProcessingToast = useShowInfoToast("Processing booking..."); const showNoInfoToast = useShowInfoToast("Missing email or dates."); + function isDateBlocked(date: Date) { + const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const day = toDay(date); + return (reservations ?? []).some(({ start: s, end: e }) => { + const start = toDay(new Date(s)); + const end = toDay(new Date(e)); + return day >= start && day <= end; + }); + } function handleSubmit(evt: React.MouseEvent) { if (!email || !dateRange[0] || !dateRange[1]) { @@ -119,6 +131,7 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { focusedInput={focusedInput} onFocusChange={setFocusedInput} showResetDates={false} + isDateBlocked={isDateBlocked} /> diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index dd558ba..6d32b7d 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useShowSuccessToast, useShowErrorToast } from "../utils/toasts"; import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; -import { bookRoom, BookingError, NewReservation, useGetRooms } from "./api"; +import { bookRoom, BookingError, NewReservation, useGetRoomReservations, useGetRooms } from "./api"; import { LoadingCard } from "../components/LoadingCard"; import { BookingDetailsModal } from "./BookingDetailsModal"; @@ -15,6 +15,7 @@ const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { export function ReservationPage() { const { isLoading, data: rooms } = useGetRooms(); const [selectedRoomNumber, setSelectedRoomNumber] = useState(""); + const { data: selectedRoomReservations } = useGetRoomReservations(selectedRoomNumber); const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); @@ -63,6 +64,7 @@ export function ReservationPage() { diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 35ddf43..61f4fdf 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -12,14 +12,14 @@ export interface NewReservation { /** The schema the API returns */ 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().email(), + start: z.string(), + end: z.string(), }); -type Reservation = z.infer; +export type Reservation = z.infer; export class BookingError extends Error { constructor(public readonly messages: string[]) { @@ -65,3 +65,17 @@ export function useGetRooms() { queryFn: () => ky.get("api/room").json().then(RoomListSchema.parseAsync), }); } + +const ReservationListSchema = ReservationSchema.array(); + +export function useGetRoomReservations(roomNumber: string) { + return useQuery({ + queryKey: ["reservations", roomNumber], + queryFn: async () => { + const raw = await ky.get(`api/reservation/room/${roomNumber}`).json(); + const list = Array.isArray(raw) ? raw : Object.values(raw as object); + return ReservationListSchema.parseAsync(list); + }, + enabled: !!roomNumber, + }); +} From 0bf1b35397d5d6285d0232ca6d91ea06bb5e6a61 Mon Sep 17 00:00:00 2001 From: Nuria Lopez Date: Fri, 17 Apr 2026 21:05:33 +0200 Subject: [PATCH 3/7] RE-001: Guest Booking - API + Added FluenValidations package to validate api requests + Added Reservation validator * Updated ReservationRepository to actually save the reservations in the DB + New endpoint in GuestController to allow adding guests through the API * Updated DB table to include a column for the surname * Updated query to incude the surname - UI * Updated bookRoom function in api to actually call the backend API + Added error handling logic to display the actual messages when the response status code is 400 + Added error toast * Updated ReservationPage to use the error toast when the response is not successful. * --- api/.gitignore | 4 +- api/Controllers/GuestController.cs | 17 ++++++++ api/Db/Setup.cs | 3 +- api/Models/Validators/ReservationValidator.cs | 41 +++++++++++++++++++ api/Program.cs | 13 +++++- api/Repositories/GuestRepository.cs | 2 +- api/Repositories/ReservationRepository.cs | 11 +++-- api/Utils/GuidTypeHandler.cs | 15 +++++++ api/api.csproj | 3 ++ ui/package-lock.json | 4 +- ui/src/components/ErrorToast.tsx | 28 +++++++++++++ ui/src/reservations/ReservationPage.tsx | 21 +++++++--- ui/src/reservations/api.ts | 27 ++++++++++-- ui/src/utils/toasts.tsx | 12 ++++++ 14 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 api/Models/Validators/ReservationValidator.cs create mode 100644 api/Utils/GuidTypeHandler.cs create mode 100644 ui/src/components/ErrorToast.tsx diff --git a/api/.gitignore b/api/.gitignore index 6b8f78f..ca2054a 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -2,4 +2,6 @@ bin/ obj/ reservations.db reservations.db-shm -reservations.db-wal \ No newline at end of file +reservations.db-wal +.DS_Store +.vs \ No newline at end of file diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index 095d570..7c753a9 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -21,5 +21,22 @@ public async Task> GetGuests() return Json(guests); } + + [HttpPost, Produces("application/json"), Route("")] + public async Task> AddGuest([FromBody] Guest guest) + { + try + { + var registeredGuest = await _repo.CreateGuest(guest); + return Created($"/guest/{guest.Email}", registeredGuest); + } + catch (Exception ex) + { + Console.WriteLine("An error occured when trying to register a new guest:"); + Console.WriteLine(ex.ToString()); + + return BadRequest("Invalid guest data"); + } + } } } diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..96f98a2 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 NOT NULL ); " ); diff --git a/api/Models/Validators/ReservationValidator.cs b/api/Models/Validators/ReservationValidator.cs new file mode 100644 index 0000000..85999fa --- /dev/null +++ b/api/Models/Validators/ReservationValidator.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using Models; + +namespace api.Models.Validators; + +public class ReservationValidator : AbstractValidator +{ + public ReservationValidator() + { + RuleFor(r => r.Start) + .NotEmpty() + .GreaterThan(DateTime.UtcNow) + .WithMessage("Time travels have not been discovered... yet"); + + RuleFor(r => r.End) + .GreaterThanOrEqualTo(r => r.Start.AddDays(1)) + .WithMessage("Minimum reservation allowed is 1 night") + .LessThanOrEqualTo(r => r.Start.AddDays(30)) + .WithMessage("Maximum reservation allowed is 30 nights") + .When(r => r.Start != default); + + RuleFor(r => r.GuestEmail) + .Cascade(CascadeMode.Stop) + .EmailAddress() + .WithMessage("Email address is missing the domain") + .Matches(@"^[^@]+@[^@]+\.[^@]+$") + .WithMessage("The email domain is incomplete"); + + RuleFor(r => r.RoomNumber) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .Must(r => r[..1] != "-") + .WithMessage("Underground rooms are not allowed") + .Must(r => r[..1] != "0") + .WithMessage("Rooms must be placed on the 1st floor or above") + .Matches(@"^\d{3}$") + .WithMessage("Invalid room number. Plese enter a number between 101 and 999 avoiding 00s") + .Must(r => r[1..] != "00") + .WithMessage("Invalid door '00'"); + } +} diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..26d8b78 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,7 +1,13 @@ using System.Data; +using api.Models.Validators; +using api.Utils; +using Controllers; +using Dapper; using Db; +using FluentValidation; using Microsoft.Data.Sqlite; using Repositories; +using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -12,6 +18,7 @@ builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; + SqlMapper.AddTypeHandler(new GuidTypeHandler()); Services.AddSingleton(_ => new SqliteConnection(connectionString)); Services.AddSingleton(sp => sp.GetRequiredService()); Services.AddSingleton(); @@ -21,6 +28,10 @@ { opt.EnableEndpointRouting = false; }); + Services + .AddFluentValidationAutoValidation() + .AddValidatorsFromAssemblyContaining(); + Services.AddCors(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); @@ -43,8 +54,8 @@ } app.UsePathBase("/api") - .UseMvc() .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) + .UseMvc() .UseSwagger() .UseSwaggerUI(); } diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 54182bc..22aaa2b 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -44,7 +44,7 @@ public async Task GetGuestByEmail(string guestEmail) public Task CreateGuest(Guest newGuest) { return _db.QuerySingleAsync( - "INSERT INTO Guests(Email, Name) Values(@Email, @Name) RETURNING *", + "INSERT INTO Guests(Email, Name, Surname) Values(@Email, @Name, @Surname) RETURNING *", newGuest ); } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..39887c9 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -49,10 +49,13 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } - ); + const string query = """ + INSERT INTO Reservations (Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) + VALUES (@Id, @GuestEmail, @RoomNumber, @Start, @End, @CheckedIn, @CheckedOut) + RETURNING * + """; + + return await _db.QuerySingleAsync(query, newReservation); } public async Task DeleteReservation(Guid reservationId) diff --git a/api/Utils/GuidTypeHandler.cs b/api/Utils/GuidTypeHandler.cs new file mode 100644 index 0000000..fe0b0cb --- /dev/null +++ b/api/Utils/GuidTypeHandler.cs @@ -0,0 +1,15 @@ +using Dapper; +using System.Data; + +namespace api.Utils; + +public class GuidTypeHandler : SqlMapper.TypeHandler +{ + public override Guid Parse(object value) => Guid.Parse(value.ToString()!); + + public override void SetValue(IDbDataParameter parameter, Guid value) + { + parameter.Value = value.ToString(); + parameter.DbType = DbType.String; + } +} diff --git a/api/api.csproj b/api/api.csproj index ef55adc..fc2d64e 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -8,7 +8,10 @@ + + + diff --git a/ui/package-lock.json b/ui/package-lock.json index cfc4957..06c0e87 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "0.0.2", + "version": "0.0.3", "dependencies": { "@datepicker-react/styled": "^2.8.4", "@radix-ui/react-dialog": "^1.1.2", diff --git a/ui/src/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..dd558ba 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; -import { useShowSuccessToast } from "../utils/toasts"; +import { useShowSuccessToast, useShowErrorToast } from "../utils/toasts"; import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; -import { bookRoom, NewReservation, useGetRooms } from "./api"; +import { bookRoom, BookingError, NewReservation, useGetRooms } from "./api"; import { LoadingCard } from "../components/LoadingCard"; import { BookingDetailsModal } from "./BookingDetailsModal"; @@ -18,14 +18,25 @@ export function ReservationPage() { const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); - const showToast = useShowSuccessToast("We have received your booking!"); + const showSuccessToast = useShowSuccessToast("We have received your booking!"); + const showErrorToast = useShowErrorToast(); function onClose() { setSelectedRoomNumber(""); } - function onSubmit(booking: NewReservation) { - bookRoom(booking).then(onClose).then(showToast); + async function onSubmit(booking: NewReservation) { + try { + await bookRoom(booking); + onClose(); + showSuccessToast(); + } catch (error) { + if (error instanceof BookingError) { + error.messages.forEach((msg) => showErrorToast(msg)); + } else { + showErrorToast("Something went wrong, please try again."); + } + } } const createClickHandler = (roomNumber: string) => () => { diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..35ddf43 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { ISO8601String, toIsoStr } from "../utils/datetime"; -import ky from "ky"; +import ky, { HTTPError } from "ky"; import { z } from "zod"; export interface NewReservation { @@ -21,7 +21,13 @@ const ReservationSchema = z.object({ type Reservation = z.infer; -export function bookRoom(booking: NewReservation) { +export class BookingError extends Error { + constructor(public readonly messages: string[]) { + super(messages.join(", ")); + } +} + +export async function bookRoom(booking: NewReservation): Promise { // unwrap branded types const newReservation = { ...booking, @@ -29,8 +35,21 @@ export function bookRoom(booking: NewReservation) { End: toIsoStr(booking.End), }; - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); + try { + return await ky.post("api/reservation", { json: newReservation }).json(); + } catch (error) { + if (error instanceof HTTPError && error.response.status === 400) { + const body = await error.response.json(); + if (body && typeof body === "object" && "errors" in body) { + const messages = Object.values(body.errors as Record).flat(); + throw new BookingError(messages); + } + if (typeof body === "string") { + throw new BookingError([body]); + } + } + throw error; + } } const RoomSchema = z.object({ diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index d3358f7..16a0972 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,14 @@ export function useShowInfoToast(message: string) { [message], ); } + +export function useShowErrorToast() { + return useCallback( + (message: string) => + toast.custom( + (t) => , + DEFAULT_TOAST_OPTIONS, + ), + [], + ); +} From 743e09d137d3d5785923bbb42eceaaed7d3ae3af Mon Sep 17 00:00:00 2001 From: Nuria Lopez Date: Fri, 17 Apr 2026 22:53:14 +0200 Subject: [PATCH 4/7] RE-002:Booking Validations * Block, in the date picker, the dates already booked to prevent the new customer from selecting them * Add double check in the backend booking endpoint, if received dates collide with another reservation of that room the endpoint returns a 400. * Created endpoint to return all the reservations for a given room. --- api/Controllers/ReservationController.cs | 12 ++++++++++ api/Repositories/ReservationRepository.cs | 10 ++++++++ ui/src/reservations/BookingDetailsModal.tsx | 19 ++++++++++++--- ui/src/reservations/ReservationPage.tsx | 4 +++- ui/src/reservations/api.ts | 26 ++++++++++++++++----- 5 files changed, 61 insertions(+), 10 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..26320f1 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -37,6 +37,14 @@ public async Task> GetRoom(Guid reservationId) } } + [HttpGet, Produces("application/json"), Route("room/{roomNumber}")] + public async Task> GetRoomReservations(string roomNumber) + { + var reservations = await _repo.GetRoomReservations(roomNumber); + + return Json(reservations); + } + /// /// Create a new reservation, to generate the GUID ID on the server, send an Empty GUID (all 0s) /// @@ -55,6 +63,10 @@ [FromBody] Reservation newBooking try { + var existingReservations = await _repo.GetRoomReservations(newBooking.RoomNumber); + if(existingReservations.Any(r => newBooking.Start >= r.Start && newBooking.Start < r.End || newBooking.End > r.Start && newBooking.End <= r.End)) + return BadRequest("The room is already booked for the provided time range"); + var createdReservation = await _repo.CreateReservation(newBooking); return Created($"/reservation/${createdReservation.Id}", createdReservation); } diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 39887c9..d311977 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -47,6 +47,16 @@ public async Task GetReservation(Guid reservationId) return reservation.ToDomain(); } + public async Task> GetRoomReservations(string roomNumber) + { + var reservations = await _db.QueryAsync("SELECT * FROM Reservations WHERE RoomNumber = @roomNumber", new { roomNumber }); + + if (reservations is null) + return []; + + return reservations.Select(r => r.ToDomain()); + } + public async Task CreateReservation(Reservation newReservation) { const string query = """ diff --git a/ui/src/reservations/BookingDetailsModal.tsx b/ui/src/reservations/BookingDetailsModal.tsx index 6f1edfe..ce04869 100644 --- a/ui/src/reservations/BookingDetailsModal.tsx +++ b/ui/src/reservations/BookingDetailsModal.tsx @@ -6,23 +6,26 @@ import { OnDatesChangeProps, } from "@datepicker-react/styled"; import { Box, Button, Dialog, Separator, TextField } from "@radix-ui/themes"; -import { NewReservation } from "./api"; +import { NewReservation, Reservation } from "./api"; import { useState } from "react"; import styled from "styled-components"; interface BookingDetailsModalProps { roomNumber: string; + reservations: Reservation[] | undefined; onSubmit: (booking: NewReservation) => void; } interface BookingFormProps { roomNumber: string; + reservations: Reservation[] | undefined; onSubmit: (booking: NewReservation) => void; } /** Must be inside a Dialog.Root that container Dialog.Triggers elsewhere */ export function BookingDetailsModal({ roomNumber, + reservations, onSubmit, }: BookingDetailsModalProps) { return ( @@ -32,7 +35,7 @@ export function BookingDetailsModal({ Provide details for your reservation - + ); } @@ -48,7 +51,7 @@ const BottomRightBox = styled(Box)` right: 0; `; -function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { +function BookingForm({ roomNumber, reservations, onSubmit }: BookingFormProps) { const [email, setEmail] = useState(""); const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([ null, @@ -57,6 +60,15 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { const [focusedInput, setFocusedInput] = useState(null); const showProcessingToast = useShowInfoToast("Processing booking..."); const showNoInfoToast = useShowInfoToast("Missing email or dates."); + function isDateBlocked(date: Date) { + const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const day = toDay(date); + return (reservations ?? []).some(({ start: s, end: e }) => { + const start = toDay(new Date(s)); + const end = toDay(new Date(e)); + return day >= start && day <= end; + }); + } function handleSubmit(evt: React.MouseEvent) { if (!email || !dateRange[0] || !dateRange[1]) { @@ -119,6 +131,7 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { focusedInput={focusedInput} onFocusChange={setFocusedInput} showResetDates={false} + isDateBlocked={isDateBlocked} /> diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index dd558ba..6d32b7d 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useShowSuccessToast, useShowErrorToast } from "../utils/toasts"; import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; -import { bookRoom, BookingError, NewReservation, useGetRooms } from "./api"; +import { bookRoom, BookingError, NewReservation, useGetRoomReservations, useGetRooms } from "./api"; import { LoadingCard } from "../components/LoadingCard"; import { BookingDetailsModal } from "./BookingDetailsModal"; @@ -15,6 +15,7 @@ const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { export function ReservationPage() { const { isLoading, data: rooms } = useGetRooms(); const [selectedRoomNumber, setSelectedRoomNumber] = useState(""); + const { data: selectedRoomReservations } = useGetRoomReservations(selectedRoomNumber); const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); @@ -63,6 +64,7 @@ export function ReservationPage() { diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 35ddf43..61f4fdf 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -12,14 +12,14 @@ export interface NewReservation { /** The schema the API returns */ 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().email(), + start: z.string(), + end: z.string(), }); -type Reservation = z.infer; +export type Reservation = z.infer; export class BookingError extends Error { constructor(public readonly messages: string[]) { @@ -65,3 +65,17 @@ export function useGetRooms() { queryFn: () => ky.get("api/room").json().then(RoomListSchema.parseAsync), }); } + +const ReservationListSchema = ReservationSchema.array(); + +export function useGetRoomReservations(roomNumber: string) { + return useQuery({ + queryKey: ["reservations", roomNumber], + queryFn: async () => { + const raw = await ky.get(`api/reservation/room/${roomNumber}`).json(); + const list = Array.isArray(raw) ? raw : Object.values(raw as object); + return ReservationListSchema.parseAsync(list); + }, + enabled: !!roomNumber, + }); +} From 20504be1e6245be50fe836b82aaa3b3a3eda2952 Mon Sep 17 00:00:00 2001 From: Nuria Lopez Date: Sun, 19 Apr 2026 19:23:46 +0200 Subject: [PATCH 5/7] RE-002:Booking Validations - API * Check date collision in a DB transaction rather than by making 2 DB calls in the controller + Create new exception to throw when the previous collision happens * Ammend return type of endpoints that return a list. * Remove unnecessary null checks in ReservationRepository - UI * Always use formatted room number in ReservationPage * Memoize isDateBlocked function --- api/Controllers/ReservationController.cs | 16 ++++--- .../Errors/ReservationConflictException.cs | 8 ++++ api/Repositories/ReservationRepository.cs | 43 ++++++++++++++----- ui/src/reservations/BookingDetailsModal.tsx | 10 ++--- ui/src/reservations/ReservationPage.tsx | 2 +- 5 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 api/Models/Errors/ReservationConflictException.cs diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 26320f1..2836621 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -16,7 +16,7 @@ public ReservationController(ReservationRepository reservationRepository) } [HttpGet, Produces("application/json"), Route("")] - public async Task> GetReservations() + public async Task>> GetReservations() { var reservations = await _repo.GetReservations(); @@ -38,7 +38,7 @@ public async Task> GetRoom(Guid reservationId) } [HttpGet, Produces("application/json"), Route("room/{roomNumber}")] - public async Task> GetRoomReservations(string roomNumber) + public async Task>> GetRoomReservations(string roomNumber) { var reservations = await _repo.GetRoomReservations(roomNumber); @@ -63,12 +63,14 @@ [FromBody] Reservation newBooking try { - var existingReservations = await _repo.GetRoomReservations(newBooking.RoomNumber); - if(existingReservations.Any(r => newBooking.Start >= r.Start && newBooking.Start < r.End || newBooking.End > r.Start && newBooking.End <= r.End)) - return BadRequest("The room is already booked for the provided time range"); - var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); + return Created($"/reservation/{createdReservation.Id}", createdReservation); + } + catch (ReservationConflictException ex) + { + Console.WriteLine("A reservation conflict occurred when trying to book a reservation:"); + Console.WriteLine(ex.ToString()); + return Conflict("Invalid reservation, dates collide with another booking"); } catch (Exception ex) { diff --git a/api/Models/Errors/ReservationConflictException.cs b/api/Models/Errors/ReservationConflictException.cs new file mode 100644 index 0000000..3ad3fa2 --- /dev/null +++ b/api/Models/Errors/ReservationConflictException.cs @@ -0,0 +1,8 @@ +namespace Models.Errors +{ + public class ReservationConflictException : Exception + { + public ReservationConflictException(string message) + : base(message) { } + } +} diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index d311977..8bd036e 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -18,11 +18,6 @@ public async Task> GetReservations() { var reservations = await _db.QueryAsync("SELECT * FROM Reservations"); - if (reservations == null) - { - return []; - } - return reservations.Select(r => r.ToDomain()); } @@ -51,21 +46,47 @@ public async Task> GetRoomReservations(string roomNumbe { var reservations = await _db.QueryAsync("SELECT * FROM Reservations WHERE RoomNumber = @roomNumber", new { roomNumber }); - if (reservations is null) - return []; - return reservations.Select(r => r.ToDomain()); } public async Task CreateReservation(Reservation newReservation) { - const string query = """ + const string queryCheckOverlap = """ + SELECT COUNT(*) FROM Reservations + WHERE RoomNumber = @RoomNumber + AND Start < @End + AND End > @Start; + """; + const string queryInsert = """ INSERT INTO Reservations (Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) VALUES (@Id, @GuestEmail, @RoomNumber, @Start, @End, @CheckedIn, @CheckedOut) RETURNING * """; - - return await _db.QuerySingleAsync(query, newReservation); + + _db.Open(); + using var transaction = _db.BeginTransaction(); + try + { + var reservationDb = new ReservationDb(newReservation); + + var overlapCount = await _db.QuerySingleAsync(queryCheckOverlap, reservationDb, transaction); + + if (overlapCount > 0) + { + throw new ReservationConflictException($"Room {newReservation.RoomNumber} is already reserved for the specified time period"); + } + + var createdReservation = await _db.QuerySingleAsync(queryInsert, reservationDb, transaction); + + transaction.Commit(); + + return createdReservation.ToDomain(); + } + catch + { + transaction.Rollback(); + throw; + } } public async Task DeleteReservation(Guid reservationId) diff --git a/ui/src/reservations/BookingDetailsModal.tsx b/ui/src/reservations/BookingDetailsModal.tsx index ce04869..09c27ed 100644 --- a/ui/src/reservations/BookingDetailsModal.tsx +++ b/ui/src/reservations/BookingDetailsModal.tsx @@ -5,9 +5,9 @@ import { FocusedInput, OnDatesChangeProps, } from "@datepicker-react/styled"; -import { Box, Button, Dialog, Separator, TextField } from "@radix-ui/themes"; +import { Box, Button, Dialog, Separator, Text, TextField } from "@radix-ui/themes"; import { NewReservation, Reservation } from "./api"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import styled from "styled-components"; interface BookingDetailsModalProps { @@ -60,15 +60,15 @@ function BookingForm({ roomNumber, reservations, onSubmit }: BookingFormProps) { const [focusedInput, setFocusedInput] = useState(null); const showProcessingToast = useShowInfoToast("Processing booking..."); const showNoInfoToast = useShowInfoToast("Missing email or dates."); - function isDateBlocked(date: Date) { + const isDateBlocked = useCallback((date: Date) => { const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); const day = toDay(date); return (reservations ?? []).some(({ start: s, end: e }) => { const start = toDay(new Date(s)); const end = toDay(new Date(e)); - return day >= start && day <= end; + return day >= start && day < end; }); - } + }, [reservations]); function handleSubmit(evt: React.MouseEvent) { if (!email || !dateRange[0] || !dateRange[1]) { diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 6d32b7d..cf4e821 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -15,9 +15,9 @@ const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { export function ReservationPage() { const { isLoading, data: rooms } = useGetRooms(); const [selectedRoomNumber, setSelectedRoomNumber] = useState(""); - const { data: selectedRoomReservations } = useGetRoomReservations(selectedRoomNumber); const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); + const { data: selectedRoomReservations } = useGetRoomReservations(selectedRoomNumber); const showSuccessToast = useShowSuccessToast("We have received your booking!"); const showErrorToast = useShowErrorToast(); From aa3f7bddb8bdc8932c9541a6de3648b3d4ecdb0f Mon Sep 17 00:00:00 2001 From: Nuria Lopez Date: Sun, 19 Apr 2026 23:36:28 +0200 Subject: [PATCH 6/7] RE-003: Staff login - API + Added endpoint in ReservationController to retrieve upcoming reservations * Replaced Console.WriteLine with ILogger methods + Implemented authorisation and authentication in StaffController using Claims * Configured Cookie authentication in Program * Fixed some typos and messages from previous commits. -UI + Added functions to retrieve upcoming reservations to the Reservations api + Created staff page to display upcoming reservations + Created Staff api to handle login --- api/Controllers/GuestController.cs | 2 +- api/Controllers/ReservationController.cs | 19 +++-- api/Controllers/StaffController.cs | 57 ++++--------- api/Models/Validators/ReservationValidator.cs | 6 +- api/Program.cs | 24 +++++- api/Repositories/ReservationRepository.cs | 14 ++++ ui/src/LandingPage.tsx | 42 ++++------ ui/src/reservations/api.ts | 14 +++- ui/src/router.tsx | 6 ++ ui/src/staff/StaffDashboardPage.tsx | 78 +++++++++++++++++ ui/src/staff/StaffLoginModal.tsx | 84 +++++++++++++++++++ ui/src/staff/api.ts | 22 +++++ 12 files changed, 288 insertions(+), 80 deletions(-) create mode 100644 ui/src/staff/StaffDashboardPage.tsx create mode 100644 ui/src/staff/StaffLoginModal.tsx create mode 100644 ui/src/staff/api.ts diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index 7c753a9..ab0d37e 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -28,7 +28,7 @@ public async Task> AddGuest([FromBody] Guest guest) try { var registeredGuest = await _repo.CreateGuest(guest); - return Created($"/guest/{guest.Email}", registeredGuest); + return Created($"/guest/{registeredGuest.Email}", registeredGuest); } catch (Exception ex) { diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 2836621..b4bda09 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; @@ -9,10 +10,12 @@ namespace Controllers public class ReservationController : Controller { private ReservationRepository _repo { get; set; } + private ILogger Logger { get; set; } - public ReservationController(ReservationRepository reservationRepository) + public ReservationController(ReservationRepository reservationRepository, ILogger logger) { _repo = reservationRepository; + Logger = logger; } [HttpGet, Produces("application/json"), Route("")] @@ -45,6 +48,13 @@ public async Task>> GetRoomReservations(st return Json(reservations); } + [HttpGet, Produces("application/json"), Route("upcoming"), Authorize] + public async Task>> GetUpcomingReservations() + { + var reservations = await _repo.GetUpcomingReservations(); + return Json(reservations); + } + /// /// Create a new reservation, to generate the GUID ID on the server, send an Empty GUID (all 0s) /// @@ -68,15 +78,12 @@ [FromBody] Reservation newBooking } catch (ReservationConflictException ex) { - Console.WriteLine("A reservation conflict occurred when trying to book a reservation:"); - Console.WriteLine(ex.ToString()); + Logger.LogWarning(ex, "A reservation conflict occurred when trying to book a reservation"); return Conflict("Invalid reservation, dates collide with another booking"); } catch (Exception ex) { - Console.WriteLine("An error occured when trying to book a reservation:"); - Console.WriteLine(ex.ToString()); - + Logger.LogError(ex, "An error occured when trying to book a reservation"); return BadRequest("Invalid reservation"); } } diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..11adab0 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,4 +1,7 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace Controllers { @@ -6,63 +9,33 @@ namespace Controllers public class StaffController : Controller { private IConfiguration Config { get; set; } + private ILogger Logger { get; set; } - public StaffController(IConfiguration config) + public StaffController(IConfiguration config, ILogger logger) { Config = config; + Logger = logger; } - /// - /// 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) - { - // TODO explore UseAuthentication - request.Cookies.TryGetValue("access", out string? accessValue); - - if (accessValue == null || accessValue == "0") - { - result = StatusCode(403); - return true; - } - - result = null; - return false; - } - - [HttpGet, Route("login")] - public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) + [HttpPost, Route("login")] + public async Task CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) { var configuredSecret = Config.GetValue("staffAccessCode"); if (configuredSecret != accessCode) { - // don't set cookie, don't indicate anything - return NoContent(); + Logger.LogWarning("Unauthorised access attempt"); + return Unauthorized(); } - 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 - } - ); + + var claimsIdentity = new ClaimsIdentity("StaffCookies"); + await HttpContext.SignInAsync("StaffCookies", new ClaimsPrincipal(claimsIdentity)); + return NoContent(); } - [HttpGet, Route("check")] + [HttpGet, Route("check"), Authorize] public IActionResult CheckCookie() { - if (IsNotStaff(Request, out IActionResult? result)) - { - return result!; - } - return Ok("Authorized"); } } diff --git a/api/Models/Validators/ReservationValidator.cs b/api/Models/Validators/ReservationValidator.cs index 85999fa..2e49928 100644 --- a/api/Models/Validators/ReservationValidator.cs +++ b/api/Models/Validators/ReservationValidator.cs @@ -9,7 +9,7 @@ public ReservationValidator() { RuleFor(r => r.Start) .NotEmpty() - .GreaterThan(DateTime.UtcNow) + .GreaterThan(DateTime.UtcNow.Date) .WithMessage("Time travels have not been discovered... yet"); RuleFor(r => r.End) @@ -22,7 +22,7 @@ public ReservationValidator() RuleFor(r => r.GuestEmail) .Cascade(CascadeMode.Stop) .EmailAddress() - .WithMessage("Email address is missing the domain") + .WithMessage("Invalid email address") .Matches(@"^[^@]+@[^@]+\.[^@]+$") .WithMessage("The email domain is incomplete"); @@ -34,7 +34,7 @@ public ReservationValidator() .Must(r => r[..1] != "0") .WithMessage("Rooms must be placed on the 1st floor or above") .Matches(@"^\d{3}$") - .WithMessage("Invalid room number. Plese enter a number between 101 and 999 avoiding 00s") + .WithMessage("Invalid room number. Please enter a number between 101 and 999 avoiding 00s") .Must(r => r[1..] != "00") .WithMessage("Invalid door '00'"); } diff --git a/api/Program.cs b/api/Program.cs index 26d8b78..6b597cd 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,7 +1,6 @@ using System.Data; using api.Models.Validators; using api.Utils; -using Controllers; using Dapper; using Db; using FluentValidation; @@ -32,6 +31,27 @@ .AddFluentValidationAutoValidation() .AddValidatorsFromAssemblyContaining(); + Services.AddAuthentication("StaffCookies") + .AddCookie("StaffCookies", options => + { + options.Cookie.Name = "access"; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.IsEssential = true; + 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.AddCors(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); @@ -55,6 +75,8 @@ app.UsePathBase("/api") .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) + .UseAuthentication() + .UseAuthorization() .UseMvc() .UseSwagger() .UseSwaggerUI(); diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 8bd036e..efe97d1 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -49,6 +49,20 @@ public async Task> GetRoomReservations(string roomNumbe return reservations.Select(r => r.ToDomain()); } + public async Task> GetUpcomingReservations() + { + var today = DateTime.UtcNow.Date; + var query = """ + SELECT * + FROM Reservations + WHERE Start >= @today + ORDER BY Start + """; + var reservations = await _db.QueryAsync(query, new {today}); + + return reservations.Select(r => r.ToDomain()); + } + public async Task CreateReservation(Reservation newReservation) { const string queryCheckOverlap = """ diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..5b8e00e 100644 --- a/ui/src/LandingPage.tsx +++ b/ui/src/LandingPage.tsx @@ -1,39 +1,31 @@ -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"); -} +import { StaffLoginModal } from "./staff/StaffLoginModal"; export function LandingPage() { return ( - - - - Key on wood board - - Login - - + + + Key on wood board + + Login + + } + /> Clean Bed Reserve diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 61f4fdf..524ed2d 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -68,13 +68,23 @@ export function useGetRooms() { const ReservationListSchema = ReservationSchema.array(); +export function useGetUpcomingReservations(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: ["reservations", "upcoming"], + queryFn: async () => { + const raw = await ky.get("api/reservation/upcoming").json(); + return ReservationListSchema.parseAsync(raw); + }, + enabled: options?.enabled, + }); +} + export function useGetRoomReservations(roomNumber: string) { return useQuery({ queryKey: ["reservations", roomNumber], queryFn: async () => { const raw = await ky.get(`api/reservation/room/${roomNumber}`).json(); - const list = Array.isArray(raw) ? raw : Object.values(raw as object); - return ReservationListSchema.parseAsync(list); + return ReservationListSchema.parseAsync(raw); }, enabled: !!roomNumber, }); diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e3020bd..1eaa8ea 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 { StaffDashboardPage } from "./staff/StaffDashboardPage"; const rootRoute = createRootRoute({ component: Layout, @@ -26,6 +27,11 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + 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..95ea3ca --- /dev/null +++ b/ui/src/staff/StaffDashboardPage.tsx @@ -0,0 +1,78 @@ +import { useEffect } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { Badge, Flex, Heading, Section, Table, Text } from "@radix-ui/themes"; +import { useGetUpcomingReservations } from "../reservations/api"; +import { useStaffAuthCheck } from "./api"; +import { LoadingCard } from "../components/LoadingCard"; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function isToday(iso: string) { + return new Date(iso).toDateString() === new Date().toDateString(); +} + +export function StaffDashboardPage() { + const navigate = useNavigate(); + const { data: isAuthorized, isLoading: authLoading } = useStaffAuthCheck(); + const { data: reservations, isLoading: reservationsLoading } = useGetUpcomingReservations({ enabled: isAuthorized === true }); + + useEffect(() => { + if (!authLoading && isAuthorized === false) { + navigate({ to: "/" }); + } + }, [authLoading, isAuthorized, navigate]); + + if (authLoading) return ; + + return ( +
+ + Upcoming Reservations + + + {reservationsLoading && } + + {!reservationsLoading && reservations?.length === 0 && ( + No upcoming reservations. + )} + + {reservations && reservations.length > 0 && ( + + + + Room + Guest Email + Check-in + Check-out + + + + {reservations.map((r) => ( + + + {r.roomNumber} + + {r.guestEmail} + + + {formatDate(r.start)} + {isToday(r.start) && ( + Today + )} + + + {formatDate(r.end)} + + ))} + + + )} +
+ ); +} diff --git a/ui/src/staff/StaffLoginModal.tsx b/ui/src/staff/StaffLoginModal.tsx new file mode 100644 index 0000000..3b7ec0d --- /dev/null +++ b/ui/src/staff/StaffLoginModal.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { Box, Button, Dialog, Separator, Text, TextField } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { staffLogin } from "./api"; + +interface StaffLoginModalProps { + trigger: React.ReactNode; +} + +export function StaffLoginModal({ trigger }: StaffLoginModalProps) { + const [accessCode, setAccessCode] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + setError(""); + try { + await staffLogin(accessCode); + await queryClient.refetchQueries({ queryKey: ["staff", "auth"] }); + navigate({ to: "/staff" }); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setIsLoading(false); + } + } + + function handleOpenChange(open: boolean) { + if (!open) { + setAccessCode(""); + setError(""); + } + } + + return ( + + {trigger} + + Staff Login + Enter the shared access code to continue. + +
+ + Access Code + + { + setAccessCode(e.target.value); + setError(""); + }} + mt="1" + mb={error ? "1" : "4"} + color={error ? "red" : undefined} + required + /> + {error && ( + + {error} + + )} + + + + + + + +
+
+ ); +} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts new file mode 100644 index 0000000..62b0761 --- /dev/null +++ b/ui/src/staff/api.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import ky from "ky"; + +export async function staffLogin(accessCode: string): Promise { + const response = await ky.post("api/staff/login", { + headers: { "X-Staff-Code": accessCode }, + throwHttpErrors: false, + }); + if (response.status === 401) throw new Error("Invalid access code"); + if (!response.ok) throw new Error("Login failed"); +} + +export function useStaffAuthCheck() { + return useQuery({ + queryKey: ["staff", "auth"], + queryFn: async () => { + const response = await ky.get("api/staff/check", { throwHttpErrors: false }); + return response.ok; + }, + retry: false, + }); +} From 9bfe74acc1d34bc1d736e05bad2625d4dca9766e Mon Sep 17 00:00:00 2001 From: Nuria Lopez Date: Mon, 20 Apr 2026 19:45:44 +0200 Subject: [PATCH 7/7] Copilot suggestions - API * Fixed typos * Replaced Console.Writelilne with ILogger * Use dynamic comparisson in ReservationValidator (I applied the change because the outcome will be the same, but it should not be necessary according to the FluentValidation repo) * Registered all repositories and DB as Scoped to prevent headaches. - UI * Expand error handling --- api/Controllers/GuestController.cs | 8 +++++--- api/Controllers/ReservationController.cs | 2 +- api/{Utils => Db}/GuidTypeHandler.cs | 2 +- api/Db/Setup.cs | 5 ++++- api/Models/Validators/ReservationValidator.cs | 2 +- api/Program.cs | 13 +++++-------- api/Repositories/GuestRepository.cs | 4 ++-- ui/src/reservations/api.ts | 10 ++++++++-- 8 files changed, 27 insertions(+), 19 deletions(-) rename api/{Utils => Db}/GuidTypeHandler.cs (94%) diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index ab0d37e..b8eb809 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Models; +using Models.Errors; using Repositories; namespace Controllers @@ -8,10 +9,12 @@ namespace Controllers public class GuestController : Controller { private GuestRepository _repo; + private ILogger Logger { get; set; } - public GuestController(GuestRepository guestRepository) + public GuestController(GuestRepository guestRepository, ILogger logger) { _repo = guestRepository; + Logger = logger; } [HttpGet, Produces("application/json"), Route("")] @@ -32,8 +35,7 @@ public async Task> AddGuest([FromBody] Guest guest) } catch (Exception ex) { - Console.WriteLine("An error occured when trying to register a new guest:"); - Console.WriteLine(ex.ToString()); + Logger.LogError(ex, "An error occurred when trying to register a new guest"); return BadRequest("Invalid guest data"); } diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index b4bda09..74546bf 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -83,7 +83,7 @@ [FromBody] Reservation newBooking } catch (Exception ex) { - Logger.LogError(ex, "An error occured when trying to book a reservation"); + Logger.LogError(ex, "An error occurred when trying to book a reservation"); return BadRequest("Invalid reservation"); } } diff --git a/api/Utils/GuidTypeHandler.cs b/api/Db/GuidTypeHandler.cs similarity index 94% rename from api/Utils/GuidTypeHandler.cs rename to api/Db/GuidTypeHandler.cs index fe0b0cb..2e9186a 100644 --- a/api/Utils/GuidTypeHandler.cs +++ b/api/Db/GuidTypeHandler.cs @@ -1,7 +1,7 @@ using Dapper; using System.Data; -namespace api.Utils; +namespace Db; public class GuidTypeHandler : SqlMapper.TypeHandler { diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 96f98a2..996715d 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -11,6 +11,9 @@ public static class Setup /// public static async void EnsureDb(IServiceScope scope) { + // Register custom handlers + SqlMapper.AddTypeHandler(new GuidTypeHandler()); + using var db = scope.ServiceProvider.GetRequiredService(); // SQLite WAL (write-ahead log) go brrrr @@ -23,7 +26,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.Surname)} TEXT NOT NULL + {nameof(Guest.Surname)} TEXT NULL ); " ); diff --git a/api/Models/Validators/ReservationValidator.cs b/api/Models/Validators/ReservationValidator.cs index 2e49928..e5d1cc2 100644 --- a/api/Models/Validators/ReservationValidator.cs +++ b/api/Models/Validators/ReservationValidator.cs @@ -9,7 +9,7 @@ public ReservationValidator() { RuleFor(r => r.Start) .NotEmpty() - .GreaterThan(DateTime.UtcNow.Date) + .GreaterThan(_ => DateTime.UtcNow.Date) .WithMessage("Time travels have not been discovered... yet"); RuleFor(r => r.End) diff --git a/api/Program.cs b/api/Program.cs index 6b597cd..e3bbe82 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,7 +1,5 @@ using System.Data; using api.Models.Validators; -using api.Utils; -using Dapper; using Db; using FluentValidation; using Microsoft.Data.Sqlite; @@ -17,12 +15,11 @@ builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; - SqlMapper.AddTypeHandler(new GuidTypeHandler()); - 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; diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 22aaa2b..3ced21d 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -41,9 +41,9 @@ public async Task GetGuestByEmail(string guestEmail) return guest; } - public Task CreateGuest(Guest newGuest) + public async Task CreateGuest(Guest newGuest) { - return _db.QuerySingleAsync( + return await _db.QuerySingleAsync( "INSERT INTO Guests(Email, Name, Surname) Values(@Email, @Name, @Surname) RETURNING *", newGuest ); diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 524ed2d..c4ddafe 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -38,8 +38,14 @@ export async function bookRoom(booking: NewReservation): Promise { try { return await ky.post("api/reservation", { json: newReservation }).json(); } catch (error) { - if (error instanceof HTTPError && error.response.status === 400) { - const body = await error.response.json(); + if (error instanceof HTTPError && (error.response.status === 400 || error.response.status === 409)) { + const text = await error.response.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + throw new BookingError([text]); + } if (body && typeof body === "object" && "errors" in body) { const messages = Object.values(body.errors as Record).flat(); throw new BookingError(messages);