From 18c2ead07432a37ead29355c2ddeb29ff3e88955 Mon Sep 17 00:00:00 2001 From: Illia Krauchenia Date: Sat, 28 Mar 2026 10:36:38 +0100 Subject: [PATCH 1/4] RE-OO1: implement guest room booking --- api/Controllers/ReservationController.cs | 10 +- .../Errors/InvalidReservationException.cs | 8 ++ api/Models/Room.cs | 20 +++ api/Repositories/ReservationRepository.cs | 118 +++++++++++++++++- ui/src/reservations/BookingDetailsModal.tsx | 77 +++++++++--- ui/src/reservations/ReservationPage.tsx | 37 +++++- ui/src/reservations/api.ts | 10 +- ui/src/utils/toasts.tsx | 14 +-- 8 files changed, 258 insertions(+), 36 deletions(-) create mode 100644 api/Models/Errors/InvalidReservationException.cs diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..25c69aa 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -56,7 +56,15 @@ [FromBody] Reservation newBooking try { var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); + return Created($"/reservation/{createdReservation.Id}", createdReservation); + } + catch (InvalidReservationException ex) + { + return BadRequest(ex.Message); + } + catch (NotFoundException ex) + { + return BadRequest(ex.Message); } catch (Exception ex) { diff --git a/api/Models/Errors/InvalidReservationException.cs b/api/Models/Errors/InvalidReservationException.cs new file mode 100644 index 0000000..9f86644 --- /dev/null +++ b/api/Models/Errors/InvalidReservationException.cs @@ -0,0 +1,8 @@ +namespace Models.Errors +{ + public class InvalidReservationException : Exception + { + public InvalidReservationException(string message) + : base(message) { } + } +} diff --git a/api/Models/Room.cs b/api/Models/Room.cs index cbd6536..e7e773d 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -29,8 +29,28 @@ public static string FormatRoomNumber(int number) return number.ToString().PadLeft(3, '0'); } + public static bool IsValidRoomNumber(string roomNumber) + { + if (roomNumber.Length != 3) + { + return false; + } + + if (!roomNumber.All(char.IsAsciiDigit)) + { + return false; + } + + return roomNumber[1] != '0' || roomNumber[2] != '0'; + } + public static int ConvertRoomNumberToInt(string roomNumber) { + if (!IsValidRoomNumber(roomNumber)) + { + throw new InvalidRoomNumber(roomNumber); + } + var success = int.TryParse(roomNumber, out int roomNumberInt); if (!success) { diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..cf8b975 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -2,16 +2,25 @@ using Dapper; using Models; using Models.Errors; +using System.Globalization; namespace Repositories { public class ReservationRepository { private IDbConnection _db { get; set; } - - public ReservationRepository(IDbConnection db) + private RoomRepository _roomRepository { get; set; } + private GuestRepository _guestRepository { get; set; } + + public ReservationRepository( + IDbConnection db, + RoomRepository roomRepository, + GuestRepository guestRepository + ) { _db = db; + _roomRepository = roomRepository; + _guestRepository = guestRepository; } public async Task> GetReservations() @@ -49,10 +58,40 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } + newReservation.GuestEmail = newReservation.GuestEmail.Trim(); + newReservation.RoomNumber = newReservation.RoomNumber.Trim(); + + ValidateReservation(newReservation); + + await _roomRepository.GetRoom(newReservation.RoomNumber); + await EnsureGuestExists(newReservation.GuestEmail); + + var createdReservation = await _db.QuerySingleAsync( + @" + INSERT INTO Reservations( + Id, + GuestEmail, + RoomNumber, + Start, + End, + CheckedIn, + CheckedOut + ) + VALUES( + @Id, + @GuestEmail, + @RoomNumber, + @Start, + @End, + @CheckedIn, + @CheckedOut + ) + RETURNING *; + ", + new ReservationDb(newReservation) ); + + return createdReservation.ToDomain(); } public async Task DeleteReservation(Guid reservationId) @@ -109,5 +148,74 @@ public Reservation ToDomain() }; } } + + private static void ValidateReservation(Reservation reservation) + { + if (!Room.IsValidRoomNumber(reservation.RoomNumber)) + { + throw new InvalidReservationException("Room number must use the ### format."); + } + + if (!LooksLikeEmailWithDomain(reservation.GuestEmail)) + { + throw new InvalidReservationException("Email must include a domain."); + } + + if (reservation.Start >= reservation.End) + { + throw new InvalidReservationException("Start date must be before the end date."); + } + + var duration = reservation.End - reservation.Start; + if (duration < TimeSpan.FromDays(1)) + { + throw new InvalidReservationException("Reservation duration must be at least 1 day."); + } + + if (duration > TimeSpan.FromDays(30)) + { + throw new InvalidReservationException("Reservation duration cannot exceed 30 days."); + } + } + + private static bool LooksLikeEmailWithDomain(string guestEmail) + { + var trimmedEmail = guestEmail.Trim(); + var parts = trimmedEmail.Split('@', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) + { + return false; + } + + return parts[1].Contains('.') && parts[1].Length > 2; + } + + private async Task EnsureGuestExists(string guestEmail) + { + try + { + await _guestRepository.GetGuestByEmail(guestEmail); + } + catch (NotFoundException) + { + await _guestRepository.CreateGuest( + new Guest { Email = guestEmail, Name = BuildGuestName(guestEmail) } + ); + } + } + + private static string BuildGuestName(string guestEmail) + { + var localPart = guestEmail.Split('@', 2)[0]; + var spacedName = localPart.Replace('.', ' ').Replace('_', ' ').Replace('-', ' '); + var candidateName = spacedName.Trim(); + + if (string.IsNullOrWhiteSpace(candidateName)) + { + return "Guest"; + } + + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(candidateName); + } } } diff --git a/ui/src/reservations/BookingDetailsModal.tsx b/ui/src/reservations/BookingDetailsModal.tsx index 6f1edfe..894fc17 100644 --- a/ui/src/reservations/BookingDetailsModal.tsx +++ b/ui/src/reservations/BookingDetailsModal.tsx @@ -12,12 +12,12 @@ import styled from "styled-components"; interface BookingDetailsModalProps { roomNumber: string; - onSubmit: (booking: NewReservation) => void; + onSubmit: (booking: NewReservation) => Promise; } interface BookingFormProps { roomNumber: string; - onSubmit: (booking: NewReservation) => void; + onSubmit: (booking: NewReservation) => Promise; } /** Must be inside a Dialog.Root that container Dialog.Triggers elsewhere */ @@ -54,25 +54,50 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { null, null, ]); + const [isSubmitting, setIsSubmitting] = useState(false); const [focusedInput, setFocusedInput] = useState(null); const showProcessingToast = useShowInfoToast("Processing booking..."); const showNoInfoToast = useShowInfoToast("Missing email or dates."); + const showInvalidEmailToast = useShowInfoToast( + "Enter an email with a domain.", + ); + const showInvalidDateToast = useShowInfoToast( + "Choose a stay from 1 to 30 days.", + ); - function handleSubmit(evt: React.MouseEvent) { + async function handleSubmit(evt: React.MouseEvent) { if (!email || !dateRange[0] || !dateRange[1]) { showNoInfoToast(); evt.preventDefault(); - return false; + return; + } + + if (!looksLikeEmailWithDomain(email)) { + showInvalidEmailToast(); + evt.preventDefault(); + return; + } + + const durationMs = dateRange[1].getTime() - dateRange[0].getTime(); + if (durationMs < ONE_DAY_MS || durationMs > MAX_DURATION_MS) { + showInvalidDateToast(); + evt.preventDefault(); + return; } showProcessingToast(); - onSubmit({ - RoomNumber: roomNumber, - GuestEmail: email, - Start: fromDateStringToIso(dateRange[0]), - End: fromDateStringToIso(dateRange[1]), - }); - return true; + setIsSubmitting(true); + + try { + await onSubmit({ + RoomNumber: roomNumber, + GuestEmail: email.trim(), + Start: fromDateStringToIso(dateRange[0]), + End: fromDateStringToIso(dateRange[1]), + }); + } finally { + setIsSubmitting(false); + } } function handleDateChange(data: OnDatesChangeProps) { @@ -101,6 +126,7 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { type="email" size="3" mb="4" + disabled={isSubmitting} > Email @@ -121,12 +147,31 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { showResetDates={false} /> - - - + ); } + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const MAX_DURATION_MS = ONE_DAY_MS * 30; + +function looksLikeEmailWithDomain(email: string) { + const [localPart, domain, ...rest] = email.trim().split("@"); + + return ( + localPart.length > 0 && + domain !== undefined && + domain.includes(".") && + rest.length === 0 + ); +} diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 06a0036..689299e 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,10 +1,15 @@ import { useState } from "react"; -import { useShowSuccessToast } from "../utils/toasts"; +import { + showInfoToast, + useShowInfoToast, + useShowSuccessToast, +} 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", @@ -19,13 +24,30 @@ export function ReservationPage() { const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); const showToast = useShowSuccessToast("We have received your booking!"); + const showBookingErrorToast = useShowInfoToast( + "We could not complete your booking.", + ); function onClose() { setSelectedRoomNumber(""); } - function onSubmit(booking: NewReservation) { - bookRoom(booking).then(onClose).then(showToast); + async function onSubmit(booking: NewReservation) { + try { + await bookRoom(booking); + onClose(); + showToast(); + } catch (error) { + if (error instanceof HTTPError) { + const errorMessage = await error.response.text(); + if (errorMessage) { + showInfoToast(errorMessage); + return; + } + } + + showBookingErrorToast(); + } } const createClickHandler = (roomNumber: string) => () => { @@ -39,7 +61,14 @@ export function ReservationPage() { - + 0} + onOpenChange={(open) => { + if (!open) { + onClose(); + } + }} + > {isLoading && } {rooms?.map((room) => ( ; export function bookRoom(booking: NewReservation) { - // unwrap branded types const newReservation = { + Id: "00000000-0000-0000-0000-000000000000", ...booking, Start: toIsoStr(booking.Start), End: toIsoStr(booking.End), + CheckedIn: false, + CheckedOut: false, }; - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); + return ky + .post("api/reservation", { json: newReservation }) + .json() + .then(ReservationSchema.parseAsync); } const RoomSchema = z.object({ diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index d3358f7..ce4d91f 100644 --- a/ui/src/utils/toasts.tsx +++ b/ui/src/utils/toasts.tsx @@ -21,12 +21,12 @@ export function useShowSuccessToast(message: string) { } export function useShowInfoToast(message: string) { - return useCallback( - () => - toast.custom( - (t) => , - DEFAULT_TOAST_OPTIONS, - ), - [message], + return useCallback(() => showInfoToast(message), [message]); +} + +export function showInfoToast(message: string) { + return toast.custom( + (t) => , + DEFAULT_TOAST_OPTIONS, ); } From 228ab23d3df5daff24de3fce556583c67fcd9248 Mon Sep 17 00:00:00 2001 From: Illia Krauchenia Date: Sat, 28 Mar 2026 11:02:15 +0100 Subject: [PATCH 2/4] RE-OO2: prevent double bookings --- api/Controllers/ReservationController.cs | 4 +++ .../Errors/ReservationConflictException.cs | 8 +++++ api/Repositories/ReservationRepository.cs | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 api/Models/Errors/ReservationConflictException.cs diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 25c69aa..c5f4fff 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -58,6 +58,10 @@ [FromBody] Reservation newBooking var createdReservation = await _repo.CreateReservation(newBooking); return Created($"/reservation/{createdReservation.Id}", createdReservation); } + catch (ReservationConflictException ex) + { + return Conflict(ex.Message); + } catch (InvalidReservationException ex) { return BadRequest(ex.Message); 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 cf8b975..b73219f 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -64,6 +64,7 @@ public async Task CreateReservation(Reservation newReservation) ValidateReservation(newReservation); await _roomRepository.GetRoom(newReservation.RoomNumber); + await EnsureNoReservationConflict(newReservation); await EnsureGuestExists(newReservation.GuestEmail); var createdReservation = await _db.QuerySingleAsync( @@ -204,6 +205,34 @@ await _guestRepository.CreateGuest( } } + private async Task EnsureNoReservationConflict(Reservation newReservation) + { + var roomNumber = Room.ConvertRoomNumberToInt(newReservation.RoomNumber); + var conflictingReservation = await _db.QueryFirstOrDefaultAsync( + @" + SELECT Id + FROM Reservations + WHERE RoomNumber = @roomNumber + AND Start < @reservationEnd + AND End > @reservationStart + LIMIT 1; + ", + new + { + roomNumber, + reservationStart = newReservation.Start, + reservationEnd = newReservation.End + } + ); + + if (!string.IsNullOrEmpty(conflictingReservation)) + { + throw new ReservationConflictException( + $"Room {newReservation.RoomNumber} is already booked for the selected dates." + ); + } + } + private static string BuildGuestName(string guestEmail) { var localPart = guestEmail.Split('@', 2)[0]; From ce615887a39fb7d1c5b87899fdae4fbf03fb2dfc Mon Sep 17 00:00:00 2001 From: Illia Krauchenia Date: Sat, 28 Mar 2026 12:57:44 +0100 Subject: [PATCH 3/4] RE-OO3: implement staff reservation access --- api/Controllers/ReservationController.cs | 14 +- api/Controllers/StaffAccessController.cs | 27 +++ api/Controllers/StaffController.cs | 25 +-- api/Repositories/ReservationRepository.cs | 21 ++ ui/src/LandingPage.tsx | 11 +- ui/src/router.tsx | 6 + ui/src/staff/StaffPage.tsx | 238 ++++++++++++++++++++++ ui/src/staff/api.ts | 52 +++++ 8 files changed, 362 insertions(+), 32 deletions(-) create mode 100644 api/Controllers/StaffAccessController.cs create mode 100644 ui/src/staff/StaffPage.tsx create mode 100644 ui/src/staff/api.ts diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index c5f4fff..a44775a 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -6,7 +6,7 @@ namespace Controllers { [Tags("Reservations"), Route("reservation")] - public class ReservationController : Controller + public class ReservationController : StaffAccessController { private ReservationRepository _repo { get; set; } @@ -18,7 +18,12 @@ public ReservationController(ReservationRepository reservationRepository) [HttpGet, Produces("application/json"), Route("")] public async Task> GetReservations() { - var reservations = await _repo.GetReservations(); + if (IsNotStaff(Request, out IActionResult? result)) + { + return result!; + } + + var reservations = await _repo.GetUpcomingReservations(); return Json(reservations); } @@ -26,6 +31,11 @@ public async Task> GetReservations() [HttpGet, Produces("application/json"), Route("{reservationId}")] public async Task> GetRoom(Guid reservationId) { + if (IsNotStaff(Request, out IActionResult? result)) + { + return result!; + } + try { var reservation = await _repo.GetReservation(reservationId); diff --git a/api/Controllers/StaffAccessController.cs b/api/Controllers/StaffAccessController.cs new file mode 100644 index 0000000..cf99c4f --- /dev/null +++ b/api/Controllers/StaffAccessController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Controllers +{ + public abstract class StaffAccessController : Controller + { + protected const string StaffAccessCookieName = "access"; + + /// + /// Checks if the request is from a staff member, if not returns true and a 403 result + /// + protected bool IsNotStaff(HttpRequest request, out IActionResult? result) + { + // TODO explore UseAuthentication + request.Cookies.TryGetValue(StaffAccessCookieName, out string? accessValue); + + if (accessValue == null || accessValue == "0") + { + result = StatusCode(403); + return true; + } + + result = null; + return false; + } + } +} diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..7570c94 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -3,7 +3,7 @@ namespace Controllers { [Route("staff")] - public class StaffController : Controller + public class StaffController : StaffAccessController { private IConfiguration Config { get; set; } @@ -12,25 +12,6 @@ public StaffController(IConfiguration config) Config = config; } - /// - /// Checks if the request is from a staff member, if not returns true and a 403 result - /// - /// - private bool IsNotStaff(HttpRequest request, out IActionResult? result) - { - // 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) { @@ -41,7 +22,7 @@ public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string access return NoContent(); } Response.Cookies.Append( - "access", + StaffAccessCookieName, "1", new CookieOptions // TODO evaluate cookie options & auth mechanism for best security practices @@ -49,7 +30,7 @@ public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string access IsEssential = true, SameSite = SameSiteMode.Strict, HttpOnly = true, - Secure = true + Secure = Request.IsHttps } ); return NoContent(); diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index b73219f..5bf60a1 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -35,6 +35,27 @@ public async Task> GetReservations() return reservations.Select(r => r.ToDomain()); } + public async Task> GetUpcomingReservations() + { + var today = DateTime.Today; + var reservations = await _db.QueryAsync( + @" + SELECT * + FROM Reservations + WHERE End > @today + ORDER BY Start ASC, RoomNumber ASC; + ", + new { today } + ); + + if (reservations == null) + { + return []; + } + + return reservations.Select(r => r.ToDomain()); + } + /// /// Find a reservation by its Guid ID, throwing if not found /// diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..8d7ff1e 100644 --- a/ui/src/LandingPage.tsx +++ b/ui/src/LandingPage.tsx @@ -1,16 +1,11 @@ -import { Box, Card, Flex, Heading, Inset } from "@radix-ui/themes"; +import { Card, Flex, Heading, Inset } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; -function handleLogin() { - // TODO have a staff view - alert("Not implemented"); -} - export function LandingPage() { return ( - + Login - + diff --git a/ui/src/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..d80ecc4 --- /dev/null +++ b/ui/src/staff/StaffPage.tsx @@ -0,0 +1,238 @@ +import { useEffect, useState } from "react"; +import styled from "styled-components"; +import { + Box, + Button, + Card, + Flex, + Grid, + Heading, + Section, + Text, + TextField, +} from "@radix-ui/themes"; +import { LoadingCard } from "../components/LoadingCard"; +import { useShowInfoToast, useShowSuccessToast } from "../utils/toasts"; +import { + checkStaffSession, + loginStaff, + StaffReservation, + useGetStaffReservations, +} from "./api"; + +const RESERVATION_GRID_COLS: React.ComponentProps["columns"] = { + sm: "1", + md: "2", +}; + +const DimSlot = styled(TextField.Slot)` + background-color: var(--gray-4); + margin-right: 8px; +`; + +export function StaffPage() { + const [accessCode, setAccessCode] = useState(""); + const [isAuthorized, setIsAuthorized] = useState(false); + const [isCheckingAccess, setIsCheckingAccess] = useState(true); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const showMissingCodeToast = useShowInfoToast( + "Enter the shared access code.", + ); + const showInvalidCodeToast = useShowInfoToast( + "That access code is incorrect.", + ); + const showStaffWelcomeToast = useShowSuccessToast("Welcome back."); + const { + data: reservations, + isLoading: isLoadingReservations, + isError: hasReservationsError, + } = useGetStaffReservations(isAuthorized); + + useEffect(() => { + let isCancelled = false; + + void checkStaffSession() + .then((authorized) => { + if (isCancelled) { + return; + } + + setIsAuthorized(authorized); + }) + .catch(() => { + if (isCancelled) { + return; + } + + setIsAuthorized(false); + }) + .finally(() => { + if (!isCancelled) { + setIsCheckingAccess(false); + } + }); + + return () => { + isCancelled = true; + }; + }, []); + + async function handleLogin() { + if (!accessCode.trim()) { + showMissingCodeToast(); + return; + } + + setIsLoggingIn(true); + + try { + const authorized = await loginStaff(accessCode.trim()); + setIsAuthorized(authorized); + + if (!authorized) { + showInvalidCodeToast(); + return; + } + + setAccessCode(""); + showStaffWelcomeToast(); + } finally { + setIsLoggingIn(false); + setIsCheckingAccess(false); + } + } + + if (isCheckingAccess) { + return ( +
+ + Staff + + + + +
+ ); + } + + if (!isAuthorized) { + return ( +
+ + Staff Login + + + + + Enter the shared access code to review current and upcoming + reservations. + + setAccessCode(evt.target.value)} + type="password" + size="3" + disabled={isLoggingIn} + > + + Access + + + + + + + +
+ ); + } + + return ( +
+ + Upcoming Reservations + + + {isLoadingReservations && ( + + + + )} + + {!isLoadingReservations && reservations?.length === 0 && ( + + + There are no reservations scheduled for today or later. + + + )} + + {hasReservationsError && ( + + + We could not load reservations right now. Please try again. + + + )} + + {reservations && reservations.length > 0 && ( + + {reservations.map((reservation) => ( + + ))} + + )} +
+ ); +} + +function ReservationSummaryCard({ + reservation, +}: { + reservation: StaffReservation; +}) { + return ( + + + + + Room + + #{reservation.roomNumber} + + + + Guest + + {reservation.guestEmail} + + + + Stay + + + {formatDate(reservation.start)} to {formatDate(reservation.end)} + + + + + ); +} + +function formatDate(dateValue: string) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }).format(new Date(dateValue)); +} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts new file mode 100644 index 0000000..4e79f6e --- /dev/null +++ b/ui/src/staff/api.ts @@ -0,0 +1,52 @@ +import { useQuery } from "@tanstack/react-query"; +import ky from "ky"; +import { z } from "zod"; + +const staffClient = ky.create({ + credentials: "same-origin", +}); + +const StaffReservationSchema = z.object({ + id: z.string(), + roomNumber: z.string(), + guestEmail: z.string().email(), + start: z.string(), + end: z.string(), + checkedIn: z.boolean(), + checkedOut: z.boolean(), +}); + +const StaffReservationListSchema = StaffReservationSchema.array(); + +export type StaffReservation = z.infer; + +export async function checkStaffSession() { + const response = await staffClient.get("api/staff/check", { + throwHttpErrors: false, + }); + + return response.ok; +} + +export async function loginStaff(accessCode: string) { + await staffClient.get("api/staff/login", { + headers: { + "X-Staff-Code": accessCode, + }, + }); + + return checkStaffSession(); +} + +export function useGetStaffReservations(enabled: boolean) { + return useQuery({ + queryKey: ["staff", "reservations"], + enabled, + retry: false, + queryFn: () => + staffClient + .get("api/reservation") + .json() + .then(StaffReservationListSchema.parseAsync), + }); +} From 23389ed6e7d59624f3229098f9aa2bbc35ef0ff4 Mon Sep 17 00:00:00 2001 From: Illia Krauchenia Date: Sat, 28 Mar 2026 13:43:02 +0100 Subject: [PATCH 4/4] RE-OO4: implement staff guest check in --- api/Controllers/ReservationController.cs | 34 +++- api/Controllers/StaffAccessController.cs | 2 +- api/Controllers/StaffController.cs | 2 +- api/Models/CheckInReservationRequest.cs | 7 + api/Models/Errors/InvalidCheckInException.cs | 8 + api/Repositories/ReservationRepository.cs | 78 +++++++++ ui/src/staff/StaffPage.tsx | 166 +++++++++++++++++-- ui/src/staff/api.ts | 11 ++ 8 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 api/Models/CheckInReservationRequest.cs create mode 100644 api/Models/Errors/InvalidCheckInException.cs diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index a44775a..956d2f8 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -18,7 +18,7 @@ public ReservationController(ReservationRepository reservationRepository) [HttpGet, Produces("application/json"), Route("")] public async Task> GetReservations() { - if (IsNotStaff(Request, out IActionResult? result)) + if (IsNotStaff(Request, out ActionResult? result)) { return result!; } @@ -31,7 +31,7 @@ public async Task> GetReservations() [HttpGet, Produces("application/json"), Route("{reservationId}")] public async Task> GetRoom(Guid reservationId) { - if (IsNotStaff(Request, out IActionResult? result)) + if (IsNotStaff(Request, out ActionResult? result)) { return result!; } @@ -96,5 +96,35 @@ public async Task DeleteReservation(Guid reservationId) return result ? NoContent() : NotFound(); } + + [HttpPost, Produces("application/json"), Route("{reservationId}/check-in")] + public async Task> CheckInReservation( + Guid reservationId, + [FromBody] CheckInReservationRequest request + ) + { + if (IsNotStaff(Request, out ActionResult? result)) + { + return result!; + } + + try + { + var checkedInReservation = await _repo.CheckInReservation( + reservationId, + request.GuestEmail + ); + + return Json(checkedInReservation); + } + catch (NotFoundException) + { + return NotFound(); + } + catch (InvalidCheckInException ex) + { + return BadRequest(ex.Message); + } + } } } diff --git a/api/Controllers/StaffAccessController.cs b/api/Controllers/StaffAccessController.cs index cf99c4f..15da697 100644 --- a/api/Controllers/StaffAccessController.cs +++ b/api/Controllers/StaffAccessController.cs @@ -9,7 +9,7 @@ public abstract class StaffAccessController : Controller /// /// Checks if the request is from a staff member, if not returns true and a 403 result /// - protected bool IsNotStaff(HttpRequest request, out IActionResult? result) + protected bool IsNotStaff(HttpRequest request, out ActionResult? result) { // TODO explore UseAuthentication request.Cookies.TryGetValue(StaffAccessCookieName, out string? accessValue); diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 7570c94..d0cefaf 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -39,7 +39,7 @@ public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string access [HttpGet, Route("check")] public IActionResult CheckCookie() { - if (IsNotStaff(Request, out IActionResult? result)) + if (IsNotStaff(Request, out ActionResult? result)) { return result!; } diff --git a/api/Models/CheckInReservationRequest.cs b/api/Models/CheckInReservationRequest.cs new file mode 100644 index 0000000..079fc97 --- /dev/null +++ b/api/Models/CheckInReservationRequest.cs @@ -0,0 +1,7 @@ +namespace Models +{ + public class CheckInReservationRequest + { + public required string GuestEmail { get; set; } + } +} diff --git a/api/Models/Errors/InvalidCheckInException.cs b/api/Models/Errors/InvalidCheckInException.cs new file mode 100644 index 0000000..690b21d --- /dev/null +++ b/api/Models/Errors/InvalidCheckInException.cs @@ -0,0 +1,8 @@ +namespace Models.Errors +{ + public class InvalidCheckInException : Exception + { + public InvalidCheckInException(string message) + : base(message) { } + } +} diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5bf60a1..f62f6eb 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -3,6 +3,7 @@ using Models; using Models.Errors; using System.Globalization; +using Microsoft.Data.Sqlite; namespace Repositories { @@ -126,6 +127,52 @@ public async Task DeleteReservation(Guid reservationId) return deleted > 0; } + public async Task CheckInReservation( + Guid reservationId, + string guestEmailConfirmation + ) + { + var reservation = await GetReservation(reservationId); + var trimmedGuestEmail = guestEmailConfirmation?.Trim() ?? ""; + + ValidateCheckIn(reservation, trimmedGuestEmail); + + if (_db is SqliteConnection sqliteConnection && sqliteConnection.State != ConnectionState.Open) + { + await sqliteConnection.OpenAsync(); + } + + using var transaction = _db.BeginTransaction(); + + try + { + await _db.ExecuteAsync( + "UPDATE Reservations SET CheckedIn = TRUE WHERE Id = @reservationIdStr;", + new { reservationIdStr = reservationId.ToString() }, + transaction + ); + + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @occupiedState WHERE Number = @roomNumber;", + new + { + occupiedState = State.Occupied, + roomNumber = Room.ConvertRoomNumberToInt(reservation.RoomNumber) + }, + transaction + ); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + + return await GetReservation(reservationId); + } + private class ReservationDb { public string Id { get; set; } @@ -200,6 +247,37 @@ private static void ValidateReservation(Reservation reservation) } } + private static void ValidateCheckIn(Reservation reservation, string guestEmailConfirmation) + { + if (!string.Equals( + reservation.GuestEmail, + guestEmailConfirmation, + StringComparison.OrdinalIgnoreCase + )) + { + throw new InvalidCheckInException( + "Guest email confirmation does not match the reservation." + ); + } + + if (reservation.CheckedIn) + { + throw new InvalidCheckInException("Reservation is already checked in."); + } + + if (reservation.CheckedOut) + { + throw new InvalidCheckInException("Checked out reservations cannot be checked in."); + } + + if (reservation.Start.Date != DateTime.Today) + { + throw new InvalidCheckInException( + "Only reservations starting today can be checked in." + ); + } + } + private static bool LooksLikeEmailWithDomain(string guestEmail) { var trimmedEmail = guestEmail.Trim(); diff --git a/ui/src/staff/StaffPage.tsx b/ui/src/staff/StaffPage.tsx index d80ecc4..d4023de 100644 --- a/ui/src/staff/StaffPage.tsx +++ b/ui/src/staff/StaffPage.tsx @@ -11,14 +11,21 @@ import { Text, TextField, } from "@radix-ui/themes"; +import { HTTPError } from "ky"; import { LoadingCard } from "../components/LoadingCard"; -import { useShowInfoToast, useShowSuccessToast } from "../utils/toasts"; import { + showInfoToast, + useShowInfoToast, + useShowSuccessToast, +} from "../utils/toasts"; +import { + checkInReservation, checkStaffSession, loginStaff, StaffReservation, useGetStaffReservations, } from "./api"; +import { useQueryClient } from "@tanstack/react-query"; const RESERVATION_GRID_COLS: React.ComponentProps["columns"] = { sm: "1", @@ -31,10 +38,16 @@ const DimSlot = styled(TextField.Slot)` `; export function StaffPage() { + const queryClient = useQueryClient(); const [accessCode, setAccessCode] = useState(""); const [isAuthorized, setIsAuthorized] = useState(false); const [isCheckingAccess, setIsCheckingAccess] = useState(true); const [isLoggingIn, setIsLoggingIn] = useState(false); + const [isCheckingInReservationId, setIsCheckingInReservationId] = + useState(""); + const [reservationFilter, setReservationFilter] = useState< + "upcoming" | "today" + >("upcoming"); const showMissingCodeToast = useShowInfoToast( "Enter the shared access code.", ); @@ -42,11 +55,20 @@ export function StaffPage() { "That access code is incorrect.", ); const showStaffWelcomeToast = useShowSuccessToast("Welcome back."); + const showCheckInSuccessToast = useShowSuccessToast( + "The guest has been checked in.", + ); const { data: reservations, isLoading: isLoadingReservations, isError: hasReservationsError, } = useGetStaffReservations(isAuthorized); + const visibleReservations = + reservations?.filter((reservation) => + reservationFilter === "today" + ? isReservationForToday(reservation.start) + : true, + ) ?? []; useEffect(() => { let isCancelled = false; @@ -102,6 +124,30 @@ export function StaffPage() { } } + async function handleCheckIn(reservationId: string, guestEmail: string) { + setIsCheckingInReservationId(reservationId); + + try { + await checkInReservation(reservationId, guestEmail); + await queryClient.invalidateQueries({ + queryKey: ["staff", "reservations"], + }); + showCheckInSuccessToast(); + } catch (error) { + if (error instanceof HTTPError) { + const errorMessage = await error.response.text(); + if (errorMessage) { + showInfoToast(errorMessage); + return; + } + } + + showInfoToast("We could not check in the guest right now."); + } finally { + setIsCheckingInReservationId(""); + } + } + if (isCheckingAccess) { return (
@@ -159,22 +205,47 @@ export function StaffPage() { return (
- Upcoming Reservations + {reservationFilter === "today" + ? "Today's Reservations" + : "Upcoming Reservations"} + + + + + {isLoadingReservations && ( )} - {!isLoadingReservations && reservations?.length === 0 && ( - - - There are no reservations scheduled for today or later. - - - )} + {!isLoadingReservations && + !hasReservationsError && + visibleReservations.length === 0 && ( + + + {reservationFilter === "today" + ? "There are no reservations starting today." + : "There are no reservations scheduled for today or later."} + + + )} {hasReservationsError && ( @@ -184,12 +255,14 @@ export function StaffPage() { )} - {reservations && reservations.length > 0 && ( + {visibleReservations.length > 0 && ( - {reservations.map((reservation) => ( + {visibleReservations.map((reservation) => ( ))} @@ -200,9 +273,30 @@ export function StaffPage() { function ReservationSummaryCard({ reservation, + isCheckingIn, + onCheckIn, }: { reservation: StaffReservation; + isCheckingIn: boolean; + onCheckIn: (reservationId: string, guestEmail: string) => Promise; }) { + const [confirmationEmail, setConfirmationEmail] = useState(""); + const showMissingConfirmationToast = useShowInfoToast( + "Enter the guest email to confirm check in.", + ); + const canCheckIn = + isReservationForToday(reservation.start) && !reservation.checkedIn; + + async function handleCheckIn() { + if (!confirmationEmail.trim()) { + showMissingConfirmationToast(); + return; + } + + await onCheckIn(reservation.id, confirmationEmail.trim()); + setConfirmationEmail(""); + } + return ( @@ -226,6 +320,45 @@ function ReservationSummaryCard({ {formatDate(reservation.start)} to {formatDate(reservation.end)} + + + Status + + + {reservation.checkedIn ? "Checked in" : "Not checked in"} + + + + {canCheckIn && ( + + + Confirm guest email to check in today's arrival. + + setConfirmationEmail(evt.target.value)} + type="email" + size="3" + disabled={isCheckingIn} + > + + Email + + + + + + + )} ); @@ -236,3 +369,14 @@ function formatDate(dateValue: string) { dateStyle: "medium", }).format(new Date(dateValue)); } + +function isReservationForToday(dateValue: string) { + const reservationDate = new Date(dateValue); + const today = new Date(); + + return ( + reservationDate.getFullYear() === today.getFullYear() && + reservationDate.getMonth() === today.getMonth() && + reservationDate.getDate() === today.getDate() + ); +} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts index 4e79f6e..3fa8abd 100644 --- a/ui/src/staff/api.ts +++ b/ui/src/staff/api.ts @@ -38,6 +38,17 @@ export async function loginStaff(accessCode: string) { return checkStaffSession(); } +export function checkInReservation(reservationId: string, guestEmail: string) { + return staffClient + .post(`api/reservation/${reservationId}/check-in`, { + json: { + GuestEmail: guestEmail, + }, + }) + .json() + .then(StaffReservationSchema.parseAsync); +} + export function useGetStaffReservations(enabled: boolean) { return useQuery({ queryKey: ["staff", "reservations"],