diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..d429a7e 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -1,6 +1,8 @@ +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; +using Models.Validators; using Repositories; namespace Controllers @@ -9,18 +11,32 @@ namespace Controllers public class ReservationController : Controller { private ReservationRepository _repo { get; set; } + private readonly ReservationValidator _validator; + private readonly RoomValidator _roomValidator; + private readonly ILogger _logger; - public ReservationController(ReservationRepository reservationRepository) + public ReservationController(ReservationRepository reservationRepository, ReservationValidator reservationValidator, RoomValidator roomValidator, ILogger logger) { _repo = reservationRepository; + _validator = reservationValidator; + _roomValidator = roomValidator; + _logger = logger; } [HttpGet, Produces("application/json"), Route("")] public async Task> GetReservations() { - var reservations = await _repo.GetReservations(); - - return Json(reservations); + try + { + var reservations = await _repo.GetReservations(); + _logger.LogInformation("Successfully fetched {ReservationCount} reservations.", reservations.Count()); + return Json(reservations); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching reservations."); + return StatusCode(500, "An error occurred while fetching the reservations."); + } } [HttpGet, Produces("application/json"), Route("{reservationId}")] @@ -29,12 +45,19 @@ public async Task> GetRoom(Guid reservationId) try { var reservation = await _repo.GetReservation(reservationId); + _logger.LogInformation("Successfully fetched reservation with ID {ReservationId}.", reservationId); return Json(reservation); } catch (NotFoundException) { + _logger.LogWarning("Reservation with ID {ReservationId} not found.", reservationId); return NotFound(); } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching the reservation with ID {ReservationId}.", reservationId); + return StatusCode(500, "An error occurred while fetching the reservation."); + } } /// @@ -43,36 +66,86 @@ public async Task> GetRoom(Guid reservationId) /// /// [HttpPost, Produces("application/json"), Route("")] - public async Task> BookReservation( - [FromBody] Reservation newBooking - ) + public async Task> BookReservation([FromBody] Reservation newBooking) + + { - // Provide a real ID if one is not provided - if (newBooking.Id == Guid.Empty) + var validationResult = _validator.ValidateReservation(newBooking); + if (!validationResult.IsValid) + { + _logger.LogWarning("Invalid reservation: {ErrorMessage}", validationResult.ErrorMessage); + return BadRequest(validationResult.ErrorMessage); + } + + if (!_roomValidator.IsValidRoomNumber(newBooking.RoomNumber)) { - newBooking.Id = Guid.NewGuid(); + _logger.LogWarning("Invalid room number provided: {RoomNumber}", newBooking.RoomNumber); + return BadRequest("Invalid room number. Ensure it follows the proper format and rules."); } try { + if (newBooking.Id == Guid.Empty) + { + newBooking.Id = Guid.NewGuid(); + } + var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); + _logger.LogInformation("Successfully created a reservation with ID {ReservationId}.", createdReservation.Id); + return Created($"/reservation/{createdReservation.Id}", createdReservation); } catch (Exception ex) { - Console.WriteLine("An error occured when trying to book a reservation:"); - Console.WriteLine(ex.ToString()); + _logger.LogError(ex, "An error occurred while trying to book a reservation."); + return BadRequest("An error occurred while processing the reservation."); + - return BadRequest("Invalid reservation"); } } [HttpDelete, Produces("application/json"), Route("{reservationId}")] public async Task DeleteReservation(Guid reservationId) { - var result = await _repo.DeleteReservation(reservationId); + try + { + var result = await _repo.DeleteReservation(reservationId); + if (result) + { + _logger.LogInformation("Reservation with ID {ReservationId} successfully deleted.", reservationId); + return NoContent(); + } + else + { + _logger.LogWarning("Reservation with ID {ReservationId} not found for deletion.", reservationId); + return NotFound(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while trying to delete reservation with ID {ReservationId}.", reservationId); + return StatusCode(500, "An error occurred while deleting the reservation."); + } + } - return result ? NoContent() : NotFound(); + [HttpGet("upcoming")] + public async Task GetUpcomingReservations() + { + try + { + var upcomingReservations = await _repo.GetUpcomingReservations(); + + if (upcomingReservations == null || !upcomingReservations.Any()) + { + return NoContent(); + } + + return Ok(upcomingReservations); + } + catch (Exception ex) + { + return StatusCode(500, $"An error occurred: {ex.Message}"); + } } } } + diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs index 6e97650..baaa47c 100644 --- a/api/Controllers/RoomController.cs +++ b/api/Controllers/RoomController.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; +using Models.Validators; using Repositories; +using Microsoft.Extensions.Caching.Memory; namespace Controllers { @@ -9,56 +11,101 @@ namespace Controllers public class RoomController : Controller { private RoomRepository _repo { get; set; } + private readonly RoomValidator _roomValidator; + private readonly ILogger _logger; + private readonly IMemoryCache _cache; - public RoomController(RoomRepository roomRepository) + public RoomController(RoomRepository roomRepository, RoomValidator roomValidator, ILogger logger, IMemoryCache memoryCache) { _repo = roomRepository; + _roomValidator = roomValidator; + _logger = logger; + _cache = memoryCache; } [HttpGet, Produces("application/json"), Route("")] public async Task> GetRooms() { - var rooms = await _repo.GetRooms(); + try + { + var rooms = await _repo.GetRooms(); - if (rooms == null) + if (rooms == null) + { + _logger.LogWarning("No rooms found in the database."); + return Json(Enumerable.Empty()); + } + + _logger.LogInformation("Retrieved all rooms from the database."); + return Json(rooms); + } + catch (Exception ex) { - return Json(Enumerable.Empty()); + _logger.LogError(ex, "An error occurred while fetching rooms."); + return StatusCode(500, "An error occurred while fetching rooms."); } - return Json(rooms); + } [HttpGet, Produces("application/json"), Route("{roomNumber}")] public async Task> GetRoom(string roomNumber) { - if (roomNumber.Length != 3) + if (!_roomValidator.IsValidRoomNumber(roomNumber)) { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + _logger.LogWarning("Invalid room number format: {RoomNumber}", roomNumber); + return BadRequest("Invalid room number. Ensure it follows the proper format and rules."); } try { var room = await _repo.GetRoom(roomNumber); + if (room == null) + { + _logger.LogWarning("Room not found: {RoomNumber}", roomNumber); + return NotFound(); + } + + _logger.LogInformation("Room retrieved successfully: {RoomNumber}", roomNumber); return Json(room); } - catch (NotFoundException) + catch (Exception ex) { - return NotFound(); + _logger.LogError(ex, "An error occurred while fetching the room with number {RoomNumber}.", roomNumber); + return StatusCode(500, "An error occurred while fetching the room."); } } [HttpPost, Produces("application/json"), Route("")] public async Task> CreateRoom([FromBody] Room newRoom) { - var createdRoom = await _repo.CreateRoom(newRoom); + if (!_roomValidator.IsValidRoomNumber(newRoom.Number)) + - if (createdRoom == null) { - return NotFound(); + _logger.LogWarning("Invalid room number format: {RoomNumber}", newRoom.Number); + return BadRequest("Invalid room number. Ensure it follows the proper format and rules."); } - return Json(createdRoom); + try + { + var createdRoom = await _repo.CreateRoom(newRoom); + + if (createdRoom == null) + { + _logger.LogWarning("Failed to create room with number: {RoomNumber}", newRoom.Number); + return NotFound(); + } + + _logger.LogInformation("Room created successfully: {RoomNumber}", newRoom.Number); + return Json(createdRoom); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while creating the room with number {RoomNumber}.", newRoom.Number); + return StatusCode(500, "An error occurred while creating the room."); + } } [HttpDelete, Produces("application/json"), Route("{roomNumber}")] @@ -73,5 +120,28 @@ public async Task DeleteRoom(string roomNumber) return deleted ? NoContent() : NotFound(); } + + [HttpGet, Produces("application/json"), Route("checkRoomAvailability")] + public async Task CheckRoomAvailability(string roomNumber, string startDate, string endDate) + { + try + { + string cacheKey = $"{roomNumber}_{startDate}_{endDate}"; + + if (!_cache.TryGetValue(cacheKey, out bool isAvailable)) + { + isAvailable = await _repo.CheckRoomAvailability(roomNumber, startDate, endDate); + + _cache.Set(cacheKey, isAvailable, TimeSpan.FromMinutes(15)); + } + + return Json(new { available = isAvailable }); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while checking room availability."); + return StatusCode(500, "An error occurred while checking availability."); + } + } } } diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..def2f0e 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,69 +1,118 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System; namespace Controllers { [Route("staff")] public class StaffController : Controller { - private IConfiguration Config { get; set; } + private readonly IConfiguration _config; + private readonly ILogger _logger; - public StaffController(IConfiguration config) + public StaffController(IConfiguration config, ILogger logger) { - Config = config; + _config = config; + _logger = logger; } /// - /// Checks if the request is from a staff member, if not returns true and a 403 result + /// Checks if the request is from a staff member. Returns 403 if not authorized. /// - /// + /// HttpRequest + /// IActionResult to be returned in case of failure + /// True if request is unauthorized, false otherwise private bool IsNotStaff(HttpRequest request, out IActionResult? result) { - // TODO explore UseAuthentication - request.Cookies.TryGetValue("access", out string? accessValue); + try + { + if (!Request.Cookies.ContainsKey("access") || Request.Cookies["access"] != "1") + { + result = StatusCode(403, "Unauthorized: Staff login is required."); + _logger.LogWarning("Unauthorized access attempt from IP {IP} at {Time}", Request.HttpContext.Connection.RemoteIpAddress, DateTime.UtcNow); + return true; + } - if (accessValue == null || accessValue == "0") + result = null; + return false; + } + catch (Exception ex) { - result = StatusCode(403); + _logger.LogError(ex, "Error checking staff access at {Time}", DateTime.UtcNow); + result = StatusCode(500, "Internal Server Error"); return true; } - - result = null; - return false; } [HttpGet, Route("login")] public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) { - var configuredSecret = Config.GetValue("staffAccessCode"); - if (configuredSecret != accessCode) + try { - // don't set cookie, don't indicate anything - return NoContent(); - } - Response.Cookies.Append( - "access", - "1", - new CookieOptions - // TODO evaluate cookie options & auth mechanism for best security practices + var configuredSecret = _config.GetValue("StaffAccessCode"); + + if (configuredSecret != accessCode) { - IsEssential = true, - SameSite = SameSiteMode.Strict, - HttpOnly = true, - Secure = true + _logger.LogWarning("Invalid access code attempt at {Time}", DateTime.UtcNow); + return Unauthorized("Invalid access code."); } - ); - return NoContent(); + + Response.Cookies.Append( + "access", + "1", + new Microsoft.AspNetCore.Http.CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict + }); + + _logger.LogInformation("Staff member logged in successfully at {Time}", DateTime.UtcNow); + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during login attempt at {Time}", DateTime.UtcNow); + return StatusCode(500, "Internal Server Error"); + } } [HttpGet, Route("check")] public IActionResult CheckCookie() { - if (IsNotStaff(Request, out IActionResult? result)) + try { - return result!; + if (IsNotStaff(Request, out IActionResult? result)) + { + return result!; + } + + return Ok("Authorized"); } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking staff cookie at {Time}", DateTime.UtcNow); + return StatusCode(500, "Internal Server Error"); + } + } - return Ok("Authorized"); + [HttpPost, Route("logout")] + public IActionResult Logout() + { + try + { + Response.Cookies.Delete("access"); + + _logger.LogInformation("Staff member logged out at {Time}", DateTime.UtcNow); + + return Redirect("/login"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during logout at {Time}", DateTime.UtcNow); + return StatusCode(500, "Internal Server Error"); + } } } } diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..072cda4 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -52,6 +52,13 @@ FOREIGN KEY ({nameof(Reservation.RoomNumber)}) REFERENCES Rooms ({nameof(Room.Number)}) ); " + ); + + await db.ExecuteAsync( + $@" + CREATE INDEX IF NOT EXISTS idx_reservations_room_dates + ON Reservations ({nameof(Reservation.RoomNumber)}, {nameof(Reservation.Start)}, {nameof(Reservation.End)}); + " ); } } diff --git a/api/Models/Database/ReservationDb.cs b/api/Models/Database/ReservationDb.cs new file mode 100644 index 0000000..0d9c068 --- /dev/null +++ b/api/Models/Database/ReservationDb.cs @@ -0,0 +1,48 @@ +namespace Models.Database +{ + public class ReservationDb + { + public string Id { get; set; } + public int RoomNumber { get; set; } + + public string GuestEmail { get; set; } + + public DateTime Start { get; set; } + public DateTime End { get; set; } + public bool CheckedIn { get; set; } + public bool CheckedOut { get; set; } + + public ReservationDb() + { + Id = Guid.Empty.ToString(); + RoomNumber = 0; + GuestEmail = ""; + } + + public ReservationDb(Reservation reservation) + { + Id = reservation.Id.ToString(); + RoomNumber = Room.ConvertRoomNumberToInt(reservation.RoomNumber); + GuestEmail = reservation.GuestEmail; + Start = reservation.Start; + End = reservation.End; + CheckedIn = reservation.CheckedIn; + CheckedOut = reservation.CheckedOut; + } + + public Reservation ToDomain() + { + return new Reservation + { + Id = Guid.Parse(Id), + RoomNumber = Room.FormatRoomNumber(RoomNumber), + GuestEmail = GuestEmail, + Start = Start, + End = End, + CheckedIn = CheckedIn, + CheckedOut = CheckedOut + }; + } + } +} + diff --git a/api/Models/Validators/ReservationValidator.cs b/api/Models/Validators/ReservationValidator.cs new file mode 100644 index 0000000..fc4cc37 --- /dev/null +++ b/api/Models/Validators/ReservationValidator.cs @@ -0,0 +1,33 @@ +using System.Text.RegularExpressions; +using Models; +using Models.Validation; + +public class ReservationValidator +{ + public ValidationResult ValidateReservation(Reservation reservation) + { + if (reservation.Start >= reservation.End) + { + return ValidationResult.Invalid("Start date must be before the end date."); + } + + var durationInDays = (reservation.End - reservation.Start).Days; + if (durationInDays < 1 || durationInDays > 30) + { + return ValidationResult.Invalid("Reservation duration must be between 1 and 30 days."); + } + + if (!IsValidEmail(reservation.GuestEmail)) + { + return ValidationResult.Invalid("Invalid email address. Ensure the email has a valid domain."); + } + + return ValidationResult.Valid(); + } + + private bool IsValidEmail(string email) + { + var emailRegex = new Regex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$"); + return emailRegex.IsMatch(email); + } +} diff --git a/api/Models/Validators/RoomValidator.cs b/api/Models/Validators/RoomValidator.cs new file mode 100644 index 0000000..24ee58e --- /dev/null +++ b/api/Models/Validators/RoomValidator.cs @@ -0,0 +1,33 @@ +using System.Text.RegularExpressions; + +namespace Models.Validators +{ + public class RoomValidator + { + // Updated Regex Explanation: + // ^ : Start of the string. + // [0-9] : The first digit must be a digit (0-9), representing the floor number. + // [0-9] : The second digit must be a digit (0-9), representing the first part of the door number. + // [1-9] : The third digit must be a non-zero digit (1-9), as '00' doors are invalid. + // $ : End of the string. + // + // Valid Examples: "101", "202", "305" (valid 3-digit room numbers). + // Invalid Examples: "-101" (negative), "000" ('00' doors are invalid), "2020" (too many digits). + private static readonly Regex RoomNumberRegex = new Regex(@"^[0-9][0-9][1-9]$"); + + /// + /// Validates a room number based on the format and rules provided. + /// + /// The room number to validate. + /// True if valid, false otherwise. + public bool IsValidRoomNumber(string roomNumber) + { + if (string.IsNullOrWhiteSpace(roomNumber)) + { + return false; + } + + return RoomNumberRegex.IsMatch(roomNumber); + } + } +} diff --git a/api/Models/Validators/ValidationResult.cs b/api/Models/Validators/ValidationResult.cs new file mode 100644 index 0000000..151deb3 --- /dev/null +++ b/api/Models/Validators/ValidationResult.cs @@ -0,0 +1,18 @@ +namespace Models.Validation +{ + public class ValidationResult + { + public bool IsValid { get; } + public string ErrorMessage { get; } + + public ValidationResult(bool isValid, string errorMessage = "") + { + IsValid = isValid; + ErrorMessage = errorMessage ?? string.Empty; + } + + public static ValidationResult Valid() => new ValidationResult(true); + + public static ValidationResult Invalid(string errorMessage) => new ValidationResult(false, errorMessage); + } +} diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..ebbb73e 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,6 +1,7 @@ using System.Data; using Db; using Microsoft.Data.Sqlite; +using Models.Validators; using Repositories; var builder = WebApplication.CreateBuilder(args); @@ -17,6 +18,9 @@ Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddMvc(opt => { opt.EnableEndpointRouting = false; diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..1b95cb5 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -1,6 +1,7 @@ using System.Data; using Dapper; using Models; +using Models.Database; using Models.Errors; namespace Repositories @@ -49,10 +50,93 @@ public async Task GetReservation(Guid reservationId) public async Task CreateReservation(Reservation newReservation) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } - ); + + if (newReservation.Id == Guid.Empty) + { + newReservation.Id = Guid.NewGuid(); + } + + if (_db is Microsoft.Data.Sqlite.SqliteConnection sqliteConnection) + { + if (sqliteConnection.State != ConnectionState.Open) + { + await sqliteConnection.OpenAsync(); + } + } + else + { + throw new InvalidOperationException("Database connection is not of type SQLiteConnection."); + } + + using (var transaction = _db.BeginTransaction()) + { + try + { + var guestExists = await _db.ExecuteScalarAsync( + "SELECT COUNT(1) FROM Guests WHERE Email = @GuestEmail", + new { GuestEmail = newReservation.GuestEmail } + ); + + if (guestExists == 0) + { + await _db.ExecuteAsync( + "INSERT INTO Guests (Email, Name) VALUES (@Email, @Name)", + new { Email = newReservation.GuestEmail, Name = newReservation.GuestEmail } + ); + } + + var roomExists = await _db.ExecuteScalarAsync( + "SELECT COUNT(1) FROM Rooms WHERE Number = @RoomNumber", + new { RoomNumber = Room.ConvertRoomNumberToInt(newReservation.RoomNumber) } + ); + + if (roomExists == 0) + { + throw new Exception("Room not found."); + } + + var result = await _db.ExecuteAsync( + @" + INSERT INTO Reservations (Id, GuestEmail, RoomNumber, Start, End, CheckedIn, CheckedOut) + VALUES (@Id, @GuestEmail, @RoomNumber, @Start, @End, @CheckedIn, @CheckedOut); + ", + new + { + Id = newReservation.Id.ToString(), + GuestEmail = newReservation.GuestEmail, + RoomNumber = Room.ConvertRoomNumberToInt(newReservation.RoomNumber), + Start = newReservation.Start, + End = newReservation.End, + CheckedIn = newReservation.CheckedIn, + CheckedOut = newReservation.CheckedOut + }, + transaction: transaction + ); + + if (result == 0) + { + throw new Exception("Failed to insert the reservation."); + } + + transaction.Commit(); + + return new Reservation + { + Id = newReservation.Id, + RoomNumber = newReservation.RoomNumber, + GuestEmail = newReservation.GuestEmail, + Start = newReservation.Start, + End = newReservation.End, + CheckedIn = newReservation.CheckedIn, + CheckedOut = newReservation.CheckedOut + }; + } + catch (Exception ex) + { + transaction.Rollback(); + throw new Exception($"An error occurred while creating the reservation: {ex.Message}", ex); + } + } } public async Task DeleteReservation(Guid reservationId) @@ -65,49 +149,24 @@ public async Task DeleteReservation(Guid reservationId) return deleted > 0; } - private class ReservationDb + public async Task> GetUpcomingReservations() { - public string Id { get; set; } - public int RoomNumber { get; set; } + var now = DateTime.UtcNow; + var query = @" + SELECT * + FROM Reservations + WHERE Start > @Now + ORDER BY Start ASC + "; - public string GuestEmail { get; set; } + var reservations = await _db.QueryAsync(query, new { Now = now }); - public DateTime Start { get; set; } - public DateTime End { get; set; } - public bool CheckedIn { get; set; } - public bool CheckedOut { get; set; } - - public ReservationDb() + if (reservations == null || !reservations.Any()) { - Id = Guid.Empty.ToString(); - RoomNumber = 0; - GuestEmail = ""; + return new List(); } - public ReservationDb(Reservation reservation) - { - Id = reservation.Id.ToString(); - RoomNumber = Room.ConvertRoomNumberToInt(reservation.RoomNumber); - GuestEmail = reservation.GuestEmail; - Start = reservation.Start; - End = reservation.End; - CheckedIn = reservation.CheckedIn; - CheckedOut = reservation.CheckedOut; - } - - public Reservation ToDomain() - { - return new Reservation - { - Id = Guid.Parse(Id), - RoomNumber = Room.FormatRoomNumber(RoomNumber), - GuestEmail = GuestEmail, - Start = Start, - End = End, - CheckedIn = CheckedIn, - CheckedOut = CheckedOut - }; - } + return reservations.Select(r => r.ToDomain()); } } } diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 2b9f904..3dd8164 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -71,6 +71,25 @@ public async Task DeleteRoom(string roomNumber) return deleted > 0; } + public async Task CheckRoomAvailability(string roomNumber, string startDate, string endDate) + { + var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + + var query = @" + SELECT 1 + FROM Reservations + WHERE RoomNumber = @roomNumberInt + AND Start < @endDate + AND End > @startDate + LIMIT 1; + "; + + + var count = await _db.ExecuteScalarAsync(query, new { roomNumberInt, startDate, endDate }); + + return count == 0; + } + // Inner class to hide the details of a direct mapping to SQLite private class RoomDb { diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..ad63f48 100644 --- a/ui/src/LandingPage.tsx +++ b/ui/src/LandingPage.tsx @@ -1,16 +1,44 @@ import { Box, Card, Flex, Heading, Inset } from "@radix-ui/themes"; -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; -function handleLogin() { - // TODO have a staff view - alert("Not implemented"); +function handleLogin(accessCode: string, navigate: ReturnType) { + fetch("/api/staff/login", { + method: "GET", + headers: { + "X-Staff-Code": accessCode, + }, + }) + .then((response) => { + if (response.ok) { + alert("Login successful!"); + navigate({ to: "/upcomingReservations" }); + } else { + alert("Invalid access code. Please try again."); + } + }) + .catch((error) => { + console.error("Error during login:", error); + alert("An error occurred while logging in. Please try again."); + }); } export function LandingPage() { + const navigate = useNavigate(); + + const handleButtonClick = () => { + const accessCode = prompt("Enter your staff access code:"); + if (!accessCode) { + alert("Access code is required."); + return; + } + handleLogin(accessCode, navigate); + }; + return ( - + - Login + Staff Login diff --git a/ui/src/hooks/useEmailValidation.ts b/ui/src/hooks/useEmailValidation.ts new file mode 100644 index 0000000..dee37c4 --- /dev/null +++ b/ui/src/hooks/useEmailValidation.ts @@ -0,0 +1,20 @@ +import { useState } from "react"; + +export function useEmailValidation(initialValue: string) { + const [email, setEmail] = useState(initialValue); + const [error, setError] = useState(null); + + const handleEmailChange = (evt: React.ChangeEvent) => { + const emailValue = evt.target.value; + setEmail(emailValue); + + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (!emailRegex.test(emailValue)) { + setError("Please enter a valid email address."); + } else { + setError(null); + } + }; + + return { email, error, handleEmailChange }; +} diff --git a/ui/src/reservations/BookingDetailsModal.tsx b/ui/src/reservations/BookingDetailsModal.tsx index 6f1edfe..6260623 100644 --- a/ui/src/reservations/BookingDetailsModal.tsx +++ b/ui/src/reservations/BookingDetailsModal.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useShowInfoToast } from "../utils/toasts"; import { fromDateStringToIso } from "../utils/datetime"; import { @@ -7,8 +8,9 @@ import { } from "@datepicker-react/styled"; import { Box, Button, Dialog, Separator, TextField } from "@radix-ui/themes"; import { NewReservation } from "./api"; -import { useState } from "react"; +import { useEmailValidation } from "../hooks/useEmailValidation"; import styled from "styled-components"; +import { checkRoomAvailability } from "../utils/roomAvailability"; interface BookingDetailsModalProps { roomNumber: string; @@ -20,7 +22,6 @@ interface BookingFormProps { onSubmit: (booking: NewReservation) => void; } -/** Must be inside a Dialog.Root that container Dialog.Triggers elsewhere */ export function BookingDetailsModal({ roomNumber, onSubmit, @@ -48,23 +49,70 @@ const BottomRightBox = styled(Box)` right: 0; `; +const DateRangeContainer = styled.div<{ isValid: boolean }>` + border: 2px solid ${({ isValid }) => (isValid ? "var(--green-9)" : "var(--red-9)")}; + border-radius: 4px; + padding: 8px; + margin-top: 16px; + + &:focus-within { + box-shadow: 0 0 0 2px ${({ isValid }) => (isValid ? "var(--green-6)" : "var(--red-6)")}; + } +`; + +const EmailContainer = styled.div<{ isValid: boolean }>` + border: 2px solid ${({ isValid }) => (isValid ? "var(--green-9)" : "var(--red-9)")}; + border-radius: 4px; + padding: 8px; + margin-bottom: 16px; + + &:focus-within { + box-shadow: 0 0 0 2px ${({ isValid }) => (isValid ? "var(--green-6)" : "var(--red-6)")}; + } +`; + +const ErrorMessage = styled.div` + color: var(--red-9); + margin-top: 8px; + font-size: 0.9rem; +`; + + function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { - const [email, setEmail] = useState(""); - const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([ - null, - null, - ]); + const { email, error: emailError, handleEmailChange } = useEmailValidation(""); + const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([null, null]); const [focusedInput, setFocusedInput] = useState(null); + const [dateError, setDateError] = useState(null); + const [bookingError, setBookingError] = useState(null); + const [roomAvailable, setRoomAvailable] = useState(null); const showProcessingToast = useShowInfoToast("Processing booking..."); const showNoInfoToast = useShowInfoToast("Missing email or dates."); + const showRoomUnavailableToast = useShowInfoToast("Room is unavailable for the selected dates."); + + const isEmailValid = email && !emailError ? true : false; + const areDatesValid = dateRange[0] && dateRange[1] && !dateError ? true : false; - function handleSubmit(evt: React.MouseEvent) { + async function handleSubmit(evt: React.MouseEvent) { if (!email || !dateRange[0] || !dateRange[1]) { showNoInfoToast(); evt.preventDefault(); return false; } + const isAvailable = await checkRoomAvailability( + roomNumber, + dateRange[0].toISOString().split('T')[0], + dateRange[1].toISOString().split('T')[0] + ); + + if (!isAvailable) { + showRoomUnavailableToast(); + setRoomAvailable(false); + setBookingError("Room is already booked for the selected dates."); + alert("room is unavailable"); + return false; + } + showProcessingToast(); onSubmit({ RoomNumber: roomNumber, @@ -76,53 +124,90 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { } function handleDateChange(data: OnDatesChangeProps) { - if (data.startDate && data.endDate) { - setDateRange([data.startDate, data.endDate]); - setFocusedInput(null); - return; + const { startDate, endDate } = data; + setDateRange([startDate || null, endDate || null]); + + + if (startDate && endDate) { + const differenceInTime = endDate.getTime() - startDate.getTime(); + const differenceInDays = differenceInTime / (1000 * 3600 * 24); + + if (startDate >= endDate) { + setDateError("End date must be at least one day after start date."); + } else if (differenceInDays > 30) { + setDateError("Booking cannot be longer than 30 days."); + } else { + setDateError(null); + + checkRoomAvailability( + roomNumber, + startDate.toISOString().split('T')[0], + endDate.toISOString().split('T')[0] + ).then(available => { + setRoomAvailable(available); + }); + } + } else { + setDateError(null); + setRoomAvailable(null); } - if (data.startDate) { - setDateRange([data.startDate, null]); + if (!startDate) { + setFocusedInput("startDate"); + } else if (!endDate) { setFocusedInput("endDate"); - return; + } else { + setFocusedInput(null); } - - setDateRange([dateRange[0] || null, data.endDate]); - setFocusedInput("startDate"); } return ( - setEmail(evt.target.value)} - value={email} - type="email" - size="3" - mb="4" - > - - Email - - - + + + + Email + + + + {emailError && {emailError}} + + + + + + {dateError && {dateError}} + {bookingError && {bookingError}} + - diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..6510f44 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -10,7 +10,7 @@ export interface NewReservation { End: ISO8601String; } -/** The schema the API returns */ + const ReservationSchema = z.object({ Id: z.string(), RoomNumber: z.string(), @@ -21,18 +21,34 @@ const ReservationSchema = z.object({ type Reservation = z.infer; -export function bookRoom(booking: NewReservation) { - // unwrap branded types + +export async function bookRoom(booking: NewReservation): Promise { const newReservation = { ...booking, Start: toIsoStr(booking.Start), End: toIsoStr(booking.End), }; - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); + try { + const response = await ky.post('api/reservation', { + json: newReservation, + }); + + if (!response.ok) { + throw new Error('Failed to book reservation'); + } + + const createdReservation: Reservation = await response.json(); + + return createdReservation; + } catch (error) { + console.error('Error booking reservation:', error); + + throw new Error('Error booking reservation'); + } } + const RoomSchema = z.object({ number: z.string(), state: z.number(), diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e3020bd..7495bc4 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 { UpcomingReservations } from "./upcomingReservations/UpcomingReservations"; const rootRoute = createRootRoute({ component: Layout, @@ -26,6 +27,11 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + createRoute({ + path: "/upcomingReservations", + getParentRoute: getRootRoute, + component: UpcomingReservations, + }), ]; const routeTree = rootRoute.addChildren(ROUTES); diff --git a/ui/src/styles/UpcomingReservation.css b/ui/src/styles/UpcomingReservation.css new file mode 100644 index 0000000..538be3e --- /dev/null +++ b/ui/src/styles/UpcomingReservation.css @@ -0,0 +1,38 @@ +.reservation-table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + font-family: 'Arial', sans-serif; + } + + .reservation-table th, .reservation-table td { + padding: 12px 15px; + text-align: left; + border: 1px solid #ddd; + } + + .reservation-table th { + background-color: #660066; + color: white; + font-weight: bold; + } + + .reservation-table tr:nth-child(even) { + background-color: #f2f2f2; + } + + .reservation-table tr:hover { + background-color: #f1f1f1; + cursor: pointer; + } + + @media (max-width: 768px) { + .reservation-table { + font-size: 14px; + } + + .reservation-table th, .reservation-table td { + padding: 8px 10px; + } + } + \ No newline at end of file diff --git a/ui/src/upcomingReservations/UpcomingReservations.tsx b/ui/src/upcomingReservations/UpcomingReservations.tsx new file mode 100644 index 0000000..ad9bfc8 --- /dev/null +++ b/ui/src/upcomingReservations/UpcomingReservations.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from "react"; +import { Box, Heading } from "@radix-ui/themes"; +import "../styles/UpcomingReservation.css"; + + +export function UpcomingReservations() { + const [reservations, setReservations] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/reservation/upcoming") + .then((response) => response.json()) + .then((data) => { + setReservations(data); + setLoading(false); + }) + .catch((error) => { + console.error("Error fetching upcoming reservations:", error); + setLoading(false); + }); + }, []); + + if (loading) { + return

