From 0748838f3f7a903ba95baa09f4e470bdca2d6694 Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 16:46:33 +0200 Subject: [PATCH 01/12] start script for powershell --- start.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 start.ps1 diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000..29f27b6 --- /dev/null +++ b/start.ps1 @@ -0,0 +1,6 @@ +# Run our three programs concurrently with some formatting +npx concurrently -ir --default-input-target 0 ` + "cd api & dotnet watch" ` + "cd ui & npm start" ` + "caddy run" + \ No newline at end of file From c3b1fd9a7090d33ab97a364c0c60019a34ae8875 Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 16:47:06 +0200 Subject: [PATCH 02/12] [RE-001|BACKEND] Allow guest booking --- .gitignore | 359 +++++++++++++++++- api/Controllers/ReservationController.cs | 17 +- api/Program.cs | 5 + api/Repositories/ReservationRepository.cs | 26 +- api/Services/IReservationValidation.cs | 65 ++++ reservations-interview.sln | 37 ++ .../Api.IntegrationTests.csproj | 28 ++ .../CustomWebApplicationFactory.cs | 39 ++ .../ReservationControllerTests.cs | 47 +++ tests/Api.UnitTests/Api.UnitTests.csproj | 27 ++ .../ReservationValidationTests.cs | 35 ++ 11 files changed, 679 insertions(+), 6 deletions(-) create mode 100644 api/Services/IReservationValidation.cs create mode 100644 reservations-interview.sln create mode 100644 tests/Api.IntegrationTests/Api.IntegrationTests.csproj create mode 100644 tests/Api.IntegrationTests/CustomWebApplicationFactory.cs create mode 100644 tests/Api.IntegrationTests/ReservationControllerTests.cs create mode 100644 tests/Api.UnitTests/Api.UnitTests.csproj create mode 100644 tests/Api.UnitTests/ReservationValidationTests.cs diff --git a/.gitignore b/.gitignore index 496ee2c..0c135ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,358 @@ -.DS_Store \ No newline at end of file +.DS_Store + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# Custom files +Database.db-* +.idea/ +appsettings.Development.json +appsettings.local.json + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ \ No newline at end of file diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index f17fe4d..23c7050 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -1,3 +1,4 @@ +using api.Services; using Microsoft.AspNetCore.Mvc; using Models; using Models.Errors; @@ -8,10 +9,14 @@ namespace Controllers [Tags("Reservations"), Route("reservation")] public class ReservationController : Controller { + private readonly IReservationValidation _reservationValidation; + private readonly RoomRepository _roomRepository; private ReservationRepository _repo { get; set; } - public ReservationController(ReservationRepository reservationRepository) + public ReservationController(ReservationRepository reservationRepository, IReservationValidation reservationValidation, RoomRepository roomRepository) { + _reservationValidation = reservationValidation; + _roomRepository = roomRepository; _repo = reservationRepository; } @@ -47,6 +52,13 @@ public async Task> BookReservation( [FromBody] Reservation newBooking ) { + // Validate booking + var validationResult = _reservationValidation.ValidateReservation(newBooking); + if (!string.IsNullOrEmpty(validationResult)) + { + return BadRequest(validationResult); + } + // Provide a real ID if one is not provided if (newBooking.Id == Guid.Empty) { @@ -55,6 +67,9 @@ [FromBody] Reservation newBooking try { + // GetRoom will throw in case room was not found + await _roomRepository.GetRoom(newBooking.RoomNumber); + var createdReservation = await _repo.CreateReservation(newBooking); return Created($"/reservation/${createdReservation.Id}", createdReservation); } diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..e507df4 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,4 +1,5 @@ using System.Data; +using api.Services; using Db; using Microsoft.Data.Sqlite; using Repositories; @@ -14,6 +15,7 @@ Services.AddSingleton(_ => new SqliteConnection(connectionString)); Services.AddSingleton(sp => sp.GetRequiredService()); + Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(); @@ -50,3 +52,6 @@ } app.Run(); + +// Necessary for internal visibility in integration tests +public partial class Program { } \ No newline at end of file diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..492a61c 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -49,10 +49,28 @@ 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 sql = """ + INSERT INTO Reservations (Id, RoomNumber, GuestEmail, Start, [End], CheckedIn, CheckedOut) + VALUES (@Id, @RoomNumber, @GuestEmail, @Start, @End, @CheckedIn, @CheckedOut) + """; + + var affectedRows = await _db.ExecuteAsync(sql, new + { + newReservation.Id, + newReservation.RoomNumber, + newReservation.GuestEmail, + newReservation.Start, + newReservation.End, + newReservation.CheckedIn, + newReservation.CheckedOut + }); + + if (affectedRows == 0) + { + throw new InvalidOperationException("Create reservation failed. No rows were affected."); + } + + return newReservation; } public async Task DeleteReservation(Guid reservationId) diff --git a/api/Services/IReservationValidation.cs b/api/Services/IReservationValidation.cs new file mode 100644 index 0000000..1f2cfa7 --- /dev/null +++ b/api/Services/IReservationValidation.cs @@ -0,0 +1,65 @@ +using System.Text.RegularExpressions; +using Models; + +namespace api.Services; + +public interface IReservationValidation +{ + string ValidateReservation(Reservation room); + string ValidateRoomNumber(string roomNumber); +} + +public partial class ReservationValidation : IReservationValidation +{ + public string ValidateReservation(Reservation room) + { + if (room.Start == default) + return "Start Date is required."; + if (room.End == default) + return "End Date is required."; + if (string.IsNullOrEmpty(room.GuestEmail)) + return "Email is required."; + if (string.IsNullOrEmpty(room.RoomNumber)) + return "Room Number is required."; + + if (room.Start >= room.End) + return "Start Date must be before the End Date."; + + var duration = (room.End - room.Start).TotalDays; + if (duration < 1) + return "Duration must be at least 1 day."; + if (duration > 30) + return "Duration cannot exceed 30 days."; + + if (!EmailValidationRegex().IsMatch(room.GuestEmail)) + return "Invalid email format. Email must include a domain."; + + var validateRoomNumber = ValidateRoomNumber(room.RoomNumber); + if (!string.IsNullOrEmpty(validateRoomNumber)) + return validateRoomNumber; + + return string.Empty; + } + + public string ValidateRoomNumber(string roomNumber) + { + if (!Regex.IsMatch(roomNumber, @"^\d{3}$")) + return "Room number must be exactly 3 digits"; + + // Parse the room number digits + var floor = int.Parse(roomNumber[0].ToString()); + var door = int.Parse(roomNumber.Substring(1, 2)); + + if (floor < 0 || floor > 9) + return "Floor number must be between 0 and 9"; + + if (door == 0) + return "Door number cannot be 00"; + + return string.Empty; + } + + // Taken from https://emailregex.com/ + [GeneratedRegex(@"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|""(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*"")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])")] + private static partial Regex EmailValidationRegex(); +} diff --git a/reservations-interview.sln b/reservations-interview.sln new file mode 100644 index 0000000..7c91d8e --- /dev/null +++ b/reservations-interview.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "api", "api\api.csproj", "{486E3D9C-83DC-4BE0-AA0B-584CB8765F6C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.IntegrationTests", "tests\Api.IntegrationTests\Api.IntegrationTests.csproj", "{C215BEC6-9238-4720-A185-38DA9F364A76}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.UnitTests", "tests\Api.UnitTests\Api.UnitTests.csproj", "{12C73EA2-B574-407A-9E93-C4B1EC1F4CD2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {486E3D9C-83DC-4BE0-AA0B-584CB8765F6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {486E3D9C-83DC-4BE0-AA0B-584CB8765F6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {486E3D9C-83DC-4BE0-AA0B-584CB8765F6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {486E3D9C-83DC-4BE0-AA0B-584CB8765F6C}.Release|Any CPU.Build.0 = Release|Any CPU + {C215BEC6-9238-4720-A185-38DA9F364A76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C215BEC6-9238-4720-A185-38DA9F364A76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C215BEC6-9238-4720-A185-38DA9F364A76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C215BEC6-9238-4720-A185-38DA9F364A76}.Release|Any CPU.Build.0 = Release|Any CPU + {12C73EA2-B574-407A-9E93-C4B1EC1F4CD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {12C73EA2-B574-407A-9E93-C4B1EC1F4CD2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {12C73EA2-B574-407A-9E93-C4B1EC1F4CD2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {12C73EA2-B574-407A-9E93-C4B1EC1F4CD2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {767B66E1-D158-4C70-B6F1-6CA9B7EA6945} + EndGlobalSection +EndGlobal diff --git a/tests/Api.IntegrationTests/Api.IntegrationTests.csproj b/tests/Api.IntegrationTests/Api.IntegrationTests.csproj new file mode 100644 index 0000000..debf68b --- /dev/null +++ b/tests/Api.IntegrationTests/Api.IntegrationTests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/Api.IntegrationTests/CustomWebApplicationFactory.cs b/tests/Api.IntegrationTests/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..42c9e43 --- /dev/null +++ b/tests/Api.IntegrationTests/CustomWebApplicationFactory.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Models; +using Repositories; + +namespace Api.IntegrationTests; + +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + var sp = services.BuildServiceProvider(); + + using var scope = sp.CreateScope(); + + var scopedServices = scope.ServiceProvider; + var roomRepository = scopedServices.GetRequiredService(); + var guestRepository = scopedServices.GetRequiredService(); + + // Seed test guest and rooms + try + { + guestRepository.CreateGuest(new Guest { Email = "test@test.it", Name = "test" }).GetAwaiter().GetResult(); + for (var i = 0; i < 5; i++) + { + roomRepository.CreateRoom(new Room { Number = $"00{i}", State = State.Ready }).GetAwaiter().GetResult(); + } + } + catch (Exception ex) + { + Console.WriteLine($"An error occurred seeding the database with test messages. Error: {ex.Message}"); + } + }); + } +} diff --git a/tests/Api.IntegrationTests/ReservationControllerTests.cs b/tests/Api.IntegrationTests/ReservationControllerTests.cs new file mode 100644 index 0000000..79a68bc --- /dev/null +++ b/tests/Api.IntegrationTests/ReservationControllerTests.cs @@ -0,0 +1,47 @@ +using System.Net; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Models; + +namespace Api.IntegrationTests; + +public class ReservationControllerTests : IClassFixture +{ + private readonly WebApplicationFactory _factory; + + private readonly JsonSerializerOptions _options = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public ReservationControllerTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task BookReservation_Should_Return_Created() + { + // Arrange + var client = _factory.CreateClient(); + var expectedReservation = new Reservation + { + RoomNumber = "001", + GuestEmail = "test@test.it", + Start = DateTime.Now, + End = DateTime.Now.AddDays(2), + }; + + // Act + var httpContent = new StringContent(JsonSerializer.Serialize(expectedReservation), Encoding.UTF8, MediaTypeNames.Application.Json); + var response = await client.PostAsync("api/reservation", httpContent); + var content = await response.Content.ReadAsStringAsync(); + var actualRreservation = JsonSerializer.Deserialize(content, _options); + + // Assert + Assert.True(response.IsSuccessStatusCode, content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(actualRreservation); + Assert.Equal(expectedReservation.GuestEmail, actualRreservation.GuestEmail); + Assert.Equal(expectedReservation.RoomNumber, actualRreservation.RoomNumber); + } +} diff --git a/tests/Api.UnitTests/Api.UnitTests.csproj b/tests/Api.UnitTests/Api.UnitTests.csproj new file mode 100644 index 0000000..f27c21e --- /dev/null +++ b/tests/Api.UnitTests/Api.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/tests/Api.UnitTests/ReservationValidationTests.cs b/tests/Api.UnitTests/ReservationValidationTests.cs new file mode 100644 index 0000000..6a33993 --- /dev/null +++ b/tests/Api.UnitTests/ReservationValidationTests.cs @@ -0,0 +1,35 @@ +using api.Services; + +namespace Api.UnitTests; + +public class ReservationValidationTests +{ + private readonly ReservationValidation _sut = new(); + + [Theory] + [InlineData("101")] + [InlineData("102")] + [InlineData("103")] + [InlineData("104")] + [InlineData("105")] + [InlineData("201")] + [InlineData("202")] + [InlineData("203")] + public void Valid_Rooms(string roomNumber) + { + Assert.Empty(_sut.ValidateRoomNumber(roomNumber)); + } + + [Theory] + [InlineData("")] + [InlineData("-101")] + [InlineData("100")] + [InlineData("0")] + [InlineData("1")] + [InlineData("2020")] + [InlineData("000")] + public void Invalid_Rooms(string roomNumber) + { + Assert.NotEmpty(_sut.ValidateRoomNumber(roomNumber)); + } +} From 6c96a18241f9e602ba621f1e3f5e8742d9e3dbef Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 17:00:15 +0200 Subject: [PATCH 03/12] [RE-001|BACKEND] Allow guest creation --- api/Controllers/GuestController.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestController.cs index 095d570..50e69dd 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestController.cs @@ -21,5 +21,19 @@ public async Task> GetGuests() return Json(guests); } + + /// + /// Create a new guest + /// + /// + /// + [HttpPost, Produces("application/json"), Route("")] + public async Task> CreateGuest( + [FromBody] Guest newGuest + ) + { + await _repo.CreateGuest(newGuest); + return Created(); + } } } From df59a08d996681125c1d0c94941c648a93bd370c Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 17:13:22 +0200 Subject: [PATCH 04/12] [RE-001|FRONTEND] Allow guest booking --- ui/src/reservations/ReservationPage.tsx | 13 +++++++++++-- ui/src/reservations/api.ts | 19 +++++++++++-------- ui/src/utils/toasts.tsx | 12 ++++++++++++ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 06a0036..83b321c 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useShowSuccessToast } from "../utils/toasts"; +import { useShowErrorToast } from "../utils/toasts"; import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; import { bookRoom, NewReservation, useGetRooms } from "./api"; @@ -19,13 +20,21 @@ export function ReservationPage() { const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); const showToast = useShowSuccessToast("We have received your booking!"); + const showErrorToast = useShowErrorToast("Error while booking the room!"); function onClose() { setSelectedRoomNumber(""); } - function onSubmit(booking: NewReservation) { - bookRoom(booking).then(onClose).then(showToast); + async function onSubmit(booking: NewReservation) { + try { + await bookRoom(booking); + } catch (error) { + showErrorToast(); + return; + } + onClose(); + showToast(); } const createClickHandler = (roomNumber: string) => () => { diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..6321873 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -12,16 +12,16 @@ 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 function bookRoom(booking: NewReservation) { +export async function bookRoom(booking: NewReservation) { // unwrap branded types const newReservation = { ...booking, @@ -29,8 +29,11 @@ export function bookRoom(booking: NewReservation) { End: toIsoStr(booking.End), }; - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); + var json = await ky.post("api/reservation", { json: newReservation }).json(); + console.log("here"); + var parsed = await ReservationSchema.parseAsync(json); + + return Promise.resolve(parsed as any as Reservation); } const RoomSchema = z.object({ diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index d3358f7..14902ba 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(message: string) { + return useCallback( + () => + toast.custom( + (t) => , + DEFAULT_TOAST_OPTIONS, + ), + [message], + ); +} From ddc522796d2c06eb17073d45f2f8e67a0e4bb548 Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 17:16:07 +0200 Subject: [PATCH 05/12] [RE-002|BACKEND] Typo --- api/Controllers/ReservationController.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 23c7050..9c1a858 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -11,19 +11,19 @@ public class ReservationController : Controller { private readonly IReservationValidation _reservationValidation; private readonly RoomRepository _roomRepository; - private ReservationRepository _repo { get; set; } + private ReservationRepository _reservationRepository; public ReservationController(ReservationRepository reservationRepository, IReservationValidation reservationValidation, RoomRepository roomRepository) { _reservationValidation = reservationValidation; _roomRepository = roomRepository; - _repo = reservationRepository; + _reservationRepository = reservationRepository; } [HttpGet, Produces("application/json"), Route("")] public async Task> GetReservations() { - var reservations = await _repo.GetReservations(); + var reservations = await _reservationRepository.GetReservations(); return Json(reservations); } @@ -33,7 +33,7 @@ public async Task> GetRoom(Guid reservationId) { try { - var reservation = await _repo.GetReservation(reservationId); + var reservation = await _reservationRepository.GetReservation(reservationId); return Json(reservation); } catch (NotFoundException) @@ -70,7 +70,7 @@ [FromBody] Reservation newBooking // GetRoom will throw in case room was not found await _roomRepository.GetRoom(newBooking.RoomNumber); - var createdReservation = await _repo.CreateReservation(newBooking); + var createdReservation = await _reservationRepository.CreateReservation(newBooking); return Created($"/reservation/${createdReservation.Id}", createdReservation); } catch (Exception ex) @@ -85,7 +85,7 @@ [FromBody] Reservation newBooking [HttpDelete, Produces("application/json"), Route("{reservationId}")] public async Task DeleteReservation(Guid reservationId) { - var result = await _repo.DeleteReservation(reservationId); + var result = await _reservationRepository.DeleteReservation(reservationId); return result ? NoContent() : NotFound(); } From 23b3fdad372a6b1ed1e8e7f73c7fd285b73521ff Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 17:35:21 +0200 Subject: [PATCH 06/12] [RE-002|BACKEND] Clean DB when running tests --- .../Api.IntegrationTests/CustomWebApplicationFactory.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/Api.IntegrationTests/CustomWebApplicationFactory.cs b/tests/Api.IntegrationTests/CustomWebApplicationFactory.cs index 42c9e43..3260a1c 100644 --- a/tests/Api.IntegrationTests/CustomWebApplicationFactory.cs +++ b/tests/Api.IntegrationTests/CustomWebApplicationFactory.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Hosting; +using Db; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -13,9 +14,14 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureTestServices(services => { + if (File.Exists("reservations.db")) + { + File.Delete("reservations.db"); + } var sp = services.BuildServiceProvider(); using var scope = sp.CreateScope(); + Setup.EnsureDb(scope); var scopedServices = scope.ServiceProvider; var roomRepository = scopedServices.GetRequiredService(); @@ -25,6 +31,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) try { guestRepository.CreateGuest(new Guest { Email = "test@test.it", Name = "test" }).GetAwaiter().GetResult(); + guestRepository.CreateGuest(new Guest { Email = "test2@test.it", Name = "test2" }).GetAwaiter().GetResult(); for (var i = 0; i < 5; i++) { roomRepository.CreateRoom(new Room { Number = $"00{i}", State = State.Ready }).GetAwaiter().GetResult(); From 6b8c34c307a808e1ba437e72f55de96146f48a56 Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 17:36:24 +0200 Subject: [PATCH 07/12] [RE-002|BACKEND] Prevent double bookings --- api/Controllers/ReservationController.cs | 7 ++++ api/Models/Reservation.cs | 2 + .../ReservationControllerTests.cs | 37 ++++++++++++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs index 9c1a858..e656666 100644 --- a/api/Controllers/ReservationController.cs +++ b/api/Controllers/ReservationController.cs @@ -59,6 +59,13 @@ [FromBody] Reservation newBooking return BadRequest(validationResult); } + // prevent double bookings + var reservations = await _reservationRepository.GetReservations(); + if (reservations.Where(r => r.RoomNumber == newBooking.RoomNumber).Any(reservation => reservation.Overlaps(newBooking))) + { + return Conflict("Reservation overlaps"); + } + // Provide a real ID if one is not provided if (newBooking.Id == Guid.Empty) { diff --git a/api/Models/Reservation.cs b/api/Models/Reservation.cs index 978929a..8a3e345 100644 --- a/api/Models/Reservation.cs +++ b/api/Models/Reservation.cs @@ -15,5 +15,7 @@ public class Reservation public DateTime End { get; set; } public bool CheckedIn { get; set; } public bool CheckedOut { get; set; } + + public bool Overlaps(Reservation newReservation) => newReservation.Start < End && Start < newReservation.End; } } diff --git a/tests/Api.IntegrationTests/ReservationControllerTests.cs b/tests/Api.IntegrationTests/ReservationControllerTests.cs index 79a68bc..bcfd426 100644 --- a/tests/Api.IntegrationTests/ReservationControllerTests.cs +++ b/tests/Api.IntegrationTests/ReservationControllerTests.cs @@ -35,13 +35,48 @@ public async Task BookReservation_Should_Return_Created() var httpContent = new StringContent(JsonSerializer.Serialize(expectedReservation), Encoding.UTF8, MediaTypeNames.Application.Json); var response = await client.PostAsync("api/reservation", httpContent); var content = await response.Content.ReadAsStringAsync(); - var actualRreservation = JsonSerializer.Deserialize(content, _options); // Assert Assert.True(response.IsSuccessStatusCode, content); Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var actualRreservation = JsonSerializer.Deserialize(content, _options); Assert.NotNull(actualRreservation); Assert.Equal(expectedReservation.GuestEmail, actualRreservation.GuestEmail); Assert.Equal(expectedReservation.RoomNumber, actualRreservation.RoomNumber); } + + [Fact] + public async Task Reservations_Should_Not_Overlap() + { + // Arrange + var client = _factory.CreateClient(); + + var reservation1 = new Reservation + { + RoomNumber = "002", + GuestEmail = "test@test.it", + Start = DateTime.Now, + End = DateTime.Now.AddDays(2), + }; + var httpContent = new StringContent(JsonSerializer.Serialize(reservation1), Encoding.UTF8, MediaTypeNames.Application.Json); + var response = await client.PostAsync("api/reservation", httpContent); + var content = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var reservation2 = new Reservation + { + RoomNumber = "002", + GuestEmail = "test2@test.it", + Start = DateTime.Now, + End = DateTime.Now.AddDays(2), + }; + + // Act + httpContent = new StringContent(JsonSerializer.Serialize(reservation2), Encoding.UTF8, MediaTypeNames.Application.Json); + response = await client.PostAsync("api/reservation", httpContent); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } } From 2a7ff57a6ed7540cb17cb3d89d55f76f6d51c157 Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 17:39:53 +0200 Subject: [PATCH 08/12] [RE-001|FRONTEND] Forgot the ErrorToast component --- ui/src/components/ErrorToast.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 ui/src/components/ErrorToast.tsx diff --git a/ui/src/components/ErrorToast.tsx b/ui/src/components/ErrorToast.tsx new file mode 100644 index 0000000..68d37a0 --- /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 BorderedSuccessBox = styled(Box)` + background-color: var(--red-5); + border-radius: var(--radius-4); + border: 1px solid var(--red-9); +`; + +/** A successful toast */ +export function ErrorToast({ toastId, message }: ErrorToastProps) { + const closeToast = useCallback(() => toast.dismiss(toastId), [toastId]); + + return ( + + + {message} + + + ); +} From 800afa264ef60b6cd9bdc800ef52b51986121a78 Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 18:22:58 +0200 Subject: [PATCH 09/12] [RE-003|BACKEND] New staff endpoints to get reservations --- api/Controllers/StaffController.cs | 24 ++++- .../StaffControllerTests.cs | 96 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/Api.IntegrationTests/StaffControllerTests.cs diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..fa959ae 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,14 +1,17 @@ using Microsoft.AspNetCore.Mvc; +using Repositories; namespace Controllers { [Route("staff")] public class StaffController : Controller { + private readonly ReservationRepository _reservationRepository; private IConfiguration Config { get; set; } - public StaffController(IConfiguration config) + public StaffController(IConfiguration config, ReservationRepository reservationRepository) { + _reservationRepository = reservationRepository; Config = config; } @@ -16,6 +19,7 @@ public StaffController(IConfiguration 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 @@ -65,5 +69,23 @@ public IActionResult CheckCookie() return Ok("Authorized"); } + + /// + /// View all reservations that are for today or in the future. + /// + /// + [HttpGet, Route("reservations")] + public async Task GetReservations() + { + if (IsNotStaff(Request, out IActionResult? result)) + { + return result!; + } + + var reservations = await _reservationRepository.GetReservations(); + + // it might be useful to have a custom ITimeProvider + return Json(reservations.Where(r => r.Start >= DateTime.Today)); + } } } diff --git a/tests/Api.IntegrationTests/StaffControllerTests.cs b/tests/Api.IntegrationTests/StaffControllerTests.cs new file mode 100644 index 0000000..d734662 --- /dev/null +++ b/tests/Api.IntegrationTests/StaffControllerTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Models; + +namespace Api.IntegrationTests; + +public class StaffControllerTests : IClassFixture +{ + private readonly WebApplicationFactory _factory; + + private readonly JsonSerializerOptions _options = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public StaffControllerTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetReservations_Should_Return_Todays_And_Future_Reservations() + { + // Arrange + + // creates an authenticated client + var client = _factory.CreateDefaultClient(new CookieDelegatingHandler("access", "1")); + await CreateReservation(client, new Reservation + { + // this is in the past + RoomNumber = "001", + GuestEmail = "test@test.it", + Start = DateTime.Now.AddDays(-10), + End = DateTime.Now.AddDays(-8), + }); + await CreateReservation(client, new Reservation + { + // today + RoomNumber = "001", + GuestEmail = "test@test.it", + Start = DateTime.Now, + End = DateTime.Now.AddDays(2), + }); + await CreateReservation(client, new Reservation + { + // future + RoomNumber = "001", + GuestEmail = "test@test.it", + Start = DateTime.Now.AddDays(10), + End = DateTime.Now.AddDays(12), + }); + + // Act + var response = await client.GetAsync("api/staff/reservations"); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var reservations = JsonSerializer.Deserialize(content, _options); + Assert.NotNull(reservations); + Assert.Equal(2, reservations.Length); + Assert.All(reservations, r => Assert.True(r.Start >= DateTime.Today)); + } + + private async Task CreateReservation(HttpClient client, Reservation reservation) + { + var httpContent = new StringContent(JsonSerializer.Serialize(reservation), Encoding.UTF8, MediaTypeNames.Application.Json); + var response = await client.PostAsync("api/reservation", httpContent); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.True(response.IsSuccessStatusCode, content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } +} + +public class CookieDelegatingHandler : DelegatingHandler +{ + private readonly string _cookieName; + private readonly string _cookieValue; + + public CookieDelegatingHandler(string cookieName, string cookieValue) + { + _cookieName = cookieName; + _cookieValue = cookieValue; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Add the cookie to the request header + request.Headers.Add("Cookie", $"{_cookieName}={_cookieValue}"); + + // Call the inner handler to continue the request processing + return await base.SendAsync(request, cancellationToken); + } +} From 553f2d747ee06773ae107cb950b9e1ddff7ccda3 Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 19:50:52 +0200 Subject: [PATCH 10/12] [RE-003|BACKEND] Revert Where on staff endpoint --- api/Controllers/StaffController.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index fa959ae..9995ec7 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -84,8 +84,11 @@ public async Task GetReservations() var reservations = await _reservationRepository.GetReservations(); + // TODO: throws an error => Collection was modified; enumeration operation may not execute // it might be useful to have a custom ITimeProvider - return Json(reservations.Where(r => r.Start >= DateTime.Today)); + // var list = reservations.Where(r => r.Start >= DateTime.Today); + + return Json(reservations); } } } From 153fbe7148f65c6a24551a81b0d03f1f1f9ceae8 Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 19:54:53 +0200 Subject: [PATCH 11/12] [RE-003|FRONTEND] Staff Login --- ui/src/LandingPage.tsx | 11 ++---- ui/src/router.tsx | 32 +++++++++++++++++ ui/src/staff/LoginPage.tsx | 70 ++++++++++++++++++++++++++++++++++++++ ui/src/staff/StaffPage.tsx | 66 +++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 ui/src/staff/LoginPage.tsx create mode 100644 ui/src/staff/StaffPage.tsx diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..1b117b8 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..6f64b35 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -2,10 +2,14 @@ import { createRootRoute, createRoute, createRouter, + redirect, } from "@tanstack/react-router"; import { Layout } from "./Layout"; import { LandingPage } from "./LandingPage"; import { ReservationPage } from "./reservations/ReservationPage"; +import { LoginPage } from "./staff/LoginPage"; +import { StaffPage } from "./staff/StaffPage"; +import ky from "ky"; const rootRoute = createRootRoute({ component: Layout, @@ -26,8 +30,36 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + createRoute({ + path: "/login", + getParentRoute: getRootRoute, + component: LoginPage, + }), + createRoute({ + path: "/staff", + getParentRoute: getRootRoute, + component: StaffPage, + beforeLoad: async () => { + const isAuthenticated = await IsAuthenticated() + if (!isAuthenticated){ + throw redirect({ + to: "/login" + }); + } + } + }), ]; +async function IsAuthenticated() : Promise { + try { + await ky.get("api/staff/check"); + return true; + } catch (error) { + console.log("Check error:", error); + return false; + } +} + const routeTree = rootRoute.addChildren(ROUTES); export const router = createRouter({ routeTree }); diff --git a/ui/src/staff/LoginPage.tsx b/ui/src/staff/LoginPage.tsx new file mode 100644 index 0000000..7e5766a --- /dev/null +++ b/ui/src/staff/LoginPage.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import ky from "ky"; +import { useNavigate } from '@tanstack/react-router'; + +export function LoginPage() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!username || !password) { + setError('Username and password are required.'); + return; + } + + setError(null); + + try { + await ky.get("api/staff/login", { + headers: { + "X-Staff-Code": password + } + }); + await ky.get("api/staff/check"); + } catch (error) { + console.log("Login error:", error); + setError("Invalid credentials"); + return; + } + + navigate({to: "/staff"}); + }; + + return ( +
+
+

