diff --git a/.github/workflows/IntegrationTest.yaml b/.github/workflows/IntegrationTest.yaml index 38b2fb0..da4f7a3 100644 --- a/.github/workflows/IntegrationTest.yaml +++ b/.github/workflows/IntegrationTest.yaml @@ -33,10 +33,10 @@ jobs: rm ./dapr/components/dapr-secretstore.json echo "{\"SignalRConnectionString\": \"${{ secrets.SAGAWAY_SIGNALR_CONNECTION_STRING }}\"}" > ./dapr/components/dapr-secretstore.json - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: | - 8.0.x + 9.0.x - name: Sagaway Core Tests run: dotnet test --verbosity normal --configuration Debug Sagaway.Tests/Sagaway.Tests.csproj - name: Docker Compose Up diff --git a/.gitignore b/.gitignore index 9491a2f..389a683 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,11 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd +/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait10_2_failed_b_1_success.received.txt +/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait5_2_success_b_1_failed_wait5_2_failed.received.txt +/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_failed_on_2_no_callback_revert.received.txt + +#web artifacrs +/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/lib +/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/css/app.Output.css diff --git a/Sagaway.Callback.Propagator/Sagaway.Callback.Propagator.csproj b/Sagaway.Callback.Propagator/Sagaway.Callback.Propagator.csproj index 08b7732..ffb22d4 100644 --- a/Sagaway.Callback.Propagator/Sagaway.Callback.Propagator.csproj +++ b/Sagaway.Callback.Propagator/Sagaway.Callback.Propagator.csproj @@ -41,8 +41,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Sagaway.Callback.Router/Sagaway.Callback.Router.csproj b/Sagaway.Callback.Router/Sagaway.Callback.Router.csproj index 90f4f3b..29f7358 100644 --- a/Sagaway.Callback.Router/Sagaway.Callback.Router.csproj +++ b/Sagaway.Callback.Router/Sagaway.Callback.Router.csproj @@ -41,13 +41,13 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Sagaway.Hosts.DaprActorHost/Sagaway.Hosts.DaprActorHost.csproj b/Sagaway.Hosts.DaprActorHost/Sagaway.Hosts.DaprActorHost.csproj index 840fd25..ce82a3b 100644 --- a/Sagaway.Hosts.DaprActorHost/Sagaway.Hosts.DaprActorHost.csproj +++ b/Sagaway.Hosts.DaprActorHost/Sagaway.Hosts.DaprActorHost.csproj @@ -46,7 +46,7 @@ - + all diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Dockerfile b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Dockerfile index aa132bc..eff224f 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Dockerfile +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Dockerfile @@ -1,12 +1,12 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER app WORKDIR /app EXPOSE 8080 EXPOSE 8081 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Sagaway.IntegrationTests.OrchestrationService.csproj", "Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/"] diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Sagaway.IntegrationTests.OrchestrationService.csproj b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Sagaway.IntegrationTests.OrchestrationService.csproj index 41ca517..5683e87 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Sagaway.IntegrationTests.OrchestrationService.csproj +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Sagaway.IntegrationTests.OrchestrationService.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 enable enable true @@ -12,17 +12,17 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/Dockerfile b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/Dockerfile index 2c54649..ed6dd77 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/Dockerfile +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/Dockerfile @@ -1,7 +1,7 @@ # See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. # This stage is used when running from VS in fast mode (Default for Debug configuration) -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER app WORKDIR /app EXPOSE 8080 @@ -9,7 +9,7 @@ EXPOSE 8081 # This stage is used to build the service project -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/Sagaway.IntegrationTests.StepRecorderTestService.csproj", "Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/"] diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/Sagaway.IntegrationTests.StepRecorderTestService.csproj b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/Sagaway.IntegrationTests.StepRecorderTestService.csproj index 6b9c525..2c29bfc 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/Sagaway.IntegrationTests.StepRecorderTestService.csproj +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.StepRecorderTestService/Sagaway.IntegrationTests.StepRecorderTestService.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net9.0 enable enable Linux @@ -10,16 +10,16 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait10_2_failed_b_1_success.approved.txt b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait10_2_failed_b_1_success.approved.txt index 79162d1..804e88e 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait10_2_failed_b_1_success.approved.txt +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait10_2_failed_b_1_success.approved.txt @@ -12,6 +12,9 @@ Saga Log: [*time*][CallA]: Registering reminder CallA:Retry for CallA with interval 00:00:10 [*time*]The Saga is deactivated. [*time*]The Saga is activated. +[*time*][CallB]: CallB Success +[*time*]The Saga is deactivated. +[*time*]The Saga is activated. [*time*][CallA]: Wake by a reminder [*time*][CallA]: OnReminderAsync: Validation for CallA returned false, retrying action. [*time*][CallA]: Retry CallA. Retry count: 2 @@ -19,9 +22,6 @@ Saga Log: [*time*][CallA]: Registering reminder CallA:Retry for CallA with interval 00:00:10 [*time*]The Saga is deactivated. [*time*]The Saga is activated. -[*time*][CallB]: CallB Success -[*time*]The Saga is deactivated. -[*time*]The Saga is activated. [*time*]The Saga is deactivated. [*time*]The Saga is activated. [*time*][CallA]: CallA Failed. Retries exhausted. @@ -113,8 +113,8 @@ Open Telemetry: } }, { - "TraceId": "id-10", - "ParentId": "id-11", + "TraceId": "id-1", + "ParentId": "id-10", "Kind": "SERVER", "Name": "name-4", "LocalEndpoint": { @@ -128,33 +128,33 @@ Open Telemetry: } }, { - "TraceId": "id-1", + "TraceId": "id-11", "ParentId": "id-12", - "Kind": null, - "Name": "name-13", + "Kind": "SERVER", + "Name": "name-4", "LocalEndpoint": { "ServiceName": "orchestrationservice.sagaway" }, "Tags": { - "operation.name": "CallA", "otel.library.name": "OrchestrationService.Sagaway", "otel.scope.name": "OrchestrationService.Sagaway", - "saga.id": "id-3" + "saga.id": "id-3", + "saga.type": "SagaTestActorOperations" } }, { "TraceId": "id-1", - "ParentId": "id-14", - "Kind": "SERVER", - "Name": "name-4", + "ParentId": "id-13", + "Kind": null, + "Name": "name-14", "LocalEndpoint": { "ServiceName": "orchestrationservice.sagaway" }, "Tags": { + "operation.name": "CallA", "otel.library.name": "OrchestrationService.Sagaway", "otel.scope.name": "OrchestrationService.Sagaway", - "saga.id": "id-3", - "saga.type": "SagaTestActorOperations" + "saga.id": "id-3" } }, { @@ -203,7 +203,7 @@ Open Telemetry: } }, { - "TraceId": "id-10", + "TraceId": "id-11", "ParentId": "id-20", "Kind": "SERVER", "Name": "name-4", diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait5_2_success_b_1_failed_wait5_2_failed.approved.txt b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait5_2_success_b_1_failed_wait5_2_failed.approved.txt index 3f39496..2422308 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait5_2_success_b_1_failed_wait5_2_failed.approved.txt +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_1_failed_wait5_2_success_b_1_failed_wait5_2_failed.approved.txt @@ -24,9 +24,17 @@ Saga Log: [*time*][CallB]: Registering reminder CallB:Retry for CallB with interval 00:00:30 [*time*]The Saga is deactivated. [*time*]The Saga is activated. -[*time*][CallA]: Wake by a reminder -[*time*][CallA]: OnReminderAsync: Validation for CallA returned false, retrying action. -[*time*][CallA]: CallA Failed. Retries exhausted. +[*time*][CallA]: CallA Success +[*time*]The Saga is deactivated. +[*time*]The Saga is activated. +[*time*]The Saga is deactivated. +[*time*]The Saga is activated. +[*time*][CallB]: Retry CallB. Retry count: 2 +[*time*][CallB]: Start Executing CallB +[*time*][CallB]: Registering reminder CallB:Retry for CallB with interval 00:00:30 +[*time*]The Saga is deactivated. +[*time*]The Saga is activated. +[*time*][CallB]: CallB Failed. Retries exhausted. [*time*][CallA]: Start Executing Revert CallA [*time*][CallA]: No undo operation for CallA. Marking as reverted [*time*][CallB]: Start Executing Revert CallB @@ -170,13 +178,73 @@ Open Telemetry: } }, { - "TraceId": "id-16", + "TraceId": "id-1", + "ParentId": "id-16", + "Kind": "SERVER", + "Name": "name-4", + "LocalEndpoint": { + "ServiceName": "orchestrationservice.sagaway" + }, + "Tags": { + "otel.library.name": "OrchestrationService.Sagaway", + "otel.scope.name": "OrchestrationService.Sagaway", + "saga.id": "id-3", + "saga.type": "SagaTestActorOperations" + } + }, + { + "TraceId": "id-10", "ParentId": "id-17", "Kind": "SERVER", "Name": "name-4", "LocalEndpoint": { "ServiceName": "orchestrationservice.sagaway" }, + "Tags": { + "otel.library.name": "OrchestrationService.Sagaway", + "otel.scope.name": "OrchestrationService.Sagaway", + "saga.id": "id-3", + "saga.type": "SagaTestActorOperations" + } + }, + { + "TraceId": "id-1", + "ParentId": "id-18", + "Kind": "SERVER", + "Name": "name-4", + "LocalEndpoint": { + "ServiceName": "orchestrationservice.sagaway" + }, + "Tags": { + "otel.library.name": "OrchestrationService.Sagaway", + "otel.scope.name": "OrchestrationService.Sagaway", + "saga.id": "id-3", + "saga.type": "SagaTestActorOperations" + } + }, + { + "TraceId": "id-1", + "ParentId": "id-19", + "Kind": null, + "Name": "name-20", + "LocalEndpoint": { + "ServiceName": "orchestrationservice.sagaway" + }, + "Tags": { + "operation.name": "CallB", + "otel.library.name": "OrchestrationService.Sagaway", + "otel.scope.name": "OrchestrationService.Sagaway", + "saga.id": "id-3" + } + }, + { + "TraceId": "id-1", + "ParentId": "id-21", + "Kind": "SERVER", + "Name": "name-4", + "LocalEndpoint": { + "ServiceName": "orchestrationservice.sagaway" + }, "Tags": { "otel.library.name": "OrchestrationService.Sagaway", "otel.scope.name": "OrchestrationService.Sagaway", diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_failed_on_2_no_callback_revert.approved.txt b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_failed_on_2_no_callback_revert.approved.txt index d092c64..606faa8 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_failed_on_2_no_callback_revert.approved.txt +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/ApprovalFiles/test_a_failed_on_2_no_callback_revert.approved.txt @@ -20,8 +20,6 @@ Saga Log: [*time*][CallA]: Registering reminder CallA:Retry for Revert CallA with interval 00:00:05 [*time*]The Saga is deactivated. [*time*]The Saga is activated. -[*time*][CallA]: Wake by a reminder -[*time*][CallA]: OnReminderAsync: Revert CallA passed validation successfully. [*time*][CallA]: Revert CallA Success Open Telemetry: @@ -147,8 +145,8 @@ Open Telemetry: } }, { - "TraceId": "id-15", - "ParentId": "id-16", + "TraceId": "id-1", + "ParentId": "id-15", "Kind": "SERVER", "Name": "name-4", "LocalEndpoint": { diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/Sagaway.IntegrationTests.TestProject.csproj b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/Sagaway.IntegrationTests.TestProject.csproj index 0462901..14635f0 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/Sagaway.IntegrationTests.TestProject.csproj +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestProject/Sagaway.IntegrationTests.TestProject.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 enable enable @@ -11,26 +11,26 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - + + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/Dockerfile b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/Dockerfile index 6841043..08c981e 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/Dockerfile +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/Dockerfile @@ -1,12 +1,12 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER app WORKDIR /app EXPOSE 8080 EXPOSE 8081 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/Sagaway.IntegrationTests.TestService.csproj", "Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/"] diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/Sagaway.IntegrationTests.TestService.csproj b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/Sagaway.IntegrationTests.TestService.csproj index 0bab55e..561d6ce 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/Sagaway.IntegrationTests.TestService.csproj +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestService/Sagaway.IntegrationTests.TestService.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 enable enable true @@ -12,17 +12,17 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/Dockerfile b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/Dockerfile index d12ca23..e53fed5 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/Dockerfile +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/Dockerfile @@ -1,7 +1,7 @@ # See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. # This stage is used when running from VS in fast mode (Default for Debug configuration) -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER app WORKDIR /app EXPOSE 8080 @@ -9,7 +9,7 @@ EXPOSE 8081 # This stage is used to build the service project -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/Sagaway.IntegrationTests.TestSubSagaCommunicationService.csproj", "Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/"] diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/Sagaway.IntegrationTests.TestSubSagaCommunicationService.csproj b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/Sagaway.IntegrationTests.TestSubSagaCommunicationService.csproj index fbbfe6e..1bba78c 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/Sagaway.IntegrationTests.TestSubSagaCommunicationService.csproj +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/Sagaway.IntegrationTests.TestSubSagaCommunicationService.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable true @@ -12,16 +12,16 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/Sagaway.OpenTelemetry/Sagaway.OpenTelemetry.csproj b/Sagaway.OpenTelemetry/Sagaway.OpenTelemetry.csproj index 75ef63d..3462d82 100644 --- a/Sagaway.OpenTelemetry/Sagaway.OpenTelemetry.csproj +++ b/Sagaway.OpenTelemetry/Sagaway.OpenTelemetry.csproj @@ -48,7 +48,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Dockerfile b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Dockerfile index d311896..7969706 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Dockerfile +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Dockerfile @@ -1,12 +1,12 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER app WORKDIR /app EXPOSE 80 EXPOSE 443 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Sagaway.ReservationDemo.BillingManagement.csproj", "Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/"] diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Program.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Program.cs index 3442e06..330b629 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Program.cs +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Program.cs @@ -84,7 +84,7 @@ { var randomNumber = rnd.Next(0, 6); var charged = randomNumber < 4; - var refund = randomNumber == 4; + var refund = randomNumber > 2; logger.LogInformation("Billing status for reservation id {reservationId} is {charged}", reservationId, charged); diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Sagaway.ReservationDemo.BillingManagement.csproj b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Sagaway.ReservationDemo.BillingManagement.csproj index 3bf62f1..4e718d1 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Sagaway.ReservationDemo.BillingManagement.csproj +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BillingManagement/Sagaway.ReservationDemo.BillingManagement.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 enable enable true @@ -12,16 +12,16 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/BookingStatus.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/BookingStatus.cs new file mode 100644 index 0000000..b212233 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/BookingStatus.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Sagaway.ReservationDemo.BookingManagement; + +/// +/// Enum representing the possible states of a reservation +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BookingStatus +{ + Unknown, // Unknown status + Reserved, // Successfully reserved + Cancelled // Successfully cancelled +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Dockerfile b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Dockerfile index 109a4f4..468ec5b 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Dockerfile +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Dockerfile @@ -1,12 +1,12 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER app WORKDIR /app EXPOSE 80 EXPOSE 443 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Sagaway.ReservationDemo.BookingManagement.csproj", "Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/"] diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Program.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Program.cs index d7f339f..8a4faff 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Program.cs +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Program.cs @@ -43,6 +43,8 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +string MakeStateStoreKey(string reservationId) => "Booking_" + reservationId; + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -63,8 +65,8 @@ [FromServices] ICallbackBindingNameProvider callbackBindingNameProvider, [FromServices] DaprClient daprClient) => { - logger.LogInformation("Received car reservation request for {CarClass} from {CustomerName}", - request.CarClass, request.CustomerName); + logger.LogInformation("Received car {request} request for {CarClass} from {CustomerName}", + request.ActionType, request.CarClass, request.CustomerName); var reservationId = request.ReservationId.ToString(); @@ -93,7 +95,7 @@ try { - (reservationState, etag) = await daprClient.GetStateAndETagAsync("statestore", reservationId, metadata: jsonMetadata); + (reservationState, etag) = await daprClient.GetStateAndETagAsync("statestore", MakeStateStoreKey(reservationId), metadata: jsonMetadata); } catch (DaprException ex) when (ex.InnerException is Grpc.Core.RpcException { Status.StatusCode: Grpc.Core.StatusCode.Internal } grpcEx) @@ -123,6 +125,7 @@ Id = request.ReservationId, ReservationStatusUpdateTime = messageDispatchTime, CustomerName = request.CustomerName, + CarClass = request.CarClass, IsReserved = false }; @@ -153,7 +156,7 @@ async Task ReserveCarAsync() try { - var result = await daprClient.TrySaveStateAsync("statestore", reservationId, + var result = await daprClient.TrySaveStateAsync("statestore", MakeStateStoreKey(reservationId), reservationState, etag, stateOptions, jsonMetadata); logger.LogInformation("Car class {CarClass} {result} reserved for {CustomerName}", @@ -179,18 +182,10 @@ async Task CancelCarReservationAsync() reservationState.IsReserved = false; - // TTL set for 5 minutes, this has the effect of deleting the entry - // but only after the Saga is done, support for compensation - var metadata = new Dictionary - { - { "ttlInSeconds", "300" }, - { "contentType", "application/json" } - }; - try { - var result = await daprClient.TrySaveStateAsync("statestore", reservationId, reservationState, - etag, stateOptions, metadata); + var result = await daprClient.TrySaveStateAsync("statestore", MakeStateStoreKey(reservationId), reservationState, + etag, stateOptions, jsonMetadata); reservationOperationResult.IsSuccess = result; logger.LogInformation("Reservation id {reservationId} {result} cancelled for {CustomerName}", @@ -214,10 +209,11 @@ async Task CancelCarReservationAsync() app.MapGet("/reservations/{reservationId}", async ([FromRoute] Guid reservationId, [FromServices] DaprClient daprClient, [FromServices] ILogger logger) => { logger.LogInformation($"Fetching reservation status for reservation ID: {reservationId}"); + var stateStoreId = MakeStateStoreKey(reservationId.ToString()); try { - var reservationState = await daprClient.GetStateAsync("statestore", reservationId.ToString(), metadata: jsonMetadata); + var reservationState = await daprClient.GetStateAsync("statestore", stateStoreId, metadata: jsonMetadata); if (reservationState == null) { @@ -252,25 +248,84 @@ async Task CancelCarReservationAsync() try { - var query = $$""" + var allResults = new List>(); + string? paginationToken = null; + + do { - "filter": { - "EQ": { "customerName": "{{customerName}}" } + // Using JsonSerializer approach + var queryObject = new Dictionary + { + ["filter"] = new Dictionary + { + ["EQ"] = new Dictionary { ["customerName"] = customerName } + }, + ["page"] = new Dictionary + { + ["limit"] = 100 // Request up to 100 items instead of default (likely 10) + } + }; + // Add pagination if token exists + if (paginationToken != null) + { + queryObject["page"] = new Dictionary { ["token"] = paginationToken }; } - } - """; - var metadata = new Dictionary - { - { "contentType", "application/json" }, - { "queryIndexName", "customerNameIndex" } - }; + var queryJson = JsonSerializer.Serialize(queryObject); - var reservations = await daprClient.QueryStateAsync("statestore", query, metadata); + var metadata = new Dictionary + { + { "contentType", "application/json" }, + { "queryIndexName", "customerNameIndex" } + }; - var customerReservations = reservations.Results; + var queryResponse = await daprClient.QueryStateAsync("statestore", queryJson, metadata); + + if (queryResponse?.Results != null) + { + allResults.AddRange(queryResponse.Results); + } + + paginationToken = queryResponse?.Token; + + //log if we have a token for pagination + if (!string.IsNullOrEmpty(paginationToken)) + { + logger.LogInformation("Pagination token received: {Token}", paginationToken); + } + else + { + logger.LogInformation("No more pages to fetch."); + } - return Results.Ok(customerReservations.Select(r => r.Data).ToArray()); + } while (!string.IsNullOrEmpty(paginationToken)); + + + // Now use allResults instead of customerReservations + if (allResults == null || allResults.Count == 0) + { + logger.LogInformation("No reservations found for customer: {CustomerName}", customerName); + return Results.NotFound(new { Message = $"No reservations found for customer: {customerName}" }); + } + else + { + logger.LogInformation("Found {Count} total reservations for customer: {CustomerName}", allResults.Count, customerName); + + var reservedCount = allResults.Count(r => r.Data?.IsReserved == true); + logger.LogInformation("Customer {CustomerName} has {ReservedCount} reserved cars.", customerName, reservedCount); + } + + return Results.Ok(allResults.Select(r => r.Data).ToArray()); + } + catch (DaprException daprException) + when (daprException.InnerException is Grpc.Core.RpcException { StatusCode: Grpc.Core.StatusCode.Internal } grpcEx + && grpcEx.Status.Detail.Contains("invalid output")) + { + // Workaround for Dapr bug: treat "invalid output" as empty result set - dapr bug #3787 + logger.LogWarning(grpcEx, + "Dapr QueryStateAsync returned invalid output for customer {CustomerName}. Returning empty list.", + customerName); + return Results.Ok(Array.Empty()); } catch (Exception ex) { diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/ReservationState.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/ReservationState.cs index d68189d..73b3a5e 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/ReservationState.cs +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/ReservationState.cs @@ -5,5 +5,6 @@ public record ReservationState public required string CustomerName { get; set; } public bool IsReserved { get; set; } public Guid Id { get; set; } + public required string CarClass { get; set; } public DateTime ReservationStatusUpdateTime { get; init; } } \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Sagaway.ReservationDemo.BookingManagement.csproj b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Sagaway.ReservationDemo.BookingManagement.csproj index 2e18c3c..dcdb8d0 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Sagaway.ReservationDemo.BookingManagement.csproj +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.BookingManagement/Sagaway.ReservationDemo.BookingManagement.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 enable enable true @@ -12,16 +12,16 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/CarClassAllocationRequest.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/CarClassAllocationRequest.cs new file mode 100644 index 0000000..87b72e7 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/CarClassAllocationRequest.cs @@ -0,0 +1,7 @@ +namespace Sagaway.ReservationDemo.InventoryManagement; + +public record CarClassAllocationRequest +{ + public required string CarClass { get; set; } + public int MaxAllocation { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/CarClassInfo.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/CarClassInfo.cs new file mode 100644 index 0000000..efe4f0a --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/CarClassInfo.cs @@ -0,0 +1,8 @@ +namespace Sagaway.ReservationDemo.InventoryManagement; + +public record CarClassInfo +{ + public required string Code { get; set; } + public int Reserved { get; set; } + public int MaxAllocation { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/CarInventoryResponse.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/CarInventoryResponse.cs new file mode 100644 index 0000000..7258d54 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/CarInventoryResponse.cs @@ -0,0 +1,6 @@ +namespace Sagaway.ReservationDemo.InventoryManagement; + +public record CarInventoryResponse +{ + public List CarClasses { get; set; } = []; +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Dockerfile b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Dockerfile index 6b3c32e..c79da8e 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Dockerfile +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Dockerfile @@ -1,12 +1,12 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER app WORKDIR /app EXPOSE 80 EXPOSE 443 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Sagaway.ReservationDemo.InventoryManagement.csproj", "Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/"] diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Program.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Program.cs index 42b1eb2..546954d 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Program.cs +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Program.cs @@ -43,6 +43,8 @@ ResourceBuilder.CreateDefault().AddService("InventoryManagementService")); }); +const string carClassIndexKey = "carclass:index"; + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -52,6 +54,7 @@ app.UseSwaggerUI(); } +string MakeStateStoreKey(string id) => "Inventory_" + id; app.MapPost("/inventory-queue", async ( [FromBody] CarInventoryRequest request, @@ -89,9 +92,10 @@ ReservationState? reservationState = null; string? orderIdEtag = null; + var stateStoreKey = MakeStateStoreKey(orderId); try { - (reservationState, orderIdEtag) = await daprClient.GetStateAndETagAsync("statestore", orderId); + (reservationState, orderIdEtag) = await daprClient.GetStateAndETagAsync("statestore", stateStoreKey); } catch (Exception e) { @@ -133,10 +137,26 @@ async Task ReserveCarAsync() //get the number of car reserved in the class var (carClassState, carClassEtag) = await daprClient.GetStateAndETagAsync("statestore", request.CarClass); - //demonstrating 2 cars per class limits that can be reserved - if (carClassState == 2) + // Get the max allocation for this car class (default to 2 if not set) + int maxAllocation = 2; // Default + try + { + var maxValue = await daprClient.GetStateAsync("statestore", $"{request.CarClass}_max"); + if (maxValue.HasValue) + { + maxAllocation = maxValue.Value; + } + } + catch { - logger.LogInformation("Car class {CarClass} is not available", request.CarClass); + // Use default if not found + } + + // Check against the dynamic max allocation + if (carClassState >= maxAllocation) + { + logger.LogInformation("Car class {CarClass} is not available (reserved: {Reserved}/{MaxAllocation})", + request.CarClass, carClassState, maxAllocation); reservationOperationResult.IsSuccess = false; @@ -145,8 +165,7 @@ async Task ReserveCarAsync() return; } - //else - + //else - the rest of the method remains the same logger.LogInformation("Car class {CarClass} is available", request.CarClass); if (!reservationState.IsReserved) @@ -155,12 +174,22 @@ async Task ReserveCarAsync() // Increment the count only if not previously reserved carClassState += 1; } + else + { + // If already reserved (e.g., duplicate message), no need to proceed further with state changes + logger.LogInformation("Order {OrderId} for car class {CarClass} is already marked as reserved. No state change needed.", orderId, request.CarClass); + // Still send success as the state matches the request + reservationOperationResult.IsSuccess = true; + await daprClient.InvokeBindingAsync(callbackBindingNameProvider.CallbackBindingName, "create", reservationOperationResult); + return; + } + - var carClassStateUpdate = new StateTransactionRequest(request.CarClass, - System.Text.Encoding.UTF8.GetBytes(carClassState.ToString()), + var carClassStateUpdate = new StateTransactionRequest(request.CarClass, + JsonSerializer.SerializeToUtf8Bytes(carClassState), // Use JsonSerializer StateOperationType.Upsert, carClassEtag); - var reservationStateUpdate = new StateTransactionRequest(orderId, + var reservationStateUpdate = new StateTransactionRequest(stateStoreKey, JsonSerializer.SerializeToUtf8Bytes(reservationState), StateOperationType.Upsert, orderIdEtag); @@ -170,15 +199,34 @@ async Task ReserveCarAsync() reservationStateUpdate }; + // Ensure the car class exists in the index + try + { + var (currentIndex, indexEtag) = await daprClient.GetStateAndETagAsync>("statestore", carClassIndexKey); + currentIndex ??= new HashSet(); + + if (currentIndex.Add(request.CarClass)) // Add returns true if the item was added (i.e., it wasn't there before) + { + logger.LogInformation("Adding car class {CarClass} to index '{IndexKey}' during reservation.", request.CarClass, carClassIndexKey); + transactionOperations.Add(new StateTransactionRequest(carClassIndexKey, JsonSerializer.SerializeToUtf8Bytes(currentIndex), StateOperationType.Upsert, indexEtag)); + } + } + catch (Exception ex) + { + // Log the error but proceed with the reservation if possible. + // Index update failure shouldn't necessarily block the reservation itself, + // but it means the inventory list might be temporarily incomplete. + logger.LogWarning(ex, "Failed to update car class index '{IndexKey}' while reserving {CarClass}. Inventory list might be incomplete.", carClassIndexKey, request.CarClass); + } + try { await daprClient.ExecuteStateTransactionAsync("statestore", transactionOperations); logger.LogInformation("Car class {CarClass} reserved for order id {orderId} - {CarClassState} cars reserved", - request.CarClass, request.OrderId, carClassState + 1); + request.CarClass, request.OrderId, carClassState); reservationOperationResult.IsSuccess = true; await daprClient.InvokeBindingAsync(callbackBindingNameProvider.CallbackBindingName, "create", reservationOperationResult); - } catch (Exception e) { @@ -189,16 +237,10 @@ async Task ReserveCarAsync() } + async Task CancelCarReservationAsync() { - // TTL set for 5 minutes, this has the effect of deleting the entry - // but only after the Saga is done, support for compensation - var metadata = new Dictionary - { - { "ttlInSeconds", "300" } - }; - - //get the number of car reserved in the class + //get the number of car reserved in the class var (carClassState, carClassEtag) = await daprClient.GetStateAndETagAsync("statestore", request.CarClass); // Cancel the reservation @@ -211,12 +253,12 @@ async Task CancelCarReservationAsync() //we need transactional update to the number of cars reserved in the class and the reservation state var carClassStateUpdate = new StateTransactionRequest(request.CarClass, - System.Text.Encoding.UTF8.GetBytes(carClassState.ToString()), + JsonSerializer.SerializeToUtf8Bytes(carClassState), // Use JsonSerializer StateOperationType.Upsert, carClassEtag); - var reservationStateUpdate = new StateTransactionRequest(orderId, - JsonSerializer.SerializeToUtf8Bytes(carClassState), - StateOperationType.Upsert, orderIdEtag, metadata); + var reservationStateUpdate = new StateTransactionRequest(stateStoreKey, + null, // No data needed for delete operation + StateOperationType.Delete, orderIdEtag); var transactionOperations = new List() { @@ -253,8 +295,10 @@ async Task CancelCarReservationAsync() { try { + var stateStoreKey = MakeStateStoreKey(orderId.ToString()); + // Attempt to fetch the reservation state for the given order ID from the Dapr state store - var reservationState = await daprClient.GetStateAsync("statestore", orderId.ToString()); + var reservationState = await daprClient.GetStateAsync("statestore", stateStoreKey); if (reservationState == null) { @@ -276,6 +320,161 @@ async Task CancelCarReservationAsync() .WithName("GetReservationState") .WithOpenApi(); + + +app.MapGet("/car-inventory", async ( + [FromServices] DaprClient daprClient, + [FromServices] ILogger logger) => +{ + logger.LogInformation("Retrieving car inventory information using index key '{IndexKey}'", carClassIndexKey); + var carClassInfoList = new List(); + + try + { + // 1. Get the list of car class codes from the index + var carClassCodes = await daprClient.GetStateAsync>("statestore", carClassIndexKey); + + if (carClassCodes == null || !carClassCodes.Any()) + { + logger.LogInformation("Car class index '{IndexKey}' is empty or not found.", carClassIndexKey); + // Return empty list or potentially seed default classes here if desired + return Results.Ok(new CarInventoryResponse { CarClasses = carClassInfoList }); + } + + logger.LogInformation("Found {Count} car classes in index: {CarClasses}", carClassCodes.Count, string.Join(", ", carClassCodes)); + + // 2. For each car class code, get its reservation count and max allocation + foreach (var carClass in carClassCodes) + { + if (string.IsNullOrWhiteSpace(carClass)) continue; // Skip empty entries if any + + // Get current count of reservations by car class + int reserved; + try + { + // This key stores the aggregated count of active reservations + reserved = await daprClient.GetStateAsync("statestore", carClass); + } + catch (Exception ex) // More specific: catch DaprException when key not found? + { + // Key might not exist if no reservations made yet, default to 0 + logger.LogWarning(ex, "Failed to get reservation count for {CarClass} or key not found. Assuming 0.", carClass); + reserved = 0; + } + + // Get max allocation setting + int maxAllocation = 2; // Default if not set + try + { + var maxValue = await daprClient.GetStateAsync("statestore", $"{carClass}_max"); + if (maxValue.HasValue) + { + maxAllocation = maxValue.Value; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to get max allocation for {CarClass} or key not found. Using default {DefaultAllocation}.", carClass, maxAllocation); + // Use default if key not found or other error + } + + carClassInfoList.Add(new CarClassInfo + { + Code = carClass, + Reserved = reserved, + MaxAllocation = maxAllocation + }); + } + + logger.LogInformation("Returning inventory for {Count} car classes.", carClassInfoList.Count); + return Results.Ok(new CarInventoryResponse { CarClasses = carClassInfoList }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving car inventory information"); + return Results.Problem("Failed to retrieve car inventory information due to an internal error."); + } +}) +.WithName("GetCarInventory") +.WithOpenApi(); + +// POST API to update max allocation for a car class AND update the index +app.MapPost("/car-inventory", async ( + [FromBody] CarClassAllocationRequest request, + [FromServices] DaprClient daprClient, + [FromServices] ILogger logger) => +{ + if (string.IsNullOrWhiteSpace(request.CarClass)) + { + return Results.BadRequest("Car class code is required"); + } + + if (request.MaxAllocation < 0) + { + return Results.BadRequest("Maximum allocation must be non-negative"); + } + + var carClass = request.CarClass.Trim(); // Ensure no leading/trailing spaces + var allocationKey = $"{carClass}_max"; + + logger.LogInformation("Setting max allocation for car class {CarClass} to {MaxAllocation}", + carClass, request.MaxAllocation); + + try + { + // Use a transaction to save allocation and update index atomically + var transactionRequests = new List(); + + // 1. Save the max allocation setting + transactionRequests.Add(new StateTransactionRequest(allocationKey, JsonSerializer.SerializeToUtf8Bytes(request.MaxAllocation), StateOperationType.Upsert)); + + // 2. Update the car class index + var (currentIndex, etag) = await daprClient.GetStateAndETagAsync>("statestore", carClassIndexKey); + currentIndex ??= new HashSet(); // Initialize if null + + bool indexChanged = currentIndex.Add(carClass); // Add returns true if the item was added + + if (indexChanged) + { + logger.LogInformation("Adding car class {CarClass} to index '{IndexKey}'", carClass, carClassIndexKey); + transactionRequests.Add(new StateTransactionRequest(carClassIndexKey, JsonSerializer.SerializeToUtf8Bytes(currentIndex), StateOperationType.Upsert, etag)); + } + else + { + logger.LogInformation("Car class {CarClass} already exists in index '{IndexKey}'", carClass, carClassIndexKey); + } + + // Execute the transaction + await daprClient.ExecuteStateTransactionAsync("statestore", transactionRequests); + + + // Get current reservation count for the response (best effort after transaction) + int reserved = 0; + try + { + reserved = await daprClient.GetStateAsync("statestore", carClass); + } + catch { /* Ignore if not found, default is 0 */ } + + return Results.Ok(new CarClassInfo + { + Code = carClass, + Reserved = reserved, + MaxAllocation = request.MaxAllocation + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error updating car class allocation for {CarClass}", carClass); + return Results.Problem($"Failed to update car class allocation for {carClass}"); + } +}) +.WithName("UpdateCarClassAllocation") +.WithOpenApi(); + + + + app.MapHealthChecks("/healthz"); app.UseSagawayContextPropagator(); app.MapControllers(); diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Sagaway.ReservationDemo.InventoryManagement.csproj b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Sagaway.ReservationDemo.InventoryManagement.csproj index 6f928b1..2145a77 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Sagaway.ReservationDemo.InventoryManagement.csproj +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.InventoryManagement/Sagaway.ReservationDemo.InventoryManagement.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 enable enable true @@ -12,16 +12,16 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/BookingInfo.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/BookingInfo.cs index 2795e1a..0a96c69 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/BookingInfo.cs +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/BookingInfo.cs @@ -6,5 +6,6 @@ public record BookingInfo // ReSharper disable UnusedAutoPropertyAccessor.Global public required string CustomerName { get; set; } public bool IsReserved { get; set; } + public required string CarClass { get; set; } public Guid Id { get; set; } } \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/CarReservation/CarReservationActor.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/CarReservation/CarReservationActor.cs index 8fa8c9c..df01997 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/CarReservation/CarReservationActor.cs +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/CarReservation/CarReservationActor.cs @@ -1,8 +1,10 @@ -using Dapr.Actors.Runtime; +using System.Text.Json; +using Dapr.Actors.Runtime; using Sagaway.Hosts; using Sagaway.ReservationDemo.ReservationManager.Actors.BillingDto; using Sagaway.ReservationDemo.ReservationManager.Actors.BookingDto; using Sagaway.ReservationDemo.ReservationManager.Actors.InventoryDto; +using Sagaway.ReservationDemo.ReservationManager.Actors.Publisher; namespace Sagaway.ReservationDemo.ReservationManager.Actors.CarReservation; @@ -14,22 +16,25 @@ public class CarReservationActor : DaprActorHost, private readonly ILogger _logger; private readonly ActorHost _actorHost; private ReservationInfo? _reservationInfo; + private readonly ISagaResultPublisher _sagaResultPublisher; // ReSharper disable once ConvertToPrimaryConstructor - public CarReservationActor(ActorHost host, ILogger logger, IServiceProvider? serviceProvider) + public CarReservationActor(ActorHost host, ILogger logger, + ISagaResultPublisher sagaResultPublisher, IServiceProvider? serviceProvider) : base(host, logger, serviceProvider) { _actorHost = host; _logger = logger; + _sagaResultPublisher = sagaResultPublisher; } protected override ISaga ReBuildSaga() { var saga = Saga.Create(_actorHost.Id.ToString(), this, _logger) - .WithOnSuccessCompletionCallback(OnSuccessCompletionCallbackAsync) - .WithOnRevertedCallback(OnRevertedCallbackAsync) - .WithOnFailedRevertedCallback(OnFailedRevertedCallbackAsync) - .WithOnFailedCallback(OnFailedCallbackAsync) + .WithOnSuccessCompletionCallback(OnSuccessCompletionCallback) + .WithOnRevertedCallback(OnRevertedCallback) + .WithOnFailedRevertedCallback(OnFailedRevertedCallback) + .WithOnFailedCallback(OnFailedCallback) .WithOperation(CarReservationActorOperations.CarBooking) .WithDoOperation(BookCarReservationAsync) @@ -44,11 +49,11 @@ protected override ISaga ReBuildSaga() .WithOperation(CarReservationActorOperations.InventoryReserving) .WithDoOperation(ReserveInventoryAsync) .WithMaxRetries(3) - .WithRetryIntervalTime(ExponentialBackoff.InMinutes()) //An example of an exponential backoff in minutes + .WithRetryIntervalTime(ExponentialBackoff.InSeconds()) //An example of an exponential backoff in seconds .WithValidateFunction(ValidateReserveInventoryAsync) .WithUndoOperation(RevertReserveInventoryAsync) .WithMaxRetries(3) - .WithUndoRetryInterval(ExponentialBackoff.InMinutes()) + .WithUndoRetryInterval(ExponentialBackoff.InSeconds()) .WithValidateFunction(ValidateRevertReserveInventoryAsync) .WithOperation(CarReservationActorOperations.Billing) @@ -336,46 +341,54 @@ private async Task ValidateRevertBillReservationAsync() #region Saga Completion Methods - private async void OnFailedRevertedCallbackAsync(string sagaLog) + private void OnFailedRevertedCallback(string sagaLog) { _logger.LogError("The car reservation has failed and left some unused resources."); _logger.LogError("The car reservation log:" + Environment.NewLine + sagaLog); - - await Task.CompletedTask; } - private async void OnRevertedCallbackAsync(string sagaLog) + private void OnRevertedCallback(string sagaLog) { _logger.LogError("The car reservation has failed and all resources are deleted."); _logger.LogError("The car reservation log:" + Environment.NewLine + sagaLog); - - await Task.CompletedTask; } - private async void OnFailedCallbackAsync(string sagaLog) + private void OnFailedCallback(string sagaLog) { _logger.LogError("The car reservation has failed starting reverting resources."); _logger.LogError("The car reservation log:" + Environment.NewLine + sagaLog); - - await Task.CompletedTask; - //Option: Send a message to the customer } - private async void OnSuccessCompletionCallbackAsync(string sagaLog) + private void OnSuccessCompletionCallback(string sagaLog) { _logger.LogInformation("The car reservation has succeeded."); _logger.LogInformation("The car reservation log:" + Environment.NewLine + sagaLog); - - await Task.CompletedTask; - //Option: Send a message to the customer } private async Task OnSagaCompletedAsync(object? _, SagaCompletionEventArgs e) { _logger.LogInformation($"Saga {e.SagaId} completed with status {e.Status}"); - await Task.CompletedTask; + + if (_reservationInfo == null) + { + _logger.LogWarning("Cannot save saga log: reservation info is null"); + return; + } + + var sagaResult = new SagaResult + { + ReservationId = _reservationInfo.ReservationId, + Outcome = "Reservation " + e.Status, + Log = e.Log, + CarClass = _reservationInfo.CarClass, + CustomerName = _reservationInfo.CustomerName + }; + + await _sagaResultPublisher.PublishMessageToSignalRAsync(sagaResult); } + + #endregion diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/CarReservationCancellation/CarReservationCancellationActor.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/CarReservationCancellation/CarReservationCancellationActor.cs index 5afe7a7..98ae080 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/CarReservationCancellation/CarReservationCancellationActor.cs +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/CarReservationCancellation/CarReservationCancellationActor.cs @@ -3,6 +3,7 @@ using Sagaway.ReservationDemo.ReservationManager.Actors.BillingDto; using Sagaway.ReservationDemo.ReservationManager.Actors.BookingDto; using Sagaway.ReservationDemo.ReservationManager.Actors.InventoryDto; +using Sagaway.ReservationDemo.ReservationManager.Actors.Publisher; namespace Sagaway.ReservationDemo.ReservationManager.Actors.CarReservationCancellation; @@ -14,23 +15,25 @@ public class CarReservationCancellationActor : DaprActorHost _logger; private readonly ActorHost _actorHost; private ReservationInfo? _reservationInfo; + private readonly ISagaResultPublisher _sagaResultPublisher; // ReSharper disable once ConvertToPrimaryConstructor public CarReservationCancellationActor(ActorHost host, ILogger logger - ,IServiceProvider serviceProvider) + ,ISagaResultPublisher sagaResultPublisher ,IServiceProvider serviceProvider) : base(host, logger, serviceProvider) { _actorHost = host; _logger = logger; + _sagaResultPublisher = sagaResultPublisher; } protected override ISaga ReBuildSaga() { var saga = Saga.Create(_actorHost.Id.ToString(), this, _logger) - .WithOnSuccessCompletionCallback(OnSuccessCompletionCallbackAsync) - .WithOnRevertedCallback(OnRevertedCallbackAsync) - .WithOnFailedRevertedCallback(OnFailedRevertedCallbackAsync) - .WithOnFailedCallback(OnFailedCallbackAsync) + .WithOnSuccessCompletionCallback(OnSuccessCompletionCallback) + .WithOnRevertedCallback(OnRevertedCallback) + .WithOnFailedRevertedCallback(OnFailedRevertedCallback) + .WithOnFailedCallback(OnFailedCallback) .WithOperation(CarCancelReservationActorOperations.CancelBooking) .WithDoOperation(CancelCarBookingAsync) @@ -45,22 +48,22 @@ protected override ISaga ReBuildSaga() .WithOperation(CarCancelReservationActorOperations.CancelInventoryReserving) .WithDoOperation(CancelInventoryReservationAsync) .WithMaxRetries(3) - .WithRetryIntervalTime(TimeSpan.FromMinutes(2)) + .WithRetryIntervalTime(ExponentialBackoff.InSeconds()) .WithValidateFunction(ValidateInventoryReservationCanceledAsync) .WithUndoOperation(RevertInventoryReservationCancellingAsync) .WithMaxRetries(3) - .WithUndoRetryInterval(TimeSpan.FromMinutes(10)) + .WithUndoRetryInterval(ExponentialBackoff.InSeconds()) .WithValidateFunction(ValidateRevertInventoryReservationCancellingAsync) .WithOperation(CarCancelReservationActorOperations.Refund) .WithDoOperation(RefundReservationBillingAsync) .WithMaxRetries(3) - .WithRetryIntervalTime(TimeSpan.FromSeconds(10)) + .WithRetryIntervalTime(ExponentialBackoff.InSeconds()) .WithValidateFunction(ValidateRefundReservationAsync) .WithPreconditions(CarCancelReservationActorOperations.CancelBooking | CarCancelReservationActorOperations.CancelInventoryReserving) .WithUndoOperation(ChargeReservationAsync) .WithMaxRetries(3) - .WithUndoRetryInterval(TimeSpan.FromSeconds(10)) + .WithUndoRetryInterval(ExponentialBackoff.InSeconds(5)) .WithValidateFunction(ValidateChargingReservationAsync) .Build(); @@ -356,47 +359,52 @@ await DaprClient.InvokeMethodAsync(HttpMethod.Get, "billing-manage #region Saga Completion Methods - private async void OnFailedRevertedCallbackAsync(string sagaLog) + private void OnFailedRevertedCallback(string sagaLog) { _logger.LogError("The car reservation cancelling has failed and left some unused resources."); _logger.LogError("The car reservation cancelling log:" + Environment.NewLine + sagaLog); - - await Task.CompletedTask; } - private async void OnRevertedCallbackAsync(string sagaLog) + private void OnRevertedCallback(string sagaLog) { _logger.LogError("The car reservation cancelling has failed and all resources are deleted."); _logger.LogError("The car reservation cancelling log:" + Environment.NewLine + sagaLog); - - await Task.CompletedTask; } - private async void OnFailedCallbackAsync(string sagaLog) + private void OnFailedCallback(string sagaLog) { _logger.LogError("The car reservation cancelling has failed starting reverting resources."); _logger.LogError("The car reservation cancelling log:" + Environment.NewLine + sagaLog); - - await Task.CompletedTask; //Option: Send a message to the customer } - private async void OnSuccessCompletionCallbackAsync(string sagaLog) + private void OnSuccessCompletionCallback(string sagaLog) { _logger.LogInformation("The car reservation cancelling has succeeded."); _logger.LogInformation("The car reservation cancelling log:" + Environment.NewLine + sagaLog); - - await Task.CompletedTask; //Option: Send a message to the customer } private async Task OnSagaCompletedAsync(object? _, SagaCompletionEventArgs e) { _logger.LogInformation($"Saga {e.SagaId} completed with status {e.Status}"); - await Task.CompletedTask; - } - - #endregion + if (_reservationInfo == null) + { + _logger.LogWarning("Cannot save saga log: reservation info is null"); + return; + } + + var sagaResult = new SagaResult + { + ReservationId = _reservationInfo.ReservationId, + Outcome = "Cancellation " + e.Status, + Log = e.Log, + CarClass = _reservationInfo.CarClass, + CustomerName = _reservationInfo.CustomerName + }; + await _sagaResultPublisher.PublishMessageToSignalRAsync(sagaResult); + } + #endregion } \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/InventoryDto/CarClassAllocationRequest.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/InventoryDto/CarClassAllocationRequest.cs new file mode 100644 index 0000000..c2b613d --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/InventoryDto/CarClassAllocationRequest.cs @@ -0,0 +1,7 @@ +namespace Sagaway.ReservationDemo.ReservationManager.Actors.InventoryDto; + +public record CarClassAllocationRequest +{ + public required string CarClass { get; set; } + public int MaxAllocation { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/InventoryDto/CarClassInfo.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/InventoryDto/CarClassInfo.cs new file mode 100644 index 0000000..7839bd1 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/InventoryDto/CarClassInfo.cs @@ -0,0 +1,8 @@ +namespace Sagaway.ReservationDemo.ReservationManager.Actors.InventoryDto; + +public record CarClassInfo +{ + public required string Code { get; set; } + public int Reserved { get; set; } + public int MaxAllocation { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/InventoryDto/CarInventoryResponse.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/InventoryDto/CarInventoryResponse.cs new file mode 100644 index 0000000..2fbd7fb --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/InventoryDto/CarInventoryResponse.cs @@ -0,0 +1,6 @@ +namespace Sagaway.ReservationDemo.ReservationManager.Actors.InventoryDto; + +public record CarInventoryResponse +{ + public List CarClasses { get; set; } = []; +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/Argument.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/Argument.cs new file mode 100644 index 0000000..550cbbc --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/Argument.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace Sagaway.ReservationDemo.ReservationManager.Actors.Publisher; + +public record Argument +{ + public string Sender { get; set; } = string.Empty; + // ReSharper disable once PropertyCanBeMadeInitOnly.Global + public JsonDocument? Text { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/ISagaResultPublisher.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/ISagaResultPublisher.cs new file mode 100644 index 0000000..5c25bec --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/ISagaResultPublisher.cs @@ -0,0 +1,6 @@ +namespace Sagaway.ReservationDemo.ReservationManager.Actors.Publisher; + +public interface ISagaResultPublisher +{ + Task PublishMessageToSignalRAsync(SagaResult result); +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/SagaResult.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/SagaResult.cs new file mode 100644 index 0000000..fdf97b8 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/SagaResult.cs @@ -0,0 +1,10 @@ +namespace Sagaway.ReservationDemo.ReservationManager.Actors.Publisher; + +public record SagaResult +{ + public required string Outcome{ get; set; } + public required Guid ReservationId { get; set; } + public required string Log { get; set; } + public required string CustomerName { get; set; } + public required string CarClass { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/SagaResultPublisher.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/SagaResultPublisher.cs new file mode 100644 index 0000000..d518a18 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/SagaResultPublisher.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using Dapr.Client; + +namespace Sagaway.ReservationDemo.ReservationManager.Actors.Publisher; + +public class SagaResultPublisher : ISagaResultPublisher +{ + private readonly DaprClient _daprClient; + private readonly ILogger _logger; + + // ReSharper disable once ConvertToPrimaryConstructor + public SagaResultPublisher(DaprClient daprClient, ILogger logger) + { + _daprClient = daprClient; + _logger = logger; + } + + public async Task PublishMessageToSignalRAsync(SagaResult result) + { + try + { + var metadata = new Dictionary + { + { "ttlInSeconds", "900" } // 15 minutes TTL + }; + + var key = result.ReservationId.ToString(); + + //first, try to fetch the existing state + var existingState = await _daprClient.GetStateAsync( + "statestore", + $"saga-log-{key}"); + + existingState = existingState == null ? string.Empty : + existingState + Environment.NewLine + Environment.NewLine + + "***************************************************************************" + + Environment.NewLine + Environment.NewLine; + + result.Log = existingState + result.Log; + + await _daprClient.SaveStateAsync( + "statestore", + $"saga-log-{key}", + result.Log, + metadata: metadata); + + _logger.LogInformation($"Saved saga log for reservation {key} with 15 minutes TTL"); + + var jsonSerializationOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var callbackRequest = JsonSerializer.Serialize(result, jsonSerializationOptions); + var jsonDocument = JsonDocument.Parse(callbackRequest); + + var argument = new Argument + { + Sender = "dapr", + Text = jsonDocument + }; + + SignalRMessage message = new() + { + UserId = "DemoUser", + Target = "SagaCompleted", + Arguments = [argument] + }; + + _logger.LogInformation("publishing message to SignalR: {argumentText}", argument.Text); + + await _daprClient.InvokeBindingAsync("reservationcallback", "create", + message); //sending through dapr to the signalR Hub + } + catch (Exception ex) + { + _logger.LogError(ex, "Error publishing message to SignalR: {Message}", ex.Message); + } + } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/SignalRMessage.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/SignalRMessage.cs new file mode 100644 index 0000000..337f551 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Actors/Publisher/SignalRMessage.cs @@ -0,0 +1,10 @@ +namespace Sagaway.ReservationDemo.ReservationManager.Actors.Publisher; + +public record SignalRMessage +{ + // ReSharper disable UnusedAutoPropertyAccessor.Global + public string? UserId { get; set; } + public string? GroupName { get; set; } + public string? Target { get; set; } + public Argument?[]? Arguments { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Dockerfile b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Dockerfile index 2399469..6abea91 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Dockerfile +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Dockerfile @@ -1,12 +1,12 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER app WORKDIR /app EXPOSE 80 EXPOSE 443 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Sagaway.ReservationDemo.ReservationManager.csproj", "Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/"] diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/IHubContextStore.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/IHubContextStore.cs new file mode 100644 index 0000000..f05b2ca --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/IHubContextStore.cs @@ -0,0 +1,8 @@ +using Microsoft.Azure.SignalR.Management; + +namespace Sagaway.ReservationDemo.ReservationManager; + +public interface IHubContextStore +{ + public ServiceHubContext? AccountManagerCallbackHubContext { get; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Program.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Program.cs index 9b356ac..4b68dcd 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Program.cs +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Program.cs @@ -10,6 +10,9 @@ using Sagaway.ReservationDemo.ReservationManager.Actors; using Sagaway.ReservationDemo.ReservationManager.Actors.CarReservation; using Sagaway.ReservationDemo.ReservationManager.Actors.CarReservationCancellation; +using Sagaway.ReservationDemo.ReservationManager; +using Sagaway.ReservationDemo.ReservationManager.Actors.InventoryDto; +using Sagaway.ReservationDemo.ReservationManager.Actors.Publisher; var builder = WebApplication.CreateBuilder(args); @@ -46,6 +49,17 @@ }; }); +builder.Services.AddSingleton() +#pragma warning disable CS8631 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match constraint type. +#pragma warning disable CS8634 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'class' constraint. + .AddHostedService(sp => sp.GetService()!) +#pragma warning restore CS8634 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'class' constraint. +#pragma warning restore CS8631 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match constraint type. + .AddSingleton(sp => sp.GetService()!) + .AddDaprClient(); + +builder.Services.AddSingleton(); + builder.Services.AddSagawayOpenTelemetry(configureTracerProvider => { configureTracerProvider @@ -64,6 +78,8 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddHostedService(); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -111,6 +127,9 @@ await daprClient.InvokeMethodAsync>(HttpMethod.Get, "booking-management", $"/customer-reservations?customerName={customerName}"); + //log the number of reservations found + logger.LogInformation("Found {Count} reservations for customer: {CustomerName}", customerReservation.Count, customerName); + return Results.Ok(customerReservation); } catch (Exception ex) @@ -122,6 +141,39 @@ await daprClient.InvokeMethodAsync>(HttpMethod.Get, "booking- .WithName("GetReservations") .WithOpenApi(); + +app.MapGet("/saga-log/{reservationId}", async ( + [FromRoute] Guid reservationId, + [FromServices] DaprClient daprClient, + [FromServices] ILogger logger) => + { + logger.LogInformation("Received request to get saga log for reservation: {ReservationId}", reservationId); + + try + { + var key = $"saga-log-{reservationId:D}"; + + // Attempt to retrieve the saga log from Dapr state + var sagaLog = await daprClient.GetStateAsync("statestore", key); + + if (string.IsNullOrEmpty(sagaLog)) + { + logger.LogWarning("Saga log not found for reservation: {ReservationId}", reservationId); + return Results.NotFound("Saga log not found."); + } + + logger.LogInformation("Successfully retrieved saga log for reservation: {ReservationId}", reservationId); + return Results.Ok(sagaLog); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving saga log for reservation: {ReservationId}", reservationId); + return Results.Problem("An error occurred while retrieving the saga log. Please try again later."); + } + }) + .WithName("GetSagaLog") + .WithOpenApi(); + app.MapPost("/reserve", async ( [FromQuery] Guid? reservationId, [FromQuery] string customerName, @@ -209,7 +261,7 @@ await daprClient.InvokeMethodAsync(HttpMethod.Get, "inventory-man try { var proxy = actorProxyFactory.CreateActorProxy( - new ActorId(reservationId.ToString("D")), "CarReservationCancellationActor"); + new ActorId(reservationId.ToString("D") + DateTime.Now.Ticks), "CarReservationCancellationActor"); await proxy.CancelCarReservationAsync(reservationInfo); @@ -226,6 +278,104 @@ await daprClient.InvokeMethodAsync(HttpMethod.Get, "inventory-man .WithOpenApi(); +app.MapPost("/negotiate", async ( + [FromServices] IHubContextStore store, + [FromServices] ILogger logger) => +{ + var accountManagerCallbackHubContext = store.AccountManagerCallbackHubContext; + + logger.LogInformation("MessageHubNegotiate: SignalR negotiate for user"); + + var negotiateResponse = await accountManagerCallbackHubContext!.NegotiateAsync(new() + { + UserId = "demoUser", //user, + EnableDetailedErrors = true + }); + + return Results.Json(new Dictionary() + { + { "url", negotiateResponse.Url! }, + { "accessToken", negotiateResponse.AccessToken! } + }); +}); + + +//For the demo purpose, we have these two APIs to manage the inventory: + +// GET endpoint for car inventory +app.MapGet("/car-inventory", async ( + [FromServices] DaprClient daprClient, + [FromServices] ILogger logger) => +{ + logger.LogInformation("Received request to get car inventory"); + + try + { + // Forward the request to inventory-management service using Dapr service invocation + var inventory = await daprClient.InvokeMethodAsync( + HttpMethod.Get, + "inventory-management", + "/car-inventory"); + + logger.LogInformation("Successfully retrieved car inventory with {Count} car classes", + inventory.CarClasses.Count); + + return Results.Ok(inventory); + } + catch (Exception ex) + { + logger.LogError(ex, "Error getting car inventory"); + return Results.Problem("An error occurred while retrieving car inventory. Please try again later."); + } +}) +.WithName("GetCarInventory") +.WithOpenApi(); + +//This API is a simplification for the Demo, in reality this should also be done an async call +// POST endpoint for updating car class allocation +app.MapPost("/update-allocation", async ( + [FromBody] CarClassAllocationRequest request, + [FromServices] DaprClient daprClient, + [FromServices] ILogger logger) => + { + if (string.IsNullOrWhiteSpace(request.CarClass)) + { + return Results.BadRequest("Car class code is required"); + } + + if (request.MaxAllocation < 0) + { + return Results.BadRequest("Maximum allocation must be non-negative"); + } + + logger.LogInformation("Received request to update allocation for car class {CarClass} to {MaxAllocation}", + request.CarClass, request.MaxAllocation); + + try + { + // Correct way to invoke a POST method with a body using Dapr + var result = await daprClient.InvokeMethodAsync( + HttpMethod.Post, + "inventory-management", + "/car-inventory", + request); + + logger.LogInformation("Successfully updated allocation for car class {CarClass} to {MaxAllocation}", + result.Code, result.MaxAllocation); + + return Results.Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Error updating car class allocation for {CarClass}", request.CarClass); + return Results.Problem($"Failed to update car class allocation for {request.CarClass}. Please try again later."); + } + }) + .WithName("UpdateCarClassAllocation") + .WithOpenApi(); + + + app.MapHealthChecks("/healthz"); app.MapControllers(); app.MapSubscribeHandler(); diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Sagaway.ReservationDemo.ReservationManager.csproj b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Sagaway.ReservationDemo.ReservationManager.csproj index 18f6f00..9c8ab92 100644 --- a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Sagaway.ReservationDemo.ReservationManager.csproj +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/Sagaway.ReservationDemo.ReservationManager.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 enable enable true @@ -12,19 +12,20 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/SignalRService.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/SignalRService.cs new file mode 100644 index 0000000..591f62d --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationManager/SignalRService.cs @@ -0,0 +1,35 @@ +using Microsoft.Azure.SignalR.Management; + +namespace Sagaway.ReservationDemo.ReservationManager; + +public class SignalRService(IConfiguration configuration, ILoggerFactory loggerFactory) + : IHostedService, IHubContextStore +{ + private const string AccountManagerCallbackHub = "reservationcallback"; + + public ServiceHubContext? AccountManagerCallbackHubContext { get; private set; } + + async Task IHostedService.StartAsync(CancellationToken cancellationToken) + { + using var serviceManager = new ServiceManagerBuilder() + .WithConfiguration(configuration) + .WithLoggerFactory(loggerFactory) + .BuildServiceManager(); + AccountManagerCallbackHubContext = await serviceManager.CreateHubContextAsync(AccountManagerCallbackHub, cancellationToken); + } + + Task IHostedService.StopAsync(CancellationToken cancellationToken) + { + if (AccountManagerCallbackHubContext != null) + { + return AccountManagerCallbackHubContext.DisposeAsync(); + } + return Task.CompletedTask; + } + + // ReSharper disable once UnusedMember.Local + private static Task Dispose(IServiceHubContext? hubContext) + { + return hubContext == null ? Task.CompletedTask : hubContext.DisposeAsync(); + } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/App.razor b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/App.razor new file mode 100644 index 0000000..2be1c75 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/App.razor @@ -0,0 +1,284 @@ +@page "/reservation" + +@using Sagaway.ReservationDemo.ReservationUI.Components +@using Sagaway.ReservationDemo.ReservationUI.Services + +@inject ILogger Logger +@inject IReservationManager ReservationManager + +Car Reservation + +
+ +