Loading...

; + } + + return ( + + + Upcoming Reservations + + + {reservations.length > 0 ? ( + + + + + + + + + + + + + {reservations.map((reservation) => ( + + + + + + + + + ))} + +
Room NumberGuest EmailStartEndChecked InChecked Out
{reservation.roomNumber}{reservation.guestEmail}{new Date(reservation.start).toLocaleString()}{new Date(reservation.end).toLocaleString()}{reservation.checkedIn ? "Yes" : "No"}{reservation.checkedOut ? "Yes" : "No"}
+ ) : ( +

No upcoming reservations.

+ )} +
+ ); +} diff --git a/ui/src/utils/roomAvailability.ts b/ui/src/utils/roomAvailability.ts new file mode 100644 index 0000000..4fc21b4 --- /dev/null +++ b/ui/src/utils/roomAvailability.ts @@ -0,0 +1,37 @@ +const availabilityCache: { [key: string]: boolean } = {}; + +export async function checkRoomAvailability(roomNumber: string, startDate: string, endDate: string) { + const cacheKey = `${roomNumber}-${startDate}-${endDate}`; + + if (availabilityCache[cacheKey] !== undefined) { + return availabilityCache[cacheKey]; + } + + try { + const response = await fetch(`/api/room/checkRoomAvailability?roomNumber=${roomNumber}&startDate=${startDate}&endDate=${endDate}`); + + if (!response.ok) { + throw new Error(`Failed to fetch. Status: ${response.status}`); + } + + const data = await response.json(); + + if (data && typeof data.available === 'boolean') { + + availabilityCache[cacheKey] = data.available; + + if (!data.available) { + alert( + `The room #${roomNumber} is already booked for the selected dates (${startDate} to ${endDate}). Please choose different dates.` + ); + } + + return data.available; + } else { + throw new Error('Invalid response format'); + } + } catch (error) { + console.error('Error checking room availability:', error); + return false; + } +} \ No newline at end of file