Login

+ + {error &&

{error}

} + +
+ setUsername(e.target.value)} + style={{ padding: '10px', width: '100%' }} + /> +
+ +
+ setPassword(e.target.value)} + style={{ padding: '10px', width: '100%' }} + /> +
+ + +
+
+ ); +} diff --git a/ui/src/staff/StaffPage.tsx b/ui/src/staff/StaffPage.tsx new file mode 100644 index 0000000..26d33fb --- /dev/null +++ b/ui/src/staff/StaffPage.tsx @@ -0,0 +1,66 @@ +import ky from "ky"; +import { useEffect, useState } from "react"; +import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; + +const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { + sm: "1", + md: "2", + lg: "4", +}; + +export interface Reservation { + id: string; + roomNumber: string; + guestEmail: string; + start: string; + end: string; +} + +export function StaffPage() { + const [reservations, setReservations] = useState([]); + + useEffect(() => { + async function GetReservations() { + try { + const reservations = await ky.get("api/staff/reservations").json(); + setReservations(reservations as unknown as Reservation[]); + } catch (error) { + console.log("Check error:", error); + } + } + + GetReservations() + }, []); + + return ( +
+ + Reservations + + + + + {reservations?.map((reservation) => ( +
+

Guest: {reservation.guestEmail}

+

Room: {reservation.roomNumber}

+

Start Date: {reservation.start}

+

End Date: {reservation.end}

+
+ ))} +
+
+
+ ); +} + +const styles = { + card: { + border: '1px solid #ccc', + borderRadius: '8px', + padding: '16px', + marginBottom: '10px', + backgroundColor: '#f9f9f9', + width: '300px', + }, +}; From dc0c5aed6d95244c7a7be57096f7c73b3406fcc7 Mon Sep 17 00:00:00 2001 From: antoniovalentini Date: Mon, 21 Oct 2024 20:16:00 +0200 Subject: [PATCH 12/12] Remove debug log --- ui/src/reservations/api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 6321873..b17733e 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -30,7 +30,6 @@ export async function bookRoom(booking: NewReservation) { }; var json = await ky.post("api/reservation", { json: newReservation }).json(); - console.log("here"); var parsed = await ReservationSchema.parseAsync(json); return Promise.resolve(parsed as any as Reservation);