Sagaway Car Reservation

+ + @if (!_isInitialized) + { +
+ @if (_initializationError != null) + { + + + } + else + { +
+

Connecting to reservation service...

+ } +
+ } + else + { +
+ + +
+ +
+ +

Choose Your Car Class

+
+ @foreach (var carType in _availableCarTypes) + { + var borderClass = _selectedCarClass?.Code == carType.Code + ? "border-4 border-indigo-600 ring-2 ring-indigo-300 scale-105" + : "border-2 border-gray-300 hover:border-gray-400"; + +
+ @carType.Name +
+ @carType.Name +
+
+ } +
+ + @if (_selectedCarClass != null && _selectedCustomerId != Guid.Empty) + { + var selectedCustomerName = _predefinedCustomers.FirstOrDefault(c => c.CustomerId == _selectedCustomerId)?.CustomerName ?? "Unknown"; +
+

Selected: @_selectedCarClass.Name for @selectedCustomerName

+ + + @if (!string.IsNullOrEmpty(_reservationMessage)) + { +

@_reservationMessage

+ } +
+ } + +
+ + + } +
+ +@code { + // Initialization state + private bool _isInitialized; + private string? _initializationError; + + // Component state + private List _availableCarTypes = new(); + private CarType? _selectedCarClass; + private List _predefinedCustomers = new(); + private Guid _selectedCustomerId = Guid.Empty; // Bound to dropdown + private bool _isProcessingReservation; + private string? _reservationMessage; + + protected override async Task OnInitializedAsync() + { + // Populate available car types + _availableCarTypes = + [ + new() { Name = "Economy", Code = "ECON", ImageUrl = "images/EconomyCar.png" }, + new() { Name = "Standard", Code = "STD", ImageUrl = "images/StandardCar.png" }, + new() { Name = "Luxury", Code = "LUX", ImageUrl = "images/LuxuryCar.png" } + ]; + + // Initialize the application - this sets up ReservationManager + try + { + await ReservationManager.InitializeAsync(); + _isInitialized = true; + + // Now get the predefined users from ReservationManager + var knownUsers = ReservationManager.GetAllUsers(); + _predefinedCustomers = knownUsers + .Select(user => new CustomerInfo { CustomerId = user.Key, CustomerName = user.Value }) + .ToList(); + + // Set default customer selection + if (_predefinedCustomers.Any()) + { + _selectedCustomerId = _predefinedCustomers.First().CustomerId; + await LoadSelectedCustomerReservations(); + } + } + catch (Exception ex) + { + _initializationError = $"Failed to connect to reservation service: {ex.Message}"; + Logger.LogError(ex, "Failed to initialize application"); + } + } + + private async Task InitializeApplication() + { + _initializationError = null; + StateHasChanged(); // Update UI to show loading spinner + + try + { + await ReservationManager.InitializeAsync(); + _isInitialized = true; + + // Get predefined users from ReservationManager + var knownUsers = ReservationManager.GetAllUsers(); + _predefinedCustomers = knownUsers + .Select(user => new CustomerInfo { CustomerId = user.Key, CustomerName = user.Value }) + .ToList(); + + // Set default customer selection + if (_predefinedCustomers.Any()) + { + _selectedCustomerId = _predefinedCustomers.First().CustomerId; + await LoadSelectedCustomerReservations(); + } + } + catch (Exception ex) + { + _initializationError = $"Failed to connect to reservation service: {ex.Message}"; + Logger.LogError(ex, "Failed to initialize application"); + } + + StateHasChanged(); // Update UI with results + } + + + private void SelectCarClass(CarType carType) + { + _selectedCarClass = carType; + _reservationMessage = null; // Clear previous message + Logger.LogInformation("Selected car class: {CarCode}", _selectedCarClass?.Code); + } + + private async Task SelectedCustomerChanged() + { + _reservationMessage = null; // Clear previous message + _selectedCarClass = null; // Reset selected car when customer changes + + var selectedCustomer = _predefinedCustomers.FirstOrDefault(c => c.CustomerId == _selectedCustomerId); + Logger.LogInformation("Customer selection changed: ID={CustomerId}, Name={CustomerName}", + _selectedCustomerId, selectedCustomer?.CustomerName); + + await LoadSelectedCustomerReservations(); + StateHasChanged(); // Explicitly update the UI + } + + + private async Task LoadSelectedCustomerReservations() + { + if (_selectedCustomerId != Guid.Empty && _isInitialized) + { + await ReservationManager.LoadReservationsForUserAsync(_selectedCustomerId); + } + } + + private async Task HandleReservation() + { + var selectedCustomer = _predefinedCustomers.FirstOrDefault(c => c.CustomerId == _selectedCustomerId); + + if (_selectedCarClass == null || selectedCustomer == null) + { + _reservationMessage = "Error: Please select a car class and customer."; + Logger.LogWarning("Attempted reservation with missing selection."); + return; + } + + _isProcessingReservation = true; + _reservationMessage = null; // Clear previous messages + StateHasChanged(); // Show spinner + + Logger.LogInformation("Attempting to reserve {CarClass} for {CustomerName} (ID: {CustomerId})", + _selectedCarClass.Code, selectedCustomer.CustomerName, _selectedCustomerId); + + try + { + // Use ReservationManager for creating the reservation + var reservationId = await ReservationManager.CreateReservationAsync( + _selectedCustomerId, + selectedCustomer.CustomerName, + _selectedCarClass.Code); + + _reservationMessage = $"Success! Reservation {reservationId} has been initiated."; + Logger.LogInformation("Reservation initiated with ID: {ReservationId}", reservationId); + + // REMOVE THIS LINE - DO NOT REFRESH: + // await ReservationManager.LoadReservationsForUserAsync(_selectedCustomerId); + + // Auto-dismiss success message after 5 seconds + _ = Task.Delay(5000).ContinueWith(_ => + { + // Only clear if it's still showing this success message + if (_reservationMessage?.StartsWith("Success!") != true) + return; + + _reservationMessage = null; + InvokeAsync(StateHasChanged); + }); + } + catch (Exception ex) + { + _reservationMessage = $"Error: Reservation failed - {ex.Message}"; + Logger.LogError(ex, "Reservation failed for {CustomerName}", selectedCustomer.CustomerName); + } + finally + { + _isProcessingReservation = false; + StateHasChanged(); + } + } + + + + // Data structures + private class CarType + { + public required string Name { get; init; } + public required string Code { get; init; } + public required string ImageUrl { get; init; } + } + + private class CustomerInfo + { + public Guid CustomerId { get; init; } + public required string CustomerName { get; init; } + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Components/CarInventoryManager.razor b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Components/CarInventoryManager.razor new file mode 100644 index 0000000..a6f4e59 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Components/CarInventoryManager.razor @@ -0,0 +1,265 @@ +@using Sagaway.ReservationDemo.ReservationUI.Services +@using Sagaway.ReservationDemo.ReservationUI.Services.DTOs +@inject IReservationApiClient ReservationApiClient +@inject ILogger Logger + +
+

Car Inventory Management

+ + @if (_loading) + { +

Loading car inventory...

+
+
+
+ } + else if (!string.IsNullOrWhiteSpace(_errorMessage)) + { +

Error: @_errorMessage

+ } + else if (_carInventory?.CarClasses == null || !_carInventory.CarClasses.Any()) + { +

No car classes found in inventory.

+ } + else + { +
+ + + + + + + + + + + + @foreach (var carClass in _carInventory.CarClasses) + { + + + + + + + + } + +
Car ClassAvailableReservedMax AllocationActions
+
+ @{ + var imgSrc = GetCarImageForClass(carClass.Code); + var carClassName = GetCarClassNameForCode(carClass.Code); + } + @carClassName + + @carClassName +
+
+ @(carClass.MaxAllocation - carClass.Reserved) + +
+ @carClass.Reserved +
+ @{ + var percentage = carClass.MaxAllocation > 0 + ? Math.Min(100, (carClass.Reserved * 100) / carClass.MaxAllocation) + : 0; + var barColorClass = percentage > 80 ? "bg-red-500" : percentage > 50 ? "bg-yellow-500" : "bg-green-500"; + } +
+
+
+
+ @if (_editingCarClass == carClass.Code) + { + + } + else + { + @carClass.MaxAllocation + } + + @if (_editingCarClass == carClass.Code) + { +
+ + +
+ } + else + { + + } +
+
+ } + +
+ +
+
+ +@code { + private bool _loading; + private string? _errorMessage; + private CarInventoryResponse? _carInventory; + private string? _editingCarClass; + private int _editMaxAllocation; + + protected override async Task OnInitializedAsync() + { + await RefreshInventory(); + } + + public async Task RefreshInventory() + { + _loading = true; + _errorMessage = null; + StateHasChanged(); + + try + { + // Add debug logging + Logger.LogInformation("Requesting car inventory data..."); + _carInventory = await ReservationApiClient.GetCarInventoryAsync(); + Logger.LogInformation("Received inventory data with {Count} car classes", + _carInventory?.CarClasses?.Count ?? 0); + + // Check for empty response + if (_carInventory?.CarClasses == null || !_carInventory.CarClasses.Any()) + { + Logger.LogWarning("Received empty car inventory response"); + } + else + { + // Log each car class received + foreach (var carClass in _carInventory.CarClasses) + { + Logger.LogInformation("Car class: {Code}, Reserved: {Reserved}/{MaxAllocation}", + carClass.Code, carClass.Reserved, carClass.MaxAllocation); + } + } + } + catch (Exception ex) + { + // Improve error message to include inner exception details + _errorMessage = $"Error loading car inventory: {ex.Message}"; + if (ex.InnerException != null) + { + _errorMessage += $" ({ex.InnerException.Message})"; + } + Logger.LogError(ex, "Error loading car inventory"); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + + private void EditAllocation(string carClass, int currentAllocation) + { + _editingCarClass = carClass; + _editMaxAllocation = currentAllocation; + StateHasChanged(); + } + + private void CancelEdit() + { + _editingCarClass = null; + StateHasChanged(); + } + + private async Task SaveAllocation(string carClass) + { + if (_carInventory?.CarClasses == null || string.IsNullOrEmpty(_editingCarClass)) + return; + + var currentCarClass = _carInventory.CarClasses.FirstOrDefault(c => c.Code == carClass); + if (currentCarClass == null) return; + + // Don't allow setting max allocation less than current reservations + if (_editMaxAllocation < currentCarClass.Reserved) + { + _errorMessage = "Maximum allocation cannot be less than the number of current reservations."; + return; + } + + try + { + // Create the request object for the API + var allocationRequest = new CarClassAllocationRequest + { + CarClass = carClass, + MaxAllocation = _editMaxAllocation + }; + + // Call the API to update allocation using ReservationApiClient + var updatedCarClass = await ReservationApiClient.UpdateCarClassAllocationAsync(allocationRequest); + + // Update local data + var index = _carInventory.CarClasses.FindIndex(c => c.Code == carClass); + if (index >= 0) + { + _carInventory.CarClasses[index] = updatedCarClass; + } + + // Exit edit mode + _editingCarClass = null; + _errorMessage = null; // Clear any previous error message + } + catch (Exception ex) + { + _errorMessage = $"Error updating allocation: {ex.Message}"; + Logger.LogError(ex, "Error updating allocation for car class {CarClass}", carClass); + } + finally + { + StateHasChanged(); + } + } + + private string GetCarClassNameForCode(string? carClassCode) + { + return carClassCode?.ToUpperInvariant() switch + { + "ECON" => "Economy", + "STD" => "Standard", + "LUX" => "Luxury", + _ => carClassCode ?? "Unknown" + }; + } + + private string GetCarImageForClass(string? carClassCode) + { + return carClassCode?.ToUpperInvariant() switch + { + "ECON" => "images/EconomyCar.png", + "STD" => "images/StandardCar.png", + "LUX" => "images/LuxuryCar.png", + _ => "images/DefaultCar.png" + }; + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Components/ReservationStatusDisplay.razor b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Components/ReservationStatusDisplay.razor new file mode 100644 index 0000000..15db3f1 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Components/ReservationStatusDisplay.razor @@ -0,0 +1,803 @@ +@using System.Net +@using System.Text.RegularExpressions +@using Sagaway.ReservationDemo.ReservationUI.Services +@using Sagaway.ReservationDemo.ReservationUI.Services.DTOs +@implements IDisposable + +@inject IReservationManager ReservationManager +@inject ILogger Logger +@inject IJSRuntime JSRuntime + +
+
+

+ Reservation Status (@(_customerName ?? "No customer selected")) +

+ +
+ + +
+
+ + @if (_loading) + { +

Loading reservation status...

+
+
+
+ } + else if (!string.IsNullOrWhiteSpace(_errorMessage)) + { +

Error: @_errorMessage

+ } + else if (CustomerId == Guid.Empty) + { +

Please select a customer to view their reservations.

+ } + else if (_reservations == null || !_reservations.Any()) + { +

No reservations found for this customer.

+ } + else + { +
+ + + + + + + + + + + @foreach (var kvp in _reservations) + { + var reservation = kvp.Value; // Get the value from the dictionary pair + + + + + + + + } + +
CarReservation IDStatusActions
+
+ @{ + var imgSrc = GetCarImageForClass(reservation.CarClassCode); + var carClassName = GetCarClassNameForCode(reservation.CarClassCode); + } + @carClassName + + @carClassName +
+
+ @kvp.Key + +
+ @if (reservation.Status == ReservationStatusType.Pending || reservation.Status == ReservationStatusType.CancelPending || reservation.IsProcessing) + { +
+ } + + @if (reservation is { Status: ReservationStatusType.CancelPending, IsProcessing: true }) + { + Cancelling... + } + else + { + @reservation.Status + } + +
+
+
+ + + @if (reservation is { Status: ReservationStatusType.Reserved or ReservationStatusType.CancelFailed, IsProcessing: false }) + { + + } +
+
+
+ } + +
+ + + + +
+ + + @if (_showingInventory) + { + + } + + + @if (_showSagaLogModal) + { +
+
+
+

+ Saga Log - Reservation + @_selectedReservationId +

+ +
+ +
+ @if (_loadingSagaLog) + { +
+
+

Loading saga log...

+
+ } + else if (!string.IsNullOrWhiteSpace(_sagaLogError)) + { +
+

+ + + + Error loading saga log: @_sagaLogError +

+
+ } + else if (string.IsNullOrWhiteSpace(_sagaLog)) + { +
+

+ + + + No saga log available for this reservation. +

+
+ } + else // Display the formatted HTML log + { +
+ @_formattedSagaLog +
+ } +
+ +
+ +
+
+
+ + } + +
+ +@code { + [Parameter] + public Guid CustomerId { get; set; } + + private string? _customerName; + private bool _loading; + private string? _errorMessage; + private Dictionary? _reservations; + private Dictionary _predefinedUsers = new(); + private IDisposable? _subscription; + private Guid _currentCustomerId; + + // Saga log modal state + private bool _showSagaLogModal; + private Guid _selectedReservationId; + private string? _sagaLog; + private MarkupString _formattedSagaLog; + private bool _loadingSagaLog; + private string? _sagaLogError; + private Guid? _cancellingReservationId; + // Add this field to control visibility of the inventory component + private bool _showingInventory; + + // Method to toggle the inventory view + private void ToggleInventoryView() + { + _showingInventory = !_showingInventory; + StateHasChanged(); + } + + protected override async Task OnInitializedAsync() + { + // Get predefined users from the ReservationManager + _predefinedUsers = ReservationManager.GetAllUsers(); + + // Load reservations for all predefined users + await LoadInitialDataAsync(); + } + + private async Task LoadInitialDataAsync() + { + _loading = true; + _errorMessage = null; + StateHasChanged(); + + try + { + // Initialize each predefined user's data + foreach (var user in _predefinedUsers) + { + await ReservationManager.LoadReservationsForUserAsync(user.Key); + } + + // If no customer is selected, but we have users, select the first one + if (CustomerId == Guid.Empty && _predefinedUsers.Count > 0) + { + var firstUserId = _predefinedUsers.First().Key; + await SelectCustomerAsync(firstUserId); + } + else if (CustomerId != Guid.Empty) + { + // If a customer is already selected, load their data + await SelectCustomerAsync(CustomerId); + } + } + catch (Exception ex) + { + _errorMessage = $"Error loading initial data: {ex.Message}"; + Logger.LogError(ex, "Error loading initial data"); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + protected override async Task OnParametersSetAsync() + { + // Check if the CustomerId has changed + if (_currentCustomerId != CustomerId) + { + _currentCustomerId = CustomerId; + + // Clear current reservations to avoid showing previous user's data + _reservations = null; + + // Reset all state + _errorMessage = null; + _cancellingReservationId = null; + + // Show loading indicator + _loading = true; + StateHasChanged(); + + // Setup subscription for the new customer - this should clear previous subscription + SetupSubscription(); + + // Load reservations for the new customer + if (CustomerId != Guid.Empty) + { + await ReservationManager.LoadReservationsForUserAsync(CustomerId); + } + + _loading = false; + } + + await base.OnParametersSetAsync(); + } + + private void OnCustomerSelected(ChangeEventArgs e) + { + var selectedCustomerId = e.Value?.ToString(); + + if (string.IsNullOrEmpty(selectedCustomerId)) + { + CustomerId = Guid.Empty; + _customerName = null; + _reservations = null; + _subscription?.Dispose(); + _subscription = null; + StateHasChanged(); + } + else if (Guid.TryParse(selectedCustomerId, out var customerId)) + { + // Use an async void pattern to handle the async operation without blocking the UI + _ = Task.Run(async () => + { + await SelectCustomerAsync(customerId); + }); + } + } + + private async Task SelectCustomerAsync(Guid customerId) + { + // Update the selected customer ID + CustomerId = customerId; + + // Update the customer name from our predefined users + _customerName = _predefinedUsers.GetValueOrDefault(customerId, "Unknown"); + + // Set up subscription to get real-time updates + SetupSubscription(); + + // Explicitly load the reservations for this user + await ReservationManager.LoadReservationsForUserAsync(CustomerId); + } + + private void SetupSubscription() + { + // Dispose of any existing subscription + _subscription?.Dispose(); + + if (CustomerId == Guid.Empty) + return; + + // Show loading state + _loading = true; + _errorMessage = null; + StateHasChanged(); + + // Create a new subscription to the reservation state for this customer + _subscription = ReservationManager.GetReservationsForUser(CustomerId) + .Subscribe( + reservations => + { + _reservations = reservations; + var customer = reservations.Values.FirstOrDefault(); + if (customer != null) + { + _customerName = customer.CustomerName; + } + _loading = false; + _errorMessage = null; + InvokeAsync(StateHasChanged); + }, + error => + { + _errorMessage = $"Error receiving updates: {error.Message}"; + _loading = false; + InvokeAsync(StateHasChanged); + }); + } + + public async Task RefreshReservations() + { + if (CustomerId == Guid.Empty) + { + return; + } + + _loading = true; + _errorMessage = null; + StateHasChanged(); + + try + { + await ReservationManager.LoadReservationsForUserAsync(CustomerId); + } + catch (Exception ex) + { + _errorMessage = $"Error refreshing reservations: {ex.Message}"; + Logger.LogError(ex, "Error refreshing reservations for customer {CustomerId}", CustomerId); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private async Task ViewSagaLog(Guid reservationId) + { + _selectedReservationId = reservationId; + _showSagaLogModal = true; + _loadingSagaLog = true; + _sagaLog = null; + _formattedSagaLog = new MarkupString(); + _sagaLogError = null; + StateHasChanged(); + + try + { + if (_reservations != null && _reservations.TryGetValue(reservationId, out var reservation)) + { + _sagaLog = reservation.SagaLog; + + if (string.IsNullOrWhiteSpace(_sagaLog)) + { + _sagaLogError = "No saga log available for this reservation."; + } + else + { + _formattedSagaLog = FormatSagaLogAsHtml(_sagaLog); + StateHasChanged(); // Force render before initializing JavaScript + + // Give DOM time to update before initializing the toggles + await Task.Delay(100); + await JSRuntime.InvokeVoidAsync("sagaLogFunctions.initializeSagaToggles"); + } + } + else + { + _sagaLogError = "Reservation not found."; + } + } + catch (Exception ex) + { + _sagaLogError = $"Error retrieving or formatting saga log: {ex.Message}"; + _formattedSagaLog = new MarkupString($"
Error processing log: {WebUtility.HtmlEncode(ex.Message)}
"); + Logger.LogError(ex, "Error retrieving/formatting saga log for {ReservationId}", reservationId); + } + finally + { + _loadingSagaLog = false; + StateHasChanged(); + } + } + + private async Task HandleCancelReservation(Guid reservationId) + { + // Prevent multiple clicks while processing + if (_cancellingReservationId.HasValue) + return; + + _cancellingReservationId = reservationId; + _errorMessage = null; + StateHasChanged(); + + Logger.LogInformation("Attempting to cancel reservation {ReservationId}", reservationId); + + try + { + // Call the ReservationManager to cancel the reservation + bool success = await ReservationManager.CancelReservationAsync(reservationId); + + if (!success) + { + _errorMessage = $"Failed to initiate cancellation for reservation {reservationId}."; + Logger.LogWarning("Cancel request failed for reservation {ReservationId}", reservationId); + } + else + { + Logger.LogInformation("Cancel request accepted for reservation {ReservationId}", reservationId); + // The ReservationManager will handle updating the state via SignalR + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error cancelling reservation {ReservationId}", reservationId); + _errorMessage = $"Error cancelling reservation: {ex.Message}"; + } + finally + { + _cancellingReservationId = null; + StateHasChanged(); + } + } + + private void CloseSagaLogModal() + { + _showSagaLogModal = false; + _sagaLog = null; + _sagaLogError = null; + StateHasChanged(); + } + + private string GetStatusClass(ReservationStatusType status) => status switch + { + ReservationStatusType.Reserved => "text-green-700 bg-green-100", + ReservationStatusType.Cancelled => "text-orange-700 bg-orange-100", + ReservationStatusType.Failed => "text-red-700 bg-red-100", + ReservationStatusType.Pending => "text-blue-700 bg-blue-100", + ReservationStatusType.CancelFailed => "text-red-700 bg-red-100", + ReservationStatusType.CancelPending => "text-yellow-700 bg-yellow-100", + _ => "text-gray-700 bg-gray-100" + }; + + private string GetCarClassNameForCode(string? carClassCode) + { + return carClassCode?.ToUpperInvariant() switch + { + "ECON" => "Economy", + "STD" => "Standard", + "LUX" => "Luxury", + _ => carClassCode ?? "Unknown" + }; + } + + private string GetCarImageForClass(string? carClassCode) + { + return carClassCode?.ToUpperInvariant() switch + { + "ECON" => "images/EconomyCar.png", + "STD" => "images/StandardCar.png", + "LUX" => "images/LuxuryCar.png", + _ => "images/DefaultCar.png" + }; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + // Initialize any saga toggles in the current view after rendering + await JSRuntime.InvokeVoidAsync("sagaLogFunctions.initializeSagaToggles"); + } + } + + private MarkupString FormatSagaLogAsHtml(string rawLog) + { + if (string.IsNullOrWhiteSpace(rawLog)) + { + return new MarkupString("

Log is empty.

"); + } + + var htmlBuilder = new System.Text.StringBuilder(); + + // Remove surrounding quotes if present + string processedLog = rawLog.Trim(); + if (processedLog.StartsWith('"') && processedLog.EndsWith('"')) + { + processedLog = processedLog.Substring(1, processedLog.Length - 2); + } + + // Replace escaped newlines with actual newlines, then split + var lines = processedLog.Replace("\\n", "\n").Split('\n'); + + // Define Tailwind classes + const string timestampClass = "text-gray-500 mr-2 font-mono"; + const string startExecutingClass = "text-blue-600 font-medium"; + const string successClass = "text-emerald-600 font-medium"; + const string failClass = "text-red-600 font-medium"; + const string revertClass = "text-orange-600 font-medium"; + const string defaultClass = "text-gray-800"; + const string lineBaseClass = "py-1 flex items-start"; + const string headerClass = "saga-header border-t-2 border-indigo-400 bg-indigo-50 rounded-t-lg mt-6 p-3 text-center font-bold cursor-pointer"; + const string contentClass = "saga-content bg-white p-4 rounded-b-lg mb-4 border-l border-r border-b border-indigo-200 shadow-sm"; + + // Generate unique IDs for collapsible sections + string uniqueId = Guid.NewGuid().ToString().Substring(0, 8); + int sagaCount = 0; + System.Text.StringBuilder currentSagaContent = new(); + bool isFirstSaga = true; + + // Create a list to store saga chunks for better analysis + List<(int Index, List Lines)> sagaChunks = new(); + List currentChunkLines = new(); + + // First pass: identify saga chunks and separators + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmedLine)) continue; + + // Check if this is a separator line + if (trimmedLine.StartsWith("*******************") || + trimmedLine.Contains("***************************************************************************")) + { + // If we've collected lines, this is the end of a chunk + if (currentChunkLines.Count > 0) + { + sagaChunks.Add((sagaCount++, currentChunkLines.ToList())); + currentChunkLines.Clear(); + } + } + else + { + // Add this line to the current chunk + currentChunkLines.Add(trimmedLine); + } + } + + // Add the final chunk if there is one + if (currentChunkLines.Count > 0) + { + sagaChunks.Add((sagaCount, currentChunkLines.ToList())); + } + + // Reset saga count for proper numbering + sagaCount = 0; + + // Process each saga chunk + foreach (var chunk in sagaChunks) + { + sagaCount++; + + // Analyze the chunk content to determine the saga type + string sagaTitle = "Processing..."; + string badgeColor = "bg-blue-500"; + + // Check for keywords in the chunk lines + string chunkContent = string.Join(" ", chunk.Lines); + if (chunkContent.Contains("[CancelBooking]") || chunkContent.Contains("[Refund]") || + chunkContent.Contains("[CancelInventoryReserving]")) + { + sagaTitle = "Cancellation Process"; + badgeColor = "bg-red-500"; + } + else if (chunkContent.Contains("[CarBooking]") || chunkContent.Contains("[Billing]") || + chunkContent.Contains("[InventoryReserving]")) + { + sagaTitle = "Reservation Process"; + badgeColor = "bg-blue-500"; + } + else if (chunkContent.Contains("[Billing]") && + (chunkContent.Contains("Billing Success") || chunkContent.Contains("validation successfully"))) + { + sagaTitle = "Billing Completion"; + badgeColor = "bg-green-500"; + } + + // Create the header for this chunk - using standard HTML without verbatim strings + htmlBuilder.Append("
"); + + htmlBuilder.Append("
"); + htmlBuilder.Append("#").Append(sagaCount).Append(""); + + htmlBuilder.Append("").Append(sagaTitle).Append(""); + + htmlBuilder.Append(""); + htmlBuilder.Append(""); + htmlBuilder.Append(""); + htmlBuilder.Append("
"); + htmlBuilder.Append("
"); + + // Process the lines in this chunk + currentSagaContent.Clear(); + foreach (var trimmedLine in chunk.Lines) + { + string timestamp = string.Empty; + string message; + string messageColorClass = defaultClass; + + // Try to extract timestamp (e.g., [HH:mm:ss]) + var match = Regex.Match(trimmedLine, @"^(\[.*?\])(.*)"); + if (match.Success) + { + timestamp = WebUtility.HtmlEncode(match.Groups[1].Value); + message = WebUtility.HtmlEncode(match.Groups[2].Value.Trim()); + } + else + { + message = WebUtility.HtmlEncode(trimmedLine); + } + + // Determine message color class based on keywords + if (message.Contains("Success", StringComparison.OrdinalIgnoreCase)) + { + messageColorClass = successClass; + } + else if (message.Contains("Start Executing", StringComparison.OrdinalIgnoreCase)) + { + messageColorClass = startExecutingClass; + } + else if (message.Contains("Fail", StringComparison.OrdinalIgnoreCase) || + message.Contains("Error", StringComparison.OrdinalIgnoreCase)) + { + messageColorClass = failClass; + } + else if (message.Contains("Revert", StringComparison.OrdinalIgnoreCase)) + { + messageColorClass = revertClass; + } + + // Build the HTML for the line + currentSagaContent.Append("
"); + + if (!string.IsNullOrEmpty(timestamp)) + { + currentSagaContent.Append("") + .Append(timestamp).Append(""); + } + + currentSagaContent.Append("") + .Append(message).Append(""); + currentSagaContent.Append("
"); + } + + // Add the content section for this chunk + htmlBuilder.Append("
"); + htmlBuilder.Append(currentSagaContent); + htmlBuilder.Append("
"); + + isFirstSaga = false; + } + + return new MarkupString(htmlBuilder.ToString()); + } + + public void Dispose() + { + _subscription?.Dispose(); + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Dockerfile b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Dockerfile new file mode 100644 index 0000000..1fe718f --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Dockerfile @@ -0,0 +1,46 @@ +# Stage 1: Build Tailwind and WebAssembly App +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +# Copy only csproj first to optimize Docker caching +COPY Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Sagaway.ReservationDemo.ReservationUI.csproj Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/ +# Consider copying package.json and tailwind config earlier too if they don't change often +COPY Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/package.json Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/ +COPY Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/package-lock.json Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/ +COPY Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/tailwind.config.js Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/ +RUN dotnet restore "Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Sagaway.ReservationDemo.ReservationUI.csproj" + +# Copy full source +COPY . . +WORKDIR /src/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI + +# Install Node.js and Tailwind dependencies +# Combine RUN commands where possible +RUN apt-get update && \ + apt-get install -y --no-install-recommends nodejs npm && \ + npm ci && \ + npx @tailwindcss/cli -i ./wwwroot/css/app.css -o ./wwwroot/css/app.output.css --minify && \ + # Clean up apt cache + rm -rf /var/lib/apt/lists/* + +# Build the Blazor WebAssembly app +RUN dotnet publish "Sagaway.ReservationDemo.ReservationUI.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# Stage 2: Serve using nginx +FROM nginx:alpine AS final + +# Remove the default nginx static website and config +RUN rm -rf /usr/share/nginx/html/* && rm /etc/nginx/conf.d/default.conf + +# Copy custom nginx configuration +# Assumes nginx.conf is in the same directory as the Dockerfile +COPY Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/nginx.conf /etc/nginx/conf.d/default.conf + +# Copy published Blazor WebAssembly app to nginx public directory +COPY --from=build /app/publish/wwwroot /usr/share/nginx/html + +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/MainLayout.razor b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/MainLayout.razor new file mode 100644 index 0000000..76eb725 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/MainLayout.razor @@ -0,0 +1,16 @@ +@inherits LayoutComponentBase +
+ + +
+
+ About +
+ +
+ @Body +
+
+
diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/MainLayout.razor.css b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/MainLayout.razor.css new file mode 100644 index 0000000..ecf25e5 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/MainLayout.razor.css @@ -0,0 +1,77 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/NavMenu.razor b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/NavMenu.razor new file mode 100644 index 0000000..c1d448f --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/NavMenu.razor @@ -0,0 +1,39 @@ + + + + +@code { + private bool collapseNavMenu = true; + + private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; + + private void ToggleNavMenu() + { + collapseNavMenu = !collapseNavMenu; + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/NavMenu.razor.css b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/NavMenu.razor.css new file mode 100644 index 0000000..617b89c --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Layout/NavMenu.razor.css @@ -0,0 +1,83 @@ +.navbar-toggler { + background-color: rgba(255, 255, 255, 0.1); +} + +.top-row { + min-height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep a { + color: #d7d7d7; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep a:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .collapse { + /* Never collapse the sidebar for wide screens */ + display: block; + } + + .nav-scrollable { + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Program.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Program.cs new file mode 100644 index 0000000..6b4e894 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Program.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Sagaway.ReservationDemo.ReservationUI; +using Sagaway.ReservationDemo.ReservationUI.Services; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(_ => new HttpClient()); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +await builder.Build().RunAsync(); diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Properties/launchSettings.json b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Properties/launchSettings.json new file mode 100644 index 0000000..7156637 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Sagaway.ReservationDemo.ReservationUI.csproj b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Sagaway.ReservationDemo.ReservationUI.csproj new file mode 100644 index 0000000..c256d57 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Sagaway.ReservationDemo.ReservationUI.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + enable + enable + Linux + ..\.. + ..\..\docker-compose.dcproj + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Sagaway.ReservationDemo.ReservationUI.sln b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Sagaway.ReservationDemo.ReservationUI.sln new file mode 100644 index 0000000..c390bbd --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Sagaway.ReservationDemo.ReservationUI.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sagaway.ReservationDemo.ReservationUI", "Sagaway.ReservationDemo.ReservationUI.csproj", "{3301C64B-430F-BCF5-ACE5-D12349FF6C83}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3301C64B-430F-BCF5-ACE5-D12349FF6C83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3301C64B-430F-BCF5-ACE5-D12349FF6C83}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3301C64B-430F-BCF5-ACE5-D12349FF6C83}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3301C64B-430F-BCF5-ACE5-D12349FF6C83}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8BF5D552-C817-4B1A-A48D-8082CB6FB9AC} + EndGlobalSection +EndGlobal diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/Argument.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/Argument.cs new file mode 100644 index 0000000..52dbcb0 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/Argument.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Nodes; + +namespace Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +public record Argument +{ + public string Sender { get; set; } = string.Empty; + public JsonObject Text { get; set; } = new JsonObject(); +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/CarClassAllocationRequest.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/CarClassAllocationRequest.cs new file mode 100644 index 0000000..cba59d6 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/CarClassAllocationRequest.cs @@ -0,0 +1,7 @@ +namespace Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +public record CarClassAllocationRequest +{ + public required string CarClass { get; set; } + public int MaxAllocation { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/CarClassInfo.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/CarClassInfo.cs new file mode 100644 index 0000000..fbaeddc --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/CarClassInfo.cs @@ -0,0 +1,8 @@ +namespace Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +public record CarClassInfo +{ + public required string Code { get; set; } + public int Reserved { get; set; } + public int MaxAllocation { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/CarInventoryResponse.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/CarInventoryResponse.cs new file mode 100644 index 0000000..7eaaad1 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/CarInventoryResponse.cs @@ -0,0 +1,6 @@ +namespace Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +public record CarInventoryResponse +{ + public List CarClasses { get; set; } = []; +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/ReservationResult.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/ReservationResult.cs new file mode 100644 index 0000000..9f3c6d1 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/ReservationResult.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +public class ReservationResult +{ + [JsonPropertyName("reservationId")] + public Guid ReservationId { get; set; } + + [JsonPropertyName("customerName")] + public string? CustomerName { get; set; } + + [JsonPropertyName("carClass")] + public string? CarClass { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/ReservationStatus.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/ReservationStatus.cs new file mode 100644 index 0000000..4dc434e --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/ReservationStatus.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +/// +/// Represents the status/details of a single reservation. +/// Mirrors the server's BookingInfo record. +/// +public class ReservationStatus // Renamed for clarity on client-side, but maps to BookingInfo +{ + [JsonPropertyName("id")] // Maps to BookingInfo.Id + public Guid ReservationId { get; set; } // Renamed from 'Id' for client-side consistency + + [JsonPropertyName("customerName")] + public required string CustomerName { get; set; } + + [JsonPropertyName("carClass")] + public required string CarClass { get; set; } + + [JsonPropertyName("isReserved")] // Maps to BookingInfo.IsReserved + public bool IsReserved { get; set; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/ReservationStatusType.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/ReservationStatusType.cs new file mode 100644 index 0000000..5304c13 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/ReservationStatusType.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +/// +/// Enum representing the possible states of a reservation +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReservationStatusType +{ + Pending, // Initial transient state when reservation request is sent + Reserved, // Final state, Successfully reserved + NotReserved, // Final state, after cancelled or failed + Failed, // Transient state, Reservation attempt failed + CancelPending, // Transient state, Cancellation has been requested but not completed + Cancelled, // Transient state, Successfully cancelled + CancelFailed // Transient state, it will be Reserved +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/SagaUpdate.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/SagaUpdate.cs new file mode 100644 index 0000000..6cb78c4 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/DTOs/SagaUpdate.cs @@ -0,0 +1,11 @@ +namespace Sagaway.ReservationDemo.ReservationUI.Services.DTOs +{ + public class SagaUpdate + { + public Guid ReservationId { get; set; } + public required string Outcome { get; set; } + public required string Log { get; set; } + public required string CustomerName { get; set; } + public required string CarClass { get; set; } + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/IReservationApiClient.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/IReservationApiClient.cs new file mode 100644 index 0000000..0ddfcde --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/IReservationApiClient.cs @@ -0,0 +1,56 @@ +using System.Net; +using Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +namespace Sagaway.ReservationDemo.ReservationUI.Services; +public interface IReservationApiClient +{ + /// + /// Initiates a car reservation request. + /// + /// The name of the customer. + /// The desired car class code (e.g., "ECON", "STD", "LUX"). + /// An optional existing reservation ID to use. + /// A ReservationResult containing details of the initiated reservation, or null if failed before getting a result. + Task ReserveCarAsync(string customerName, string carClass, Guid? reservationId = null); + + /// + /// Retrieves the current status of reservations for a specific customer. + /// + /// The name of the customer whose reservations to fetch. + /// A list of ReservationStatus objects (mapped from BookingInfo), or null/empty list if none found or an error occurred. + Task?> GetReservationsAsync(string customerName); + + /// + /// Retrieves the details of a specific reservation. + /// + /// The ID of the reservation to fetch. + /// A ReservationStatus object (mapped from BookingInfo), or null if not found or an error occurred. + Task GetReservationAsync(Guid reservationId); + + /// + /// Initiates the cancellation process for a specific reservation. + /// + /// The ID of the reservation to cancel. + /// A tuple containing: success status and HTTP status code if applicable + Task<(bool Success, HttpStatusCode StatusCode)> CancelReservationAsync(Guid reservationId); + + /// + /// Retrieves the saga log for a specific reservation. + /// + /// The ID of the reservation whose saga log to fetch. + /// The saga log as a string, or null if not found or an error occurred. + Task GetSagaLogAsync(Guid reservationId); + + /// + /// Updates the car class allocation with the provided request details. + /// + /// The request containing car class and allocation details. + /// A CarClassInfo object containing updated allocation details. + Task UpdateCarClassAllocationAsync(CarClassAllocationRequest allocationRequest); + + /// + /// Retrieves the current car inventory details. + /// + /// A CarInventoryResponse object containing the list of car classes and their allocation details. + Task GetCarInventoryAsync(); +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/IReservationManager.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/IReservationManager.cs new file mode 100644 index 0000000..7b4cc19 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/IReservationManager.cs @@ -0,0 +1,54 @@ +namespace Sagaway.ReservationDemo.ReservationUI.Services; + +// Type aliases for complex dictionary types +using ReservationStateObservable = IObservable>; + +public interface IReservationManager +{ + /// + /// Initializes the reservation manager and prepares it for use. + /// + /// A task that represents the asynchronous initialization operation. + Task InitializeAsync(); + + /// + /// Gets all known customers with their IDs + /// + Dictionary GetAllUsers(); + + /// + /// Gets an observable stream of reservation states for a specific user. + /// + /// The unique identifier of the user. + /// An observable that provides updates to the reservation states for the user. + ReservationStateObservable GetReservationsForUser(Guid userId); + + /// + /// Creates a new reservation for a customer. + /// + /// The unique identifier of the customer. + /// The name of the customer. + /// The class of the car being reserved. + /// A task that represents the asynchronous operation. The task result contains the unique identifier of the created reservation. + Task CreateReservationAsync(Guid customerId, string customerName, string carClass); + + /// + /// Loads all reservations for a specific user. + /// + /// The unique identifier of the customer. + /// A task that represents the asynchronous operation. + Task LoadReservationsForUserAsync(Guid customerId); + + /// + /// Initiates the cancellation process for a specific reservation. + /// + /// The ID of the reservation to cancel. + /// True if the cancellation request was accepted, false otherwise. + Task CancelReservationAsync(Guid reservationId); + + /// + /// Event triggered when the state of reservations changes. + /// + event Action StateChanged; +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ISignalRService.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ISignalRService.cs new file mode 100644 index 0000000..dc4abe3 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ISignalRService.cs @@ -0,0 +1,10 @@ +using Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +namespace Sagaway.ReservationDemo.ReservationUI.Services; + +public interface ISignalRService +{ + event Action? OnSagaCompleted; + Task InitializeAsync(); + bool IsConnected { get; } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ReservationApiClient.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ReservationApiClient.cs new file mode 100644 index 0000000..5dccb16 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ReservationApiClient.cs @@ -0,0 +1,332 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Components; +using Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +namespace Sagaway.ReservationDemo.ReservationUI.Services +{ + /// + /// Interface for the client accessing the Reservation API. + /// + + /// + /// Implementation of the client accessing the Reservation API. + /// + + public class ReservationApiClient : IReservationApiClient + { + private readonly HttpClient _http; + private readonly NavigationManager _navigationManager; // Inject NavigationManager + private readonly ILogger _logger; + + // Constructor injection for HttpClient, NavigationManager, and ILogger + // ReSharper disable once ConvertToPrimaryConstructor + public ReservationApiClient(HttpClient http, NavigationManager navigationManager, + ILogger logger) + { + _http = http ?? throw new ArgumentNullException(nameof(http)); + _navigationManager = + navigationManager ?? + throw new ArgumentNullException(nameof(navigationManager)); // Store injected NavigationManager + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Initiates a car reservation request via a POST call to /reserve. + /// + public async Task ReserveCarAsync(string customerName, string carClass, + Guid? reservationId = null) + { + // Build the relative path and query string + var relativePathAndQuery = + $"reserve?customerName={Uri.EscapeDataString(customerName)}&carClass={Uri.EscapeDataString(carClass)}"; + if (reservationId.HasValue && reservationId != Guid.Empty) + { + relativePathAndQuery += $"&reservationId={reservationId.Value}"; + } + + // *** CHANGED: Construct Absolute URI using NavigationManager *** + var absoluteUri = _navigationManager.ToAbsoluteUri(relativePathAndQuery); + _logger.LogInformation("Sending POST request to absolute URI: {AbsoluteUri}", absoluteUri); + + // Use the absolute URI when creating the HttpRequestMessage + using var request = new HttpRequestMessage(HttpMethod.Post, absoluteUri); + + try + { + // Send the request using SendAsync + using var response = await _http.SendAsync(request); + response.EnsureSuccessStatusCode(); // Throws HttpRequestException on non-success + + var result = await response.Content.ReadFromJsonAsync(); + _logger.LogInformation("Successfully initiated reservation via /reserve, ID: {ReservationId}", + result?.ReservationId); + return result; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed when calling {AbsoluteUri}. Status Code: {StatusCode}", + absoluteUri, ex.StatusCode); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred during ReserveCarAsync for URI {AbsoluteUri}", + absoluteUri); + throw; + } + } + + /// + /// Retrieves reservation statuses for a customer via a GET call to /reservations/{customerName}. + /// + public async Task?> GetReservationsAsync(string customerName) + { + var relativePath = $"reservations/{Uri.EscapeDataString(customerName)}"; + var absoluteUri = _navigationManager.ToAbsoluteUri(relativePath); // Construct absolute URI + _logger.LogInformation("Sending GET request to absolute URI: {AbsoluteUri}", absoluteUri); + + using var request = new HttpRequestMessage(HttpMethod.Get, absoluteUri); // Use absolute URI + + try + { + using var response = await _http.SendAsync(request); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogInformation( + "No reservations found for customer {CustomerName} via {AbsoluteUri} (404)", customerName, + absoluteUri); + return []; + } + + response.EnsureSuccessStatusCode(); + + var statuses = await response.Content.ReadFromJsonAsync>(); + _logger.LogInformation( + "Successfully retrieved {Count} reservation statuses for {CustomerName} via {AbsoluteUri}", + statuses?.Count ?? 0, customerName, absoluteUri); + return statuses; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed when calling {AbsoluteUri}. Status Code: {StatusCode}", + absoluteUri, ex.StatusCode); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, + "An unexpected error occurred during GetReservationsAsync for URI {AbsoluteUri}", absoluteUri); + return null; + } + } + + /// + /// Retrieves the details of a specific reservation via GET /reservation/{reservationId}. + /// + public async Task GetReservationAsync(Guid reservationId) + { + var relativePath = $"reservation/{reservationId:D}"; + var absoluteUri = _navigationManager.ToAbsoluteUri(relativePath); // Construct absolute URI + _logger.LogInformation("Sending GET request to absolute URI: {AbsoluteUri}", absoluteUri); + + using var request = new HttpRequestMessage(HttpMethod.Get, absoluteUri); // Use absolute URI + + try + { + using var response = await _http.SendAsync(request); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogInformation("Reservation {ReservationId} not found via {AbsoluteUri} (404)", + reservationId, absoluteUri); + return null; + } + + response.EnsureSuccessStatusCode(); + + var status = await response.Content.ReadFromJsonAsync(); + _logger.LogInformation( + "Successfully retrieved reservation status for {ReservationId} via {AbsoluteUri}", + reservationId, absoluteUri); + return status; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed when calling {AbsoluteUri}. Status Code: {StatusCode}", + absoluteUri, ex.StatusCode); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, + "An unexpected error occurred during GetReservationAsync for URI {AbsoluteUri}", absoluteUri); + return null; + } + } + + /// + /// Retrieves the saga log for a specific reservation via GET /saga-log/{reservationId}. + /// + public async Task GetSagaLogAsync(Guid reservationId) + { + var relativePath = $"saga-log/{reservationId:D}"; + var absoluteUri = _navigationManager.ToAbsoluteUri(relativePath); // Construct absolute URI + _logger.LogInformation("Sending GET request for saga log to absolute URI: {AbsoluteUri}", absoluteUri); + + using var request = new HttpRequestMessage(HttpMethod.Get, absoluteUri); // Use absolute URI + + try + { + using var response = await _http.SendAsync(request); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogInformation("Saga log for reservation {ReservationId} not found via {AbsoluteUri} (404)", + reservationId, absoluteUri); + return null; + } + + response.EnsureSuccessStatusCode(); + + var sagaLog = await response.Content.ReadAsStringAsync(); + _logger.LogInformation( + "Successfully retrieved saga log for reservation {ReservationId} via {AbsoluteUri}", + reservationId, absoluteUri); + return sagaLog; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed when calling {AbsoluteUri} for saga log. Status Code: {StatusCode}", + absoluteUri, ex.StatusCode); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, + "An unexpected error occurred during GetSagaLogAsync for URI {AbsoluteUri}", absoluteUri); + return null; + } + } + + public async Task UpdateCarClassAllocationAsync(CarClassAllocationRequest allocationRequest) + { + // Define the relative path for updating car class allocation. + var relativePath = "update-allocation"; + var absoluteUri = _navigationManager.ToAbsoluteUri(relativePath); + _logger.LogInformation("Sending POST request to absolute URI: {AbsoluteUri} for updating allocation", absoluteUri); + + // Prepare the HTTP POST request with JSON content. + using var request = new HttpRequestMessage(HttpMethod.Post, absoluteUri); + request.Content = JsonContent.Create(allocationRequest); + + try + { + using var response = await _http.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(); + _logger.LogInformation("Successfully updated car class allocation for {CarClass}", allocationRequest.CarClass); + return result ?? throw new InvalidOperationException("Response deserialization returned null."); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed when calling {AbsoluteUri}. Status Code: {StatusCode}", absoluteUri, ex.StatusCode); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred during UpdateCarClassAllocationAsync for URI {AbsoluteUri}", absoluteUri); + throw; + } + } + + public async Task GetCarInventoryAsync() + { + // Change from relative path to use the correct inventory service endpoint + var relativePath = "/car-inventory"; // Start with a slash to ensure correct path resolution + var absoluteUri = _navigationManager.ToAbsoluteUri(relativePath); + _logger.LogInformation("Sending GET request to absolute URI: {AbsoluteUri}", absoluteUri); + + using var request = new HttpRequestMessage(HttpMethod.Get, absoluteUri); + + try + { + using var response = await _http.SendAsync(request); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogInformation("Car inventory not found at {AbsoluteUri} (404)", absoluteUri); + return new CarInventoryResponse { CarClasses = new List() }; + } + + response.EnsureSuccessStatusCode(); + + var inventory = await response.Content.ReadFromJsonAsync(); + _logger.LogInformation("Successfully retrieved car inventory from {AbsoluteUri}", absoluteUri); + return inventory ?? new CarInventoryResponse { CarClasses = new List() }; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed when calling {AbsoluteUri}. Status Code: {StatusCode}", + absoluteUri, ex.StatusCode); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred during GetCarInventoryAsync for URI {AbsoluteUri}", absoluteUri); + throw; + } + } + /// + /// Initiates the cancellation process for a specific reservation via POST /cancel. + /// + /// A tuple containing: success status and HTTP status code + public async Task<(bool Success, HttpStatusCode StatusCode)> CancelReservationAsync(Guid reservationId) + { + var relativePathAndQuery = $"cancel?reservationId={reservationId:D}"; + var absoluteUri = _navigationManager.ToAbsoluteUri(relativePathAndQuery); // Construct absolute URI + _logger.LogInformation("Sending POST request to absolute URI: {AbsoluteUri}", absoluteUri); + + using var request = new HttpRequestMessage(HttpMethod.Post, absoluteUri); // Use absolute URI + + try + { + using var response = await _http.SendAsync(request); + var statusCode = response.StatusCode; + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation( + "Successfully requested cancellation for reservation {ReservationId} via {AbsoluteUri}", + reservationId, absoluteUri); + return (true, statusCode); + } + //else + + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogWarning( + "Cancellation request failed for reservation {ReservationId} via {AbsoluteUri}. Status Code: {StatusCode}. Response: {Response}", + reservationId, absoluteUri, response.StatusCode, errorContent); + return (false, statusCode); + + } + catch (HttpRequestException ex) + { + var statusCode = ex.StatusCode ?? HttpStatusCode.InternalServerError; + + _logger.LogError(ex, "HTTP request failed when calling {AbsoluteUri}. Status Code: {StatusCode}", + absoluteUri, ex.StatusCode); + return (false, statusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, + "An unexpected error occurred during CancelReservationAsync for URI {AbsoluteUri}", + absoluteUri); + return (false, HttpStatusCode.InternalServerError); + } + } + } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ReservationManager.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ReservationManager.cs new file mode 100644 index 0000000..7011ace --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ReservationManager.cs @@ -0,0 +1,654 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Collections.Concurrent; + +namespace Sagaway.ReservationDemo.ReservationUI.Services; + +// Type aliases for complex dictionary types +using UserReservationsMap = Dictionary; +using UserReservationsConcurrentMap = ConcurrentDictionary>; +using ReservationIdToCustomerMap = ConcurrentDictionary; +using UserSubjectMap = ConcurrentDictionary>>; + +using DTOs; +using System.Net; + +/// +/// Client-side manager that handles reservation operations and state for the Blazor UI +/// +public class ReservationManager : IReservationManager, IAsyncDisposable +{ + private readonly IReservationApiClient _apiClient; + private readonly ISignalRService _signalRService; + private readonly ILogger _logger; + + // State storage + private readonly UserReservationsConcurrentMap _userReservations = new(); + private readonly UserSubjectMap _userSubjects = new(); + private readonly ReservationIdToCustomerMap _reservationToCustomerId = new(); + + // Simplified customer management - default predefined users + private readonly Dictionary _knownCustomers = new() + { + { Guid.Parse("12345678-1234-1234-1234-123456789abc"), "John Doe" }, + { Guid.Parse("abcdef12-3456-7890-abcd-ef1234567890"), "Jane Smith" }, + { Guid.Parse("98765432-1098-7654-3210-987654321fed"), "Guest User" } + }; + + public event Action? StateChanged; + + public ReservationManager( + IReservationApiClient apiClient, + ISignalRService signalRService, + ILogger logger) + { + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _signalRService = signalRService ?? throw new ArgumentNullException(nameof(signalRService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // Subscribe to SignalR events immediately + _signalRService.OnSagaCompleted += HandleSagaCompleted; + } + + public async Task InitializeAsync() + { + try + { + await _signalRService.InitializeAsync(); + _logger.LogInformation("SignalR initialized successfully"); + + // Preload all known customers' data + foreach (var customer in _knownCustomers) + { + // We don't await this to avoid blocking the initialization + // These will be loaded in the background + await LoadReservationsForUserAsync(customer.Key).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize SignalR"); + throw; // Rethrow so the UI knows initialization failed + } + } + + /// + /// Gets all known customers with their IDs + /// + public Dictionary GetAllUsers() + { + return _knownCustomers; + } + + public IObservable> GetReservationsForUser(Guid userId) + { + // Check if this is a known customer + if (!_knownCustomers.ContainsKey(userId)) + { + _logger.LogWarning("Attempted to get reservations for unknown customer {CustomerId}", userId); + return Observable.Return(new Dictionary()); + } + + var subject = _userSubjects.GetOrAdd(userId, _ => + new BehaviorSubject(GetUserReservationsSnapshot(userId))); + + return subject.AsObservable(); + } + + public async Task CreateReservationAsync(Guid customerId, string customerName, string carClass) + { + if (string.IsNullOrWhiteSpace(customerName) || string.IsNullOrWhiteSpace(carClass)) + { + throw new ArgumentException("Customer name and car class are required"); + } + + // Verify this is a known customer + if (!_knownCustomers.ContainsKey(customerId)) + { + _logger.LogWarning("Attempted to create reservation for unknown customer {CustomerId}", customerId); + throw new ArgumentException("Unknown customer ID", nameof(customerId)); + } + + _logger.LogInformation("Creating reservation for {CustomerName} ({CustomerId}), car class: {CarClass}", + customerName, customerId, carClass); + + // Call API to create reservation + var result = await _apiClient.ReserveCarAsync(customerName, carClass); + + if (result == null || result.ReservationId == Guid.Empty) + { + _logger.LogWarning("API call failed or returned invalid reservation ID"); + throw new InvalidOperationException("Reservation creation failed"); + } + + // Add to local state + AddReservationToState(result.ReservationId, customerId, customerName, carClass); + + return result.ReservationId; + } + + public async Task LoadReservationsForUserAsync(Guid customerId) + { + // Verify this is a known customer + if (!_knownCustomers.TryGetValue(customerId, out var customerName)) + { + _logger.LogWarning("Attempted to load reservations for unknown customer {CustomerId}", customerId); + return; + } + + _logger.LogInformation("Loading reservations for {CustomerName}", customerName); + + var reservations = await _apiClient.GetReservationsAsync(customerName); + if (reservations == null) + { + _logger.LogWarning("API returned null for customer reservations"); + return; + } + + UpdateReservationsFromApi(customerId, customerName, reservations); + await LoadSagaLogsForUserReservations(customerId); + } + + private void AddReservationToState(Guid reservationId, Guid customerId, string customerName, string carClass) + { + // Create reservation state with "Pending" status + var reservation = new ReservationState + { + ReservationId = reservationId, + CustomerId = customerId, + CustomerName = customerName, + CarClassCode = carClass, + Status = ReservationStatusType.Pending, // Initial status is always Reservation Pending + IsProcessing = true, // New reservations are always processing + CreatedAt = DateTime.UtcNow, + SagaLog = string.Empty + }; + + // Add to customer's reservations dictionary + var userReservations = _userReservations.GetOrAdd(customerId, _ => + new ConcurrentDictionary()); + + // Update existing or add new + userReservations[reservationId] = reservation; + + // Add to lookup index + _reservationToCustomerId[reservationId] = customerId; + + // Notify subscribers immediately + NotifyStateChanged(customerId); + + _logger.LogInformation("Added pending reservation {ReservationId} to state for {CustomerName}, car class {CarClass}", + reservationId, customerName, carClass); + } + + + private void UpdateReservationsFromApi(Guid customerId, string customerName, IEnumerable apiReservations) + { + var userReservations = _userReservations.GetOrAdd(customerId, _ => + new ConcurrentDictionary()); + + // Track reservation IDs received from the API + var reservationStatusEnumerable = apiReservations as ReservationStatus[] ?? apiReservations.ToArray(); + var apiReservationIds = new HashSet(reservationStatusEnumerable.Select(r => r.ReservationId)); + + // Log the received reservation counts + int totalReceived = reservationStatusEnumerable.Length; + int reservedCount = reservationStatusEnumerable.Count(r => r.IsReserved); + _logger.LogInformation("Received {TotalCount} reservations for {CustomerName} from API. {ReservedCount} are marked as reserved.", + totalReceived, customerName, reservedCount); + + // Remove reservations that are no longer in the API response + foreach (var reservationId in userReservations.Keys.ToList().Where(reservationId => !apiReservationIds.Contains(reservationId))) + { + userReservations.TryRemove(reservationId, out _); + _reservationToCustomerId.TryRemove(reservationId, out _); + + _logger.LogInformation("Removed reservation {ReservationId} for customer {CustomerName} as it no longer exists in the system.", + reservationId, customerName); + } + + // Update existing reservations or add new ones + foreach (var apiRes in reservationStatusEnumerable) + { + if (apiRes.ReservationId == Guid.Empty) + continue; + + UpdateCustomerReservation(customerId, customerName, apiRes); + } + + NotifyStateChanged(customerId); + } + + private void UpdateCustomerReservation(Guid customerId, string customerName, ReservationStatus newReservationStatus) + { + var previousReservationStatus = ReservationStatusType.Pending; + ReservationState? existingReservation = null; + + //try to get the user reservation by the customerId and the reservation id from the newReservationStatus + if (!_userReservations.TryGetValue(customerId, out var userReservations) || + !userReservations.TryGetValue(newReservationStatus.ReservationId, out existingReservation)) + { + // If not found, create a new dictionary for the user + userReservations = _userReservations.GetOrAdd(customerId, _ => + new ConcurrentDictionary()); + } + else + { + previousReservationStatus = existingReservation.Status; + } + + // For initial loading, we need to handle transient states properly + bool isInitialLoad = previousReservationStatus == ReservationStatusType.Pending && existingReservation == null; + + ReservationStatusType status = (newReservationStatus.IsReserved, previousReservationStatus, isInitialLoad) switch + { + // If it's reserved and was pending, it's now confirmed/reserved + (true, ReservationStatusType.Pending, _) => ReservationStatusType.Reserved, + + // If it's reserved and was in CancelPending, cancellation failed (still reserved) + (true, ReservationStatusType.CancelPending, _) => ReservationStatusType.CancelFailed, + + // If it's not reserved and was pending, and this is initial load, default to Cancelled + // This assumes non-reserved cars in API were successfully cancelled rather than failed + (false, ReservationStatusType.Pending, true) => ReservationStatusType.Cancelled, + + // If it's not reserved and was pending, but we're updating an existing record, it's a failure + (false, ReservationStatusType.Pending, false) => ReservationStatusType.Failed, + + // If it's not reserved and was in CancelPending, cancellation succeeded + (false, ReservationStatusType.CancelPending, _) => ReservationStatusType.Cancelled, + + // If it was Reserved and is now not reserved, it was cancelled + (false, ReservationStatusType.Reserved, _) => ReservationStatusType.Cancelled, + + // Default: maintain current status + _ => previousReservationStatus + }; + + if (existingReservation != null) + { + // Update existing reservation while preserving saga log if it exists + var updatedReservation = existingReservation with + { + Status = status, + IsProcessing = false, + // Keep the existing saga log + }; + userReservations[newReservationStatus.ReservationId] = updatedReservation; + return; + } + + // Create new reservation + userReservations[newReservationStatus.ReservationId] = new ReservationState + { + ReservationId = newReservationStatus.ReservationId, + CustomerId = customerId, + CustomerName = customerName, + CarClassCode = newReservationStatus.CarClass, + Status = status, + IsProcessing = false, + CreatedAt = DateTime.UtcNow, + SagaLog = string.Empty + }; + + // Add to lookup index + _reservationToCustomerId[newReservationStatus.ReservationId] = customerId; + } + + private async Task LoadSagaLogsForUserReservations(Guid customerId) + { + if (!_userReservations.TryGetValue(customerId, out var userReservations)) + { + return; + } + + // Process saga logs in parallel for efficiency + var loadTasks = new List(); + + foreach (var reservationId in userReservations.Keys) + { + loadTasks.Add(LoadSagaLogForReservationAsync(reservationId)); + } + + await Task.WhenAll(loadTasks); + } + + private async Task LoadSagaLogForReservationAsync(Guid reservationId) + { + if (!_reservationToCustomerId.TryGetValue(reservationId, out var customerId)) + return; + + try + { + var sagaLog = await _apiClient.GetSagaLogAsync(reservationId); + + // If no saga log was returned, don't update + if (string.IsNullOrEmpty(sagaLog)) + return; + + // Update the reservation if it exists + if (_userReservations.TryGetValue(customerId, out var userReservations) && + userReservations.TryGetValue(reservationId, out var reservation)) + { + // Only update the saga log, preserve other properties + var updatedReservation = reservation with { SagaLog = sagaLog }; + userReservations[reservationId] = updatedReservation; + + _logger.LogInformation("Updated saga log for reservation {ReservationId}", reservationId); + + // Notify subscribers of the saga log update + NotifyStateChanged(customerId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load saga log for reservation {ReservationId}", reservationId); + } + } + + private UserReservationsMap GetUserReservationsSnapshot(Guid userId) + { + if (!_userReservations.TryGetValue(userId, out var reservations)) + return new UserReservationsMap(); + + // Create a safe copy of all the reservation state objects + var snapshot = new UserReservationsMap(); + foreach (var (reservationId, reservation) in reservations) + { + snapshot[reservationId] = reservation; // Records are immutable so no need for deep copy + } + + return snapshot; + } + + private void NotifyStateChanged(Guid customerId) + { + if (_userSubjects.TryGetValue(customerId, out var subject)) + { + subject.OnNext(GetUserReservationsSnapshot(customerId)); + } + + StateChanged?.Invoke(); + } + + private void HandleSagaCompleted(SagaUpdate update) + { + _logger.LogInformation("Received saga update for reservation {ReservationId}, outcome: {Outcome}", + update.ReservationId, update.Outcome); + + // First try to find customer ID using O(1) lookup + if (_reservationToCustomerId.TryGetValue(update.ReservationId, out var customerId)) + { + // Found customer by reservation ID + UpdateReservationFromSaga(customerId, update); + return; + } + + // Try to find by customer name among known customers + customerId = FindCustomerIdByName(update.CustomerName); + if (customerId != Guid.Empty) + { + // Found customer, store for future lookups + _reservationToCustomerId[update.ReservationId] = customerId; + UpdateReservationFromSaga(customerId, update); + return; + } + + _logger.LogInformation("Ignoring update for unknown reservation {ReservationId} with customer {CustomerName}", + update.ReservationId, update.CustomerName); + } + + private Guid FindCustomerIdByName(string customerName) + { + // Find the customer ID by name in our known customers dictionary + foreach (var kvp in _knownCustomers) + { + if (kvp.Value.Equals(customerName, StringComparison.OrdinalIgnoreCase)) + { + return kvp.Key; + } + } + + return Guid.Empty; + } + + private void UpdateReservationFromSaga(Guid customerId, SagaUpdate update) + { + // Get user reservations dictionary + var userReservations = _userReservations.GetOrAdd(customerId, _ => + new ConcurrentDictionary()); + + // Determine the status based on the saga outcome + ReservationStatusType status; + bool isProcessing = false; + + if (update.Outcome.StartsWith("Reservation", StringComparison.OrdinalIgnoreCase)) + { + // For reservation creation saga + status = update.Outcome.Equals("Reservation Succeeded", StringComparison.OrdinalIgnoreCase) + ? ReservationStatusType.Reserved : ReservationStatusType.Failed; + } + else if (update.Outcome.StartsWith("Cancellation", StringComparison.OrdinalIgnoreCase)) + { + // For cancellation saga + status = update.Outcome.Equals("Cancellation Succeeded", StringComparison.OrdinalIgnoreCase) + ? ReservationStatusType.Cancelled + : ReservationStatusType.CancelFailed; + } + else + { + // Generic outcome handling + status = update.Outcome.Contains("Success", StringComparison.OrdinalIgnoreCase) + ? ReservationStatusType.Reserved + : ReservationStatusType.Failed; + } + + // Check if we already have this reservation + ReservationState updatedReservation; + if (userReservations.TryGetValue(update.ReservationId, out var existingReservation)) + { + // Update existing reservation with new data + updatedReservation = existingReservation with + { + Status = status, + IsProcessing = isProcessing, + SagaLog = update.Log + }; + } + else + { + // Find the customer name + _knownCustomers.TryGetValue(customerId, out var customerName); + + // Create new reservation with all data in one go + updatedReservation = new ReservationState + { + ReservationId = update.ReservationId, + CustomerId = customerId, + CustomerName = customerName ?? update.CustomerName, // Prefer our known name, fall back to update + CarClassCode = update.CarClass, + CreatedAt = DateTime.UtcNow, + Status = status, + IsProcessing = isProcessing, + SagaLog = update.Log + }; + } + + // Store the reservation + userReservations[update.ReservationId] = updatedReservation; + + _logger.LogInformation("Updated reservation {ReservationId} status to {Status} based on outcome: {Outcome}", + update.ReservationId, status, update.Outcome); + + // Notify subscribers + NotifyStateChanged(customerId); + } + + public async Task CancelReservationAsync(Guid reservationId) + { + if (reservationId == Guid.Empty) + { + throw new ArgumentException("Reservation ID cannot be empty", nameof(reservationId)); + } + + // Find the customer ID for this reservation + if (!_reservationToCustomerId.TryGetValue(reservationId, out var customerId)) + { + _logger.LogWarning("Cannot cancel reservation {ReservationId} - not found in local state", reservationId); + return false; + } + + // Get current reservation state + if (!_userReservations.TryGetValue(customerId, out var userReservations) || + !userReservations.TryGetValue(reservationId, out var reservation)) + { + _logger.LogWarning("Cannot cancel reservation {ReservationId} - not found in user reservations", reservationId); + return false; + } + + // If already cancelled, treat as success + if (reservation.Status == ReservationStatusType.Cancelled) + { + _logger.LogInformation("Reservation {ReservationId} is already cancelled", reservationId); + return true; // Return success for already cancelled reservations + } + + // Reject cancellation if not in a cancellable state + if (reservation.Status is not (ReservationStatusType.Reserved or ReservationStatusType.CancelFailed) || + reservation.IsProcessing) + { + _logger.LogWarning("Cannot cancel reservation {ReservationId} - status is {Status}, IsProcessing: {IsProcessing}", + reservationId, reservation.Status, reservation.IsProcessing); + return false; + } + + _logger.LogInformation("Initiating cancellation for reservation {ReservationId} for customer {CustomerName}", + reservationId, reservation.CustomerName); + + try + { + // Call the updated API method that returns a tuple + var (cancellationAccepted, statusCode) = await _apiClient.CancelReservationAsync(reservationId); + + if (cancellationAccepted) + { + // Update local state to show reservation is being processed (cancellation pending) + var updatedReservation = reservation with + { + IsProcessing = true, + Status = ReservationStatusType.CancelPending + }; + + // Update in the dictionary + userReservations[reservationId] = updatedReservation; + + // Notify subscribers that the state has changed + NotifyStateChanged(customerId); + + _logger.LogInformation("Cancellation request accepted for reservation {ReservationId}", reservationId); + return true; + } + + // Handle "already cancelled" case (404 Not Found) + if (statusCode == HttpStatusCode.NotFound) + { + _logger.LogInformation("Reservation {ReservationId} not found on server - likely already cancelled", reservationId); + + // Update local state to show as cancelled + var updatedReservation = reservation with + { + IsProcessing = false, + Status = ReservationStatusType.Cancelled + }; + + // Update in the dictionary + userReservations[reservationId] = updatedReservation; + + // Notify subscribers that the state has changed + NotifyStateChanged(customerId); + + return true; // Consider this a successful cancellation + } + + _logger.LogWarning("Cancellation request was rejected by API for reservation {ReservationId} with status code {StatusCode}", + reservationId, statusCode); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while trying to cancel reservation {ReservationId}", reservationId); + return false; + } + } + + public async Task> GetCarInventoryAsync() + { + try + { + var inventoryResponse = await _apiClient.GetCarInventoryAsync(); + + return inventoryResponse.CarClasses; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while retrieving car inventory."); + throw; + } + } + + public async Task UpdateCarClassAllocationAsync(string carClass, int maxAllocation) + { + if (string.IsNullOrWhiteSpace(carClass)) + { + throw new ArgumentException("Car class code is required.", nameof(carClass)); + } + + if (maxAllocation < 0) + { + throw new ArgumentException("Maximum allocation must be non-negative.", nameof(maxAllocation)); + } + + try + { + var allocationRequest = new CarClassAllocationRequest + { + CarClass = carClass, + MaxAllocation = maxAllocation + }; + + var updatedCarClassInfo = await _apiClient.UpdateCarClassAllocationAsync(allocationRequest); + if (updatedCarClassInfo == null) + { + _logger.LogWarning("Failed to update car class allocation for {CarClass}.", carClass); + throw new InvalidOperationException("Failed to update car class allocation."); + } + + return updatedCarClassInfo; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while updating car class allocation for {CarClass}.", carClass); + throw; + } + } + + public async ValueTask DisposeAsync() + { + // Unsubscribe from SignalR + _signalRService.OnSagaCompleted -= HandleSagaCompleted; + + // Clean up subjects + foreach (var subject in _userSubjects.Values) + { + subject.OnCompleted(); + subject.Dispose(); + } + + // Clear collections + _userSubjects.Clear(); + _userReservations.Clear(); + _reservationToCustomerId.Clear(); + + await ValueTask.CompletedTask; + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ReservationState.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ReservationState.cs new file mode 100644 index 0000000..4ea0438 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/ReservationState.cs @@ -0,0 +1,15 @@ +using Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +namespace Sagaway.ReservationDemo.ReservationUI.Services; + +public record ReservationState +{ + public Guid ReservationId { get; init; } + public Guid CustomerId { get; init; } + public string CustomerName { get; init; } = string.Empty; + public string CarClassCode { get; init; } = string.Empty; + public ReservationStatusType Status { get; set; } = ReservationStatusType.Pending; + public bool IsProcessing { get; set; } = true; + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; + public string SagaLog { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/SignalRService.cs b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/SignalRService.cs new file mode 100644 index 0000000..952b790 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Services/SignalRService.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using Sagaway.ReservationDemo.ReservationUI.Services.DTOs; + +namespace Sagaway.ReservationDemo.ReservationUI.Services; + +public class SignalRService : ISignalRService, IAsyncDisposable +{ + private readonly NavigationManager _navigationManager; + private readonly ILogger _logger; + private HubConnection? _hubConnection; + + public event Action? OnSagaCompleted; + + // ReSharper disable once ConvertToPrimaryConstructor + public SignalRService(NavigationManager navigationManager, ILogger logger) + { + _navigationManager = navigationManager; + _logger = logger; + } + + public async Task InitializeAsync() + { + if (_hubConnection != null) + { + return; + } + + // Construct the SignalR hub URL - this will be proxied through Nginx + var hubUrl = _navigationManager.ToAbsoluteUri("/reservationcallback"); + + // Get negotiate endpoint to establish the connection + _hubConnection = new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + // First connect to negotiate endpoint to get connection info + options.SkipNegotiation = false; + }) + .WithAutomaticReconnect([TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30) + ]) + .Build(); + + // Register handlers for SignalR messages + // In SignalRService.cs + _hubConnection.On("SagaCompleted", argument => + { + try + { + _logger.LogInformation("SignalR message received on SagaCompleted handler: {Args}", + argument.Text); + + // Create a SagaUpdate from the JSON properties in Text + var update = new SagaUpdate + { + // Extract properties directly from the JsonObject + ReservationId = argument.Text["reservationId"]?.GetValue() ?? Guid.Empty, + Outcome = argument.Text["outcome"]?.GetValue() ?? string.Empty, + Log = argument.Text["log"]?.GetValue() ?? string.Empty, + CarClass = argument.Text["carClass"]?.GetValue() ?? string.Empty, + CustomerName = argument.Text["customerName"]?.GetValue() ?? string.Empty, + }; + + _logger.LogInformation($"SignalR: Saga completed for reservation {update.ReservationId} with outcome {update.Outcome}"); + OnSagaCompleted?.Invoke(update); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing SignalR message"); + } + }); + + + + + try + { + await _hubConnection.StartAsync(); + _logger.LogInformation("SignalR connection started successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting SignalR connection"); + } + } + + public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected; + + public async ValueTask DisposeAsync() + { + if (_hubConnection != null) + { + await _hubConnection.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/_Imports.razor b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/_Imports.razor new file mode 100644 index 0000000..b51f91f --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using Sagaway.ReservationDemo.ReservationUI +@using Sagaway.ReservationDemo.ReservationUI.Layout diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/nginx.conf b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/nginx.conf new file mode 100644 index 0000000..6a6540a --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/nginx.conf @@ -0,0 +1,100 @@ +# /etc/nginx/conf.d/default.conf + +server { + listen 80; + server_name localhost; + + # Root directory for Blazor app files + root /usr/share/nginx/html; + index index.html index.htm; + + # Proxy API requests to the reservation-manager service + location /reserve { + proxy_pass http://reservation-manager:80/reserve; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /cancel { + proxy_pass http://reservation-manager:80/cancel; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /reservations/ { + proxy_pass http://reservation-manager:80/reservations/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /saga-log/ { + proxy_pass http://reservation-manager:80/saga-log/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # New endpoint for car inventory + location /car-inventory { + proxy_pass http://reservation-manager:80/car-inventory; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # New endpoint for updating car class allocation + location /update-allocation { + proxy_pass http://reservation-manager:80/update-allocation; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # SignalR negotiate endpoint + location /reservationcallback/negotiate { + proxy_pass http://reservation-manager:80/negotiate; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + + # SignalR WebSocket support for the reservationcallback hub + location /reservationcallback/ { + proxy_pass http://reservation-manager:80/reservationcallback/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } + + # Default location for Blazor files + location / { + try_files $uri $uri/ /index.html =404; + } + + # Caching for Blazor framework files + location /_framework/ { + add_header Cache-Control "public, max-age=604800"; + } + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/xml application/wasm application/octet-stream; +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/package-lock.json b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/package-lock.json new file mode 100644 index 0000000..14929a5 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/package-lock.json @@ -0,0 +1,1270 @@ +{ + "name": "sagaway.reservationdemo.reservationui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sagaway.reservationdemo.reservationui", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@tailwindcss/cli": "^4.1.4", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.4" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.4.tgz", + "integrity": "sha512-gP05Qihh+cZ2FqD5fa0WJXx3KEk2YWUYv/RBKAyiOg0V4vYVDr/xlLc0sacpnVEXM45BVUR9U2hsESufYs6YTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.1.4", + "@tailwindcss/oxide": "4.1.4", + "enhanced-resolve": "^5.18.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.1.4" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.4.tgz", + "integrity": "sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.29.2", + "tailwindcss": "4.1.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.4.tgz", + "integrity": "sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.4", + "@tailwindcss/oxide-darwin-arm64": "4.1.4", + "@tailwindcss/oxide-darwin-x64": "4.1.4", + "@tailwindcss/oxide-freebsd-x64": "4.1.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.4", + "@tailwindcss/oxide-linux-x64-musl": "4.1.4", + "@tailwindcss/oxide-wasm32-wasi": "4.1.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.4.tgz", + "integrity": "sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.4.tgz", + "integrity": "sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.4.tgz", + "integrity": "sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.4.tgz", + "integrity": "sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.4.tgz", + "integrity": "sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.4.tgz", + "integrity": "sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.4.tgz", + "integrity": "sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.4.tgz", + "integrity": "sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.4.tgz", + "integrity": "sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.4.tgz", + "integrity": "sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.0", + "@emnapi/runtime": "^1.4.0", + "@emnapi/wasi-threads": "^1.0.1", + "@napi-rs/wasm-runtime": "^0.2.8", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.4.tgz", + "integrity": "sha512-VlnhfilPlO0ltxW9/BgfLI5547PYzqBMPIzRrk4W7uupgCt8z6Trw/tAj6QUtF2om+1MH281Pg+HHUJoLesmng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.4.tgz", + "integrity": "sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001715", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz", + "integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.143", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.143.tgz", + "integrity": "sha512-QqklJMOFBMqe46k8iIOwA9l2hz57V2OKMmP5eSWcUvwx+mASAsbU+wkF1pHjn9ZVSBPrsYWr4/W/95y5SwYg2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz", + "integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + } + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/package.json b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/package.json new file mode 100644 index 0000000..5cfa53a --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/package.json @@ -0,0 +1,19 @@ +{ + "name": "sagaway.reservationdemo.reservationui", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "build:css": "npx @tailwindcss/cli -i ./wwwroot/css/app.css -o ./wwwroot/css/app.output.css --minify", + "watch:css": "npx @tailwindcss/cli -i ./wwwroot/css/app.css -o ./wwwroot/css/app.output.css --watch" +}, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@tailwindcss/cli": "^4.1.4", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.4" + } +} diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/postcss.config.js b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/postcss.config.js new file mode 100644 index 0000000..28d577f --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + } + } + \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/tailwind.config.js b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/tailwind.config.js new file mode 100644 index 0000000..60af78e --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + // Ensure this pattern correctly finds your Blazor files + './**/*.{razor,html,cshtml}' + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/css/app.css b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/css/app.css new file mode 100644 index 0000000..c61e901 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/css/app.css @@ -0,0 +1,119 @@ +/* Styles/app.css */ +@import "tailwindcss"; + +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +a, .btn-link { + color: #0071c1; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid red; +} + +.validation-message { + color: red; +} + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} + +/* You can add custom base styles or components below if needed */ \ No newline at end of file diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/favicon.png b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/favicon.png new file mode 100644 index 0000000..8422b59 Binary files /dev/null and b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/favicon.png differ diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/icon-192.png b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/icon-192.png new file mode 100644 index 0000000..166f56d Binary files /dev/null and b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/icon-192.png differ diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/DefaultCar.png b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/DefaultCar.png new file mode 100644 index 0000000..4919c91 Binary files /dev/null and b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/DefaultCar.png differ diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/EconomyCar.png b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/EconomyCar.png new file mode 100644 index 0000000..3b21ff5 Binary files /dev/null and b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/EconomyCar.png differ diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/LuxuryCar.png b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/LuxuryCar.png new file mode 100644 index 0000000..5844bd8 Binary files /dev/null and b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/LuxuryCar.png differ diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/StandardCar.png b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/StandardCar.png new file mode 100644 index 0000000..626fd9a Binary files /dev/null and b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/images/StandardCar.png differ diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/index.html b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/index.html new file mode 100644 index 0000000..5ad4e5c --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/index.html @@ -0,0 +1,39 @@ + + + + + + + Reservation UI + + + + + + + + + + + +
+ + + + +
+
+ + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + diff --git a/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/js/saga-log.js b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/js/saga-log.js new file mode 100644 index 0000000..9416b32 --- /dev/null +++ b/Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/wwwroot/js/saga-log.js @@ -0,0 +1,97 @@ +window.sagaLogFunctions = { + toggleSagaDisplay: function (sagaId) { + const contentElement = document.getElementById('saga-content-' + sagaId); + const headerElement = document.getElementById('saga-header-' + sagaId); + const chevronElement = document.getElementById('chevron-' + sagaId); + + if (!contentElement || !headerElement || !chevronElement) return; + + if (contentElement.style.display === 'none') { + // Show content + contentElement.style.display = 'block'; + headerElement.classList.add('active'); + chevronElement.classList.remove('rotated'); + } else { + // Hide content + contentElement.style.display = 'none'; + headerElement.classList.remove('active'); + chevronElement.classList.add('rotated'); + } + }, + + initializeSagaToggles: function () { + // First, analyze the content to properly identify saga types + this.analyzeSagaContent(); + + // Initialize all toggle elements + document.querySelectorAll('[data-saga-toggle]').forEach((element, index) => { + if (!element.hasAttribute('data-initialized')) { + const sagaId = element.getAttribute('data-saga-toggle'); + + // Update the numbering to be sequential + const numberBadge = element.querySelector('.saga-number-badge'); + if (numberBadge) { + numberBadge.textContent = '#' + (index + 1); + } + + element.addEventListener('click', function () { + window.sagaLogFunctions.toggleSagaDisplay(sagaId); + }); + + element.setAttribute('data-initialized', 'true'); + } + }); + + // Add a class to all chevron icons for easier styling + document.querySelectorAll('[id^="chevron-"]').forEach(icon => { + icon.classList.add('chevron-icon'); + }); + }, + + analyzeSagaContent: function () { + document.querySelectorAll('.saga-content').forEach(content => { + let sagaType = "Operation Log"; + let badgeColor = "bg-gray-500"; + + // Determine the saga type by inspecting the content + const htmlContent = content.innerHTML; + + if (htmlContent.includes("[CancelBooking]") || + htmlContent.includes("[CancelInventoryReserving]") || + htmlContent.includes("[Refund]")) { + sagaType = "Cancellation Saga"; + badgeColor = "bg-red-500"; + } else if (htmlContent.includes("[CarBooking]") || + htmlContent.includes("[InventoryReserving]") || + htmlContent.includes("[Billing]")) { + sagaType = "Reservation Saga"; + badgeColor = "bg-blue-500"; + } else if (htmlContent.includes("[Billing]") && + (htmlContent.includes("Billing Success") || + htmlContent.includes("passed validation successfully"))) { + sagaType = "Billing Process"; + badgeColor = "bg-green-500"; + } + + // Find corresponding header + const sagaId = content.id.replace('saga-content-', ''); + const header = document.getElementById('saga-header-' + sagaId); + + if (header) { + const titleSpan = header.querySelector('.saga-title'); + const badge = header.querySelector('.saga-badge'); + + if (titleSpan) { + titleSpan.textContent = sagaType; + } + + if (badge) { + // Remove all existing color classes + badge.className = badge.className.replace(/bg-\w+-\d+/g, '').trim(); + // Add the new color class + badge.classList.add(badgeColor); + } + } + }); + } +}; diff --git a/Sagaway.Tests/Sagaway.Tests.csproj b/Sagaway.Tests/Sagaway.Tests.csproj index 50f5aa5..6a00e6b 100644 --- a/Sagaway.Tests/Sagaway.Tests.csproj +++ b/Sagaway.Tests/Sagaway.Tests.csproj @@ -1,7 +1,7 @@ - + - net8.0 + net9.0 enable enable @@ -10,16 +10,15 @@ - - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Sagaway.sln b/Sagaway.sln index 3014b02..72c2c39 100644 --- a/Sagaway.sln +++ b/Sagaway.sln @@ -24,11 +24,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dapr", "dapr", "{F2614066-2 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "components", "components", "{D9272F34-3167-4C17-91FF-0ABD427E7DA8}" ProjectSection(SolutionItems) = preProject + dapr\components\actorsecretstore.yaml = dapr\components\actorsecretstore.yaml dapr\components\billing-queue.yaml = dapr\components\billing-queue.yaml dapr\components\booking-queue.yaml = dapr\components\booking-queue.yaml dapr\components\dapr-secretstore.json = dapr\components\dapr-secretstore.json dapr\components\inventory-queue.yaml = dapr\components\inventory-queue.yaml dapr\components\reservation-response-queue.yaml = dapr\components\reservation-response-queue.yaml + dapr\components\reservationcallback.yaml = dapr\components\reservationcallback.yaml dapr\components\secretstore.yaml = dapr\components\secretstore.yaml dapr\components\statestore.yaml = dapr\components\statestore.yaml dapr\components\test-queue.yaml = dapr\components\test-queue.yaml @@ -83,6 +85,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sagaway.IntegrationTests.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sagaway.IntegrationTests.StepRecorderTestService", "Sagaway.IntegrationTests\Sagaway.IntegrationTests.StepRecorderTestService\Sagaway.IntegrationTests.StepRecorderTestService.csproj", "{4615A789-C36B-4CAF-9842-3872DC02E780}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sagaway.ReservationDemo.ReservationUI", "Sagaway.ReservationDemo\Sagaway.ReservationDemo.ReservationUI\Sagaway.ReservationDemo.ReservationUI.csproj", "{B820A3FC-B8DE-2C80-73C8-98B38CE54881}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -153,6 +157,10 @@ Global {4615A789-C36B-4CAF-9842-3872DC02E780}.Debug|Any CPU.Build.0 = Debug|Any CPU {4615A789-C36B-4CAF-9842-3872DC02E780}.Release|Any CPU.ActiveCfg = Release|Any CPU {4615A789-C36B-4CAF-9842-3872DC02E780}.Release|Any CPU.Build.0 = Release|Any CPU + {B820A3FC-B8DE-2C80-73C8-98B38CE54881}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B820A3FC-B8DE-2C80-73C8-98B38CE54881}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B820A3FC-B8DE-2C80-73C8-98B38CE54881}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B820A3FC-B8DE-2C80-73C8-98B38CE54881}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Sagaway.sln.DotSettings b/Sagaway.sln.DotSettings index f6102bf..e28518d 100644 --- a/Sagaway.sln.DotSettings +++ b/Sagaway.sln.DotSettings @@ -1,11 +1,15 @@  + JS True True + True True True + True True True + True True True True diff --git a/dapr/components/actorsecretstore.yaml b/dapr/components/actorsecretstore.yaml new file mode 100644 index 0000000..5867135 --- /dev/null +++ b/dapr/components/actorsecretstore.yaml @@ -0,0 +1,15 @@ +# filepath: dapr/components/actorstatestore.yaml +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: actorstatestore +spec: + type: state.redis + version: v1 + metadata: + - name: redisHost + value: redisserver:6379 + - name: redisPassword + value: "" + - name: actorStateStore + value: "true" \ No newline at end of file diff --git a/dapr/components/reservationcallback.yaml b/dapr/components/reservationcallback.yaml new file mode 100644 index 0000000..ec5043b --- /dev/null +++ b/dapr/components/reservationcallback.yaml @@ -0,0 +1,16 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: reservationcallback +spec: + type: bindings.azure.signalr + version: v1 + metadata: + - name: connectionString + secretKeyRef: + name: SignalRConnectionString + key: SignalRConnectionString + - name: hub + value: reservationcallback +auth: + secretStore: local-secret-store diff --git a/dapr/components/statestore.yaml b/dapr/components/statestore.yaml index d4cf6f7..58e7c5d 100644 --- a/dapr/components/statestore.yaml +++ b/dapr/components/statestore.yaml @@ -10,8 +10,6 @@ spec: value: redisserver:6379 - name: redisPassword value: "" - - name: actorStateStore - value: "true" - name: queryIndexes value: | [ diff --git a/dapr/config.yaml b/dapr/config.yaml index 2e23b7b..04abf7c 100644 --- a/dapr/config.yaml +++ b/dapr/config.yaml @@ -12,4 +12,7 @@ spec: expandParams: true includeBody: true zipkin: - endpointAddress: http://zipkin:9411/api/v2/spans \ No newline at end of file + endpointAddress: http://zipkin:9411/api/v2/spans + features: + - name: SchedulerReminders + enabled: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 15968e3..91614e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.7' - services: ################################################## # Reservation Manager Microservice + Dapr sidecars @@ -15,6 +13,7 @@ services: - sagaway-network environment: - ACTOR_TYPE=CarReservationActor,CarReservationCancellationActor + - AZURE__SignalR__ConnectionString=Endpoint=http://localhost:8888;Port=8888;AccessKey=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH;Version=1.0; - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://reservation-manager:80 depends_on: @@ -24,7 +23,7 @@ services: - placement reservation-manager-dapr: - image: "daprio/daprd:1.14.4" + image: "daprio/daprd:1.15.3" deploy: restart_policy: condition: on-failure @@ -35,6 +34,7 @@ services: "-app-id", "reservation-manager", "-app-port", "80", "-placement-host-address", "placement:50006", + "-scheduler-host-address", "scheduler:50007", "-dapr-http-port", "3500", "-resources-path", "/dapr/components", "-config", "/dapr/config.yaml"] @@ -65,7 +65,7 @@ services: - ASPNETCORE_URLS=http://billing-management:80 billing-management-dapr: - image: "daprio/daprd:1.14.4" + image: "daprio/daprd:1.15.3" deploy: restart_policy: condition: on-failure @@ -76,6 +76,7 @@ services: "-app-id", "billing-management", "-app-port", "80", "-placement-host-address", "placement:50006", + "-scheduler-host-address", "scheduler:50007", "-dapr-http-port", "3500", "-resources-path", "/dapr/components", "-config", "/dapr/config.yaml"] @@ -105,7 +106,7 @@ services: - ASPNETCORE_URLS=http://inventory-management:80 inventory-management-dapr: - image: "daprio/daprd:1.14.4" + image: "daprio/daprd:1.15.3" deploy: restart_policy: condition: on-failure @@ -116,6 +117,7 @@ services: "-app-id", "inventory-management", "-app-port", "80", "-placement-host-address", "placement:50006", + "-scheduler-host-address", "scheduler:50007", "-dapr-http-port", "3500", "-resources-path", "/dapr/components", "-config", "/dapr/config.yaml"] @@ -145,7 +147,7 @@ services: - ASPNETCORE_URLS=http://booking-management:80 booking-management-dapr: - image: "daprio/daprd:1.14.4" + image: "daprio/daprd:1.15.3" deploy: restart_policy: condition: on-failure @@ -156,6 +158,7 @@ services: "-app-id", "booking-management", "-app-port", "80", "-placement-host-address", "placement:50006", + "-scheduler-host-address", "scheduler:50007", "-dapr-http-port", "3500", "-resources-path", "/dapr/components", "-config", "/dapr/config.yaml"] @@ -165,6 +168,23 @@ services: - booking-management network_mode: "service:booking-management" +################################################## +# Reservation Demo Blazor App +################################################## + reservation-ui: + build: + context: . + dockerfile: Sagaway.ReservationDemo/Sagaway.ReservationDemo.ReservationUI/Dockerfile + networks: + - sagaway-network + ports: + - "5000:80" + environment: + # - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:80 + depends_on: + - reservation-manager + ############################ # Test Services ############################ @@ -191,7 +211,7 @@ services: - placement orchestrationservice-dapr: - image: "daprio/daprd:1.14.4" + image: "daprio/daprd:1.15.3" deploy: restart_policy: condition: on-failure @@ -202,6 +222,7 @@ services: "-app-id", "orchestrationservice", "-app-port", "80", "-placement-host-address", "placement:50006", + "-scheduler-host-address", "scheduler:50007", "-dapr-http-port", "3500", "-resources-path", "/dapr/components", "-config", "/dapr/config.yaml"] @@ -229,7 +250,7 @@ services: - placement testservice-dapr: - image: "daprio/daprd:1.14.4" + image: "daprio/daprd:1.15.3" deploy: restart_policy: condition: on-failure @@ -240,6 +261,7 @@ services: "-app-id", "testservice", "-app-port", "80", "-placement-host-address", "placement:50006", + "-scheduler-host-address", "scheduler:50007", "-dapr-http-port", "3500", "-resources-path", "/dapr/components", "-config", "/dapr/config.yaml"] @@ -271,7 +293,7 @@ services: - placement testsubsagacommunicationservice-dapr: - image: "daprio/daprd:1.14.4" + image: "daprio/daprd:1.15.3" deploy: restart_policy: condition: on-failure @@ -282,6 +304,7 @@ services: "-app-id", "testsubsagacommunicationservice", "-app-port", "80", "-placement-host-address", "placement:50006", + "-scheduler-host-address", "scheduler:50007", "-dapr-http-port", "3500", "-resources-path", "/dapr/components", "-config", "/dapr/config.yaml"] @@ -313,7 +336,7 @@ services: - placement steprecordertestservice-dapr: - image: "daprio/daprd:1.14.4" + image: "daprio/daprd:1.15.3" deploy: restart_policy: condition: on-failure @@ -324,6 +347,7 @@ services: "-app-id", "steprecordertestservice", "-app-port", "80", "-placement-host-address", "placement:50006", + "-scheduler-host-address", "scheduler:50007", "-dapr-http-port", "3500", "-resources-path", "/dapr/components", "-config", "/dapr/config.yaml"] @@ -361,13 +385,27 @@ services: ## Dapr placement service ############################# placement: - image: "daprio/dapr:1.14.4" + image: "daprio/dapr:1.15.3" command: ["./placement", "-port", "50006"] ports: - "50006:50006" networks: - sagaway-network - + +############################# +## Dapr scheduler service +############################# + scheduler: + image: daprio/dapr:1.15.3 + user: root + command: ["./scheduler", "--port", "50007", "--etcd-data-dir", "/data"] + ports: + - "50007:50007" + volumes: + - scheduler-data:/data + networks: + - sagaway-network + ############################ # Dapr zipkin service ############################ @@ -418,6 +456,8 @@ networks: volumes: workspace: + scheduler-data: + diff --git a/launchSettings.json b/launchSettings.json index 4f89644..2c7a805 100644 --- a/launchSettings.json +++ b/launchSettings.json @@ -5,7 +5,7 @@ "commandVersion": "1.0", "composeLaunchAction": "LaunchBrowser", "composeLaunchServiceName": "reservation-manager", - "composeLaunchUrl": "{Scheme}://localhost:{ServicePort}/swagger", + "composeLaunchUrl": "http://localhost:5000", "serviceActions": { "billing-management": "StartDebugging", "booking-management": "StartDebugging", @@ -13,6 +13,7 @@ "reservation-manager": "StartDebugging", "billing-management-dapr": "StartWithoutDebugging", "booking-management-dapr": "StartWithoutDebugging", + "reservation-ui": "StartWithoutDebugging", "dapr-dashboard": "StartWithoutDebugging", "inventory-management-dapr": "StartWithoutDebugging", "placement": "StartWithoutDebugging", @@ -28,7 +29,8 @@ "testsubsagacommunicationservice": "StartDebugging", "testsubsagacommunicationservice-dapr": "StartWithoutDebugging", "steprecordertestservice": "StartDebugging", - "steprecordertestservice-dapr": "StartWithoutDebugging" + "steprecordertestservice-dapr": "StartWithoutDebugging", + "scheduler": "StartWithoutDebugging" } } }