diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a591825c..601f09c7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -43,55 +43,7 @@ updates: open-pull-requests-limit: 5 - package-ecosystem: "docker" - directory: "/src/APITemplate.Api" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "docker" - directory: "/src/Services/ProductCatalog/ProductCatalog.Api" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "docker" - directory: "/src/Services/Reviews/Reviews.Api" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "docker" - directory: "/src/Services/Identity/Identity.Api" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "docker" - directory: "/src/Services/Notifications/Notifications.Api" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "docker" - directory: "/src/Services/FileStorage/FileStorage.Api" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "docker" - directory: "/src/Services/BackgroundJobs/BackgroundJobs.Api" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "docker" - directory: "/src/Services/Webhooks/Webhooks.Api" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "docker" - directory: "/src/Gateway/Gateway.Api" + directory: "/src/APITemplate" schedule: interval: "weekly" open-pull-requests-limit: 5 diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 7b3d10e6..0a42cce1 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -8,6 +8,7 @@ on: pull_request: branches: - main + - master jobs: build-and-test: diff --git a/.vscode/launch.json b/.vscode/launch.json index 5e617018..de5c1368 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,6 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "prepare: debug http", - "postDebugTask": "docker: infra down (dev)", "program": "${workspaceFolder}/src/APITemplate.Api/bin/Debug/net10.0/APITemplate.Api.dll", "args": [], "cwd": "${workspaceFolder}/src/APITemplate.Api", @@ -29,7 +28,6 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "prepare: debug https", - "postDebugTask": "docker: infra down (dev)", "program": "${workspaceFolder}/src/APITemplate.Api/bin/Debug/net10.0/APITemplate.Api.dll", "args": [], "cwd": "${workspaceFolder}/src/APITemplate.Api", @@ -125,173 +123,6 @@ "Observability__Exporters__Otlp__Enabled": "true" }, "console": "integratedTerminal" - }, - { - "name": "MS: Gateway", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "ms: prepare debug gateway", - "program": "${workspaceFolder}/src/Gateway/Gateway.Api/bin/Debug/net10.0/Gateway.Api.dll", - "args": [], - "cwd": "${workspaceFolder}/src/Gateway/Gateway.Api", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:8080", - "ReverseProxy__Clusters__product-catalog__Destinations__destination1__Address": "http://localhost:5010", - "ReverseProxy__Clusters__reviews__Destinations__destination1__Address": "http://localhost:5020", - "ReverseProxy__Clusters__identity__Destinations__destination1__Address": "http://localhost:5030", - "ReverseProxy__Clusters__notifications__Destinations__destination1__Address": "http://localhost:5040", - "ReverseProxy__Clusters__file-storage__Destinations__destination1__Address": "http://localhost:5050", - "ReverseProxy__Clusters__background-jobs__Destinations__destination1__Address": "http://localhost:5060", - "ReverseProxy__Clusters__webhooks__Destinations__destination1__Address": "http://localhost:5070" - }, - "console": "integratedTerminal" - }, - { - "name": "MS: Product Catalog", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "ms: prepare debug product-catalog", - "program": "${workspaceFolder}/src/Services/ProductCatalog/ProductCatalog.Api/bin/Debug/net10.0/ProductCatalog.Api.dll", - "args": [], - "cwd": "${workspaceFolder}/src/Services/ProductCatalog/ProductCatalog.Api", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5010", - "ConnectionStrings__ProductCatalogDb": "Host=localhost;Port=5432;Database=productcatalog_db;Username=postgres;Password=postgres", - "ConnectionStrings__RabbitMQ": "amqp://guest:guest@localhost:5672", - "MongoDB__ConnectionString": "mongodb://localhost:27017", - "MongoDB__DatabaseName": "productcatalog", - "Keycloak__realm": "api-template", - "Keycloak__auth-server-url": "http://localhost:8180/", - "Keycloak__resource": "api-template", - "Observability__Otlp__Endpoint": "http://localhost:4317" - }, - "console": "integratedTerminal" - }, - { - "name": "MS: Reviews", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "ms: prepare debug reviews", - "program": "${workspaceFolder}/src/Services/Reviews/Reviews.Api/bin/Debug/net10.0/Reviews.Api.dll", - "args": [], - "cwd": "${workspaceFolder}/src/Services/Reviews/Reviews.Api", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5020", - "ConnectionStrings__ReviewsDb": "Host=localhost;Port=5432;Database=reviews_db;Username=postgres;Password=postgres", - "ConnectionStrings__RabbitMQ": "amqp://guest:guest@localhost:5672", - "Keycloak__realm": "api-template", - "Keycloak__auth-server-url": "http://localhost:8180/", - "Keycloak__resource": "api-template", - "Observability__Otlp__Endpoint": "http://localhost:4317" - }, - "console": "integratedTerminal" - }, - { - "name": "MS: Identity", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "ms: prepare debug identity", - "program": "${workspaceFolder}/src/Services/Identity/Identity.Api/bin/Debug/net10.0/Identity.Api.dll", - "args": [], - "cwd": "${workspaceFolder}/src/Services/Identity/Identity.Api", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5030", - "ConnectionStrings__IdentityDb": "Host=localhost;Port=5432;Database=identity_db;Username=postgres;Password=postgres", - "ConnectionStrings__RabbitMQ": "amqp://guest:guest@localhost:5672", - "Keycloak__realm": "api-template", - "Keycloak__auth-server-url": "http://localhost:8180/", - "Keycloak__resource": "api-template", - "Keycloak__credentials__secret": "dev-client-secret", - "Observability__Otlp__Endpoint": "http://localhost:4317" - }, - "console": "integratedTerminal" - }, - { - "name": "MS: Notifications", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "ms: prepare debug notifications", - "program": "${workspaceFolder}/src/Services/Notifications/Notifications.Api/bin/Debug/net10.0/Notifications.Api.dll", - "args": [], - "cwd": "${workspaceFolder}/src/Services/Notifications/Notifications.Api", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5040", - "ConnectionStrings__DefaultConnection": "Host=localhost;Port=5432;Database=notifications_db;Username=postgres;Password=postgres", - "ConnectionStrings__RabbitMQ": "amqp://guest:guest@localhost:5672", - "Email__SmtpHost": "localhost", - "Email__SmtpPort": "1025", - "Email__UseSsl": "false", - "Email__SenderEmail": "noreply@api-template.local", - "Email__SenderName": "API Template", - "Observability__Otlp__Endpoint": "http://localhost:4317" - }, - "console": "integratedTerminal" - }, - { - "name": "MS: File Storage", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "ms: prepare debug file-storage", - "program": "${workspaceFolder}/src/Services/FileStorage/FileStorage.Api/bin/Debug/net10.0/FileStorage.Api.dll", - "args": [], - "cwd": "${workspaceFolder}/src/Services/FileStorage/FileStorage.Api", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5050", - "ConnectionStrings__FileStorageDb": "Host=localhost;Port=5432;Database=filestorage_db;Username=postgres;Password=postgres", - "ConnectionStrings__RabbitMQ": "amqp://guest:guest@localhost:5672", - "FileStorage__BasePath": "${workspaceFolder}/tmp/storage", - "Observability__Otlp__Endpoint": "http://localhost:4317" - }, - "console": "integratedTerminal" - }, - { - "name": "MS: Background Jobs", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "ms: prepare debug background-jobs", - "program": "${workspaceFolder}/src/Services/BackgroundJobs/BackgroundJobs.Api/bin/Debug/net10.0/BackgroundJobs.Api.dll", - "args": [], - "cwd": "${workspaceFolder}/src/Services/BackgroundJobs/BackgroundJobs.Api", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5060", - "ConnectionStrings__DefaultConnection": "Host=localhost;Port=5432;Database=backgroundjobs_db;Username=postgres;Password=postgres", - "ConnectionStrings__RabbitMQ": "amqp://guest:guest@localhost:5672", - "ConnectionStrings__Dragonfly": "localhost:6379", - "Observability__Otlp__Endpoint": "http://localhost:4317" - }, - "console": "integratedTerminal" - }, - { - "name": "MS: Webhooks", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "ms: prepare debug webhooks", - "program": "${workspaceFolder}/src/Services/Webhooks/Webhooks.Api/bin/Debug/net10.0/Webhooks.Api.dll", - "args": [], - "cwd": "${workspaceFolder}/src/Services/Webhooks/Webhooks.Api", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5070", - "ConnectionStrings__DefaultConnection": "Host=localhost;Port=5432;Database=webhooks_db;Username=postgres;Password=postgres", - "ConnectionStrings__RabbitMQ": "amqp://guest:guest@localhost:5672", - "Observability__Otlp__Endpoint": "http://localhost:4317" - }, - "console": "integratedTerminal" } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 88f898b6..1c2500c2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -299,280 +299,6 @@ ], "dependsOrder": "sequence", "problemMatcher": [] - }, - { - "label": "ms: build all", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/APITemplate.Microservices.slnx" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: test all", - "type": "shell", - "command": "dotnet test ${workspaceFolder}/tests/SharedKernel.Tests && dotnet test ${workspaceFolder}/tests/ProductCatalog.Tests && dotnet test ${workspaceFolder}/tests/Reviews.Tests && dotnet test ${workspaceFolder}/tests/Identity.Tests && dotnet test ${workspaceFolder}/tests/Notifications.Tests && dotnet test ${workspaceFolder}/tests/FileStorage.Tests && dotnet test ${workspaceFolder}/tests/BackgroundJobs.Tests && dotnet test ${workspaceFolder}/tests/Webhooks.Tests && dotnet test ${workspaceFolder}/tests/Integration.Tests --filter \"Category=Integration.SmokeStartup\"", - "problemMatcher": "$msCompile" - }, - { - "label": "ms: smoke startup", - "type": "process", - "command": "dotnet", - "args": [ - "test", - "${workspaceFolder}/tests/Integration.Tests/Integration.Tests.csproj", - "--filter", - "Category=Integration.SmokeStartup" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: build gateway", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/src/Gateway/Gateway.Api/Gateway.Api.csproj" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: build product-catalog", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/src/Services/ProductCatalog/ProductCatalog.Api/ProductCatalog.Api.csproj" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: build reviews", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/src/Services/Reviews/Reviews.Api/Reviews.Api.csproj" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: build identity", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/src/Services/Identity/Identity.Api/Identity.Api.csproj" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: build notifications", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/src/Services/Notifications/Notifications.Api/Notifications.Api.csproj" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: build file-storage", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/src/Services/FileStorage/FileStorage.Api/FileStorage.Api.csproj" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: build background-jobs", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/src/Services/BackgroundJobs/BackgroundJobs.Api/BackgroundJobs.Api.csproj" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: build webhooks", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/src/Services/Webhooks/Webhooks.Api/Webhooks.Api.csproj" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "ms: docker infra up", - "type": "process", - "command": "docker", - "args": [ - "compose", - "-f", - "docker-compose.microservices.yml", - "up", - "-d", - "postgres", - "mongodb", - "dragonfly", - "rabbitmq", - "mailpit" - ], - "problemMatcher": [] - }, - { - "label": "ms: docker infra ensure up", - "type": "process", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - "$ErrorActionPreference = 'Stop'; function Start-Or-Skip([string]$composeFile, [string]$service) { $output = docker compose -f $composeFile up -d $service 2>&1; $exitCode = $LASTEXITCODE; if ($exitCode -eq 0) { $output | Out-Host; return }; $msg = ($output | Out-String); if ($msg -match 'port is already allocated' -or $msg -match 'Bind for 0\\.0\\.0\\.0') { Write-Host \"$service port is already allocated. Continuing.\"; return }; throw $msg }; Start-Or-Skip 'docker-compose.yml' 'keycloak-db'; Start-Or-Skip 'docker-compose.yml' 'keycloak'; Start-Or-Skip 'docker-compose.microservices.yml' 'postgres'; Start-Or-Skip 'docker-compose.microservices.yml' 'mongodb'; Start-Or-Skip 'docker-compose.microservices.yml' 'dragonfly'; Start-Or-Skip 'docker-compose.microservices.yml' 'rabbitmq'; Start-Or-Skip 'docker-compose.microservices.yml' 'mailpit'; exit 0" - ], - "problemMatcher": [] - }, - { - "label": "ms: docker infra + observability up", - "type": "process", - "command": "docker", - "args": [ - "compose", - "-f", - "docker-compose.microservices.yml", - "up", - "-d", - "postgres", - "mongodb", - "dragonfly", - "rabbitmq", - "mailpit", - "alloy", - "prometheus", - "loki", - "tempo", - "grafana" - ], - "problemMatcher": [] - }, - { - "label": "ms: docker all up", - "type": "process", - "command": "docker", - "args": [ - "compose", - "-f", - "docker-compose.microservices.yml", - "up", - "--build", - "-d" - ], - "problemMatcher": [] - }, - { - "label": "ms: docker all down", - "type": "process", - "command": "docker", - "args": [ - "compose", - "-f", - "docker-compose.microservices.yml", - "down" - ], - "problemMatcher": [] - }, - { - "label": "ms: docker logs", - "type": "process", - "command": "docker", - "args": [ - "compose", - "-f", - "docker-compose.microservices.yml", - "logs", - "-f", - "--tail", - "100" - ], - "problemMatcher": [] - }, - { - "label": "ms: prepare debug gateway", - "dependsOn": [ - "ms: build gateway" - ], - "dependsOrder": "sequence", - "problemMatcher": [] - }, - { - "label": "ms: prepare debug product-catalog", - "dependsOn": [ - "ms: docker infra ensure up", - "ms: build product-catalog" - ], - "dependsOrder": "sequence", - "problemMatcher": [] - }, - { - "label": "ms: prepare debug identity", - "dependsOn": [ - "ms: docker infra ensure up", - "ms: build identity" - ], - "dependsOrder": "sequence", - "problemMatcher": [] - }, - { - "label": "ms: prepare debug notifications", - "dependsOn": [ - "ms: docker infra ensure up", - "ms: build notifications" - ], - "dependsOrder": "sequence", - "problemMatcher": [] - }, - { - "label": "ms: prepare debug reviews", - "dependsOn": [ - "ms: docker infra ensure up", - "ms: build reviews" - ], - "dependsOrder": "sequence", - "problemMatcher": [] - }, - { - "label": "ms: prepare debug file-storage", - "dependsOn": [ - "ms: docker infra ensure up", - "ms: build file-storage" - ], - "dependsOrder": "sequence", - "problemMatcher": [] - }, - { - "label": "ms: prepare debug background-jobs", - "dependsOn": [ - "ms: docker infra ensure up", - "ms: build background-jobs" - ], - "dependsOrder": "sequence", - "problemMatcher": [] - }, - { - "label": "ms: prepare debug webhooks", - "dependsOn": [ - "ms: docker infra ensure up", - "ms: build webhooks" - ], - "dependsOrder": "sequence", - "problemMatcher": [] } ] } diff --git a/APITemplate.slnx b/APITemplate.slnx index 25e85f46..7df42cb4 100644 --- a/APITemplate.slnx +++ b/APITemplate.slnx @@ -1,191 +1,27 @@ - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Directory.Packages.props b/Directory.Packages.props index f6d36144..e5a26a58 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,6 @@ true - @@ -21,9 +20,9 @@ - - - + + + @@ -33,10 +32,8 @@ - - @@ -44,30 +41,25 @@ - - - - - - + + - - - - + + + - + @@ -81,13 +73,10 @@ - - - - + + - diff --git a/README.md b/README.md index d2c745f2..3b94c8d6 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ classDiagram * **GraphQL Core:** HotChocolate `15.1` * **Auth:** Keycloak 26 (JWT Bearer + BFF Cookie via OIDC) * **Utilities:** `Serilog.AspNetCore`, `FluentValidation`, `Ardalis.Specification`, `Kot.MongoDB.Migrations` -* **Test Suite:** xUnit 3, `Microsoft.AspNetCore.Mvc.Testing`, Moq, Shouldly (exclusive assertion style; see `docs/testing.md`), `FluentValidation.TestHelper`, Testcontainers.PostgreSql, Respawn +* **Test Suite:** xUnit 3, `Microsoft.AspNetCore.Mvc.Testing`, Moq, Shouldly, `FluentValidation.TestHelper`, Testcontainers.PostgreSql, Respawn --- diff --git a/TODO-Architecture.md b/TODO-Architecture.md index ea7a3839..cbeb9836 100644 --- a/TODO-Architecture.md +++ b/TODO-Architecture.md @@ -1,200 +1,267 @@ -# Architecture: Microservices with A-Frame + Wolverine + RabbitMQ +# Architecture Evolution: Monolith to Microservices -## Status: IMPLEMENTED +## Current State -All 7 microservices extracted from the monolith and running independently. +Well-structured **Clean Architecture monolith** with: +- Vertical slice features (Product, Category, User, Tenant, Email, Webhooks, Jobs) +- Wolverine as message bus (supports distributed transport) +- Dual database (PostgreSQL + MongoDB) +- Multi-tenancy with isolated query filters +- Domain events for cross-feature communication --- -## Open TODOs +## Identified Bounded Contexts -### Reliability -- Replace in-memory `ChannelJobQueue` in BackgroundJobs with durable persistence/replay so queued jobs survive process restarts. -- Replace in-memory `ChannelEmailQueue` in Notifications with durable persistence/replay so pending emails are not lost on restart between enqueue and delivery. +| # | Service | Entities | Database | +|---|---------|----------|----------| +| 1 | **Product Catalog** | Product, Category, ProductData, ProductDataLink, ProductCategoryStats | PostgreSQL + MongoDB | +| 2 | **Reviews** | ProductReview | PostgreSQL | +| 3 | **Identity & Tenancy** | AppUser, Tenant, TenantInvitation | PostgreSQL + Keycloak | +| 4 | **Notifications** | FailedEmail, email templates | PostgreSQL | +| 5 | **File Storage** | StoredFile | PostgreSQL + filesystem/S3 | +| 6 | **Background Jobs** | JobExecution | PostgreSQL (TickerQ) | +| 7 | **Webhooks** | Incoming/Outgoing webhooks | In-memory queues | -### File Storage Cascade -- Implement real product-related file cleanup in FileStorage on `ProductDeletedIntegrationEvent`. -- Introduce an explicit relation from stored files to product-owned resources, so `FilesCascadeCompleted` is emitted only after actual cleanup instead of a placeholder acknowledgment (currently publishes `FilesCascadeCompleted` with `count = 0`). +--- -### Messaging — Idempotency -- Add `Guid MessageId` to all integration event records and guard consumers with an idempotency check against a seen-message store (RabbitMQ redelivery can duplicate work today). +## Recommended Strategy: Modular Monolith → Strangler Fig -### DRY — Product validators -- Consolidate `ProductValidationRules` and `ProductRequestValidatorBase` from `APITemplate.Application` and `ProductCatalog.Application` into a single shared location (e.g. `SharedKernel.Application`). +### Phase 1 — Modular Monolith -### Test Coverage -- Add integration/runtime coverage for microservices shared auth: permission policies, BFF cookie/OIDC flow (where applicable), tenant claim enrichment, and locked-down Wolverine HTTP endpoints. +Transform the current monolith into isolated modules with explicit boundaries while keeping everything in a single deployable unit. -### Gateway — Demo / Example APIs -- Expose monolith-style demo endpoints (`IdempotentController`, `PatchController`, `SseController` under `src/APITemplate.Api/`) via a dedicated Examples service or Gateway host, and register routes in YARP. +#### Step 1: Create Module Structure -### Optional polish -- **Output cache:** ProductCatalog, Identity, Reviews, and FileStorage use `AddSharedOutputCaching` + `[OutputCache]` + invalidation. BackgroundJobs still runs with `useOutputCaching: false` (it has a GET on `JobsController`); enable caching there only if you want read responses cached like the other APIs. +Create a new directory layout under `src/Modules/`: ---- +``` +src/Modules/ + ProductCatalog/ + ProductCatalog.Domain/ + ProductCatalog.Application/ + ProductCatalog.Infrastructure/ + ProductCatalog.Api/ + Reviews/ + Reviews.Domain/ + Reviews.Application/ + Reviews.Infrastructure/ + Reviews.Api/ + Identity/ + Identity.Domain/ + Identity.Application/ + Identity.Infrastructure/ + Identity.Api/ + Notifications/ + Notifications.Domain/ + Notifications.Application/ + Notifications.Infrastructure/ + Notifications.Api/ + FileStorage/ + FileStorage.Domain/ + FileStorage.Application/ + FileStorage.Infrastructure/ + FileStorage.Api/ + BackgroundJobs/ + BackgroundJobs.Domain/ + BackgroundJobs.Application/ + BackgroundJobs.Infrastructure/ + BackgroundJobs.Api/ + Webhooks/ + Webhooks.Domain/ + Webhooks.Application/ + Webhooks.Infrastructure/ + Webhooks.Api/ +``` -## Completed ([x]) +#### Step 2: Extract Shared Kernel -### [x] Saga — EF Core persistence -`ProductDeletionSaga` and `TenantDeactivationSaga` are mapped in each service `DbContext`, with EF migrations and saga schema (`sagas`). +Create `src/SharedKernel/` containing cross-cutting concerns shared by all modules: -### [x] Saga — Timeout handling -Both sagas schedule Wolverine timeouts (`ProductDeletionSagaTimeout`, `TenantDeactivationSagaTimeout`) with compensation-style handling. +- `IAuditableTenantEntity`, `IAuditableEntity`, `ISoftDeletable`, `IHasId` +- `AuditInfo` value object +- `ITenantProvider`, `IActorProvider` +- `PagedResponse` +- `IUnitOfWork` abstraction +- `IRepository` base interface +- Multi-tenancy infrastructure (global query filters, tenant resolution) +- Soft-delete base infrastructure +- Common domain exceptions (`NotFoundException`, `ValidationException`) +- Audit stamping logic -### [x] Output caching (microservices read APIs) -Tenant-aware policies, Redis/Dragonfly-backed output cache registration (`AddSharedOutputCaching`), `UseSharedOutputCaching` in the shared pipeline, `CacheInvalidationHandler`, and write-side `CacheInvalidationCascades` are implemented in `SharedKernel.Api` / `SharedKernel.Application` and wired for **ProductCatalog, Identity, Reviews, FileStorage** (including `[OutputCache]` on their GET controllers). +#### Step 3: Split AppDbContext -### [x] Saga-flow integration tests -`tests/Integration.Tests/Sagas/ProductDeletionSagaIntegrationTests.cs` and `TenantDeactivationSagaIntegrationTests.cs` exercise cross-service correlation and completion messages. +Replace the single `AppDbContext` with per-module DbContexts: ---- +- `ProductCatalogDbContext` — Products, Categories, ProductDataLinks, ProductCategoryStats +- `ReviewsDbContext` — ProductReviews +- `IdentityDbContext` — AppUsers, Tenants, TenantInvitations +- `NotificationsDbContext` — FailedEmails +- `FileStorageDbContext` — StoredFiles +- `BackgroundJobsDbContext` — JobExecutions -## Bounded Contexts (Implemented) +All contexts share the same PostgreSQL database but enforce module boundaries — a module must not query another module's tables directly. -| # | Service | Entities | Database | Transport | -|---|---------|----------|----------|-----------| -| 1 | **Product Catalog** | Product, Category, ProductData, ProductDataLink, ProductCategoryStats | PostgreSQL + MongoDB | RabbitMQ (product-catalog.events) | -| 2 | **Reviews** | ProductReview, ProductProjection | PostgreSQL | RabbitMQ (reviews.events) | -| 3 | **Identity & Tenancy** | AppUser, Tenant, TenantInvitation | PostgreSQL + Keycloak | RabbitMQ (identity.events) | -| 4 | **Notifications** | FailedEmail | PostgreSQL | RabbitMQ (consumer only) | -| 5 | **File Storage** | StoredFile | PostgreSQL + filesystem | RabbitMQ (consumer only) | -| 6 | **Background Jobs** | JobExecution | PostgreSQL + TickerQ | RabbitMQ (consumer only) | -| 7 | **Webhooks** | WebhookSubscription, DeliveryLog, EventType | PostgreSQL | RabbitMQ (consumer only) | +#### Step 4: Define Module Contracts (Events) ---- +Create `src/Contracts/` as a shared NuGet package containing only: + +- Integration events (cross-module communication) +- Shared DTOs for inter-module queries +- No domain logic, no entities + +Example events: +``` +ProductCreatedEvent { ProductId, TenantId, Name } +ProductDeletedEvent { ProductId, TenantId } +UserRegisteredEvent { UserId, TenantId, Email } +TenantDeactivatedEvent { TenantId } +``` -## Architecture Patterns +#### Step 5: Replace Direct Cross-Module Calls with Events -### A-Frame Architecture (per handler) -- **Load** (Infrastructure) - fetch data, validate existence -- **Handle** (Domain Logic) - pure business decisions -- **Conductor** (Wolverine) - orchestrates Load -> Handle -> side effects +Current direct dependencies to refactor: -### Clean Architecture (per service) -- Domain -> Application -> Infrastructure -> Api -- SharedKernel for cross-cutting concerns (5 projects) +| Caller | Callee | Current | Target | +|--------|--------|---------|--------| +| Product soft-delete | Reviews cascade | `ProductSoftDeleteCascadeRule` calls ReviewRepository directly | Publish `ProductDeletedEvent` → Reviews module handles cascade | +| Tenant soft-delete | Users, Products cascade | `TenantSoftDeleteCascadeRule` accesses multiple repositories | Publish `TenantDeactivatedEvent` → each module handles own cleanup | +| ProductReview creation | User validation | Queries UserRepository | Reviews module stores read-only user projection, updated via `UserUpdatedEvent` | +| Product creation | Category validation | Queries CategoryRepository | Both in same module (Product Catalog) — no change needed | +| Email handlers | User/Tenant data | Queries user/tenant repos | Notifications module receives all needed data in the event payload | -### Vertical Slice (per feature) -- Co-located command/query + handler + validator + DTOs in Application layer +#### Step 6: Enforce Module Isolation -### DDD -- Aggregate roots per bounded context -- Integration events for cross-service communication -- Saga orchestration for distributed workflows +- Each module exposes only its public API (controllers, events, query interfaces) +- No module references another module's `Domain` or `Infrastructure` project +- Communication exclusively through Wolverine events (in-process for now) +- Add architecture tests (NetArchTest or ArchUnitNET) to enforce boundaries ---- +#### Step 7: Split GraphQL Schema -## Solution Structure +- Each module defines its own GraphQL types, queries, and mutations +- Use Hot Chocolate Schema Stitching to compose the unified schema +- Prepare for future Hot Chocolate Federation when modules become services -``` -src/ - SharedKernel/ - SharedKernel.Domain/ # Entity contracts, value objects, exceptions - SharedKernel.Application/ # ErrorOr middleware, batch, DTOs, queue abstractions - SharedKernel.Infrastructure/ # TenantAuditableDbContext, UoW, RepositoryBase, queue impl - SharedKernel.Messaging/ # Wolverine conventions, RabbitMQ topology, retry policies - SharedKernel.Api/ # Base controller, ErrorOr mapping, auth, observability, DI helpers - Contracts/ - Contracts.IntegrationEvents/ # Integration events + saga messages - Services/ - {ServiceName}/ - {ServiceName}.Domain/ - {ServiceName}.Application/ - {ServiceName}.Infrastructure/ - {ServiceName}.Api/ - Gateway/ - Gateway.Api/ # YARP reverse proxy -tests/ - {ServiceName}.Tests/ # Unit tests per service -``` +#### Step 8: Split REST Controllers + +- Move controllers into their respective module's `Api` project +- Host module still composes all endpoints in `Program.cs` +- Each module registers its own services via `IServiceCollection` extensions --- -## Messaging Topology (RabbitMQ) - -### Exchanges (fanout, durable) -| Exchange | Publisher | -|----------|-----------| -| `identity.events` | Identity | -| `product-catalog.events` | Product Catalog | -| `reviews.events` | Reviews | - -### Queue Bindings -| Queue | Exchange | Consumer | -|-------|----------|----------| -| `reviews.product-created` | product-catalog.events | Reviews | -| `reviews.product-deleted` | product-catalog.events | Reviews | -| `reviews.tenant-deactivated` | identity.events | Reviews | -| `file-storage.product-deleted` | product-catalog.events | File Storage | -| `notifications.user-registered` | identity.events | Notifications | -| `notifications.user-role-changed` | identity.events | Notifications | -| `notifications.invitation-created` | identity.events | Notifications | -| `webhooks.product-created` | product-catalog.events | Webhooks | -| `webhooks.product-deleted` | product-catalog.events | Webhooks | -| `webhooks.review-created` | reviews.events | Webhooks | -| `webhooks.category-deleted` | product-catalog.events | Webhooks | -| `background-jobs.tenant-deactivated` | identity.events | Background Jobs | -| `identity.tenant-deactivated` | identity.events | Identity | -| `identity.users-cascade-completed` | — (direct) | Identity | -| `identity.products-cascade-completed` | — (direct) | Identity | -| `identity.categories-cascade-completed` | — (direct) | Identity | -| `product-catalog.tenant-deactivated` | identity.events | ProductCatalog | +### Phase 2 — Strangler Fig Extraction ---- +Extract modules into independent services when scaling demands it. Start with the least coupled modules. + +#### Extraction Order (least to most coupled) -## Sagas +1. **Notifications** — no inbound queries, only consumes events +2. **File Storage** — simple CRUD, minimal dependencies +3. **Webhooks** — event-driven by nature +4. **Background Jobs** — independent scheduler +5. **Reviews** — depends on Product (read-only projection) +6. **Identity & Tenancy** — central but well-defined API (Keycloak handles heavy lifting) +7. **Product Catalog** — core domain, extract last -### ProductDeletionSaga (Product Catalog) -- Triggered by `StartProductDeletionSaga` from DeleteProductsCommand -- Publishes `ProductDeletedIntegrationEvent` to cascade to Reviews + FileStorage -- Waits for `ReviewsCascadeCompleted` + `FilesCascadeCompleted` +#### Step 1: Deploy API Gateway -### TenantDeactivationSaga (Identity) -- Triggered by `StartTenantDeactivationSaga` from DeleteTenantCommand -- Publishes `TenantDeactivatedIntegrationEvent` to all dependent services -- Waits for `UsersCascadeCompleted` + `ProductsCascadeCompleted` + `CategoriesCascadeCompleted` +- Add YARP or Ocelot as reverse proxy +- Route all traffic through gateway +- Initially, gateway proxies everything to the monolith + +#### Step 2: Extract First Service (Notifications) + +1. Create standalone ASP.NET project from Notifications module +2. Give it its own PostgreSQL database (or schema) +3. Switch Wolverine transport from in-process to RabbitMQ: + ```csharp + // Before (in-process) + opts.PublishMessage().Locally(); + + // After (distributed) + opts.PublishMessage() + .ToRabbitQueue("notifications"); + ``` +4. Update API Gateway to route `/api/v1/notifications/*` to new service +5. Remove Notifications module from monolith + +#### Step 3: Configure Distributed Messaging + +- Deploy RabbitMQ (or Azure Service Bus) +- Enable Wolverine outbox pattern for guaranteed delivery: + ```csharp + opts.UseRabbitMq(rabbit => { ... }) + .AutoProvision() + .UseConventionalRouting(); + opts.Policies.UseDurableOutboxOnAllSendingEndpoints(); + ``` +- Each service gets its own durable inbox/outbox + +#### Step 4: Database-per-Service + +- Each extracted service gets its own PostgreSQL database +- Migrate data from shared DB to service-owned DB +- Remove tables from monolith's DB after migration +- MongoDB stays with Product Catalog service + +#### Step 5: Repeat for Each Service + +Follow the same pattern for each module in extraction order: +1. Stand up independent service from module code +2. Point its Wolverine transport to RabbitMQ +3. Give it its own database +4. Update API Gateway routing +5. Remove module from monolith + +#### Step 6: Handle Cross-Service Queries + +For queries that span multiple services: + +- **API Composition** — Gateway aggregates responses from multiple services +- **CQRS Read Models** — Services maintain denormalized read projections updated via events +- **GraphQL Federation** — Hot Chocolate Federation composes subgraphs from each service --- -## Infrastructure (Docker Compose) - -| Service | Image | Port | Purpose | -|---------|-------|------|---------| -| RabbitMQ | rabbitmq:4-management | 5672, 15672 | Message broker | -| PostgreSQL | postgres:17 | 5432 | 7 databases (one per service) | -| MongoDB | mongo:8 | 27017 | Product Catalog documents | -| Dragonfly | dragonflydb | 6379 | Cache + TickerQ coordination | -| Mailpit | axllent/mailpit | 1025, 8025 | Dev email | -| Alloy | grafana/alloy | 4317 | OTLP collector | -| Prometheus | prom/prometheus | 9090 | Metrics | -| Loki | grafana/loki | 3100 | Logs | -| Tempo | grafana/tempo | 3200 | Traces | -| Grafana | grafana/grafana | 3001 | Dashboards | -| YARP Gateway | custom | 8080 | API routing | - -### Running -```bash -docker compose -f docker-compose.microservices.yml up -d -``` +## Infrastructure Requirements + +### Phase 1 (Modular Monolith) +- No new infrastructure needed +- Same PostgreSQL, MongoDB, Redis/DragonFly, Keycloak + +### Phase 2 (Microservices) +- **Message Broker:** RabbitMQ or Azure Service Bus +- **API Gateway:** YARP or Ocelot +- **Container Orchestration:** Docker Compose (dev) → Kubernetes (prod) +- **Service Discovery:** Kubernetes DNS or Consul +- **Distributed Tracing:** Already have OpenTelemetry — works across services +- **Centralized Logging:** Already have Serilog + OTLP — works across services +- **Per-Service Databases:** Multiple PostgreSQL instances (or schemas) --- -## Observability +## Risk Mitigation -- **Tracing**: OpenTelemetry -> Alloy -> Tempo -> Grafana -- **Metrics**: OpenTelemetry -> Alloy -> Prometheus -> Grafana -- **Logging**: Serilog -> Console + OTLP -> Alloy -> Loki -> Grafana -- **Instrumentation**: ASP.NET Core, HTTP Client, Npgsql, Wolverine, Runtime, Process +| Risk | Mitigation | +|------|------------| +| Data inconsistency across services | Wolverine outbox pattern + idempotent handlers (already have `IIdempotencyStore`) | +| Lost events | Durable messaging with RabbitMQ persistent queues + Wolverine dead letter queue | +| Debugging complexity | OpenTelemetry distributed tracing (already configured) | +| Service discovery failures | Kubernetes DNS + health checks (already have health check infrastructure) | +| Database migration errors | Per-module DbContext in Phase 1 validates data boundaries before physical split | +| GraphQL schema fragmentation | Hot Chocolate Federation maintains unified schema | --- -## Key Design Decisions +## Key Wolverine Advantages + +The current Wolverine setup makes this transition significantly easier: -1. **EF Core (not Marten)** - existing codebase uses EF Core, no migration to event sourcing -2. **REST only (no GraphQL)** - simplified API surface for microservices -3. **Notifications = A-Frame pilot** - Wolverine HTTP Endpoints instead of ASP.NET Controllers -4. **Other services = ASP.NET Controllers** - proven pattern, familiar to team -5. **Database-per-service** - separate PostgreSQL databases via init script -6. **SharedKernel DRY** - TenantAuditableDbContext base, shared service registration, common queue infrastructure -7. **Saga for cascading deletes** - replaces synchronous SoftDeleteCascadeRules -8. **ProductProjection read model** - Reviews stores denormalized product data, updated via events +1. **Transport agnostic** — switch from in-process to RabbitMQ/Azure SB with config change, not code change +2. **Built-in outbox** — guaranteed message delivery across service boundaries +3. **Durable inbox** — idempotent message processing +4. **Saga support** — orchestrate multi-service workflows +5. **FluentValidation middleware** — works identically in monolith and microservices +6. **Handler discovery** — static handlers work the same regardless of deployment topology diff --git a/docker-compose.microservices.yml b/docker-compose.microservices.yml deleted file mode 100644 index a47e70b4..00000000 --- a/docker-compose.microservices.yml +++ /dev/null @@ -1,302 +0,0 @@ -services: - # Infrastructure - rabbitmq: - image: rabbitmq:4.2.5-management - ports: - - "5672:5672" - - "15672:15672" - healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - postgres: - image: postgres:18.3 - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - - ./infrastructure/docker/init-microservices-databases.sql:/docker-entrypoint-initdb.d/init.sql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 5s - retries: 5 - - mongodb: - image: mongo:8.2.6-noble - ports: - - "27017:27017" - volumes: - - mongodata:/data/db - - dragonfly: - image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.37.2 - ports: - - "6379:6379" - - mailpit: - image: axllent/mailpit:v1.22 - ports: - - "8025:8025" - - "1025:1025" - - # Observability - alloy: - image: grafana/alloy:v1.11.0 - ports: - - "4317:4317" - - "4318:4318" - - "12345:12345" - volumes: - - ./infrastructure/observability/alloy/config.alloy:/etc/alloy/config.alloy:ro - command: - - run - - /etc/alloy/config.alloy - - prometheus: - image: prom/prometheus:v2.55.1 - command: - - --config.file=/etc/prometheus/prometheus.yml - - --storage.tsdb.path=/prometheus - - --web.enable-remote-write-receiver - ports: - - "9090:9090" - volumes: - - ./infrastructure/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheusdata:/prometheus - - loki: - image: grafana/loki:3.4.2 - command: ["-config.file=/etc/loki/config.yml"] - ports: - - "3100:3100" - volumes: - - ./infrastructure/observability/loki/config.yml:/etc/loki/config.yml:ro - - lokidata:/loki - - tempo: - image: grafana/tempo:2.6.1 - command: ["-config.file=/etc/tempo/config.yml"] - ports: - - "3200:3200" - - "4319:4317" - volumes: - - ./infrastructure/observability/tempo/config.yml:/etc/tempo/config.yml:ro - - tempodata:/var/tempo - - grafana: - image: grafana/grafana:12.0.0 - environment: - GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} - ports: - - "3001:3000" - depends_on: - - prometheus - - loki - - tempo - volumes: - - ./infrastructure/observability/grafana/grafana.ini:/etc/grafana/grafana.ini:ro - - ./infrastructure/observability/grafana/provisioning:/etc/grafana/provisioning:ro - - ./infrastructure/observability/grafana/dashboards:/var/lib/grafana/dashboards:ro - - grafanadata:/var/lib/grafana - - # Gateway - gateway: - build: - context: . - dockerfile: src/Gateway/Gateway.Api/Dockerfile - ports: - - "8080:8080" - environment: - Observability__Otlp__Endpoint: "http://alloy:4317" - depends_on: - product-catalog: - condition: service_healthy - reviews: - condition: service_healthy - identity: - condition: service_healthy - notifications: - condition: service_healthy - file-storage: - condition: service_healthy - background-jobs: - condition: service_healthy - webhooks: - condition: service_healthy - - # Services (each with own database) - product-catalog: - build: - context: . - dockerfile: src/Services/ProductCatalog/ProductCatalog.Api/Dockerfile - environment: - ConnectionStrings__DefaultConnection: "Host=postgres;Database=productcatalog_db;Username=postgres;Password=postgres" - MongoDB__ConnectionString: "mongodb://mongodb:27017" - MongoDB__DatabaseName: "productcatalog" - RabbitMQ__HostName: rabbitmq - Keycloak__AuthServerUrl: "http://keycloak:8180/" - Keycloak__Realm: "api-template" - Keycloak__Resource: "api-template" - Observability__Otlp__Endpoint: "http://alloy:4317" - depends_on: - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - mongodb: - condition: service_started - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 10s - timeout: 5s - retries: 5 - - reviews: - build: - context: . - dockerfile: src/Services/Reviews/Reviews.Api/Dockerfile - environment: - ConnectionStrings__DefaultConnection: "Host=postgres;Database=reviews_db;Username=postgres;Password=postgres" - RabbitMQ__HostName: rabbitmq - Keycloak__AuthServerUrl: "http://keycloak:8180/" - Keycloak__Realm: "api-template" - Keycloak__Resource: "api-template" - Observability__Otlp__Endpoint: "http://alloy:4317" - depends_on: - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 10s - timeout: 5s - retries: 5 - - identity: - build: - context: . - dockerfile: src/Services/Identity/Identity.Api/Dockerfile - environment: - ConnectionStrings__DefaultConnection: "Host=postgres;Database=identity_db;Username=postgres;Password=postgres" - RabbitMQ__HostName: rabbitmq - Keycloak__AuthServerUrl: "http://keycloak:8180/" - Keycloak__Realm: "api-template" - Keycloak__Resource: "api-template" - Keycloak__Credentials__Secret: "your-secret-here" - Observability__Otlp__Endpoint: "http://alloy:4317" - depends_on: - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 10s - timeout: 5s - retries: 5 - - notifications: - build: - context: . - dockerfile: src/Services/Notifications/Notifications.Api/Dockerfile - environment: - ConnectionStrings__DefaultConnection: "Host=postgres;Database=notifications_db;Username=postgres;Password=postgres" - RabbitMQ__HostName: rabbitmq - Email__SmtpHost: mailpit - Email__SmtpPort: "1025" - Email__UseSsl: "false" - Email__SenderEmail: "noreply@api-template.local" - Email__SenderName: "API Template" - Observability__Otlp__Endpoint: "http://alloy:4317" - depends_on: - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - mailpit: - condition: service_started - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 10s - timeout: 5s - retries: 5 - - file-storage: - build: - context: . - dockerfile: src/Services/FileStorage/FileStorage.Api/Dockerfile - environment: - ConnectionStrings__DefaultConnection: "Host=postgres;Database=filestorage_db;Username=postgres;Password=postgres" - RabbitMQ__HostName: rabbitmq - FileStorage__BasePath: /app/storage - Observability__Otlp__Endpoint: "http://alloy:4317" - volumes: - - filestorage:/app/storage - depends_on: - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 10s - timeout: 5s - retries: 5 - - background-jobs: - build: - context: . - dockerfile: src/Services/BackgroundJobs/BackgroundJobs.Api/Dockerfile - environment: - ConnectionStrings__DefaultConnection: "Host=postgres;Database=backgroundjobs_db;Username=postgres;Password=postgres" - ConnectionStrings__Redis: "dragonfly:6379" - RabbitMQ__HostName: rabbitmq - Observability__Otlp__Endpoint: "http://alloy:4317" - depends_on: - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - dragonfly: - condition: service_started - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 10s - timeout: 5s - retries: 5 - - webhooks: - build: - context: . - dockerfile: src/Services/Webhooks/Webhooks.Api/Dockerfile - environment: - ConnectionStrings__DefaultConnection: "Host=postgres;Database=webhooks_db;Username=postgres;Password=postgres" - RabbitMQ__HostName: rabbitmq - Observability__Otlp__Endpoint: "http://alloy:4317" - depends_on: - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - pgdata: - mongodata: - filestorage: - prometheusdata: - lokidata: - tempodata: - grafanadata: diff --git a/docker-compose.production.yml b/docker-compose.production.yml index cd42132a..81d8b064 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -1,6 +1,6 @@ services: alloy: - image: grafana/alloy:v1.11.0 + image: grafana/alloy:v1.10.2 restart: unless-stopped volumes: - ./infrastructure/observability/alloy/config.alloy:/etc/alloy/config.alloy:ro @@ -9,7 +9,7 @@ services: - /etc/alloy/config.alloy prometheus: - image: prom/prometheus:v2.55.1 + image: prom/prometheus:v3.4.2 restart: unless-stopped command: - --config.file=/etc/prometheus/prometheus.yml @@ -21,7 +21,7 @@ services: - prometheusdata:/prometheus loki: - image: grafana/loki:3.4.2 + image: grafana/loki:3.5.5 restart: unless-stopped command: ["-config.file=/etc/loki/config.yml"] volumes: @@ -29,7 +29,7 @@ services: - lokidata:/loki tempo: - image: grafana/tempo:2.6.1 + image: grafana/tempo:2.9.1 restart: unless-stopped command: ["-config.file=/etc/tempo/config.yml"] volumes: @@ -37,7 +37,7 @@ services: - tempodata:/var/tempo grafana: - image: grafana/grafana:12.0.0 + image: grafana/grafana:12.3.1 restart: unless-stopped environment: GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} @@ -54,7 +54,7 @@ services: - grafanadata:/var/lib/grafana postgres: - image: postgres:18.3 + image: postgres:17.2 restart: unless-stopped environment: POSTGRES_USER: ${DB_USERNAME} @@ -69,7 +69,7 @@ services: retries: 5 mongodb: - image: mongo:8.2.6-noble + image: mongo:8.0.4 restart: unless-stopped volumes: - mongodata:/data/db @@ -80,7 +80,7 @@ services: retries: 5 keycloak-db: - image: postgres:18.3 + image: postgres:17.2 restart: unless-stopped environment: POSTGRES_USER: ${KC_DB_USERNAME} @@ -95,7 +95,7 @@ services: retries: 5 keycloak: - image: quay.io/keycloak/keycloak:26.5.6 + image: quay.io/keycloak/keycloak:26.1 restart: unless-stopped command: start --optimized environment: @@ -119,7 +119,7 @@ services: start_period: 60s dragonfly-master: - image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.37.2 + image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.27.1 restart: unless-stopped volumes: - dragonfly-master-data:/data @@ -131,7 +131,7 @@ services: retries: 5 dragonfly-replica: - image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.37.2 + image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.27.1 restart: unless-stopped volumes: - dragonfly-replica-data:/data @@ -146,7 +146,7 @@ services: retries: 5 dragonfly-proxy: - image: haproxy:3.3.6 + image: haproxy:3.1-alpine restart: unless-stopped volumes: - ./infrastructure/dragonfly/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro diff --git a/docker-compose.yml b/docker-compose.yml index 8ccd5ce6..775ddd3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: alloy: - image: grafana/alloy:v1.11.0 + image: grafana/alloy:v1.10.2 ports: - "4317:4317" - "4318:4318" @@ -12,7 +12,7 @@ services: - /etc/alloy/config.alloy prometheus: - image: prom/prometheus:v2.55.1 + image: prom/prometheus:v3.4.2 command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus @@ -25,7 +25,7 @@ services: - prometheusdata:/prometheus loki: - image: grafana/loki:3.4.2 + image: grafana/loki:3.5.5 command: ["-config.file=/etc/loki/config.yml"] ports: - "3100:3100" @@ -34,7 +34,7 @@ services: - lokidata:/loki tempo: - image: grafana/tempo:2.6.1 + image: grafana/tempo:2.9.1 command: ["-config.file=/etc/tempo/config.yml"] ports: - "3200:3200" @@ -44,7 +44,7 @@ services: - tempodata:/var/tempo grafana: - image: grafana/grafana:12.0.0 + image: grafana/grafana:12.3.1 environment: GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} @@ -62,7 +62,7 @@ services: - grafanadata:/var/lib/grafana aspire-dashboard: - image: mcr.microsoft.com/dotnet/aspire-dashboard:9.1 + image: mcr.microsoft.com/dotnet/aspire-dashboard:9.5 profiles: ["aspire"] ports: - "${ASPIRE_OTLP_GRPC_PORT:-4317}:18889" @@ -89,7 +89,7 @@ services: retries: 5 mongodb: - image: mongo:8.2.6-noble + image: mongo:8.2 ports: - "27017:27017" volumes: @@ -115,7 +115,7 @@ services: retries: 5 keycloak: - image: quay.io/keycloak/keycloak:26.5.6 + image: quay.io/keycloak/keycloak:26.5 command: start-dev --import-realm environment: KC_DB: postgres @@ -140,7 +140,7 @@ services: start_period: 60s dragonfly: - image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.37.2 + image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.27.1 ports: - "6379:6379" volumes: @@ -153,7 +153,7 @@ services: retries: 5 mailpit: - image: axllent/mailpit:v1.22 + image: axllent/mailpit:v1.29.0 ports: - "8025:8025" - "1025:1025" diff --git a/docs/Architecture-Microservices-Plan.md b/docs/Architecture-Microservices-Plan.md deleted file mode 100644 index 1561b11b..00000000 --- a/docs/Architecture-Microservices-Plan.md +++ /dev/null @@ -1,710 +0,0 @@ -# Microservices Migration Plan: A-Frame + Wolverine + RabbitMQ + DDD - -## Context - -The API-Template project is a well-structured Clean Architecture monolith (.NET 10) with Wolverine as in-process message bus, EF Core + PostgreSQL + MongoDB, multi-tenancy, and 7 identified bounded contexts. The goal is to transform it into independent microservices using: - -- **A-Frame Architecture** (Jeremy D. Miller) with Wolverine as conductor -- **Wolverine Sagas** for cross-service orchestration -- **RabbitMQ** for inter-service messaging -- **Clean Architecture + Vertical Slice + DDD** within each service -- **YARP** API Gateway -- **EF Core** persistence (no Marten) -- **REST only** (GraphQL removed) -- **Notifications service** as Wolverine HTTP Endpoints pilot (A-Frame) -- Remaining services use ASP.NET Controllers - ---- - -## 1. Solution Structure - -``` -solution/ - src/ - SharedKernel/ - SharedKernel.Domain/ # Entity contracts, value objects, exceptions - SharedKernel.Application/ # Cross-cutting: ErrorOr middleware, batch, DTOs - SharedKernel.Infrastructure/ # Base DbContext, audit, soft-delete, UoW, repos - SharedKernel.Messaging/ # Wolverine conventions, RabbitMQ topology, tenant propagation - SharedKernel.Api/ # Base controller, ErrorOr mapping, auth, health checks - - Contracts/ - Contracts.IntegrationEvents/ # Integration event records ONLY (no domain logic) - - Services/ - ProductCatalog/ - ProductCatalog.Domain/ - ProductCatalog.Application/ - ProductCatalog.Infrastructure/ - ProductCatalog.Api/ # ASP.NET Controllers - Reviews/ - Reviews.Domain/ - Reviews.Application/ - Reviews.Infrastructure/ - Reviews.Api/ # ASP.NET Controllers - Identity/ - Identity.Domain/ - Identity.Application/ - Identity.Infrastructure/ - Identity.Api/ # ASP.NET Controllers - Notifications/ - Notifications.Domain/ - Notifications.Application/ - Notifications.Infrastructure/ - Notifications.Api/ # Wolverine HTTP Endpoints (A-Frame pilot) - FileStorage/ - FileStorage.Domain/ - FileStorage.Application/ - FileStorage.Infrastructure/ - FileStorage.Api/ # ASP.NET Controllers - BackgroundJobs/ - BackgroundJobs.Domain/ - BackgroundJobs.Application/ - BackgroundJobs.Infrastructure/ - BackgroundJobs.Api/ # ASP.NET Controllers - Webhooks/ - Webhooks.Domain/ - Webhooks.Application/ - Webhooks.Infrastructure/ - Webhooks.Api/ # ASP.NET Controllers - - Gateway/ - Gateway.Api/ # YARP reverse proxy - - tests/ - SharedKernel.Tests/ - ProductCatalog.Tests/ - Reviews.Tests/ - Identity.Tests/ - Notifications.Tests/ - FileStorage.Tests/ - BackgroundJobs.Tests/ - Webhooks.Tests/ - Integration.Tests/ # Cross-service E2E tests - - infrastructure/ - docker/ - observability/ -``` - -### Project Reference Rules - -- **Domain** -> SharedKernel.Domain only -- **Application** -> Domain, SharedKernel.Application, Contracts.IntegrationEvents -- **Infrastructure** -> Application, Domain, SharedKernel.Infrastructure -- **Api** -> Application, Infrastructure, SharedKernel.Api, SharedKernel.Messaging -- **No service references another service's projects** - only Contracts.IntegrationEvents - ---- - -## 2. A-Frame Architecture Pattern (Per Service) - -Three functional areas in each handler: - -| Area | Role | Who | -|------|------|-----| -| **Load** (Infrastructure) | Fetch data, validate existence | Static `LoadAsync` method | -| **Handle** (Domain Logic) | Pure business decisions | Static `Handle`/`HandleAsync` method | -| **Conductor** | Orchestrates Load -> Handle -> side effects | Wolverine (generated) | - -### Example: Delete Products Handler (A-Frame) - -```csharp -// ProductCatalog.Application/Features/Products/Commands/DeleteProducts/DeleteProductsHandler.cs -public static class DeleteProductsHandler -{ - // LOAD: Infrastructure concern - public static async Task<(HandlerContinuation, IReadOnlyList?)> LoadAsync( - DeleteProductsCommand command, - IProductRepository repository, - CancellationToken ct) - { - IReadOnlyList products = await repository.ListAsync( - new ProductsByIdsSpec(command.Ids.ToHashSet()), ct); - if (products.Count != command.Ids.Count) - return (HandlerContinuation.Stop, null); - return (HandlerContinuation.Continue, products); - } - - // HANDLE: Pure domain logic - returns response + integration event - public static (BatchResponse, ProductDeletedIntegrationEvent) Handle( - DeleteProductsCommand command, - IReadOnlyList products) - { - foreach (Product product in products) - product.SoftDeleteProductDataLinks(); - return ( - new BatchResponse([], command.Ids.Count, 0), - new ProductDeletedIntegrationEvent(products.Select(p => p.Id).ToList()) - ); - } -} -``` - -### Vertical Slice within Clean Architecture - -Each feature is a vertical slice (co-located command, handler, validator, DTOs) inside the Application layer. Clean Architecture layers remain project boundaries enforcing dependency direction. - -``` -Application/Features/Products/Commands/CreateProducts/ - CreateProductsCommand.cs # Message record - CreateProductsHandler.cs # A-Frame Load + Handle - CreateProductsValidator.cs # FluentValidation - CreateProductsResponse.cs # DTO -``` - ---- - -## 3. SharedKernel Contents - -### SharedKernel.Domain -*Migrated from `APITemplate.Domain/Entities/Contracts/` and `APITemplate.Domain/Exceptions/`* - -- `IAuditableTenantEntity`, `IAuditableEntity`, `ISoftDeletable`, `IHasId`, `ITenantEntity` -- `AuditInfo` value object, `AuditDefaults` -- `PagedResponse` -- `IRepository` base (Ardalis) -- `IUnitOfWork` -- Domain exceptions: `AppException`, `NotFoundException`, `ValidationException`, `ConflictException` - -### SharedKernel.Application -*Migrated from `APITemplate.Application/Common/`* - -- `ITenantProvider`, `IActorProvider` -- `BatchResponse`, `BatchFailureContext`, batch rules -- `ErrorOrValidationMiddleware` -- `ErrorCatalog` (general section) -- Shared validation, sorting, search helpers - -### SharedKernel.Infrastructure -*Migrated from `APITemplate.Infrastructure/Persistence/` and `APITemplate.Infrastructure/Repositories/`* - -- **`SharedDbContext`** base class with tenant filters, audit stamping, soft-delete processing -- `AuditableEntityStateManager` -- `ISoftDeleteCascadeRule`, `SoftDeleteProcessor` -- `UnitOfWork`, `EfCoreTransactionProvider`, `ManagedTransactionScope` -- `RepositoryBase` -- Pagination helpers -- `IIdempotencyStore`, `DistributedCacheIdempotencyStore` -- OpenTelemetry + Serilog registration helpers - -### SharedKernel.Messaging -- Wolverine convention registration (handler discovery, middleware policy) -- RabbitMQ topology conventions (exchange/queue naming) -- `TenantAwareEnvelopeMapper` (propagates TenantId via `x-tenant-id` header) -- Outbox/inbox policy registration -- Retry/error handling policy defaults - -### SharedKernel.Api -- `ApiControllerBase` with ErrorOr helpers -- `ToActionResult`, `ToBatchResult` extensions -- Global `ProblemDetails` exception handler -- `RequirePermissionAttribute`, Permission constants -- Idempotency filter -- Health check registration -- Rate limiting, CORS, OpenAPI helpers - ---- - -## 4. Integration Events (Contracts.IntegrationEvents) - -```csharp -// Identity events -public sealed record UserRegisteredIntegrationEvent(Guid UserId, Guid TenantId, string Email, string Username, DateTime OccurredAtUtc); -public sealed record UserRoleChangedIntegrationEvent(Guid UserId, Guid TenantId, string Email, string Username, string OldRole, string NewRole, DateTime OccurredAtUtc); -public sealed record TenantDeactivatedIntegrationEvent(Guid CorrelationId, Guid TenantId, Guid ActorId, DateTime OccurredAtUtc); -public sealed record TenantInvitationCreatedIntegrationEvent(Guid InvitationId, string Email, string TenantName, string Token, DateTime OccurredAtUtc); - -// Product Catalog events -public sealed record ProductCreatedIntegrationEvent(Guid ProductId, Guid TenantId, string Name, DateTime OccurredAtUtc); -public sealed record ProductDeletedIntegrationEvent(IReadOnlyList ProductIds, Guid TenantId, DateTime OccurredAtUtc); -public sealed record CategoryDeletedIntegrationEvent(Guid CategoryId, Guid TenantId, DateTime OccurredAtUtc); - -// Reviews events -public sealed record ReviewCreatedIntegrationEvent(Guid ReviewId, Guid ProductId, Guid UserId, Guid TenantId, int Rating, DateTime OccurredAtUtc); - -// Saga responses -public sealed record ReviewsCascadeCompleted(Guid CorrelationId, int DeletedCount); -public sealed record FilesCascadeCompleted(Guid CorrelationId, int DeletedCount); -public sealed record ProductsCascadeCompleted(Guid CorrelationId, Guid TenantId, int DeletedCount); -public sealed record UsersCascadeCompleted(Guid CorrelationId, Guid TenantId, int DeactivatedCount); -public sealed record CategoriesCascadeCompleted(Guid CorrelationId, Guid TenantId, int DeletedCount); -``` - ---- - -## 5. RabbitMQ Messaging Topology - -### Exchanges (topic, durable) - -| Exchange | Publisher | Routing Keys | -|----------|-----------|-------------| -| `identity.events` | Identity | `user.registered`, `user.role-changed`, `tenant.deactivated`, `tenant.invitation.created` | -| `product-catalog.events` | Product Catalog | `product.created`, `product.deleted`, `category.deleted` | -| `reviews.events` | Reviews | `review.created` | - -### Queue Bindings - -| Queue | Exchange | Routing Key | Consumer | -|-------|----------|------------|----------| -| `reviews.product-deleted` | product-catalog.events | `product.deleted` | Reviews | -| `reviews.tenant-deactivated` | identity.events | `tenant.deactivated` | Reviews | -| `file-storage.product-deleted` | product-catalog.events | `product.deleted` | File Storage | -| `notifications.user-registered` | identity.events | `user.registered` | Notifications | -| `notifications.user-role-changed` | identity.events | `user.role-changed` | Notifications | -| `notifications.invitation-created` | identity.events | `tenant.invitation.*` | Notifications | -| `product-catalog.tenant-deactivated` | identity.events | `tenant.deactivated` | Product Catalog | -| `webhooks.product-created` | product-catalog.events | `product.created` | Webhooks | -| `background-jobs.tenant-deactivated` | identity.events | `tenant.deactivated` | Background Jobs | - -### Dead Letter - -All queues: `x-dead-letter-exchange: dlx.default` -> `dlq.default` - -### Wolverine RabbitMQ Config (per service) - -```csharp -opts.UseRabbitMq(rabbit => { rabbit.HostName = "rabbitmq"; }) - .AutoProvision() - .UseConventionalRouting(); -opts.PersistMessagesWithPostgresql(connectionString); // Outbox in service DB -opts.Policies.UseDurableInboxOnAllListeners(); -opts.Policies.UseDurableOutboxOnAllSendingEndpoints(); -``` - -### Required NuGet Packages per Service - -```xml - - - - - -``` - ---- - -## 6. Wolverine Saga Patterns - -### 6.1 Product Deletion Cascade Saga (hosted in Product Catalog) - -Replaces `ProductSoftDeleteCascadeRule` which currently queries Reviews + ProductDataLinks in same DbContext. - -```csharp -public class ProductDeletionSaga : Saga -{ - public string? Id { get; set; } - public IReadOnlyList ProductIds { get; set; } = []; - public Guid TenantId { get; set; } - public bool ReviewsCascaded { get; set; } - public bool FilesCascaded { get; set; } - - public static (ProductDeletionSaga, SagaTimeout) Start(StartProductDeletionSaga command) - { - return (new ProductDeletionSaga - { - Id = command.CorrelationId.ToString(), - ProductIds = command.ProductIds, - TenantId = command.TenantId - }, new SagaTimeout(command.CorrelationId)); - } - - public void Handle(ReviewsCascadeCompleted _) { ReviewsCascaded = true; TryComplete(); } - public void Handle(FilesCascadeCompleted _) { FilesCascaded = true; TryComplete(); } - public void Handle(SagaTimeout _) => MarkCompleted(); // Accept partial on timeout - - private void TryComplete() - { - if (ReviewsCascaded && FilesCascaded) MarkCompleted(); - } -} -``` - -### 6.2 Tenant Deactivation Cascade Saga (hosted in Identity) - -Replaces `TenantSoftDeleteCascadeRule` which queries Users, Products, Categories in same DbContext. - -```csharp -public class TenantDeactivationSaga : Saga -{ - public string? Id { get; set; } - public Guid TenantId { get; set; } - public bool UsersCascaded { get; set; } - public bool ProductsCascaded { get; set; } - public bool CategoriesCascaded { get; set; } - - public static (TenantDeactivationSaga, SagaTimeout) Start(StartTenantDeactivationSaga command) - => (new TenantDeactivationSaga { Id = command.CorrelationId.ToString(), TenantId = command.TenantId }, - new SagaTimeout(command.CorrelationId)); - - public void Handle(UsersCascadeCompleted _) { UsersCascaded = true; TryComplete(); } - public void Handle(ProductsCascadeCompleted _) { ProductsCascaded = true; TryComplete(); } - public void Handle(CategoriesCascadeCompleted _) { CategoriesCascaded = true; TryComplete(); } - public void Handle(SagaTimeout _) => MarkCompleted(); - - private void TryComplete() - { - if (UsersCascaded && ProductsCascaded && CategoriesCascaded) MarkCompleted(); - } -} -``` - -### 6.3 Choreography (no saga needed) - -| Workflow | Pattern | Why | -|----------|---------|-----| -| User registration -> welcome email | Choreography | Single consumer, fire-and-forget | -| Tenant invitation -> invitation email | Choreography | Single consumer, fire-and-forget | -| Role change -> notification | Choreography | Single consumer, fire-and-forget | -| Review creation -> product validation | Local read projection | Reviews stores `ProductProjection` table updated via events | - -### Saga persistence - -Two verified options: -- **Option A (Recommended)**: `WolverineFx.EntityFrameworkCore` - saga state mapped as entity in service's DbContext. Full EF Core integration, same transaction as business data. -- **Option B**: `WolverineFx.Postgresql` lightweight saga storage - JSON serialized saga state in auto-created `{SagaName}_saga` tables. No DbContext mapping needed. - ---- - -## 7. Notifications Service (A-Frame Pilot - Wolverine HTTP Endpoints) - -### Domain - -``` -Notifications.Domain/ - Entities/FailedEmail.cs # Migrated - Interfaces/IFailedEmailRepository.cs -``` - -### Application (Wolverine HTTP Endpoints) - -```csharp -// GET /api/v1/notifications/failed-emails -public static class GetFailedEmailsEndpoint -{ - [WolverineGet("/api/v1/notifications/failed-emails")] - public static async Task> HandleAsync( - [FromQuery] PaginationFilter filter, - IFailedEmailRepository repository, - CancellationToken ct) - { - return await repository.GetPagedAsync(/* ... */); - } -} - -// POST /api/v1/notifications/failed-emails/{id}/retry (A-Frame Load + Handle) -public static class RetryFailedEmailEndpoint -{ - // LOAD - public static async Task<(ProblemDetails?, FailedEmail?)> LoadAsync( - [FromRoute] Guid id, - IFailedEmailRepository repository, - CancellationToken ct) - { - FailedEmail? email = await repository.GetByIdAsync(id, ct); - return email is null - ? (new ProblemDetails { Status = 404, Detail = $"Email {id} not found" }, null) - : (WolverineContinue.NoProblems, email); - } - - // HANDLE - [WolverinePost("/api/v1/notifications/failed-emails/{id}/retry")] - public static RetryEmailCommand Handle([FromRoute] Guid id, FailedEmail email) - { - return new RetryEmailCommand(email.Id, email.To, email.Subject, email.HtmlBody); - } -} -``` - -### Event Consumers (from RabbitMQ) - -```csharp -public static class UserRegisteredNotificationHandler -{ - public static async Task HandleAsync( - UserRegisteredIntegrationEvent @event, - IEmailTemplateRenderer renderer, - IEmailQueue queue, - CancellationToken ct) - { - string html = await renderer.RenderAsync("user-registration", new { @event.Username }, ct); - await queue.EnqueueAsync(new EmailMessage(@event.Email, "Welcome!", html), ct); - } -} -``` - -### Infrastructure - -Migrated from monolith: `MailKit` sender, `Fluid` template renderer, email queue, failed email store, `NotificationsDbContext`. - -### Program.cs - -```csharp -builder.Host.UseWolverine(opts => -{ - opts.Discovery.IncludeAssembly(typeof(UserRegisteredNotificationHandler).Assembly); - opts.UseRabbitMq(r => { r.HostName = "rabbitmq"; }).AutoProvision().UseConventionalRouting(); - opts.PersistMessagesWithPostgresql(connectionString); - opts.Policies.UseDurableInboxOnAllListeners(); - opts.ListenToRabbitQueue("notifications.user-registered"); - opts.ListenToRabbitQueue("notifications.user-role-changed"); - opts.ListenToRabbitQueue("notifications.invitation-created"); -}); -app.MapWolverineEndpoints(); // No MapControllers -``` - ---- - -## 8. YARP API Gateway - -### Gateway.Api/Program.cs - -```csharp -builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); -app.UseRateLimiter(); -app.MapReverseProxy(); -app.MapHealthChecks("/health"); -``` - -### Routes (appsettings.json) - -| Route Pattern | Cluster | -|---------------|---------| -| `/api/v1/products/**` | product-catalog:8080 | -| `/api/v1/categories/**` | product-catalog:8080 | -| `/api/v1/productreviews/**` | reviews:8080 | -| `/api/v1/users/**`, `/api/v1/tenants/**`, `/api/v1/tenantinvitations/**` | identity:8080 | -| `/api/v1/notifications/**` | notifications:8080 | -| `/api/v1/files/**` | file-storage:8080 | -| `/api/v1/jobs/**` | background-jobs:8080 | -| `/api/v1/webhooks/**` | webhooks:8080 | - -Gateway handles: routing, rate limiting, CORS, correlation IDs, health aggregation. No business logic. - ---- - -## 9. Database-per-Service - -| Service | Database | Special | -|---------|----------|---------| -| Product Catalog | `productcatalog_db` | + MongoDB for ProductData | -| Reviews | `reviews_db` | + `ProductProjection` read model | -| Identity | `identity_db` | + Keycloak | -| Notifications | `notifications_db` | | -| File Storage | `filestorage_db` | + filesystem/S3 | -| Background Jobs | `backgroundjobs_db` | + TickerQ tables | -| Webhooks | `webhooks_db` | | - -Each DB also contains Wolverine envelope tables (outbox/inbox) via `WolverineFx.EntityFrameworkCore`. - -Reviews service maintains `ProductProjection(ProductId, TenantId, Name, IsActive)` updated via `ProductCreatedIntegrationEvent` / `ProductDeletedIntegrationEvent` - replaces direct FK to Products. - ---- - -## 10. Error Handling & Retry - -```csharp -// Per-service Wolverine config -opts.Policies.Failures - .Handle() - .RetryWithCooldown(50.Milliseconds(), 100.Milliseconds(), 250.Milliseconds()) - .Then.MoveToErrorQueue(); - -opts.Policies.Failures - .Handle() - .RetryWithCooldown(100.Milliseconds(), 500.Milliseconds()) - .Then.MoveToErrorQueue(); - -opts.Policies.Failures - .Handle() - .ScheduleRetry(5.Seconds(), 30.Seconds(), 2.Minutes()) - .Then.MoveToErrorQueue(); -``` - -- **Idempotency**: Wolverine durable inbox (dedup by envelope ID) + existing `IIdempotencyStore` for non-naturally-idempotent ops -- **Saga timeouts**: 5-minute `SagaTimeout` message, accept partial completion -- **Dead letters**: `dlx.default` -> `dlq.default`, Wolverine `wolverine_dead_letters` table - ---- - -## 11. Docker Compose - -New services added to existing compose: - -```yaml -services: - rabbitmq: - image: rabbitmq:4-management - ports: ["5672:5672", "15672:15672"] - - postgres: # Shared instance, init.sql creates per-service databases - volumes: - - ./infrastructure/docker/init-databases.sql:/docker-entrypoint-initdb.d/init.sql - - gateway: - build: { dockerfile: src/Gateway/Gateway.Api/Dockerfile } - ports: ["8080:8080"] - - product-catalog: - build: { dockerfile: src/Services/ProductCatalog/ProductCatalog.Api/Dockerfile } - environment: - ConnectionStrings__DefaultConnection: "Host=postgres;Database=productcatalog_db;..." - RabbitMQ__HostName: rabbitmq - - reviews: - build: { dockerfile: src/Services/Reviews/Reviews.Api/Dockerfile } - identity: - build: { dockerfile: src/Services/Identity/Identity.Api/Dockerfile } - notifications: - build: { dockerfile: src/Services/Notifications/Notifications.Api/Dockerfile } - file-storage: - build: { dockerfile: src/Services/FileStorage/FileStorage.Api/Dockerfile } - background-jobs: - build: { dockerfile: src/Services/BackgroundJobs/BackgroundJobs.Api/Dockerfile } - webhooks: - build: { dockerfile: src/Services/Webhooks/Webhooks.Api/Dockerfile } - - # Existing: mongodb, keycloak, dragonfly, mailpit, alloy, prometheus, loki, tempo, grafana -``` - ---- - -## 12. Feasibility Assessment (Verified Against Docs + Codebase) - -### Required New NuGet Packages - -| Package | Purpose | Verified | -|---------|---------|----------| -| `WolverineFx.RabbitMQ` | RabbitMQ transport | Yes - `UseRabbitMq()`, `AutoProvision()`, `UseConventionalRouting()` confirmed in docs | -| `WolverineFx.Http` | Wolverine HTTP Endpoints | Yes - `[WolverineGet]`, `[WolverinePost]`, `MapWolverineEndpoints()` confirmed in docs | -| `WolverineFx.EntityFrameworkCore` | EF Core integration + saga persistence | Yes - `UseEntityFrameworkCoreTransactions()`, saga state via DbContext mapping confirmed | -| `Yarp.ReverseProxy` | API Gateway | Yes - Microsoft official package, stable | - -### Current Packages (already referenced in `Directory.Packages.props`) - -- `WolverineFx` v5.22.0 -- `WolverineFx.FluentValidation` v5.22.0 - -### Component Feasibility - -| Component | Feasibility | Verification Source | Details | -|-----------|------------|---------------------|---------| -| **A-Frame with Wolverine** | **HIGH** | Codebase + Jeremy D. Miller blog | Monolith already uses static Wolverine handlers (`CreateProductsCommandHandler`, `CacheInvalidationHandler`). Load/Handle separation is a naming/structure convention on top of existing pattern. No new library needed. | -| **Wolverine HTTP Endpoints** | **HIGH** | wolverine.netlify.app/guide/http/ | `[WolverineGet]`/`[WolverinePost]` documented with full Load/Handle pattern. ProblemDetails natively supported. `WolverineHttp` adds `AddWolverineHttp()` + `MapWolverineEndpoints()`. Pilot only (Notifications). | -| **Wolverine Sagas with EF Core** | **HIGH** | wolverine.netlify.app/guide/durability/sagas + /efcore | Saga persistence supports EF Core, PostgreSQL, SQL Server, SQLite, MySQL, RavenDb, CosmosDB, Oracle. For EF Core: DbContext must have mapping for saga state entity. `WolverineFx.EntityFrameworkCore` package. Lightweight saga storage (JSON in DB) also available via `WolverineFx.Postgresql`. | -| **RabbitMQ Transport** | **HIGH** | wolverine.netlify.app/guide/messaging/transports/rabbitmq | `UseRabbitMq(Uri)`, `UseRabbitMqUsingNamedConnection()`, `AutoProvision()`, `ListenToRabbitQueue()`, `PublishMessage().ToRabbitExchange()`. Channel config, control queues, partitioning all documented. | -| **Outbox with EF Core (no Marten)** | **HIGH** | wolverine.netlify.app/guide/durability/efcore | `WolverineFx.EntityFrameworkCore` provides outbox integrated with EF Core's `SaveChangesAsync()`. Wolverine "both calls DbContext.SaveChangesAsync() and flushes persisted messages" in same transaction. Limitation: only one database registration per outbox. | -| **Tenant Propagation via RabbitMQ** | **HIGH** | Wolverine docs (envelope mapper) | Custom `IRabbitMqEnvelopeMapper` allows header injection/extraction. TenantId via `x-tenant-id` header. | -| **YARP Gateway** | **HIGH** | Microsoft official | Config-driven reverse proxy. Production-ready, actively maintained. | -| **SharedKernel extraction** | **HIGH** | Codebase analysis | All shared code exists in monolith under well-defined paths. Extraction = move files + update project references. | -| **Database-per-service** | **MEDIUM** | Architectural pattern | Fresh Code-First migrations per service. Reviews FK->Products replaced by `ProductProjection` read model (eventual consistency). Risk: data migration scripts needed for existing data. | -| **UnitOfWork + Wolverine outbox** | **MEDIUM** | Codebase + Wolverine docs | Current `UnitOfWork` uses `DbContext.Database.BeginTransactionAsync()`. Wolverine's EF Core middleware manages its own transaction. Rule: handlers publishing events use Wolverine transaction; internal-only handlers use custom UnitOfWork. No conflict if separated. | -| **TickerQ per-service** | **MEDIUM** | Codebase (TickerQ v10.2.2) | Each service needing schedules embeds own TickerQ instance. Background Jobs service centralizes cross-cutting jobs only. | -| **GraphQL removal** | **HIGH** | Greenfield project | Remove HotChocolate 15.1 packages + delete `Api/GraphQL/` folder. No legacy clients to break. | - -### Potential Blockers - -| Blocker | Severity | Resolution | -|---------|----------|------------| -| Wolverine outbox supports only 1 DB registration | LOW | Each microservice has exactly 1 database - not an issue | -| Saga state serialization for complex types (`IReadOnlyList`) | LOW | Wolverine uses System.Text.Json for lightweight saga storage - works with standard types | -| `CritterStackDefaults` with `TypeLoadMode.Static` requires code generation | LOW | Pre-generate Wolverine code per service at build time (`dotnet build` generates) | -| Current `MessageBusExtensions.PublishSafeAsync` swallows errors | LOW | Replace with outbox pattern - errors handled by retry/dead-letter, not swallowed | - ---- - -## 13. Risk Analysis - -| Risk | Impact | Mitigation | -|------|--------|------------| -| Data inconsistency during saga | HIGH | Wolverine durable outbox (at-least-once) + idempotent handlers + saga timeouts | -| Operational complexity increase | HIGH | Shared observability (OpenTelemetry + Grafana already configured), Docker Compose for dev | -| Reviews needs Product data | MEDIUM | `ProductProjection` read model updated via integration events. Eventual consistency acceptable. | -| Two transaction managers | MEDIUM | Clear rule: outbox-publishing handlers use Wolverine transaction, internal handlers use UnitOfWork | -| Migration effort | HIGH | Phased approach - Notifications first validates patterns before committing all services | -| Network partitions | MEDIUM | Durable outbox persists locally, delivered when RabbitMQ recovers | - ---- - -## 14. Implementation Phases - -### Phase 1: Foundation -1. Create solution structure with all project shells -2. Extract SharedKernel from monolith -3. Create Contracts.IntegrationEvents -4. Set up Gateway.Api with YARP - -### Phase 2: Notifications Service (A-Frame Pilot) -5. Create Notifications service with Wolverine HTTP Endpoints -6. Migrate email infrastructure (MailKit, Fluid, queue, templates) -7. Configure RabbitMQ transport + outbox -8. E2E test: Identity -> RabbitMQ -> Notifications -> email sent - -### Phase 3: Identity & Tenancy -9. Create Identity service with own DbContext -10. Migrate Keycloak integration -11. Implement TenantDeactivation saga -12. Publish integration events for user/tenant lifecycle - -### Phase 4: File Storage + Webhooks -13. Create File Storage service (simple CRUD) -14. Create Webhooks service (HMAC validation, delivery) - -### Phase 5: Reviews -15. Create Reviews service with own DbContext -16. Add ProductProjection read model -17. Handle cascade via ProductDeletedIntegrationEvent - -### Phase 6: Product Catalog -18. Create Product Catalog service (core domain, last extracted) -19. Own DbContext + MongoDB connection -20. Implement ProductDeletion saga - -### Phase 7: Background Jobs -21. Create Background Jobs service with TickerQ -22. Centralized scheduling for cross-service jobs - -### Phase 8: Cleanup -23. Remove old monolith projects -24. Remove GraphQL (HotChocolate) -25. E2E integration tests through YARP gateway -26. Load testing - ---- - -## 15. Key Files to Modify/Migrate - -| Source (Monolith) | Target | -|-------------------|--------| -| `src/APITemplate.Domain/Entities/Contracts/` | SharedKernel.Domain | -| `src/APITemplate.Application/Common/Middleware/ErrorOrValidationMiddleware.cs` | SharedKernel.Application | -| `src/APITemplate.Application/Common/Events/EmailEvents.cs` | Contracts.IntegrationEvents (expanded) | -| `src/APITemplate.Application/Common/Events/SoftDeleteEvents.cs` | Contracts.IntegrationEvents (expanded) | -| `src/APITemplate.Infrastructure/Persistence/AppDbContext.cs` | Decomposed into per-service DbContexts inheriting SharedDbContext | -| `src/APITemplate.Infrastructure/Persistence/SoftDelete/ProductSoftDeleteCascadeRule.cs` | Replaced by ProductDeletionSaga | -| `src/APITemplate.Infrastructure/Persistence/SoftDelete/TenantSoftDeleteCascadeRule.cs` | Replaced by TenantDeactivationSaga | -| `src/APITemplate.Infrastructure/Repositories/RepositoryBase.cs` | SharedKernel.Infrastructure | -| `src/APITemplate.Infrastructure/Persistence/UnitOfWork/` | SharedKernel.Infrastructure | -| `src/APITemplate.Infrastructure/Persistence/Auditing/` | SharedKernel.Infrastructure | -| `src/APITemplate.Infrastructure/Email/` | Notifications.Infrastructure | -| `src/APITemplate.Api/Api/Controllers/V1/` | Per-service Api projects | -| `src/APITemplate.Api/Api/GraphQL/` | **DELETED** | -| `src/APITemplate.Api/Program.cs` | Per-service Program.cs + Gateway Program.cs | - ---- - -## 16. Verification Plan - -1. **Unit tests**: A-Frame Handle methods are pure functions -> test with simple assertions -2. **Integration tests per service**: `WebApplicationFactory` + Testcontainers (PostgreSQL + RabbitMQ) -3. **Cross-service E2E**: Docker Compose up all services + Gateway, run Saga scenarios -4. **Specific scenarios to verify**: - - Product deletion -> Reviews cascade via saga -> confirmation - - Tenant deactivation -> all services cascade -> saga completion - - User registration -> Notifications receives event -> email sent - - YARP routes all endpoints correctly - - Tenant propagation via RabbitMQ headers - - Outbox delivery after RabbitMQ recovery - - Dead letter handling on repeated failures -5. **Build**: `dotnet build` all projects, ensure no cross-service references -6. **Existing tests**: Adapt to per-service structure, ensure parity diff --git a/docs/monolith-migration-gaps.md b/docs/monolith-migration-gaps.md deleted file mode 100644 index 6f63ba35..00000000 --- a/docs/monolith-migration-gaps.md +++ /dev/null @@ -1,232 +0,0 @@ -# Monolith -> Microservices Migration Gaps - -What the old monolith (APITemplate.*) had that microservices still need, including how each feature was implemented. - -**Legend:** ❌ MISSING — ⚠️ PARTIAL — ✅ IMPLEMENTED - -## Status Overview - -| # | Gap | Status | -|---|-----|--------| -| 1 | CORS | ❌ MISSING | -| 2 | Rate Limiting | ❌ MISSING | -| 3 | CSRF Validation | ⚠️ PARTIAL — constants defined, middleware absent | -| 4 | Request Context Middleware | ❌ MISSING | -| 5 | Serilog Request Logging Pipeline | ⚠️ PARTIAL — basic `UseSharedSerilog`, no full pipeline | -| 6 | Keycloak Readiness Check | ❌ MISSING | -| 7 | Distributed BFF Session Persistence | ❌ MISSING | -| 8 | GraphQL | ❌ MISSING | -| 9 | Observability / Telemetry | ⚠️ PARTIAL — generic OTEL only, no domain telemetry | -| 10 | Inbound Webhook Processing | ⚠️ PARTIAL — signer only, no validator/filter/queue | -| 11 | SSE Streaming Endpoint | ❌ MISSING | -| 12 | JSON Patch Endpoint | ❌ MISSING | -| 13 | Idempotent Endpoint demo | ⚠️ PARTIAL — infra in SharedKernel, no service uses `[Idempotent]` | -| 14 | Email Retry Background Job | ⚠️ PARTIAL — `FailedEmail` entity/repo exists, no retry job/service | -| 15 | Auth Bootstrap Seeder | ⚠️ PARTIAL — `BootstrapTenantOptions` exists, no seeder implementation | -| 16 | Startup Task Coordinator | ❌ MISSING | -| 17 | SoftDeleteCascadeRule Implementations | ⚠️ PARTIAL — interface in SharedKernel, no implementations | -| 18 | Entity Normalization Service | ⚠️ PARTIAL — interface in SharedKernel, no implementation | -| 19 | MongoDB Migration Support | ❌ MISSING | -| 20 | MongoDB Health Check | ❌ MISSING | -| 21 | Log Redaction / PII Classification | ❌ MISSING | -| 22 | External Integration Sync Job | ❌ MISSING | -| 23 | BackgroundJobs Options Validator | ⚠️ PARTIAL — `[ValidateObjectMembers]` annotations, no `IValidateOptions` impl | -| 24 | Expired Invitation Cleanup | ❌ MISSING | -| 25 | Cross-Service Soft-Delete Purge | ❌ MISSING | -| 26 | Orphaned MongoDB ProductData Cleanup | ❌ MISSING | - ---- - -## High Impact - Core Cross-Cutting Concerns - -### 1. CORS — ❌ MISSING -- No CORS config in any microservice or Gateway -- **Recommendation:** Configure at Gateway level (YARP) -- **How it was implemented:** - Default CORS policy in `AuthenticationServiceCollectionExtensions.AddAuthenticationOptions()`. Options class `CorsOptions` with `string[] AllowedOrigins` bound from `Cors:AllowedOrigins`. Policy: `WithOrigins(...).AllowAnyHeader().AllowAnyMethod().AllowCredentials()`. Applied via `app.UseCors()` inside `UseSecurityPipeline()`. - -### 2. Rate Limiting — ❌ MISSING -- No rate limiting in any microservice -- **Recommendation:** Add to Gateway or per-service via SharedKernel -- **How it was implemented:** - Fixed-window per-client rate limiter in `ApiServiceCollectionExtensions.AddRateLimiting()`. Options class `RateLimitingOptions` (`PermitLimit=100`, `WindowMinutes=1`) bound from `RateLimiting:Fixed`. Partition key: JWT `Identity.Name` > `RemoteIpAddress` > `"anonymous"`. Used `IConfigureOptions` for deferred resolution. Recorded rejections via `ApiMetrics.RecordRateLimitRejection()`. Applied with `app.UseRateLimiter()` and `.RequireRateLimiting()` on `MapControllers()`/`MapGraphQL()`. - -### 3. CSRF Validation — ⚠️ PARTIAL -- `AuthConstants.Csrf` constants (`X-CSRF` header name/value) are defined in Identity service, but `CsrfValidationMiddleware` is not implemented -- **Recommendation:** Add middleware to Identity.Api pipeline -- **How it was implemented:** - Custom `CsrfValidationMiddleware` (primary constructor with `RequestDelegate` + `IProblemDetailsService`). Skipped safe methods (GET/HEAD/OPTIONS) and Bearer-authenticated requests. For cookie-authenticated mutations, required `X-CSRF: 1` header (constants in `AuthConstants.Csrf.HeaderName`/`HeaderValue`). Returned RFC 7807 ProblemDetails 403 on failure. Ran after `UseAuthentication()`, before `UseAuthorization()`. - -### 4. Request Context Middleware — ❌ MISSING -- Not present in SharedKernel pipeline or any service -- **Recommendation:** Add to SharedKernel.Api pipeline -- **How it was implemented:** - `RequestContextMiddleware` resolved correlation ID from `X-Correlation-Id` header (fallback: `TraceIdentifier`). Emitted `X-Correlation-Id`, `X-Trace-Id`, `X-Elapsed-Ms` response headers. Enriched Serilog via `LogContext.PushProperty` (CorrelationId, TenantId). Tagged `IHttpMetricsTagsFeature` with `api.surface` and `authenticated`. Constants in `RequestContextConstants`. - -### 5. Serilog Request Logging Pipeline — ⚠️ PARTIAL -- `SharedKernel.Api/Extensions/SerilogExtensions.cs` provides basic `UseSharedSerilog()` used by all services, but the full request-level pipeline is absent -- Missing: `UseSerilogRequestLogging()` with custom level logic, `ActivityTraceEnricher`, `AddApplicationRedaction()` -- **Recommendation:** Enhance SharedKernel Serilog setup -- **How it was implemented:** - `UseRequestContextPipeline()` chained `UseMiddleware()` then `UseSerilogRequestLogging()`. Custom `GetLevel`: client-aborted = Info, 500+ = Error, 400+ = Warning, else Info. Enriched `DiagnosticContext` with `RequestHost`/`RequestScheme`. Also used `ActivityTraceEnricher` for trace/span ID enrichment and `AddApplicationRedaction()` for PII masking (HMAC for sensitive data, erasing for personal). Serilog sinks included `Serilog.Sinks.OpenTelemetry` with gRPC protocol. - -### 6. Keycloak Readiness Check — ❌ MISSING -- No microservice waits for Keycloak at startup -- **Recommendation:** Add to services that depend on Keycloak (Identity, any with JWT) -- **How it was implemented:** - `WaitForKeycloakAsync()` extension on `WebApplication`. Read `KeycloakOptions` (AuthServerUrl, Realm, SkipReadinessCheck). Built OIDC discovery URL via `KeycloakUrlHelper.BuildDiscoveryUrl()`. Used Polly `ResiliencePipeline` keyed `ResiliencePipelineKeys.KeycloakReadiness` for retry. HTTP GET to discovery endpoint with 5s timeout. Threw `InvalidOperationException` after `KeycloakOptions.ReadinessMaxRetries` retries. Wrapped in `StartupTelemetry.WaitForKeycloakReadinessAsync()`. - -### 7. Distributed BFF Session Persistence — ❌ MISSING -- Identity has BFF cookie auth but no distributed session store — cookie tickets are in-memory only, lost on pod restart -- **Recommendation:** Add to Identity.Api -- **How it was implemented:** - **ValkeyTicketStore** (was DragonflyTicketStore): implemented `ITicketStore`. Stored ASP.NET Core `AuthenticationTicket` in `IDistributedCache` (Redis/Valkey). Key prefix: `"bff:ticket:"` + GUID. Encrypted ticket bytes via `IDataProtector` (purpose: `"bff:ticket"`). TTL from `BffOptions.SessionTimeoutMinutes`. Handled `CryptographicException` gracefully on retrieve. - **CookieSessionRefresher**: static class with `OnValidatePrincipal` callback for `CookieAuthenticationOptions.Events`. Checked token expiry against `BffOptions.TokenRefreshThresholdMinutes`. Sent refresh_token grant to Keycloak token endpoint via `IHttpClientFactory` (client: `AuthConstants.HttpClients.KeycloakToken`). Updated cookie tokens on success, rejected principal on failure. Recorded telemetry via `AuthTelemetry`. - ---- - -## Medium Impact - Feature Gaps - -### 8. GraphQL — ❌ MISSING -- Not present in any microservice -- **Recommendation:** If needed, add to ProductCatalog.Api or a dedicated GraphQL gateway -- **How it was implemented:** - HotChocolate `AddGraphQLServer()`. Schema: `AddQueryType()` with extensions `CategoryQueries`, `ProductReviewQueries`. Mutations: `AddMutationType()` with extension `ProductReviewMutations`. Custom types: `ProductType`, `ProductReviewType`. DataLoaders: `ProductReviewsByProductDataLoader`. Instrumentation: `GraphQlExecutionMetricsListener` (diagnostic event listener). Pagination: `MaxPageSize` from `PaginationFilter.MaxPageSize`, `IncludeTotalCount=true`. Security: `AddAuthorization()`. Depth limit: `AddMaxExecutionDepthRule(5)`. Mapped via `app.MapGraphQL()` with rate limiting + `MapNitroApp("/graphql/ui")`. - -### 9. Observability / Telemetry — ⚠️ PARTIAL -- `SharedKernel.Api/Extensions/ObservabilityExtensions.cs` provides generic OTEL setup (tracing, metrics, OTLP export) -- Missing: `ApiMetrics`, `AuthTelemetry`, `CacheTelemetry`, `ConflictTelemetry`, `ValidationTelemetry`, `StoredProcedureTelemetry`, `StartupTelemetry`, `HealthCheckMetricsPublisher`, `ObservabilityConventions` -- **Recommendation:** Move shared telemetry to SharedKernel, service-specific to each service -- **How it was implemented:** - Core: `ObservabilityConventions` (ActivitySourceName/MeterName = `"APITemplate"`), `ApiMetrics` (static `Meter` + counters for rate-limit rejections, handled exceptions). Domain-specific: `AuthTelemetry`, `CacheTelemetry`, `ConflictTelemetry`, `GraphQlTelemetry`, `ValidationTelemetry`, `StoredProcedureTelemetry`, `StartupTelemetry` — each with static methods wrapping `ActivitySource.StartActivity()`. Support: `HttpRouteResolver` (display-friendly route names), `TelemetryApiSurfaceResolver` (REST vs GraphQL classification), `HealthCheckMetricsPublisher` (health check results as metrics). Registration in `AddObservability()`: OpenTelemetry tracing (ASP.NET Core, HttpClient, HotChocolate, Redis, Npgsql, MongoDB) + metrics (runtime, process, custom histograms). Config section: `"Observability"` with `ObservabilityOptions` (ServiceName, Exporters.Aspire/Otlp/Console). Auto-detected Aspire in dev, OTLP in containers. - -### 10. Inbound Webhook Processing — ⚠️ PARTIAL -- `HmacWebhookPayloadSigner` exists in Webhooks service (outbound signing only) -- Missing: `HmacWebhookPayloadValidator`, `WebhookSignatureResourceFilter`, `ValidateWebhookSignatureAttribute`, `IWebhookProcessingQueue`, `WebhookProcessingBackgroundService`, `LoggingWebhookEventHandler` -- **Recommendation:** Add inbound processing to Webhooks service -- **How it was implemented:** - Full chain: - - **Validator**: `HmacWebhookPayloadValidator` (`IWebhookPayloadValidator`) — HMAC-SHA256 signature + timestamp header validation. - - **Filter**: `WebhookSignatureResourceFilter` (`IAsyncResourceFilter`) — read raw body with `EnableBuffering()`, checked for `[ValidateWebhookSignature]` attribute, threw `UnauthorizedException` on failure. - - **Queue**: `ChannelWebhookQueue` (Channel-based `IWebhookProcessingQueue`) + `WebhookProcessingBackgroundService` consumer. - - **Handler**: `LoggingWebhookEventHandler` (`IWebhookEventHandler`). - - Config: `WebhookOptions` section. Headers: `WebhookConstants.SignatureHeader`, `WebhookConstants.TimestampHeader`. Controller: `[AllowAnonymous]`, 1MB request limit. - -### 11. SSE Streaming Endpoint — ❌ MISSING -- Not in any microservice -- **Recommendation:** Add to Notifications service or a dedicated Realtime service -- **How it was implemented:** - `SseController` with `[HttpGet("stream")]`. Set headers: `text/event-stream`, `no-cache`, `keep-alive`. Sent `GetNotificationStreamQuery` via Wolverine `IMessageBus.InvokeAsync>()`. Wrote `data: \n\n` frames via `StreamWriter` on `Response.Body`, flushing after each item. Took `SseStreamRequest` from query params. Required `Permission.Examples.Read`. - -### 12. JSON Patch Endpoint — ❌ MISSING -- Not in any microservice -- **Recommendation:** Add to ProductCatalog if needed -- **How it was implemented:** - `PatchController` with `[HttpPatch("products/{id:guid}")]`. Used `SystemTextJsonPatch` library (not Newtonsoft). Received `JsonPatchDocument`. Sent `PatchProductCommand(id, dto => patchDocument.ApplyTo(dto))` — command carried an `Action` apply-delegate so the application layer controlled mutation. Returned `ErrorOr` via `ToActionResult()`. Required `Permission.Examples.Update`. - -### 13. Idempotent Endpoint — ⚠️ PARTIAL -- `IdempotentAttribute` and `IdempotencyActionFilter` exist in SharedKernel.Api, `DistributedCacheIdempotencyStore` and `InMemoryIdempotencyStore` exist in SharedKernel.Infrastructure -- No controller in any service currently uses `[Idempotent]` -- **Recommendation:** Apply `[Idempotent]` attribute to appropriate POST endpoints (e.g., payment, order creation) - -### 14. Email Retry Background Job — ⚠️ PARTIAL -- `FailedEmail` entity, `IFailedEmailRepository`, `FailedEmailRepository`, `FailedEmailErrorNormalizer` exist in Notifications service -- Missing: `EmailRetryRecurringJob`, `IEmailRetryService`, `EmailRetryService`, stored procedures (`ClaimExpiredFailedEmailsProcedure`, `ClaimRetryableFailedEmailsProcedure`), Polly `SmtpSend` pipeline -- **Recommendation:** Add to Notifications service (TickerQ job) or BackgroundJobs service -- **How it was implemented:** - TickerQ recurring job: `EmailRetryRecurringJob` with `[TickerFunction(TickerQFunctionNames.EmailRetry)]`. Gated by `IDistributedJobCoordinator.ExecuteIfLeaderAsync()` for multi-node safety. Delegated to `EmailRetryService` (`IEmailRetryService`). Optimistic per-record claiming: `ClaimRetryableBatchAsync()` with owner = `"{MachineName}:{ProcessId}"` and lease timeout. Per-email commit for crash safety. Dead-lettered via `ClaimExpiredBatchAsync()`. Config: `BackgroundJobsOptions.EmailRetry` with `EmailRetryJobOptions` (Cron=`"*/15 * * * *"`, MaxRetryAttempts=5, BatchSize=50, DeadLetterAfterHours=48, ClaimLeaseMinutes=10). Used Polly `SmtpSend` pipeline for delivery retry. Stored procedures: `ClaimExpiredFailedEmailsProcedure`, `ClaimRetryableFailedEmailsProcedure`. - -### 15. Auth Bootstrap Seeder — ⚠️ PARTIAL -- `BootstrapTenantOptions` class exists and is bound in Identity.Api `Program.cs` -- Missing: `AuthBootstrapSeeder` implementation — no tenant is seeded on startup -- **Recommendation:** Add to Identity.Api startup -- **How it was implemented:** - `AuthBootstrapSeeder` with deps: `AppDbContext`, `IOptions`. Seeded default tenant (hardcoded ID `00000000-0000-0000-0000-000000000001`). Config: `Bootstrap:Tenant` section with `Code` and `Name`. Used `IgnoreQueryFilters(["SoftDelete", "Tenant"])` to find existing. Restored soft-deleted/deactivated tenants. Only called `SaveChangesAsync` if changes made. Called during startup in `UseDatabaseAsync()` wrapped by `StartupTelemetry.RunAuthBootstrapSeedAsync()`. - -### 16. Startup Task Coordinator — ❌ MISSING -- Prevents concurrent migrations in multi-instance deployments; not present in any service -- **Recommendation:** Add to SharedKernel.Infrastructure -- **How it was implemented:** - `PostgresAdvisoryLockStartupTaskCoordinator` (`IStartupTaskCoordinator`). Used PostgreSQL `pg_advisory_lock(@lockKey)` / `pg_advisory_unlock(@lockKey)` on a dedicated `NpgsqlConnection`. `StartupTaskName` enum values as stable lock keys. Returned `IAsyncDisposable` lease (`PostgresAdvisoryLockLease`). Fell back to `NoOpAsyncDisposable` for non-Npgsql providers. Connection string from `DbContext.Database.GetConnectionString()`. - ---- - -## Lower Impact - Completeness - -### 17. SoftDeleteCascadeRule Implementations — ⚠️ PARTIAL -- `ISoftDeleteCascadeRule` and `ISoftDeleteProcessor` / `SoftDeleteProcessor` exist in SharedKernel.Infrastructure and are wired into all DbContexts (ProductCatalog, Identity, Reviews, FileStorage) -- Missing: concrete implementations — no `ProductSoftDeleteCascadeRule`, `TenantSoftDeleteCascadeRule` in any service -- **Recommendation:** Implement per-service as needed (e.g., cascade-soft-delete ProductData/Reviews when Product is soft-deleted) - -### 18. Entity Normalization Service — ⚠️ PARTIAL -- `IEntityNormalizationService` exists in SharedKernel.Infrastructure and is invoked by `TenantAuditableDbContext` -- Missing: any implementation — `AppUserEntityNormalizationService` not present in Identity service -- **Recommendation:** Add to Identity.Infrastructure -- **How it was implemented:** - `AppUserEntityNormalizationService` (`IEntityNormalizationService`). Single method `Normalize(IAuditableTenantEntity entity)` — type-checked for `AppUser`, then set `NormalizedUsername = AppUser.NormalizeUsername(user.Username)` and `NormalizedEmail = AppUser.NormalizeEmail(user.Email)`. Normalization methods were static on the `AppUser` domain entity. - -### 19. MongoDB Migration Support — ❌ MISSING -- ProductCatalog has `MongoDbContext` but no migration runner -- **Recommendation:** Add to ProductCatalog.Api startup -- **How it was implemented:** - Used `Kot.MongoDB.Migrations` package. Called `IMigrator.MigrateAsync()` at startup in `UseDatabaseAsync()`. - -### 20. MongoDB Health Check — ❌ MISSING -- ProductCatalog registers generic `AddHealthChecks()` but no MongoDB-specific check -- **Recommendation:** Add to ProductCatalog.Api -- **How it was implemented:** - Custom `MongoDbHealthCheck` registered via `services.AddHealthChecks().AddCheck()`. - -### 21. Log Redaction / PII Classification — ❌ MISSING -- **Recommendation:** Add to SharedKernel when compliance required -- **How it was implemented:** - `Microsoft.Extensions.Compliance.Redaction` with `AddApplicationRedaction()` in `Program.cs`. `LogDataClassifications` defined taxonomies (Sensitive, Personal). Configured HMAC redaction for sensitive data, erasing for personal data. - -### 22. External Integration Sync Job — ❌ MISSING -- `IExternalIntegrationSyncService` with recurring job not implemented -- **Recommendation:** Add when needed - -### 23. BackgroundJobs Options Validator — ⚠️ PARTIAL -- `BackgroundJobsOptions` uses `[ValidateObjectMembers]` data annotations for nested validation -- Missing: explicit `BackgroundJobsOptionsValidator : IValidateOptions` — no startup-time validation failure with descriptive messages -- **Recommendation:** Add to BackgroundJobs service -- **How it was implemented:** - `BackgroundJobsOptionsValidator` — startup validation of job config using `IValidateOptions`. - -### 24. Expired Invitation Cleanup — ❌ MISSING -- `ICleanupService` in BackgroundJobs has no `CleanupExpiredInvitationsAsync` method -- Expired pending invitations accumulate indefinitely in the Identity database -- **Recommendation:** Add to Identity service (own cleanup job) or extend BackgroundJobs via a cross-service event/API call -- **How it was implemented:** - Monolith `CleanupService.CleanupExpiredInvitationsAsync(int retentionHours, int batchSize)`. Queried `TenantInvitations` with `IgnoreQueryFilters()` filtering `Status == InvitationStatus.Pending && ExpiresAtUtc < cutoff`. Used `ExecuteDeleteAsync()` in a batch loop until fewer than `batchSize` rows deleted. Config: `CleanupJobOptions.ExpiredInvitationRetentionHours`. Called by `CleanupRecurringJob` alongside soft-delete and orphan cleanup. - -### 25. Cross-Service Soft-Delete Purge — ❌ MISSING -- Microservices `BackgroundJobs.CleanupService.CleanupSoftDeletedRecordsAsync` only purges its own `JobExecution` records -- Soft-deleted entities in all other services (Products, Categories, Users, Tenants, Reviews, Files, StoredFiles) are **never physically deleted** -- **Recommendation:** Add `ISoftDeleteCleanupStrategy` + `SoftDeleteCleanupStrategy` to each relevant service and expose a cleanup endpoint or internal job -- **How it was implemented:** - Monolith: `ISoftDeleteCleanupStrategy` interface (`EntityName`, `CleanupAsync(DateTime cutoff, int batchSize, CancellationToken ct)`). Generic implementation `SoftDeleteCleanupStrategy` used `EF Core ExecuteDeleteAsync` with `IgnoreQueryFilters()` on `ISoftDeletable` entities. Registered per entity type via DI (`services.AddScoped>()`). `CleanupService` received `IEnumerable` and iterated all strategies. Monolith cleaned: `Product`, `Category`, `AppUser`, `Tenant`, `TenantInvitation`, `StoredFile`, `ProductReview`. - -### 26. Orphaned MongoDB ProductData Cleanup — ❌ MISSING -- No safety-net cleanup exists for MongoDB `ProductData` documents that lose their PostgreSQL `ProductDataLink` -- Can occur after transaction failures, cascade bugs, or manual DB edits -- **Recommendation:** Add to ProductCatalog service as a periodic maintenance job -- **How it was implemented:** - Monolith `CleanupService.CleanupOrphanedProductDataAsync(int retentionDays, int batchSize)`. Paginated through MongoDB `ProductData` collection (cursor via `lastSeenId`), filtered `CreatedAt < cutoff`. For each page, fetched linked IDs from `ProductDataLinks` in PostgreSQL (with `IgnoreQueryFilters()`). Computed orphan IDs as set difference. Deleted orphans via `MongoDB.DeleteManyAsync(Builders.Filter.In(...))`. Config: `CleanupJobOptions.OrphanRetentionDays`. - ---- - -## Testing Gaps - -### Missing test categories in microservices: -- **Auth integration tests** - no service tests auth flows end-to-end -- **Postgres-specific tests** - search, tenant isolation, cascade, transactions -- **GraphQL tests** - follows from feature gap -- **SSE/Patch/Idempotent/Webhook-receive tests** - follow from feature gaps -- **Infrastructure tests** - CORS, rate limiting behavior - -### Testing infrastructure unique to monolith: -- Alba-based integration fixtures (microservices use WebApplicationFactory) -- InMemoryProductRepository for unit tests -- WebhookTestHelper -- TestOutputCacheStore diff --git a/docs/testing.md b/docs/testing.md index 05ceb3f5..73e16eb0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,14 +10,6 @@ This guide explains how to add unit tests and integration tests for this project **PostgreSQL integration:** Testcontainers.PostgreSql **Database reset:** Respawn (resets PostgreSQL to a clean state between tests without re-creating the schema) -### Assertions - -Use **Shouldly** for all test assertions (`ShouldBe`, `ShouldNotBeNull`, `ShouldContain`, and so on). Do not add **FluentAssertions** or another general-purpose assertion library unless the team explicitly agrees; mixing styles makes failures harder to read and reviews noisier. - -Shared JSON options and HTTP response helpers used by multiple test projects live in **`tests/Tests.Common`** (`TestJsonOptions`, `HttpResponseAssertionExtensions.ShouldHaveStatusAsync`, etc.). - -For APIs that accept `CancellationToken`, prefer **`TestContext.Current.CancellationToken`** in test code so cancellation is responsive (xUnit analyzer **xUnit1051**). - Run all tests: ```bash diff --git a/infrastructure/docker/init-microservices-databases.sql b/infrastructure/docker/init-microservices-databases.sql deleted file mode 100644 index 3779b591..00000000 --- a/infrastructure/docker/init-microservices-databases.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Create per-service databases for microservices architecture -CREATE DATABASE productcatalog_db; -CREATE DATABASE reviews_db; -CREATE DATABASE identity_db; -CREATE DATABASE notifications_db; -CREATE DATABASE filestorage_db; -CREATE DATABASE backgroundjobs_db; -CREATE DATABASE webhooks_db; diff --git a/infrastructure/keycloak/realms/api-template-realm.json b/infrastructure/keycloak/realms/api-template-realm.json index ab134770..419a9422 100644 --- a/infrastructure/keycloak/realms/api-template-realm.json +++ b/infrastructure/keycloak/realms/api-template-realm.json @@ -12,7 +12,7 @@ "duplicateEmailsAllowed": false, "resetPasswordAllowed": false, "editUsernameAllowed": false, - "passwordPolicy": "length(4)", + "passwordPolicy": "length(8) and upperCase(1) and digits(1) and forceExpiredPasswordChange(365)", "bruteForceProtected": true, "failureFactor": 5, "waitIncrementSeconds": 60, @@ -168,7 +168,7 @@ "credentials": [ { "type": "password", - "value": "admin", + "value": "Admin123", "temporary": false } ], diff --git a/monolith/API-Template-mono/.dockerignore b/monolith/API-Template-mono/.dockerignore deleted file mode 100644 index 1ed9ea04..00000000 --- a/monolith/API-Template-mono/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -**/bin -**/obj -**/.vs -**/node_modules -**/.idea -*.user -*.suo diff --git a/monolith/API-Template-mono/.gitignore b/monolith/API-Template-mono/.gitignore deleted file mode 100644 index 4f9a26b4..00000000 --- a/monolith/API-Template-mono/.gitignore +++ /dev/null @@ -1,66 +0,0 @@ -# Build outputs -bin/ -obj/ -out/ - -# Visual Studio -.vs/ -*.user -*.suo -*.userosscache -*.sln.docstates - -# Rider -.idea/ -*.sln.iml - -# NuGet -*.nupkg -*.snupkg -packages/ -project.lock.json -project.fragment.lock.json -artifacts/ - -# .NET -*.orig - -# Agent files -.claude/**/*local* -.cursor/** -docs/superpowers/** -**/obj-codex/** -CLAUDE.md -AGENTS.md - -# Logs -*.log -logs/ - -# OS -.DS_Store -Thumbs.db - - -# Environment / secrets -.env -.env.local -*.env -appsettings.Development.json - -# Docker -docker-compose.override.yml - -# Test results -TestResults/ -coverage/ -*.trx -*.coveragexml - -# Published output -publish/ - - -# Git worktrees -.worktrees/ - diff --git a/monolith/API-Template-mono/APITemplate.slnx b/monolith/API-Template-mono/APITemplate.slnx deleted file mode 100644 index 7df42cb4..00000000 --- a/monolith/API-Template-mono/APITemplate.slnx +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/monolith/API-Template-mono/Directory.Build.targets b/monolith/API-Template-mono/Directory.Build.targets deleted file mode 100644 index 5a161a05..00000000 --- a/monolith/API-Template-mono/Directory.Build.targets +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/monolith/API-Template-mono/Directory.Packages.props b/monolith/API-Template-mono/Directory.Packages.props deleted file mode 100644 index e5a26a58..00000000 --- a/monolith/API-Template-mono/Directory.Packages.props +++ /dev/null @@ -1,82 +0,0 @@ - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/monolith/API-Template-mono/LICENSE b/monolith/API-Template-mono/LICENSE deleted file mode 100644 index f288702d..00000000 --- a/monolith/API-Template-mono/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/monolith/API-Template-mono/README.md b/monolith/API-Template-mono/README.md deleted file mode 100644 index 3b94c8d6..00000000 --- a/monolith/API-Template-mono/README.md +++ /dev/null @@ -1,1279 +0,0 @@ -# APITemplate - -[![PR Validation](https://github.com/zribktad/API-Template/actions/workflows/pr-validation.yml/badge.svg)](https://github.com/zribktad/API-Template/actions/workflows/pr-validation.yml) - -A scalable, clean, and modern template designed to jumpstart **.NET 10** Web API and Data-Driven applications. By providing a curated set of industry-standard libraries and combining modern **REST** APIs side-by-side with a robust **GraphQL** backend, it bridges the gap between typical monolithic development speed and Clean Architecture principles within a single maintainable repository. - -## 📚 How-To Guides - -Step-by-step guides for the most common workflows in this project: - -| Guide | Description | -| ---------------------------------------------------- | --------------------------------------------------------------------------- | -| [GraphQL Endpoint](docs/graphql-endpoint.md) | Add a type, query, mutation, and optional DataLoader | -| [REST Endpoint](docs/rest-endpoint.md) | Full workflow: entity → DTO → validator → Wolverine handler → controller | -| [EF Core Migration](docs/ef-migration.md) | Create and apply PostgreSQL schema migrations | -| [MongoDB Migration](docs/mongodb-migration.md) | Create index and data migrations with Kot.MongoDB.Migrations | -| [Transactions](docs/transactions.md) | Wrap multiple operations in an atomic Unit of Work transaction | -| [Authentication](docs/AUTHENTICATION.md) | JWT login flow, protecting endpoints, and production guidance | -| [Stored Procedures](docs/stored-procedures.md) | Add a PostgreSQL function and call it safely from C# | -| [MongoDB Polymorphism](docs/mongodb-polymorphism.md) | Store multiple document subtypes in one collection | -| [Validation](docs/validation.md) | Add FluentValidation rules, cross-field rules, and shared validators | -| [Specifications](docs/specifications.md) | Write reusable EF Core query specifications with Ardalis | -| [Scalar & GraphQL UI](docs/scalar-and-graphql-ui.md) | Use the Scalar REST explorer and Nitro GraphQL playground | -| [Testing](docs/testing.md) | Write unit tests (services, validators, repositories) and integration tests | -| [Observability](docs/observability.md) | Run OpenTelemetry locally with Aspire Dashboard or Grafana LGTM | -| [Caching](docs/CACHING.md) | Configure output caching, rate limiting, and DragonFly backing store | -| [Result Pattern](docs/result-pattern.md) | Guidelines for introducing selective `Result` flow in phase 2 | -| [Git Hooks](docs/GIT_HOOKS.md) | Auto-install Husky.Net hooks and format staged C# files with CSharpier | - ---- - -## 🚀 Key Features - -* **Architecture Pattern:** Clean mapping of concerns inside a monolithic solution (emulating Clean Architecture). `Domain` rules and interfaces are isolated from `Application` logic and `Infrastructure`. -* **Dual API Modalities:** - * **REST API:** Clean HTTP endpoints using versioned controllers (`Asp.Versioning.Mvc`). - * **GraphQL API:** Complex query batching via `HotChocolate`, integrated Mutations and DataLoaders to eliminate the N+1 problem. -* **Modern Interactive Documentation:** Native `.NET 10` OpenAPI integrations displayed smoothly in the browser using **Scalar** `/scalar`. Includes **Nitro UI** `/graphql/ui` for testing queries natively. -* **Dual Database Architecture:** - * **PostgreSQL + EF Core 10:** Relational entities (Products, Categories, Reviews, Tenants, Users) with the Repository + Unit of Work pattern. - * **MongoDB:** Semi-structured media metadata (ProductData) with a polymorphic document model and BSON discriminators. -* **Multi-Tenancy:** Every relational entity implements `IAuditableTenantEntity`. `AppDbContext` enforces per-tenant read isolation via global query filters (`TenantId == currentTenant && !IsDeleted`). New rows are automatically stamped with the current tenant from the request JWT. -* **Soft Delete with Cascade:** Delete operations are converted to soft-delete updates in `AppDbContext.SaveChangesAsync`. Cascade rules (e.g. `ProductSoftDeleteCascadeRule`) propagate soft-deletes to dependent entities without relying on database-level cascades. -* **Audit Fields:** All entities carry `AuditInfo` (owned EF type) with `CreatedAtUtc`, `CreatedBy`, `UpdatedAtUtc`, `UpdatedBy`. Fields are stamped automatically in `SaveChangesAsync`. -* **Optimistic Concurrency:** PostgreSQL native `xmin` system column configured as a concurrency token. `DbUpdateConcurrencyException` is mapped to HTTP 409 by `ApiExceptionHandler`. -* **Rate Limiting:** Fixed-window per-client rate limiter (`100 req/min` default). Partition key priority: JWT username → remote IP → `"anonymous"`. Returns HTTP 429 on breach. Limits are configurable via `RateLimiting:Fixed`. -* **Output Caching:** Tenant-isolated ASP.NET Core output cache backed by **DragonFly** (Redis-compatible). Policies: `Products` (30 s), `Categories` (60 s), `Reviews` (30 s). Mutations evict affected tags. Falls back to in-memory when `Dragonfly:ConnectionString` is absent. -* **Domain Filtering:** Seamless filtering, sorting, and paging powered by `Ardalis.Specification` to decouple query models from infrastructural EF abstractions. -* **Enterprise-Grade Utilities:** - * **Validation:** Pipelined model validation using `FluentValidation.AspNetCore`. - * **Cross-Cutting Concerns:** Unified configuration via `Serilog` (structured logging with `MachineName` and `ThreadId` enrichers) and centralized exception handling via `IExceptionHandler` + RFC 7807 `ProblemDetails`. - * **Data Redaction:** Sensitive log properties (PII, secrets) are classified with `Microsoft.Extensions.Compliance` (`[PersonalData]`, `[SensitiveData]`) and HMAC-redacted before writing. - * **Authentication:** Pre-configured Keycloak JWT + BFF Cookie dual-auth with production hardening: secure-only cookies in production, server-side session store (`DragonflyTicketStore`) backed by DragonFly, silent token refresh before expiry, and CSRF protection (`X-CSRF: 1` header required for cookie-authenticated mutations). - * **Observability:** Health Checks (`/health`) natively tracking PostgreSQL, MongoDB, and DragonFly state. -* **Role-Based Access Control:** Three-tier role model (`PlatformAdmin`, `TenantAdmin`, `User`) enforced via Keycloak claims and ASP.NET Core policy-based authorization. `PermissionRequirement` handlers gate controller actions and GraphQL mutations by role. -* **Robust Testing Engine:** Provides isolated `Integration` tests using `UseInMemoryDatabase` combined with `WebApplicationFactory` for fast feedback, **Testcontainers PostgreSQL** for high-fidelity tenant isolation and transaction tests, plus a comprehensive `Unit` test suite (Moq, Shouldly, FluentValidation.TestHelper). - ---- - -## 🏗 Architecture Diagram - -The application leverages a single `.csproj` separated rationally via namespaces that conform to typical clean layer boundaries. The goal is friction-free deployments and dependency chains while ensuring long-term code organization. - -```mermaid -graph TD - subgraph APITemplate [APITemplate Web API] - direction TB - - subgraph PresentationLayer [API Layer] - REST[Controllers V1] - GQL[GraphQL Queries & Mutations] - UI[Scalar / Nitro UI] - MID[Middleware & Logging] - end - - subgraph ApplicationLayer [Application Layer] - Services[Business Services] - DTO[Data Transfer Objects] - Validators[Fluent Validation] - Spec[Ardalis Specifications] - end - - subgraph DomainLayer [Domain Layer] - Entities[Entities & Aggregate Roots] - Ex[Domain Exceptions] - Irepo[Abstract Interfaces] - end - - subgraph InfrastructureLayer [Infrastructure Layer] - Repo[Concrete Repositories] - UoW[Unit of Work] - EF[EF Core AppDbContext] - Mongo[MongoDbContext] - end - - %% Linkages representing Dependencies - REST --> MID - GQL --> MID - REST --> Services - GQL --> Services - GQL -.-> DataLoaders[DataLoaders] - DataLoaders --> Services - - Services --> Irepo - Services --> Spec - Services -.-> DTO - Services -.-> Validators - - Repo -.-> Irepo - Repo --> EF - Repo --> Mongo - UoW -.-> Irepo - Irepo -.-> Entities - EF -.-> Entities - Mongo -.-> Entities - - PresentationLayer --> ApplicationLayer - ApplicationLayer --> DomainLayer - InfrastructureLayer --> DomainLayer - end - - DB[(PostgreSQL)] - MDB[(MongoDB)] - DF[(DragonFly)] - EF ---> DB - Mongo ---> MDB - REST -..-> DF -``` - ---- - -## 📦 Domain Class Diagram - -This class diagram models the aggregate roots and entities located natively within `Domain/Entities/`. - -```mermaid -classDiagram - class Tenant { - +Guid Id - +string Code - +string Name - +bool IsActive - +ICollection~AppUser~ Users - +AuditInfo Audit - +bool IsDeleted - } - - class AppUser { - +Guid Id - +string Username - +string NormalizedUsername - +string Email - +string PasswordHash - +bool IsActive - +UserRole Role - +Tenant Tenant - +Guid TenantId - +AuditInfo Audit - +bool IsDeleted - } - - class Category { - +Guid Id - +string Name - +string? Description - +Guid TenantId - +AuditInfo Audit - +bool IsDeleted - +ICollection~Product~ Products - } - - class Product { - +Guid Id - +string Name - +string? Description - +decimal Price - +Guid? CategoryId - +Guid TenantId - +AuditInfo Audit - +bool IsDeleted - +ICollection~ProductReview~ Reviews - } - - class ProductReview { - +Guid Id - +Guid ProductId - +Guid UserId - +string? Comment - +int Rating - +AppUser User - +Guid TenantId - +AuditInfo Audit - +bool IsDeleted - +Product Product - } - - class AuditInfo { - +DateTime CreatedAtUtc - +Guid CreatedBy - +DateTime UpdatedAtUtc - +Guid UpdatedBy - } - - class ProductData { - <> - +string Id - +string Title - +string? Description - +DateTime CreatedAt - } - - class ImageProductData { - +int Width - +int Height - +string Format - +long FileSizeBytes - } - - class VideoProductData { - +int DurationSeconds - +string Resolution - +string Format - +long FileSizeBytes - } - - Tenant "1" --> "0..*" AppUser : users - Tenant "1" --> "0..*" Category : scope - Tenant "1" --> "0..*" Product : scope - Tenant "1" --> "0..*" ProductReview : scope - Category "1" --> "0..*" Product : products/category - Product "1" --> "0..*" ProductReview : reviews/product - AppUser "1" --> "0..*" ProductReview : reviews/user - Product --> AuditInfo - Category --> AuditInfo - AppUser --> AuditInfo - Tenant --> AuditInfo - ProductReview --> AuditInfo - ProductData <|-- ImageProductData : discriminator image - ProductData <|-- VideoProductData : discriminator video -``` - ---- - -## 🛠 Technology Stack - -* **Runtime:** `.NET 10.0` Web SDK -* **Relational Database:** PostgreSQL 18 (`Npgsql`) -* **Document Database:** MongoDB 8 (`MongoDB.Driver`) -* **Cache / Rate Limit Backing Store:** DragonFly 1.27 (Redis-compatible, `StackExchange.Redis`) -* **ORM:** Entity Framework Core (`Microsoft.EntityFrameworkCore.Design`, `10.0`) -* **API Toolkit:** ASP.NET Core, Asp.Versioning, `Scalar.AspNetCore` -* **GraphQL Core:** HotChocolate `15.1` -* **Auth:** Keycloak 26 (JWT Bearer + BFF Cookie via OIDC) -* **Utilities:** `Serilog.AspNetCore`, `FluentValidation`, `Ardalis.Specification`, `Kot.MongoDB.Migrations` -* **Test Suite:** xUnit 3, `Microsoft.AspNetCore.Mvc.Testing`, Moq, Shouldly, `FluentValidation.TestHelper`, Testcontainers.PostgreSql, Respawn - ---- - -## 📂 Project Structure - -The solution follows a strict **four-project Clean Architecture** split. Each project has a single, well-defined responsibility and a one-way dependency rule: outer layers depend on inner layers — never the reverse. - -``` -APITemplate.Domain ← APITemplate.Application ← APITemplate.Infrastructure - ← ← APITemplate.Api -``` - -### Project responsibilities - -| Project | Role | Key rule | -| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `APITemplate.Domain` | Core business model — entities, enums, domain exceptions, repository interfaces | No dependencies on any other project or NuGet package except .NET BCL | -| `APITemplate.Application` | Use-case layer — Wolverine commands/queries/handlers, DTOs, FluentValidation validators, specifications | Depends only on Domain; never references EF Core, ASP.NET, or any infrastructure detail | -| `APITemplate.Infrastructure` | Technical implementations — EF Core `AppDbContext`, MongoDB context, repository classes, Unit of Work, migrations, security services, observability | Depends on Domain (implements interfaces) and Application (reads options) | -| `APITemplate.Api` | Presentation entry point — REST controllers, GraphQL types/queries/mutations/DataLoaders, middleware, DI composition root, `Program.cs` | Depends on all other projects; owns `IMessageBus` dispatch and HTTP/GraphQL mapping | -| `APITemplate.Tests` | Test suite — unit tests (Moq), in-memory integration tests (`WebApplicationFactory`), Testcontainers PostgreSQL tests (Respawn) | References all production projects; never ships to production | - -### Folder layout - -```text -src/APITemplate.Api/ -├── Api/ -│ ├── Controllers/V1/ # REST endpoints (ProductsController, CategoriesController, …) -│ ├── GraphQL/ # Types, Queries, Mutations, DataLoaders -│ ├── Middleware/ # RequestContextMiddleware, CsrfValidationMiddleware -│ ├── Authorization/ # PermissionRequirement, BffAuthenticationSchemes -│ ├── Cache/ # TenantAwareOutputCachePolicy, CacheInvalidationNotificationHandler -│ ├── OpenApi/ # Scalar OAuth2 transformer -│ └── ExceptionHandling/ # ApiExceptionHandler → RFC 7807 ProblemDetails -├── Extensions/ # AddApplicationServices, AddPersistence, AddGraphQLConfiguration, … -├── Program.cs -└── appsettings*.json - -src/APITemplate.Application/ -├── Features/ -│ ├── Product/ # GetProductsQuery, CreateProductCommand, ProductRequestHandlers, DTOs, validators -│ ├── Category/ # same vertical slice structure -│ ├── ProductReview/ -│ ├── ProductData/ -│ └── User/ -├── Common/ -│ ├── Behaviors/ # ValidationBehavior (IPipelineBehavior) -│ ├── Events/ # ProductsChangedNotification, CategoriesChangedNotification, … -│ ├── Options/ # BffOptions, RateLimitingOptions, CachingOptions, … -│ └── Security/ # Permission constants, custom claim types - -src/APITemplate.Domain/ -├── Entities/ # Tenant, AppUser, Category, Product, ProductReview, ProductData, … -├── Enums/ # UserRole -├── Exceptions/ # NotFoundException, ValidationException, ConflictException, … -└── Interfaces/ # IProductRepository, ICategoryRepository, IUnitOfWork, … - -src/APITemplate.Infrastructure/ -├── Persistence/ # AppDbContext (EF Core), MongoDbContext, UnitOfWork -├── Repositories/ # ProductRepository, CategoryRepository, ProductDataRepository, … -├── Migrations/ # EF Core migrations + Kot.MongoDB.Migrations -├── Database/ # Embedded SQL stored-procedure scripts -├── Security/ # DragonflyTicketStore, CookieSessionRefresher, KeycloakClaimMapper, CsrfValidationMiddleware -└── Observability/ # Health checks (PostgreSQL, MongoDB, DragonFly, Keycloak) - -tests/APITemplate.Tests/ -├── Integration/ # CustomWebApplicationFactory (InMemory DB + mocked infra) -│ ├── Postgres/ # PostgresWebApplicationFactory (Testcontainers + Respawn) -│ └── *.cs # REST, GraphQL, BFF/CSRF integration tests -└── Unit/ - ├── Services/ - ├── Repositories/ - ├── Validators/ - ├── Middleware/ - └── ExceptionHandling/ -``` - -### Dependency rule in practice - -- A handler in `APITemplate.Application` calls `IProductRepository` (Domain interface) — it never imports `ProductRepository` (Infrastructure class). -- `APITemplate.Infrastructure` implements `IProductRepository` and registers it in DI inside `APITemplate.Api`'s composition root. -- `APITemplate.Api` controllers reference only `IMessageBus` (Wolverine) — they have no direct dependency on any Application service or Infrastructure class. - ---- - -## 🌐 REST API Reference - -All versioned REST resource endpoints sit under the base path `api/v{version}`. JWT `Authorization: Bearer ` is required for these versioned API routes. Authentication is handled externally by Keycloak (see [Authentication](#-authentication) section). Utility endpoints such as `/health` and `/graphql/ui` are anonymous, and `/scalar` is only mapped in Development. - -> **Rate limiting:** all controller routes require the `fixed` rate-limit policy (100 requests per minute per authenticated user or remote IP). - -### Products - -| Method | Path | Auth Required | Description | -| -------- | ----------------------- | :-----------: | ---------------------------------------------- | -| `GET` | `/api/v1/Products` | ✅ | List products with filtering, sorting & paging | -| `GET` | `/api/v1/Products/{id}` | ✅ | Get a single product by GUID | -| `POST` | `/api/v1/Products` | ✅ | Create a new product | -| `PUT` | `/api/v1/Products/{id}` | ✅ | Update an existing product | -| `DELETE` | `/api/v1/Products/{id}` | ✅ | Soft-delete a product (cascades to reviews) | - -### Categories - -| Method | Path | Auth Required | Description | -| -------- | ------------------------------- | :-----------: | ------------------------------------- | -| `GET` | `/api/v1/Categories` | ✅ | List all categories | -| `GET` | `/api/v1/Categories/{id}` | ✅ | Get a category by GUID | -| `POST` | `/api/v1/Categories` | ✅ | Create a new category | -| `PUT` | `/api/v1/Categories/{id}` | ✅ | Update a category | -| `DELETE` | `/api/v1/Categories/{id}` | ✅ | Soft-delete a category | -| `GET` | `/api/v1/Categories/{id}/stats` | ✅ | Aggregated stats via stored procedure | - -### Product Reviews - -| Method | Path | Auth Required | Description | -| -------- | ----------------------------------------------- | :-----------: | ------------------------------------ | -| `GET` | `/api/v1/ProductReviews` | ✅ | List reviews with filtering & paging | -| `GET` | `/api/v1/ProductReviews/{id}` | ✅ | Get a review by GUID | -| `GET` | `/api/v1/ProductReviews/by-product/{productId}` | ✅ | All reviews for a given product | -| `POST` | `/api/v1/ProductReviews` | ✅ | Create a new review | -| `DELETE` | `/api/v1/ProductReviews/{id}` | ✅ | Soft-delete a review | - -### Product Data (MongoDB) - -| Method | Path | Auth Required | Description | -| -------- | ---------------------------- | :-----------: | ------------------------------------------ | -| `GET` | `/api/v1/product-data` | ✅ | List all or filter by `type` (image/video) | -| `GET` | `/api/v1/product-data/{id}` | ✅ | Get by MongoDB ObjectId | -| `POST` | `/api/v1/product-data/image` | ✅ | Create image media metadata | -| `POST` | `/api/v1/product-data/video` | ✅ | Create video media metadata | -| `DELETE` | `/api/v1/product-data/{id}` | ✅ | Delete by MongoDB ObjectId | - -### Users - -| Method | Path | Auth Required | Description | -| ------ | ------------------------------- | :-----------: | ----------------------------------------------------- | -| `GET` | `/api/v1/Users` | ✅ | List all users (PlatformAdmin only) | -| `GET` | `/api/v1/Users/{id}` | ✅ | Get a user by GUID | -| `POST` | `/api/v1/Users/register` | ❌ | Register a new user | -| `PUT` | `/api/v1/Users/{id}/activate` | ✅ | Activate a user (TenantAdmin / PlatformAdmin) | -| `PUT` | `/api/v1/Users/{id}/deactivate` | ✅ | Deactivate a user (TenantAdmin / PlatformAdmin) | -| `PUT` | `/api/v1/Users/{id}/role` | ✅ | Assign a role to a user (TenantAdmin / PlatformAdmin) | - -### Utility - -| Method | Path | Auth Required | Description | -| ------ | ------------- | :-----------: | ----------------------------------------------------------------------------- | -| `GET` | `/health` | ❌ | JSON health status for PostgreSQL, MongoDB & DragonFly | -| `GET` | `/scalar` | ❌ | Interactive Scalar OpenAPI UI (**Development only** — disabled in Production) | -| `GET` | `/graphql/ui` | ❌ | HotChocolate Nitro GraphQL IDE | - ---- - -## ⚙️ Configuration Reference - -All configuration lives in `appsettings.json` (production defaults) and is overridden by `appsettings.Development.json` locally or by environment variables at runtime. - -**Override priority (highest → lowest):** -1. Environment variables (e.g. `ConnectionStrings__DefaultConnection=...`) -2. `appsettings.Development.json` (local development) -3. `appsettings.json` (production baseline — committed to source control, must not contain real secrets) - -> **Security note:** Never commit real secrets to `appsettings.json`. Supply `Keycloak:credentials:secret`, database passwords, and any other sensitive values via environment variables, Docker secrets, or a secret manager such as Azure Key Vault. - -Configuration sections are bound to strongly-typed `IOptions` classes registered in DI (e.g. `RateLimitingOptions`, `CachingOptions`, `BffOptions`), so every setting is validated at startup and injectable into any service without raw `IConfiguration` access. - -### Databases - -| Key | Example Value | Description | -| ------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ConnectionStrings:DefaultConnection` | `Host=localhost;Port=5432;Database=apitemplate;Username=postgres;Password=postgres` | Npgsql connection string for the primary PostgreSQL database. Used by EF Core `AppDbContext` for all relational data (tenants, users, products, categories, reviews). | -| `MongoDB:ConnectionString` | `mongodb://localhost:27017` | MongoDB connection string. Used by `MongoDbContext` for the `product_data` collection (polymorphic media metadata). | -| `MongoDB:DatabaseName` | `apitemplate` | Name of the MongoDB database. All MongoDB collections are created inside this database. | - -### Cache & Session - -| Key | Example Value | Description | -| ---------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Dragonfly:ConnectionString` | `localhost:6379` | StackExchange.Redis connection string pointing to a DragonFly instance. Used for three purposes: distributed output cache (GET responses), server-side BFF session store (`DragonflyTicketStore`), and shared DataProtection key ring. **Omit or leave empty** to fall back to in-memory cache — suitable for single-instance development only. | - -### Authentication — Keycloak - -| Key | Example Value | Description | -| ----------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Keycloak:auth-server-url` | `http://localhost:8180/` | Base URL of the Keycloak server. Used for JWT token validation (OIDC discovery endpoint) and BFF OIDC login flow. | -| `Keycloak:realm` | `api-template` | Name of the Keycloak realm that issues tokens for this application. | -| `Keycloak:resource` | `api-template` | Keycloak client ID. Must match the client configured in the realm. Used as the JWT `aud` (audience) claim. | -| `Keycloak:credentials:secret` | `dev-client-secret` | Keycloak client secret for the confidential client. Required for BFF OIDC code exchange and token refresh. **Never commit a real secret** — supply via environment variable or secret manager in production. | -| `Keycloak:SkipReadinessCheck` | `false` | When `true`, the startup `WaitForKeycloakAsync()` probe is skipped. Useful in CI environments where Keycloak is not available. | - -### BFF Cookie Session - -| Key | Example Value | Description | -| ---------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Bff:CookieName` | `.APITemplate.Auth` | Name of the `httpOnly` session cookie issued after a successful BFF login. The cookie contains only a session key — the actual auth ticket is stored in DragonFly. | -| `Bff:SessionTimeoutMinutes` | `60` | How long the BFF session cookie remains valid after the last activity. | -| `Bff:PostLogoutRedirectUri` | `/` | URI the browser is redirected to after `GET /api/v1/bff/logout` completes the Keycloak back-channel logout. | -| `Bff:Scopes` | `["openid","profile","email","offline_access"]` | OIDC scopes requested from Keycloak during the BFF login flow. `offline_access` is required for silent token refresh via refresh token. | -| `Bff:TokenRefreshThresholdMinutes` | `2` | `CookieSessionRefresher` exchanges the refresh token with Keycloak when the access token will expire within this many minutes. Prevents mid-request token expiry without requiring a full re-login. | - -### Rate Limiting - -| Key | Example Value | Description | -| ---------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `RateLimiting:Fixed:PermitLimit` | `100` | Maximum number of requests allowed per client within a single window. Partition key: JWT username → remote IP → `"anonymous"`. Exceeded requests receive HTTP 429. | -| `RateLimiting:Fixed:WindowMinutes` | `1` | Duration of the fixed rate-limit window in minutes. The counter resets at the end of each window. | - -### Output Caching - -| Key | Example Value | Description | -| ------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Caching:ProductsExpirationSeconds` | `30` | Cache TTL for the `Products` output-cache policy applied to `GET /api/v1/Products` and `GET /api/v1/Products/{id}`. Entries are also evicted immediately when any product mutation publishes `ProductsChangedNotification`. | -| `Caching:CategoriesExpirationSeconds` | `60` | Cache TTL for the `Categories` output-cache policy. | -| `Caching:ReviewsExpirationSeconds` | `30` | Cache TTL for the `Reviews` output-cache policy. | - -### Persistence & Transactions - -| Key | Example Value | Description | -| -------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Persistence:Transactions:IsolationLevel` | `ReadCommitted` | Default SQL isolation level for explicit `IUnitOfWork.ExecuteInTransactionAsync(...)` calls. Accepted values: `ReadUncommitted`, `ReadCommitted`, `RepeatableRead`, `Serializable`. Per-call overrides are possible via `TransactionOptions`. | -| `Persistence:Transactions:TimeoutSeconds` | `30` | Command timeout applied to the database connection while an explicit transaction is active. Prevents long-running transactions from holding locks indefinitely. | -| `Persistence:Transactions:RetryEnabled` | `true` | Enables the Npgsql EF Core execution strategy that automatically retries the entire transaction block on transient failures (e.g. connection drops, deadlocks). | -| `Persistence:Transactions:RetryCount` | `3` | Maximum number of retry attempts before the execution strategy gives up and re-throws. | -| `Persistence:Transactions:RetryDelaySeconds` | `5` | Maximum back-off delay (in seconds) between retry attempts. Actual delay is randomised up to this value. | - -### Bootstrap & Identity - -| Key | Example Value | Description | -| ------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Bootstrap:Tenant:Code` | `default` | Short code of the seed tenant created automatically on first startup if no tenants exist yet. Used as the default tenant for the seeded admin user. | -| `Bootstrap:Tenant:Name` | `Default Tenant` | Human-readable display name of the seed tenant. | -| `SystemIdentity:DefaultActorId` | `00000000-0000-0000-0000-000000000000` | Fallback `CreatedBy` / `UpdatedBy` GUID stamped in audit fields when no authenticated user is present (e.g. during startup seeding). | - -### CORS - -| Key | Example Value | Description | -| --------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Cors:AllowedOrigins` | `["http://localhost:3000","http://localhost:5173"]` | List of origins permitted by the default CORS policy. Add your SPA development server and production domain here. Requests from unlisted origins will be blocked by the browser preflight check. | - -> **Security note:** `Keycloak:credentials:secret` must be supplied via an environment variable or secret manager in production — never from a committed config file. - ---- - -## 🔐 Authentication - -Authentication is handled by **Keycloak** using a hybrid approach that supports both **JWT Bearer tokens** (for API clients and Scalar) and **BFF Cookie sessions** (for SPA frontends). - -| Flow | Use Case | How it works | -| -------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- | -| **JWT Bearer** | Scalar UI, API clients, service-to-service | `Authorization: Bearer ` header | -| **BFF Cookie** | SPA frontend | `/api/v1/bff/login` → Keycloak login → session cookie → direct API calls with cookie + `X-CSRF: 1` header | - -#### BFF Production Hardening - -| Feature | Detail | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Secure cookie** | `CookieSecurePolicy.Always` in production; `SameAsRequest` in development | -| **Server-side session store** | `DragonflyTicketStore` serialises the auth ticket to DragonFly — the cookie contains only a GUID key, keeping cookie size small and preventing token leakage | -| **Shared DataProtection keys** | Keys persisted to DragonFly under `DataProtection:Keys` so multiple instances can decrypt each other's cookies | -| **Silent token refresh** | `CookieSessionRefresher.OnValidatePrincipal` exchanges the refresh token with Keycloak when the access token is within `Bff:TokenRefreshThresholdMinutes` (default 2 min) of expiry | -| **CSRF protection** | `CsrfValidationMiddleware` requires the `X-CSRF: 1` header on all non-GET/HEAD/OPTIONS requests authenticated via the cookie scheme. JWT Bearer requests are exempt. Call `GET /api/v1/bff/csrf` to retrieve the expected header name/value | - -### BFF Endpoints - -| Method | Path | Auth | Description | -| ------ | -------------------- | :---: | ---------------------------------------------------------------- | -| `GET` | `/api/v1/bff/login` | ❌ | Redirects to Keycloak login page | -| `GET` | `/api/v1/bff/logout` | 🍪 | Signs out from both cookie and Keycloak | -| `GET` | `/api/v1/bff/user` | 🍪 | Returns current user info (id, username, email, tenantId, roles) | -| `GET` | `/api/v1/bff/csrf` | ❌ | Returns the required CSRF header name and value (`X-CSRF: 1`) | - -### Manual Testing Guide - -#### Option A: Scalar UI with OAuth2 (recommended) - -1. Start the infrastructure: - ```bash - docker compose up -d - ``` -2. Run the API (via VS Code debugger or CLI): - ```bash - dotnet run --project src/APITemplate - ``` -3. Open **Scalar UI**: `http://localhost:5174/scalar` -4. Click the **Authorize** button in Scalar -5. You will be redirected to Keycloak — log in with `admin` / `Admin123` -6. After successful login, Scalar will automatically attach the JWT token to all requests -7. Try any endpoint (e.g. `GET /api/v1/Products`) - -#### Option B: BFF Cookie flow (browser) - -1. Open `http://localhost:5174/api/v1/bff/login` in a browser -2. Log in with `admin` / `Admin123` on the Keycloak page -3. After redirect, call API endpoints directly in the browser — the session cookie is sent automatically with every request -4. Check your session: `http://localhost:5174/api/v1/bff/user` - -#### Option C: Direct token via cURL - -```bash -# Get a token from Keycloak (requires Direct Access Grants enabled on the client) -TOKEN=$(curl -s -X POST http://localhost:8180/realms/api-template/protocol/openid-connect/token \ - -d "grant_type=password&client_id=api-template&client_secret=dev-client-secret&username=admin&password=Admin123" \ - | jq -r '.access_token') - -# Use the token -curl -H "Authorization: Bearer $TOKEN" http://localhost:5174/api/v1/products -``` - -> **Note:** Direct Access Grants (password grant) is disabled by default. Enable it in Keycloak Admin (`http://localhost:8180/admin` → api-template client → Settings) if needed. - -### ⚡ GraphQL DataLoaders (N+1 Problem Solved) -By leveraging HotChocolate's built-in **DataLoaders** pipeline (`ProductReviewsByProductDataLoader`), fetching deeply nested parent-child relationships avoids querying the database `n` times. The framework collects IDs requested entirely within the GraphQL query, then queries the underlying EF Core PostgreSQL implementation precisely *once*. - -**Example GraphQL Query:** -```graphql -query { - products(input: { pageNumber: 1, pageSize: 10 }) { - items { - id - name - price - # Below triggers ONE bulk DataLoader fetch under the hood! - reviews { - reviewerName - rating - } - } - pageNumber - pageSize - totalCount - } -} -``` - -**Example GraphQL Mutation:** -```graphql -mutation { - createProducts(input: { - items: [ - { - name: "New Masterpiece Board Game" - price: 49.99 - description: "An epic adventure game" - } - ] - }) { - successCount - failureCount - } -} -``` - ---- - -## 🏆 Design Patterns & Best Practices - -This template deliberately applies a number of industry-accepted patterns. Understanding *why* each pattern is used helps when extending the solution. - -### 1 — Repository Pattern - -Every data-store interaction is hidden behind a typed interface defined in `Domain/Interfaces/`. Application services depend only on `IProductRepository`, `ICategoryRepository`, etc., while controllers depend on those services — never directly on `AppDbContext` or `IMongoCollection`. - -**Benefits:** -- Database provider can be swapped without touching business logic. -- Repositories can be replaced with in-memory fakes or Moq mocks in tests. - -### 2 — Unit of Work Pattern - -`IUnitOfWork` (implemented by `UnitOfWork`) is the only commit boundary for relational persistence. Repositories stage changes in EF Core's change tracker, but they never call `SaveChangesAsync` directly. Relational write services call `ExecuteInTransactionAsync(...)` directly when they need an explicit transaction boundary. - -**Rules:** -- Query services own API/read-model reads that return DTOs. -- Paginated, filtered, cross-aggregate, and batching reads belong in query services, usually backed by specifications or projections. -- Command-side validation lookups stay in the write service and use repositories directly. -- Write services load entities they intend to mutate through repositories, not query services. -- `ExecuteInTransactionAsync(...)` is the explicit relational transaction entry point used by services. -- Some single-write flows do not strictly require an explicit transaction; use `CommitAsync()` when a direct save is enough and `ExecuteInTransactionAsync(...)` when you want one explicit transaction shape. -- `Persistence:Transactions` configures the default isolation level, timeout, and retry policy for explicit relational transactions. -- Explicit transactional writes run inside EF Core's execution strategy so the full transaction block can be replayed on transient provider failures. -- Nested transactional writes use savepoints inside the current `UnitOfWork` transaction instead of opening a second top-level transaction. -- Per-call overrides use `ExecuteInTransactionAsync(action, ct, new TransactionOptions { ... })`; effective policy is `configured defaults + per-call override`. -- Nested transaction calls inherit the active outer policy. Passing conflicting nested options fails fast instead of silently changing isolation, timeout, or retry behavior. - -```csharp -// Wraps two repository writes in a single database transaction -await _unitOfWork.ExecuteInTransactionAsync(async () => -{ - await _productRepository.AddAsync(product); - await _reviewRepository.AddAsync(review); -}); -// Both rows committed or both rolled back -``` - -```csharp -await _unitOfWork.ExecuteInTransactionAsync( - async () => - { - await _productRepository.AddAsync(product, ct); - await _reviewRepository.AddAsync(review, ct); - }, - ct, - new TransactionOptions - { - IsolationLevel = IsolationLevel.Serializable, - TimeoutSeconds = 15, - RetryEnabled = false - }); -``` - -Service code can call `IUnitOfWork` directly for explicit transactional writes: - -```csharp -await _unitOfWork.ExecuteInTransactionAsync(async () => -{ - await _repository.AddAsync(product, ct); - return product; -}, ct); -``` - -### 3 — Specification Pattern (Ardalis.Specification) - -Query logic — filtering, ordering, pagination — lives in reusable `Specification` classes rather than being scattered across services or repositories. A single `ProductSpecification` encapsulates all product-list query rules. - -```csharp -// Application/Specifications/ProductSpecification.cs -public sealed class ProductSpecification : Specification -{ - public ProductSpecification(ProductFilter filter) - { - Query.ApplyFilter(filter); // dynamic WHERE clauses - Query.OrderByDescending(p => p.CreatedAt) - .Select(p => new ProductResponse(...)); // projection to DTO - Query.Skip((filter.PageNumber - 1) * filter.PageSize) - .Take(filter.PageSize); - } -} -``` - -**Benefits:** -- Keeps EF Core queries out of service classes. -- Specifications are independently testable. -- `ISpecificationEvaluator` (provided by `Ardalis.Specification.EntityFrameworkCore`) translates specs to SQL. - -### 4 — FluentValidation with Auto-Validation & Cross-Field Rules - -Models are validated automatically by `FluentValidationActionFilter` before the controller action body executes. Unlike Data Annotations, FluentValidation supports dynamic, cross-field business rules: - -```csharp -// A shared base validator reused by both Create and Update validators -public abstract class ProductRequestValidatorBase : AbstractValidator - where T : IProductRequest -{ - protected ProductRequestValidatorBase() - { - // Cross-field: Description is required only for expensive products - RuleFor(x => x.Description) - .NotEmpty().WithMessage("Description is required for products priced above 1000.") - .When(x => x.Price > 1000); - } -} -``` - -Validator classes are auto-discovered via `AddValidatorsFromAssemblyContaining()` — no manual registration needed. - -### 5 — Global Exception Handling (`IExceptionHandler` + ProblemDetails) - -`ApiExceptionHandler` sits in the ASP.NET exception pipeline (`UseExceptionHandler`) and converts typed `AppException` instances into RFC 7807 `ProblemDetails` responses. HTTP status/title are mapped by exception type (`ValidationException`, `NotFoundException`, `ConflictException`, `ForbiddenException`), while `ErrorCode` is resolved from `AppException.ErrorCode` or metadata fallback. `DbUpdateConcurrencyException` is mapped directly to HTTP 409. - -| Exception type | HTTP Status | Logged at | -| ------------------------------ | ----------- | --------- | -| `NotFoundException` | 404 | Warning | -| `ValidationException` | 400 | Warning | -| `ForbiddenException` | 403 | Warning | -| `ConflictException` | 409 | Warning | -| `DbUpdateConcurrencyException` | 409 | Warning | -| Anything else | 500 | Error | - -Response extensions are standardized through `AddProblemDetails(...)` customization: -- `errorCode` (primary code, e.g. `PRD-0404`) -- `traceId` (request correlation) -- `metadata` (optional structured details for business errors) - -Example payload: - -```json -{ - "type": "https://api-template.local/errors/PRD-0404", - "title": "Not Found", - "status": 404, - "detail": "Product with id '...' not found.", - "instance": "/api/v1/products/...", - "traceId": "0HN...", - "errorCode": "PRD-0404" -} -``` - -Error code catalog: - -| Code | HTTP | Meaning | -| ---------------------- | ---- | ---------------------------------------- | -| `GEN-0001` | 500 | Unknown/unhandled server error | -| `GEN-0400` | 400 | Generic validation failure | -| `GEN-0404` | 404 | Generic resource not found | -| `GEN-0409` | 409 | Generic conflict | -| `GEN-0409-CONCURRENCY` | 409 | Optimistic concurrency conflict | -| `AUTH-0403` | 403 | Forbidden | -| `PRD-0404` | 404 | Product not found | -| `CAT-0404` | 404 | Category not found | -| `REV-0404` | 404 | Review not found | -| `REV-2101` | 404 | Product not found when creating a review | - -> GraphQL requests are explicitly bypassed — HotChocolate handles its own error serialisation. - -### 6 — API Versioning (URL Segment) - -All controllers use URL-segment versioning (`/api/v1/…`) via `Asp.Versioning.Mvc`. The default version is `1.0`; new breaking changes should be introduced as `v2` controllers rather than modifying existing ones. - -```csharp -[ApiVersion(1.0)] -[Route("api/v{version:apiVersion}/[controller]")] -public sealed class ProductsController : ControllerBase { ... } -``` - -### 7 — Multi-Tenancy & Audit - -All relational entities implement `IAuditableTenantEntity` (combines `ITenantEntity`, `IAuditableEntity`, `ISoftDeletable`). `AppDbContext` automatically: - -- **Applies global query filters** on every read: `!entity.IsDeleted && entity.TenantId == currentTenant`. -- **Stamps audit fields** on Add (`CreatedAtUtc`, `CreatedBy`) and Modify (`UpdatedAtUtc`, `UpdatedBy`). -- **Auto-assigns TenantId** on insert from the JWT claim resolved by `HttpTenantProvider`. -- **Converts hard deletes to soft deletes**, running registered `ISoftDeleteCascadeRule` implementations to propagate to dependents (e.g. `ProductSoftDeleteCascadeRule` cascades to `ProductReviews`). - -### 8 — Rate Limiting - -All REST controller routes require the `fixed` rate-limit policy. Partitioning isolates limits per authenticated user or per IP for anonymous callers: - -``` -Partition key priority: - 1. JWT username (authenticated users each get their own bucket) - 2. Remote IP (anonymous callers share a per-IP bucket) - 3. "anonymous" (fallback when neither is available) -``` - -Limits are configured in `appsettings.json` under `RateLimiting:Fixed` and resolved via `IOptions` so integration tests can override them without rebuilding the host. - -### 9 — Output Caching (Tenant-Isolated, DragonFly-backed) - -GET endpoints on Products, Categories, and Reviews use `[OutputCache(PolicyName = ...)]` with the `TenantAwareOutputCachePolicy`. This policy: - -1. **Enables caching for authenticated requests** (ASP.NET Core's default skips Authorization-header requests). -2. **Varies the cache key by tenant ID** so one tenant never receives another tenant's cached response. - -When `Dragonfly:ConnectionString` is configured, all cache entries are stored in **DragonFly** so every application instance shares a single distributed cache. Without it, each instance maintains its own in-memory cache. - -Mutations (Create / Update / Delete) evict the relevant tag via `IOutputCacheStore.EvictByTagAsync()` so stale data is immediately invalidated. - -### 10 — GraphQL Security & Performance Guards - -HotChocolate is configured with several safeguards: - -| Guard | Setting | Purpose | -| ----------------------------- | ---------------------- | ------------------------------------------------- | -| `MaxPageSize` | 100 | Prevents unbounded result sets | -| `DefaultPageSize` | 20 | Sensible default for clients | -| `AddMaxExecutionDepthRule(5)` | depth ≤ 5 | Prevents deeply nested query attacks | -| `AddAuthorization()` | policy support enabled | Enables `[Authorize]` on GraphQL fields/mutations | - -GraphQL query and mutation fields are protected with `[Authorize]`. - -### 11 — Automatic Schema Migration at Startup - -`UseDatabaseAsync()` runs EF Core migrations, auth bootstrap seeding, and MongoDB migrations automatically on startup. This means a fresh container deployment is fully self-initialising — no manual `dotnet ef database update` step required in production. - -```csharp -// Extensions/ApplicationBuilderExtensions.cs -await dbContext.Database.MigrateAsync(); // PostgreSQL (skipped for InMemory provider) -await seeder.SeedAsync(); // bootstrap tenant + admin user -await migrator.MigrateAsync(); // MongoDB (Kot.MongoDB.Migrations) -``` - -### 12 — Multi-Stage Docker Build - -The `Dockerfile` follows Docker's multi-stage build best practice to minimise the final image size: - -``` -Stage 1 (build) — mcr.microsoft.com/dotnet/sdk:10.0 ← includes compiler tools -Stage 2 (publish) — same SDK, runs dotnet publish -c Release -Stage 3 (final) — mcr.microsoft.com/dotnet/aspnet:10.0 ← runtime only, ~60 MB -``` - -Only the compiled artefacts from Stage 2 are copied into the slim Stage 3 runtime image. - -### 13 — Polyglot Persistence Decision Guide - -| Data characteristic | Recommended store | -| ----------------------------------- | ----------------------------- | -| Relational data with foreign keys | PostgreSQL | -| Fixed, well-defined schema | PostgreSQL | -| ACID transactions across tables | PostgreSQL | -| Complex aggregations / reporting | PostgreSQL + stored procedure | -| Semi-structured or evolving schemas | MongoDB | -| Polymorphic document hierarchies | MongoDB | -| Media metadata, logs, audit events | MongoDB | - -### 14 — Message Dispatch + CQRS Pattern (WolverineFx) - -All application logic is dispatched through **WolverineFx**. Controllers and GraphQL resolvers never call services directly — they send a typed command or query object through `IMessageBus`, and Wolverine routes it to the correct handler by convention. - -``` -Controller / GraphQL Resolver - │ bus.InvokeAsync(new GetProductsQuery(filter)) - ▼ - Wolverine pipeline - │ FluentValidation middleware (UseFluentValidation()) ← validation runs here - ▼ - Handler (static HandleAsync method) - │ dependencies injected as method parameters - ▼ - Response returned to caller -``` - -#### Commands and Queries - -Each feature vertical defines commands/queries as plain records, each with a dedicated handler class containing a static `HandleAsync` method. Dependencies are injected as method parameters: - -```csharp -// Application/Features/Product/Queries/GetProductsQuery.cs -public sealed record GetProductsQuery(ProductFilter Filter); - -public sealed class GetProductsQueryHandler -{ - public static async Task HandleAsync( - GetProductsQuery query, - IProductRepository repository, - CancellationToken ct) - { - return await repository.GetPagedAsync( - new ProductSpecification(query.Filter), query.Filter.PageNumber, query.Filter.PageSize, ct); - } -} - -// Application/Features/Product/Commands/CreateProductsCommand.cs -public sealed record CreateProductsCommand(CreateProductsRequest Request); - -public sealed class CreateProductsCommandHandler -{ - public static async Task HandleAsync( - CreateProductsCommand command, - IProductRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - CancellationToken ct) - { ... } -} -``` - -The same pattern applies across all features: Products, Categories, ProductReviews, Users, and ProductData. - -#### Controller dispatch via IMessageBus - -Controllers inject only `IMessageBus` — they have no reference to any service or repository: - -```csharp -public sealed class ProductsController(IMessageBus bus) : ApiControllerBase -{ - [HttpGet] - public async Task> GetAll( - [FromQuery] ProductFilter filter, CancellationToken ct) - => Ok(await bus.InvokeAsync(new GetProductsQuery(filter), ct)); - - [HttpPost] - public async Task> Create( - CreateProductRequest request, CancellationToken ct) - { - var product = await bus.InvokeAsync(new CreateProductCommand(request), ct); - return CreatedAtAction(nameof(GetById), new { id = product.Id, version = "1.0" }, product); - } -} -``` - -GraphQL resolvers and DataLoaders follow the same pattern using `[Service] IMessageBus bus` parameter injection. - -#### Cache invalidation via IMessageBus - -Write handlers publish cache invalidation events after a successful mutation using `IMessageBus.PublishAsync`. A dedicated handler listens and evicts the affected output-cache tags — keeping the mutation handler decoupled from any caching concern: - -```csharp -// Application/Common/Events/CacheInvalidationNotification.cs -public sealed record CacheInvalidationNotification(string CacheTag); - -// Handler publishes after mutation: -await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Products)); -``` - -#### FluentValidation middleware - -Wolverine's `UseFluentValidation()` middleware runs before every handler. It collects all `FluentValidation` failures for the request and throws a domain `ValidationException` if any fail — so handler code never receives invalid input. No manual pipeline behavior registration is needed. - -#### DI registration - -Wolverine discovers handlers by convention from registered assemblies — no manual per-handler registration needed: - -```csharp -builder.Host.UseWolverine(opts => -{ - opts.Discovery.IncludeAssembly(typeof(CreateProductsCommand).Assembly); // Application handlers - opts.Discovery.IncludeAssembly(typeof(Program).Assembly); // API handlers - opts.UseFluentValidation(); // validation middleware - opts.Durability.Mode = DurabilityMode.MediatorOnly; // in-process only -}); -``` - -**Benefits:** -- Controllers and GraphQL resolvers are free of business logic — they only translate HTTP/GraphQL inputs to commands/queries. -- Handlers are simple static methods with no base class or interface ceremony — dependencies arrive as method parameters. -- Adding a new cross-cutting concern (logging, authorisation checks, timing) requires only a new Wolverine middleware (Before/After conventions) — no changes to any handler. -- Each command or query is an explicit, named contract; the full request/response shape is visible at a glance. -- Handler classes are independently unit-testable by directly instantiating them with mocked repositories. - ---- - -## 🗄 Stored Procedure Pattern (EF Core + PostgreSQL) - -EF Core's `FromSql()` lets you call stored procedures while still getting full object materialisation and parameterised queries. The pattern below is used for the `GET /api/v1/categories/{id}/stats` endpoint. - -### When to use a stored procedure - -| Situation | Use LINQ | Use Stored Procedure | -| ----------------------------------- | -------- | -------------------- | -| Simple CRUD filtering / paging | ✅ | | -| Complex multi-table aggregations | | ✅ | -| Reusable DB-side business logic | | ✅ | -| Query needs full EF change tracking | ✅ | | - -### 4-step implementation - -**Step 1 — Keyless entity** (no backing table, only a result-set shape) - -```csharp -// Domain/Entities/ProductCategoryStats.cs -public sealed class ProductCategoryStats -{ - public Guid CategoryId { get; set; } - public string CategoryName { get; set; } = string.Empty; - public long ProductCount { get; set; } - public decimal AveragePrice { get; set; } - public long TotalReviews { get; set; } -} -``` - -**Step 2 — EF configuration** (`HasNoKey` + `ExcludeFromMigrations`) - -```csharp -// Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs -public sealed class ProductCategoryStatsConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasNoKey(); - // No backing table — skip this type during 'dotnet ef migrations add' - builder.ToTable("ProductCategoryStats", t => t.ExcludeFromMigrations()); - } -} -``` - -**Step 3 — Migration** (create the PostgreSQL function in `Up`, drop it in `Down`) - -```csharp -migrationBuilder.Sql(""" - CREATE OR REPLACE FUNCTION get_product_category_stats(p_category_id UUID) - RETURNS TABLE( - category_id UUID, category_name TEXT, - product_count BIGINT, average_price NUMERIC, total_reviews BIGINT - ) - LANGUAGE plpgsql AS $$ - BEGIN - RETURN QUERY - SELECT c."Id", c."Name"::TEXT, - COUNT(DISTINCT p."Id"), - COALESCE(AVG(p."Price"), 0), - COUNT(pr."Id") - FROM "Categories" c - LEFT JOIN "Products" p ON p."CategoryId" = c."Id" - LEFT JOIN "ProductReviews" pr ON pr."ProductId" = p."Id" - WHERE c."Id" = p_category_id - GROUP BY c."Id", c."Name"; - END; - $$; - """); - -// Down: -migrationBuilder.Sql("DROP FUNCTION IF EXISTS get_product_category_stats(UUID);"); -``` - -**Step 4 — Repository call** via `FromSql` (auto-parameterised, injection-safe) - -```csharp -// Infrastructure/Repositories/CategoryRepository.cs -public Task GetStatsByIdAsync(Guid categoryId, CancellationToken ct = default) -{ - // The interpolated {categoryId} is converted to a @p0 parameter by EF Core — - // never use string concatenation here. - return AppDb.ProductCategoryStats - .FromSql($"SELECT * FROM get_product_category_stats({categoryId})") - .FirstOrDefaultAsync(ct); -} -``` - -### Full request flow - -``` -GET /api/v1/categories/{id}/stats - │ - ▼ -CategoriesController.GetStats() - │ - ▼ -CategoryService.GetStatsAsync() - │ - ▼ -CategoryRepository.GetStatsByIdAsync() - │ FromSql($"SELECT * FROM get_product_category_stats({id})") - ▼ -PostgreSQL → get_product_category_stats(p_category_id) - │ returns: category_id, category_name, product_count, average_price, total_reviews - ▼ -EF Core maps columns → ProductCategoryStats (keyless entity) - │ - ▼ -ProductCategoryStatsResponse (DTO returned to client) -``` - ---- - -## 🍃 MongoDB Polymorphic Pattern (ProductData) - -The `ProductData` feature demonstrates a **polymorphic document model** in MongoDB, where a single collection stores two distinct subtypes (`ImageProductData`, `VideoProductData`) using the BSON discriminator pattern. - -### When to use MongoDB vs PostgreSQL - -| Situation | Use PostgreSQL | Use MongoDB | -| ----------------------------------- | -------------- | ----------- | -| Relational data with foreign keys | ✅ | | -| Fixed, well-defined schema | ✅ | | -| ACID transactions across tables | ✅ | | -| Semi-structured or evolving schemas | | ✅ | -| Polymorphic document hierarchies | | ✅ | -| Media metadata, logs, events | | ✅ | - -### Discriminator-based inheritance - -```csharp -// Domain/Entities/ProductData.cs -[BsonDiscriminator(RootClass = true)] -[BsonKnownTypes(typeof(ImageProductData), typeof(VideoProductData))] -public abstract class ProductData -{ - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; init; } = ObjectId.GenerateNewId().ToString(); - public string Title { get; init; } = string.Empty; - public string? Description { get; init; } - public DateTime CreatedAt { get; init; } = DateTime.UtcNow; -} - -// Domain/Entities/ImageProductData.cs -[BsonDiscriminator("image")] -public sealed class ImageProductData : ProductData -{ - public int Width { get; init; } - public int Height { get; init; } - public string Format { get; init; } = string.Empty; // jpg | png | gif | webp - public long FileSizeBytes { get; init; } -} - -// Domain/Entities/VideoProductData.cs -[BsonDiscriminator("video")] -public sealed class VideoProductData : ProductData -{ - public int DurationSeconds { get; init; } - public string Resolution { get; init; } = string.Empty; // 720p | 1080p | 4K - public string Format { get; init; } = string.Empty; // mp4 | avi | mkv - public long FileSizeBytes { get; init; } -} -``` - -MongoDB stores a `_t` discriminator field automatically, enabling polymorphic queries against the single `product_data` collection. - -### REST endpoints - -Base route: `api/v{version}/product-data` — all endpoints require JWT authorization. - -| Method | Endpoint | Request | Response | Purpose | -| -------- | -------- | ------------------------------- | --------------------------- | -------------------------- | -| `GET` | `/` | Query: `type` (optional) | `List` | List all or filter by type | -| `GET` | `/{id}` | MongoDB ObjectId string | `ProductDataResponse` / 404 | Get by ID | -| `POST` | `/image` | `CreateImageProductDataRequest` | `ProductDataResponse` 201 | Create image metadata | -| `POST` | `/video` | `CreateVideoProductDataRequest` | `ProductDataResponse` 201 | Create video metadata | -| `DELETE` | `/{id}` | MongoDB ObjectId string | 204 No Content | Delete by ID | - -### Full request flow - -``` -POST /api/v1/product-data/image - │ - ▼ -ProductDataController.CreateImage() - │ FluentValidation auto-validates CreateImageProductDataRequest - ▼ -ProductDataService.CreateImageAsync() - │ Maps request → ImageProductData entity - ▼ -ProductDataRepository.CreateAsync() - │ InsertOneAsync into product_data collection - ▼ -MongoDB → stores { _t: "image", Title, Width, Height, Format, ... } - │ - ▼ -ProductDataMappings.ToResponse() (switch expression, polymorphic) - │ - ▼ -ProductDataResponse (Type, Id, Title, Width, Height, Format, ...) -``` - ---- - -## 🚀 CI/CD & Deployments - -While not natively shipped via default configuration files, this structure allows simple portability across cloud ecosystems: - -**GitHub Actions / Azure Pipelines Structure:** -1. **Restore:** `dotnet restore APITemplate.slnx` -2. **Build:** `dotnet build --no-restore APITemplate.slnx` -3. **Test:** `dotnet test --no-build APITemplate.slnx` -4. **Publish Container:** `docker build -t apitemplate-image:1.0 -f src/APITemplate.Api/Dockerfile .` -5. **Push Registry:** `docker push /apitemplate-image:1.0` - -Because the application encompasses the database (natively via DI) and HTTP context fully self-contained using containerization, it scales efficiently behind Kubernetes Ingress (Nginx) or any App Service / Container Apps equivalent, maintaining state natively using PostgreSQL and MongoDB. - ---- - -## 🧪 Testing - -The repository maintains an inclusive combination of **Unit Tests** and **Integration Tests** executing over a seamless Test-Host infrastructure. - -### Test structure - -| Folder | Technology | What it tests | -| ------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------- | -| `tests/APITemplate.Tests/Unit/Services/` | xUnit + Moq | Service business logic in isolation | -| `tests/APITemplate.Tests/Unit/Repositories/` | xUnit + Moq | Repository filtering/query logic | -| `tests/APITemplate.Tests/Unit/Validators/` | xUnit + FluentValidation.TestHelper | Validator rules per DTO | -| `tests/APITemplate.Tests/Unit/ExceptionHandling/` | xUnit + Moq | Explicit `errorCode` mapping and exception-to-HTTP conversion in `ApiExceptionHandler` | -| `tests/APITemplate.Tests/Integration/` | xUnit + `WebApplicationFactory` | Full HTTP round-trips over in-memory database | -| `tests/APITemplate.Tests/Integration/Postgres/` | xUnit + Testcontainers.PostgreSql | Tenant isolation and transaction behaviour against a real PostgreSQL instance | - -### Integration test isolation - -`CustomWebApplicationFactory` replaces the Npgsql provider with `UseInMemoryDatabase`, removes `MongoDbContext`, and registers a mocked `IProductDataRepository` so DI validation passes. Each test class gets its own database name (a fresh `Guid`) so tests never share state. - -```csharp -// Each factory instance gets its own isolated in-memory database -private readonly string _dbName = Guid.NewGuid().ToString(); -services.AddDbContext(options => - options.UseInMemoryDatabase(_dbName)); -``` - -### Running tests - -```bash -# Run all tests -dotnet test - -# Run only unit tests -dotnet test --filter "FullyQualifiedName~Unit" - -# Run only integration tests (in-memory, no external dependencies) -dotnet test --filter "FullyQualifiedName~Integration&Category!=Integration.Postgres" - -# Run Testcontainers PostgreSQL tests (requires Docker) -dotnet test --filter "Category=Integration.Postgres" -``` - ---- - -## 🏃 Getting Started - -### Prerequisites -* [.NET 10 SDK installed locally](https://dotnet.microsoft.com/) -* [Docker Desktop](https://www.docker.com/) (Optional, convenient for running infrastructure). - -### Quick Start (Using Docker Compose) - -The template consists of a ready-to-use Docker environment to spool up PostgreSQL, MongoDB, Keycloak, DragonFly, and the built API container: - -```bash -# Start up all services including the API container -docker compose up -d --build -``` -> The API will bind natively to `http://localhost:8080`. - -### Running Locally without Containerization - -Start the infrastructure services only, then run the API on the host: - -```bash -# Start only the databases and Keycloak -docker compose up -d postgres mongodb keycloak dragonfly -``` - -Apply your connection strings in `src/APITemplate.Api/appsettings.Development.json`, then run: - -```bash -dotnet run --project src/APITemplate.Api -``` - -EF Core migrations and MongoDB migrations run automatically at startup — no manual `dotnet ef database update` needed. - -### Available Endpoints & User Interfaces - -Once fully spun up under a Development environment, check out: -- **Interactive REST API Documentation (Scalar):** `http://localhost:/scalar` -- **Native GraphQL IDE (Nitro UI):** `http://localhost:/graphql/ui` -- **Environment & Database Health Check:** `http://localhost:/health` diff --git a/monolith/API-Template-mono/TODO-Architecture.md b/monolith/API-Template-mono/TODO-Architecture.md deleted file mode 100644 index cbeb9836..00000000 --- a/monolith/API-Template-mono/TODO-Architecture.md +++ /dev/null @@ -1,267 +0,0 @@ -# Architecture Evolution: Monolith to Microservices - -## Current State - -Well-structured **Clean Architecture monolith** with: -- Vertical slice features (Product, Category, User, Tenant, Email, Webhooks, Jobs) -- Wolverine as message bus (supports distributed transport) -- Dual database (PostgreSQL + MongoDB) -- Multi-tenancy with isolated query filters -- Domain events for cross-feature communication - ---- - -## Identified Bounded Contexts - -| # | Service | Entities | Database | -|---|---------|----------|----------| -| 1 | **Product Catalog** | Product, Category, ProductData, ProductDataLink, ProductCategoryStats | PostgreSQL + MongoDB | -| 2 | **Reviews** | ProductReview | PostgreSQL | -| 3 | **Identity & Tenancy** | AppUser, Tenant, TenantInvitation | PostgreSQL + Keycloak | -| 4 | **Notifications** | FailedEmail, email templates | PostgreSQL | -| 5 | **File Storage** | StoredFile | PostgreSQL + filesystem/S3 | -| 6 | **Background Jobs** | JobExecution | PostgreSQL (TickerQ) | -| 7 | **Webhooks** | Incoming/Outgoing webhooks | In-memory queues | - ---- - -## Recommended Strategy: Modular Monolith → Strangler Fig - -### Phase 1 — Modular Monolith - -Transform the current monolith into isolated modules with explicit boundaries while keeping everything in a single deployable unit. - -#### Step 1: Create Module Structure - -Create a new directory layout under `src/Modules/`: - -``` -src/Modules/ - ProductCatalog/ - ProductCatalog.Domain/ - ProductCatalog.Application/ - ProductCatalog.Infrastructure/ - ProductCatalog.Api/ - Reviews/ - Reviews.Domain/ - Reviews.Application/ - Reviews.Infrastructure/ - Reviews.Api/ - Identity/ - Identity.Domain/ - Identity.Application/ - Identity.Infrastructure/ - Identity.Api/ - Notifications/ - Notifications.Domain/ - Notifications.Application/ - Notifications.Infrastructure/ - Notifications.Api/ - FileStorage/ - FileStorage.Domain/ - FileStorage.Application/ - FileStorage.Infrastructure/ - FileStorage.Api/ - BackgroundJobs/ - BackgroundJobs.Domain/ - BackgroundJobs.Application/ - BackgroundJobs.Infrastructure/ - BackgroundJobs.Api/ - Webhooks/ - Webhooks.Domain/ - Webhooks.Application/ - Webhooks.Infrastructure/ - Webhooks.Api/ -``` - -#### Step 2: Extract Shared Kernel - -Create `src/SharedKernel/` containing cross-cutting concerns shared by all modules: - -- `IAuditableTenantEntity`, `IAuditableEntity`, `ISoftDeletable`, `IHasId` -- `AuditInfo` value object -- `ITenantProvider`, `IActorProvider` -- `PagedResponse` -- `IUnitOfWork` abstraction -- `IRepository` base interface -- Multi-tenancy infrastructure (global query filters, tenant resolution) -- Soft-delete base infrastructure -- Common domain exceptions (`NotFoundException`, `ValidationException`) -- Audit stamping logic - -#### Step 3: Split AppDbContext - -Replace the single `AppDbContext` with per-module DbContexts: - -- `ProductCatalogDbContext` — Products, Categories, ProductDataLinks, ProductCategoryStats -- `ReviewsDbContext` — ProductReviews -- `IdentityDbContext` — AppUsers, Tenants, TenantInvitations -- `NotificationsDbContext` — FailedEmails -- `FileStorageDbContext` — StoredFiles -- `BackgroundJobsDbContext` — JobExecutions - -All contexts share the same PostgreSQL database but enforce module boundaries — a module must not query another module's tables directly. - -#### Step 4: Define Module Contracts (Events) - -Create `src/Contracts/` as a shared NuGet package containing only: - -- Integration events (cross-module communication) -- Shared DTOs for inter-module queries -- No domain logic, no entities - -Example events: -``` -ProductCreatedEvent { ProductId, TenantId, Name } -ProductDeletedEvent { ProductId, TenantId } -UserRegisteredEvent { UserId, TenantId, Email } -TenantDeactivatedEvent { TenantId } -``` - -#### Step 5: Replace Direct Cross-Module Calls with Events - -Current direct dependencies to refactor: - -| Caller | Callee | Current | Target | -|--------|--------|---------|--------| -| Product soft-delete | Reviews cascade | `ProductSoftDeleteCascadeRule` calls ReviewRepository directly | Publish `ProductDeletedEvent` → Reviews module handles cascade | -| Tenant soft-delete | Users, Products cascade | `TenantSoftDeleteCascadeRule` accesses multiple repositories | Publish `TenantDeactivatedEvent` → each module handles own cleanup | -| ProductReview creation | User validation | Queries UserRepository | Reviews module stores read-only user projection, updated via `UserUpdatedEvent` | -| Product creation | Category validation | Queries CategoryRepository | Both in same module (Product Catalog) — no change needed | -| Email handlers | User/Tenant data | Queries user/tenant repos | Notifications module receives all needed data in the event payload | - -#### Step 6: Enforce Module Isolation - -- Each module exposes only its public API (controllers, events, query interfaces) -- No module references another module's `Domain` or `Infrastructure` project -- Communication exclusively through Wolverine events (in-process for now) -- Add architecture tests (NetArchTest or ArchUnitNET) to enforce boundaries - -#### Step 7: Split GraphQL Schema - -- Each module defines its own GraphQL types, queries, and mutations -- Use Hot Chocolate Schema Stitching to compose the unified schema -- Prepare for future Hot Chocolate Federation when modules become services - -#### Step 8: Split REST Controllers - -- Move controllers into their respective module's `Api` project -- Host module still composes all endpoints in `Program.cs` -- Each module registers its own services via `IServiceCollection` extensions - ---- - -### Phase 2 — Strangler Fig Extraction - -Extract modules into independent services when scaling demands it. Start with the least coupled modules. - -#### Extraction Order (least to most coupled) - -1. **Notifications** — no inbound queries, only consumes events -2. **File Storage** — simple CRUD, minimal dependencies -3. **Webhooks** — event-driven by nature -4. **Background Jobs** — independent scheduler -5. **Reviews** — depends on Product (read-only projection) -6. **Identity & Tenancy** — central but well-defined API (Keycloak handles heavy lifting) -7. **Product Catalog** — core domain, extract last - -#### Step 1: Deploy API Gateway - -- Add YARP or Ocelot as reverse proxy -- Route all traffic through gateway -- Initially, gateway proxies everything to the monolith - -#### Step 2: Extract First Service (Notifications) - -1. Create standalone ASP.NET project from Notifications module -2. Give it its own PostgreSQL database (or schema) -3. Switch Wolverine transport from in-process to RabbitMQ: - ```csharp - // Before (in-process) - opts.PublishMessage().Locally(); - - // After (distributed) - opts.PublishMessage() - .ToRabbitQueue("notifications"); - ``` -4. Update API Gateway to route `/api/v1/notifications/*` to new service -5. Remove Notifications module from monolith - -#### Step 3: Configure Distributed Messaging - -- Deploy RabbitMQ (or Azure Service Bus) -- Enable Wolverine outbox pattern for guaranteed delivery: - ```csharp - opts.UseRabbitMq(rabbit => { ... }) - .AutoProvision() - .UseConventionalRouting(); - opts.Policies.UseDurableOutboxOnAllSendingEndpoints(); - ``` -- Each service gets its own durable inbox/outbox - -#### Step 4: Database-per-Service - -- Each extracted service gets its own PostgreSQL database -- Migrate data from shared DB to service-owned DB -- Remove tables from monolith's DB after migration -- MongoDB stays with Product Catalog service - -#### Step 5: Repeat for Each Service - -Follow the same pattern for each module in extraction order: -1. Stand up independent service from module code -2. Point its Wolverine transport to RabbitMQ -3. Give it its own database -4. Update API Gateway routing -5. Remove module from monolith - -#### Step 6: Handle Cross-Service Queries - -For queries that span multiple services: - -- **API Composition** — Gateway aggregates responses from multiple services -- **CQRS Read Models** — Services maintain denormalized read projections updated via events -- **GraphQL Federation** — Hot Chocolate Federation composes subgraphs from each service - ---- - -## Infrastructure Requirements - -### Phase 1 (Modular Monolith) -- No new infrastructure needed -- Same PostgreSQL, MongoDB, Redis/DragonFly, Keycloak - -### Phase 2 (Microservices) -- **Message Broker:** RabbitMQ or Azure Service Bus -- **API Gateway:** YARP or Ocelot -- **Container Orchestration:** Docker Compose (dev) → Kubernetes (prod) -- **Service Discovery:** Kubernetes DNS or Consul -- **Distributed Tracing:** Already have OpenTelemetry — works across services -- **Centralized Logging:** Already have Serilog + OTLP — works across services -- **Per-Service Databases:** Multiple PostgreSQL instances (or schemas) - ---- - -## Risk Mitigation - -| Risk | Mitigation | -|------|------------| -| Data inconsistency across services | Wolverine outbox pattern + idempotent handlers (already have `IIdempotencyStore`) | -| Lost events | Durable messaging with RabbitMQ persistent queues + Wolverine dead letter queue | -| Debugging complexity | OpenTelemetry distributed tracing (already configured) | -| Service discovery failures | Kubernetes DNS + health checks (already have health check infrastructure) | -| Database migration errors | Per-module DbContext in Phase 1 validates data boundaries before physical split | -| GraphQL schema fragmentation | Hot Chocolate Federation maintains unified schema | - ---- - -## Key Wolverine Advantages - -The current Wolverine setup makes this transition significantly easier: - -1. **Transport agnostic** — switch from in-process to RabbitMQ/Azure SB with config change, not code change -2. **Built-in outbox** — guaranteed message delivery across service boundaries -3. **Durable inbox** — idempotent message processing -4. **Saga support** — orchestrate multi-service workflows -5. **FluentValidation middleware** — works identically in monolith and microservices -6. **Handler discovery** — static handlers work the same regardless of deployment topology diff --git a/monolith/API-Template-mono/TODO.md b/monolith/API-Template-mono/TODO.md deleted file mode 100644 index da020c59..00000000 --- a/monolith/API-Template-mono/TODO.md +++ /dev/null @@ -1,127 +0,0 @@ -# TODO - -## Observability - -- [x] Add observability stack and instrumentation for metrics, tracing, and alerting. -- [x] Add OpenTelemetry for traces, metrics, and correlation across database, HTTP, and cache operations. - -## User Workflows - -- [x] Add user registration workflow. -- [x] Add user lifecycle workflows such as activation, deactivation, and role management. - -## Tenant Management - -- [ ] Add tenant creation workflow. -- [ ] Add tenant removal workflow. - -## Product Data - -- [x] Add workflow for attaching `ProductData` records to products. -- [x] Support many-to-many relationship where a single product can have multiple `ProductData` entries. - -## Notifications - -- [ ] Add email notification for user registration. -- [ ] Add email notification for tenant invitation workflow. -- [ ] Add email notification for password reset workflow. -- [ ] Add email notification for user role changes. - -## Real-Time Communication (SignalR) - -Implement real-time notifications and chat using ASP.NET Core SignalR. - -**Architecture:** -- NotificationHub: job status, data updates, user status -- ChatHub: 1:1, groups, channels -- Redis backplane for multi-instance -- Optional persistence (flexible, add later if needed) - -**Implementation:** -- [ ] Setup SignalR infrastructure (Hubs, backplane, middleware) -- [ ] NotificationHub: job/product/user status updates -- [ ] ChatHub: 1:1 messaging -- [ ] ChatHub: group and channel messaging -- [ ] Authorization and connection management -- [ ] Client SDK (JavaScript/TypeScript) -- [ ] Message persistence layer (pluggable design) -- [ ] Error handling, reconnection, idempotency - -## Contracts - -- [ ] Extract request/response DTOs and shared contract models into a separate NuGet package. - -## Search - -- [x] Add full-text search for products and categories. -- [x] Add faceted filtering for search results. - -## Background Jobs - -- [x] Add cleanup jobs for expired or orphaned data. -- [x] Add reindex jobs for search data. -- [x] Add retry jobs for failed notifications. -- [x] Add periodic synchronization tasks for external integrations. -- [x] Cursor-based pagination for orphaned ProductData cleanup to bound memory usage at scale. -- [x] Distributed locking (`SELECT ... FOR UPDATE SKIP LOCKED` or claim column) for email retry to prevent duplicate sends in multi-instance deployments. -- [x] Migrate from `PeriodicTimer` to Quartz.NET (or TickerQ) for CRON scheduling, persistent job state, and distributed locking. - -## Permissions - -- [ ] Add a finer-grained permissions model beyond roles. -- [ ] Add policy-based access control per action and resource. - -## File and Media Handling - -- [ ] Add file upload support for `ProductData`. -- [ ] Add storage abstraction for local and S3-compatible backends. -- [ ] Add cleanup workflow for orphaned files. - - -## Soft delete and Data Retention -- [x] Hard delete for soft-deleted products after a configurable retention period. -- [x] Add workflow for permanently deleting soft-deleted products after retention period. -- [ ] Wolverine durable outbox or CAP for reliable messaging and eventual consistency in data deletion across related entities. (WolverineFx is now integrated as the in-process mediator; durable outbox mode can be enabled when needed.) - -## Result Pattern - -- [ ] Introduce `Result` pattern (e.g. via `OneOf` or custom type) for expected failures instead of exceptions as flow control. -- [ ] Migrate validation, not-found, and conflict scenarios from exceptions to explicit return types. - -## Testing Improvements - -- [ ] Migrate key integration tests from in-memory EF Core to Testcontainers PostgreSQL for realistic database behavior. -- [ ] Add tests covering PostgreSQL-specific behavior: migrations, `xmin` concurrency tokens, full-text search queries. - -## Modularization (Phase 1) - -- [ ] Split `AppDbContext` into per-module contexts (ProductCatalogDbContext, ReviewsDbContext, IdentityDbContext, etc.). -- [ ] Replace direct cross-module calls (soft-delete cascade rules) with Wolverine integration events. -- [ ] Add ArchUnitNET or NetArchTest architecture tests to enforce module boundaries. -- [ ] See `TODO-Architecture.md` for full modular monolith plan. - -## Wolverine Outbox - -- [ ] Enable `UseDurableOutboxOnAllSendingEndpoints()` for reliable eventual consistency across modules. - -## Prioritization - -### High Priority - -**Tenant Management** — Tenant creation and removal workflows are core functionality for a multi-tenant system. Without them, tenants cannot be fully managed — currently only a bootstrap tenant exists via configuration. Includes tenant creation, admin assignment, deactivation, and complete removal with cascading cleanup of all related data (users, products, categories). - -**Notifications** — Email infrastructure is fully in place (SMTP client, FailedEmail entity, retry jobs with distributed locking). Only business logic is missing — email templates and handlers for registration, tenant invitation, password reset, and role changes. Minimal effort with high UX impact. - -### Medium Priority - -**Modularization (Phase 1)** — Split the monolith into isolated modules (ProductCatalog, Reviews, Identity, Notifications, FileStorage, BackgroundJobs, Webhooks). Includes splitting `AppDbContext` into per-module contexts, replacing direct cross-module calls with Wolverine integration events, and adding architecture tests to enforce boundaries. Prepares the project for future microservices extraction without changing business logic. See `TODO-Architecture.md` for the full plan. - -**Testing Improvements** — Migrate key integration tests from in-memory EF Core to Testcontainers PostgreSQL for realistic database behavior. The in-memory provider does not capture PostgreSQL-specific behavior — `xmin` concurrency tokens, full-text search, migrations, JSON operators. Testcontainers setup already exists in the project and needs to be extended to critical test suites. - -### Lower Priority - -**Result Pattern** — Gradually migrate from exceptions (`ValidationException`, `NotFoundException`) to explicit return types for expected failures. Removes exception throwing overhead in common scenarios and makes method signatures more transparent. Best introduced incrementally, starting with new features. - -**Contracts NuGet Package** — Extract request/response DTOs into a standalone package. Allows clients to reference only contracts without depending on the Application layer. Essential for future microservices extraction and sharing types with frontend clients. - -**Permissions** — Extend the 3-tier role model (PlatformAdmin, TenantAdmin, User) with finer-grained policy-based access control. Per-action and per-resource permissions enable more granular access control without needing to create new roles for every combination of privileges. diff --git a/monolith/API-Template-mono/docker-compose.production.yml b/monolith/API-Template-mono/docker-compose.production.yml deleted file mode 100644 index 81d8b064..00000000 --- a/monolith/API-Template-mono/docker-compose.production.yml +++ /dev/null @@ -1,206 +0,0 @@ -services: - alloy: - image: grafana/alloy:v1.10.2 - restart: unless-stopped - volumes: - - ./infrastructure/observability/alloy/config.alloy:/etc/alloy/config.alloy:ro - command: - - run - - /etc/alloy/config.alloy - - prometheus: - image: prom/prometheus:v3.4.2 - restart: unless-stopped - command: - - --config.file=/etc/prometheus/prometheus.yml - - --storage.tsdb.path=/prometheus - - --web.enable-remote-write-receiver - volumes: - - ./infrastructure/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ./infrastructure/observability/prometheus/rules:/etc/prometheus/rules:ro - - prometheusdata:/prometheus - - loki: - image: grafana/loki:3.5.5 - restart: unless-stopped - command: ["-config.file=/etc/loki/config.yml"] - volumes: - - ./infrastructure/observability/loki/config.yml:/etc/loki/config.yml:ro - - lokidata:/loki - - tempo: - image: grafana/tempo:2.9.1 - restart: unless-stopped - command: ["-config.file=/etc/tempo/config.yml"] - volumes: - - ./infrastructure/observability/tempo/config.yml:/etc/tempo/config.yml:ro - - tempodata:/var/tempo - - grafana: - image: grafana/grafana:12.3.1 - restart: unless-stopped - environment: - GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD must be set} - GF_AUTH_ANONYMOUS_ENABLED: "false" - depends_on: - - prometheus - - loki - - tempo - volumes: - - ./infrastructure/observability/grafana/grafana.ini:/etc/grafana/grafana.ini:ro - - ./infrastructure/observability/grafana/provisioning:/etc/grafana/provisioning:ro - - ./infrastructure/observability/grafana/dashboards:/var/lib/grafana/dashboards:ro - - grafanadata:/var/lib/grafana - - postgres: - image: postgres:17.2 - restart: unless-stopped - environment: - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_NAME:-apitemplate} - volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}"] - interval: 10s - timeout: 5s - retries: 5 - - mongodb: - image: mongo:8.0.4 - restart: unless-stopped - volumes: - - mongodata:/data/db - healthcheck: - test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] - interval: 10s - timeout: 5s - retries: 5 - - keycloak-db: - image: postgres:17.2 - restart: unless-stopped - environment: - POSTGRES_USER: ${KC_DB_USERNAME} - POSTGRES_PASSWORD: ${KC_DB_PASSWORD} - POSTGRES_DB: keycloak - volumes: - - keycloak-pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${KC_DB_USERNAME}"] - interval: 10s - timeout: 5s - retries: 5 - - keycloak: - image: quay.io/keycloak/keycloak:26.1 - restart: unless-stopped - command: start --optimized - environment: - KC_DB: postgres - KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak - KC_DB_USERNAME: ${KC_DB_USERNAME} - KC_DB_PASSWORD: ${KC_DB_PASSWORD} - KC_HOSTNAME: ${KC_HOSTNAME} - KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/conf/server.crt - KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/conf/server.key - ports: - - "8443:8443" - depends_on: - keycloak-db: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8443"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 60s - - dragonfly-master: - image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.27.1 - restart: unless-stopped - volumes: - - dragonfly-master-data:/data - command: dragonfly --maxmemory 512mb --proactor_threads 2 --requirepass ${DRAGONFLY_PASSWORD:-} - healthcheck: - test: ["CMD", "redis-cli", "-a", "${DRAGONFLY_PASSWORD:-}", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - dragonfly-replica: - image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.27.1 - restart: unless-stopped - volumes: - - dragonfly-replica-data:/data - command: dragonfly --maxmemory 512mb --proactor_threads 2 --replicaof dragonfly-master 6379 --masterauth ${DRAGONFLY_PASSWORD:-} --requirepass ${DRAGONFLY_PASSWORD:-} - depends_on: - dragonfly-master: - condition: service_healthy - healthcheck: - test: ["CMD", "redis-cli", "-a", "${DRAGONFLY_PASSWORD:-}", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - dragonfly-proxy: - image: haproxy:3.1-alpine - restart: unless-stopped - volumes: - - ./infrastructure/dragonfly/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro - depends_on: - dragonfly-master: - condition: service_healthy - dragonfly-replica: - condition: service_healthy - healthcheck: - test: ["CMD", "haproxy", "-c", "-f", "/usr/local/etc/haproxy/haproxy.cfg"] - interval: 10s - timeout: 5s - retries: 5 - - api: - build: - context: . - dockerfile: src/APITemplate.Api/Dockerfile - restart: unless-stopped - ports: - - "8080:8080" - environment: - ASPNETCORE_ENVIRONMENT: Production - ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=${DB_NAME:-apitemplate};Username=${DB_USERNAME};Password=${DB_PASSWORD}" - MongoDB__ConnectionString: "mongodb://mongodb:27017" - MongoDB__DatabaseName: "${DB_NAME:-apitemplate}" - Keycloak__realm: "${KC_REALM:-api-template}" - Keycloak__auth-server-url: "https://keycloak:8443/" - Keycloak__resource: "${KC_CLIENT_ID:-api-template}" - Keycloak__credentials__secret: "${KC_CLIENT_SECRET}" - Dragonfly__ConnectionString: "dragonfly-proxy:6379,password=${DRAGONFLY_PASSWORD:-}" - APITEMPLATE_REDACTION_HMAC_KEY: "${REDACTION_HMAC_KEY}" - Observability__Otlp__Endpoint: "http://alloy:4317" - Observability__Exporters__Otlp__Enabled: "true" - Observability__Exporters__Aspire__Enabled: "false" - depends_on: - alloy: - condition: service_started - postgres: - condition: service_healthy - mongodb: - condition: service_healthy - keycloak: - condition: service_healthy - dragonfly-proxy: - condition: service_healthy - -volumes: - prometheusdata: - lokidata: - tempodata: - grafanadata: - pgdata: - mongodata: - keycloak-pgdata: - dragonfly-master-data: - dragonfly-replica-data: diff --git a/monolith/API-Template-mono/docker-compose.yml b/monolith/API-Template-mono/docker-compose.yml deleted file mode 100644 index 775ddd3b..00000000 --- a/monolith/API-Template-mono/docker-compose.yml +++ /dev/null @@ -1,216 +0,0 @@ -services: - alloy: - image: grafana/alloy:v1.10.2 - ports: - - "4317:4317" - - "4318:4318" - - "12345:12345" - volumes: - - ./infrastructure/observability/alloy/config.alloy:/etc/alloy/config.alloy:ro - command: - - run - - /etc/alloy/config.alloy - - prometheus: - image: prom/prometheus:v3.4.2 - command: - - --config.file=/etc/prometheus/prometheus.yml - - --storage.tsdb.path=/prometheus - - --web.enable-remote-write-receiver - ports: - - "9090:9090" - volumes: - - ./infrastructure/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ./infrastructure/observability/prometheus/rules:/etc/prometheus/rules:ro - - prometheusdata:/prometheus - - loki: - image: grafana/loki:3.5.5 - command: ["-config.file=/etc/loki/config.yml"] - ports: - - "3100:3100" - volumes: - - ./infrastructure/observability/loki/config.yml:/etc/loki/config.yml:ro - - lokidata:/loki - - tempo: - image: grafana/tempo:2.9.1 - command: ["-config.file=/etc/tempo/config.yml"] - ports: - - "3200:3200" - - "4319:4317" - volumes: - - ./infrastructure/observability/tempo/config.yml:/etc/tempo/config.yml:ro - - tempodata:/var/tempo - - grafana: - image: grafana/grafana:12.3.1 - environment: - GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} - GF_AUTH_ANONYMOUS_ENABLED: "false" - ports: - - "3001:3000" - depends_on: - - prometheus - - loki - - tempo - volumes: - - ./infrastructure/observability/grafana/grafana.ini:/etc/grafana/grafana.ini:ro - - ./infrastructure/observability/grafana/provisioning:/etc/grafana/provisioning:ro - - ./infrastructure/observability/grafana/dashboards:/var/lib/grafana/dashboards:ro - - grafanadata:/var/lib/grafana - - aspire-dashboard: - image: mcr.microsoft.com/dotnet/aspire-dashboard:9.5 - profiles: ["aspire"] - ports: - - "${ASPIRE_OTLP_GRPC_PORT:-4317}:18889" - - "${ASPIRE_OTLP_HTTP_PORT:-4318}:18890" - - "18888:18888" - environment: - ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "true" - ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL: http://0.0.0.0:18890 - - postgres: - image: postgres:18.3 - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: apitemplate - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - - mongodb: - image: mongo:8.2 - ports: - - "27017:27017" - volumes: - - mongodata:/data/db - healthcheck: - test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] - interval: 10s - timeout: 5s - retries: 5 - - keycloak-db: - image: postgres:18.3 - environment: - POSTGRES_USER: keycloak - POSTGRES_PASSWORD: keycloak - POSTGRES_DB: keycloak - volumes: - - keycloak-pgdata:/var/lib/postgresql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U keycloak"] - interval: 10s - timeout: 5s - retries: 5 - - keycloak: - image: quay.io/keycloak/keycloak:26.5 - command: start-dev --import-realm - environment: - KC_DB: postgres - KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak - KC_DB_USERNAME: keycloak - KC_DB_PASSWORD: keycloak - KC_HTTP_PORT: 8180 - KC_BOOTSTRAP_ADMIN_USERNAME: admin - KC_BOOTSTRAP_ADMIN_PASSWORD: admin - ports: - - "8180:8180" - depends_on: - keycloak-db: - condition: service_healthy - volumes: - - ./infrastructure/keycloak/realms:/opt/keycloak/data/import - healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 60s - - dragonfly: - image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.27.1 - ports: - - "6379:6379" - volumes: - - dragonflydata:/data - command: dragonfly --maxmemory 512mb --proactor_threads 2 - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - mailpit: - image: axllent/mailpit:v1.29.0 - ports: - - "8025:8025" - - "1025:1025" - environment: - MP_MAX_MESSAGES: 5000 - MP_SMTP_AUTH_ACCEPT_ANY: 1 - MP_SMTP_AUTH_ALLOW_INSECURE: 1 - healthcheck: - test: ["CMD", "mailpit", "ready-check"] - interval: 10s - timeout: 5s - retries: 5 - - api: - build: - context: . - dockerfile: src/APITemplate.Api/Dockerfile - ports: - - "8080:8080" - environment: - ASPNETCORE_ENVIRONMENT: Development - ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=apitemplate;Username=postgres;Password=postgres" - MongoDB__ConnectionString: "mongodb://mongodb:27017" - MongoDB__DatabaseName: "apitemplate" - Keycloak__realm: "api-template" - Keycloak__auth-server-url: "http://keycloak:8180/" - Keycloak__resource: "api-template" - Keycloak__credentials__secret: "${KC_DEV_CLIENT_SECRET:-dev-client-secret}" - Dragonfly__ConnectionString: "dragonfly:6379" - Observability__Otlp__Endpoint: "http://alloy:4317" - Observability__Exporters__Otlp__Enabled: "true" - Observability__Exporters__Aspire__Enabled: "false" - Email__SmtpHost: "mailpit" - Email__SmtpPort: "1025" - Email__UseSsl: "false" - depends_on: - alloy: - condition: service_started - postgres: - condition: service_healthy - mongodb: - condition: service_healthy - keycloak: - condition: service_healthy - dragonfly: - condition: service_healthy - mailpit: - condition: service_healthy - -volumes: - prometheusdata: - lokidata: - tempodata: - grafanadata: - pgdata: - mongodata: - keycloak-pgdata: - dragonflydata: - - diff --git a/monolith/API-Template-mono/global.json b/monolith/API-Template-mono/global.json deleted file mode 100644 index a11f48e1..00000000 --- a/monolith/API-Template-mono/global.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "sdk": { - "version": "10.0.0", - "rollForward": "latestMajor", - "allowPrerelease": true - } -} \ No newline at end of file diff --git a/monolith/API-Template-mono/infrastructure/dragonfly/haproxy.cfg b/monolith/API-Template-mono/infrastructure/dragonfly/haproxy.cfg deleted file mode 100644 index c744fa95..00000000 --- a/monolith/API-Template-mono/infrastructure/dragonfly/haproxy.cfg +++ /dev/null @@ -1,19 +0,0 @@ -global - maxconn 256 - -defaults - mode tcp - timeout connect 5s - timeout client 30s - timeout server 30s - -frontend dragonfly_front - bind *:6379 - default_backend dragonfly_back - -backend dragonfly_back - option tcp-check - tcp-check send "PING\r\n" - tcp-check expect string +PONG - server master dragonfly-master:6379 check inter 3s fall 3 rise 2 - server replica dragonfly-replica:6379 check inter 3s fall 3 rise 2 backup diff --git a/monolith/API-Template-mono/infrastructure/keycloak/realms/api-template-realm.json b/monolith/API-Template-mono/infrastructure/keycloak/realms/api-template-realm.json deleted file mode 100644 index 419a9422..00000000 --- a/monolith/API-Template-mono/infrastructure/keycloak/realms/api-template-realm.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "realm": "api-template", - "enabled": true, - "rememberMe": true, - "ssoSessionIdleTimeout": 1800, - "ssoSessionMaxLifespan": 36000, - "ssoSessionIdleTimeoutRememberMe": 604800, - "ssoSessionMaxLifespanRememberMe": 1296000, - "sslRequired": "none", - "registrationAllowed": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, - "resetPasswordAllowed": false, - "editUsernameAllowed": false, - "passwordPolicy": "length(8) and upperCase(1) and digits(1) and forceExpiredPasswordChange(365)", - "bruteForceProtected": true, - "failureFactor": 5, - "waitIncrementSeconds": 60, - "maxFailureWaitSeconds": 900, - "maxDeltaTimeSeconds": 3600, - "revokeRefreshToken": true, - "refreshTokenMaxReuse": 0, - "roles": { - "realm": [ - { - "name": "PlatformAdmin", - "description": "Platform administrator with full access" - }, - { - "name": "User", - "description": "Regular user" - } - ] - }, - "clients": [ - { - "clientId": "api-template-scalar", - "name": "API Template Scalar UI", - "enabled": true, - "publicClient": true, - "redirectUris": ["http://localhost:5174/*", "http://localhost:8080/*"], - "webOrigins": ["http://localhost:5174", "http://localhost:8080"], - "attributes": { - "pkce.code.challenge.method": "S256" - }, - "standardFlowEnabled": true, - "directAccessGrantsEnabled": false, - "protocol": "openid-connect", - "fullScopeAllowed": true, - "protocolMappers": [ - { - "name": "tenant_id", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "tenant_id", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "tenant_id", - "jsonType.label": "String" - } - }, - { - "name": "audience-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": false, - "config": { - "included.client.audience": "api-template", - "id.token.claim": "false", - "access.token.claim": "true" - } - }, - { - "name": "realm-roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "claim.name": "realm_access.roles", - "multivalued": "true", - "id.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - } - ] - }, - { - "clientId": "api-template", - "name": "API Template Client", - "enabled": true, - "clientAuthenticatorType": "client-secret", - "secret": "dev-client-secret", - "redirectUris": [ - "http://localhost:5174/*", - "http://localhost:8080/*" - ], - "webOrigins": [ - "http://localhost:5174", - "http://localhost:8080" - ], - "attributes": { - "post.logout.redirect.uris": "http://localhost:5174/*##http://localhost:8080/*", - "pkce.code.challenge.method": "S256" - }, - "standardFlowEnabled": true, - "serviceAccountsEnabled": true, - "directAccessGrantsEnabled": false, - "publicClient": false, - "protocol": "openid-connect", - "fullScopeAllowed": true, - "protocolMappers": [ - { - "name": "tenant_id", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "tenant_id", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "tenant_id", - "jsonType.label": "String" - } - }, - { - "name": "audience-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": false, - "config": { - "included.client.audience": "api-template", - "id.token.claim": "false", - "access.token.claim": "true" - } - }, - { - "name": "realm-roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "claim.name": "realm_access.roles", - "multivalued": "true", - "id.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - } - ] - } - ], - "users": [ - { - "username": "admin", - "enabled": true, - "emailVerified": true, - "email": "admin@example.com", - "firstName": "Admin", - "lastName": "User", - "attributes": { - "tenant_id": ["00000000-0000-0000-0000-000000000001"] - }, - "credentials": [ - { - "type": "password", - "value": "Admin123", - "temporary": false - } - ], - "realmRoles": ["PlatformAdmin"] - } - ] -} diff --git a/monolith/API-Template-mono/infrastructure/kubernetes/dragonfly/README.md b/monolith/API-Template-mono/infrastructure/kubernetes/dragonfly/README.md deleted file mode 100644 index 9e44dab8..00000000 --- a/monolith/API-Template-mono/infrastructure/kubernetes/dragonfly/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# DragonFly on Kubernetes - -## Prerequisites - -Install the DragonFly Kubernetes operator via Helm: - -```bash -helm repo add dragonfly https://dragonflydb.github.io/dragonfly-operator/ -helm repo update -helm install dragonfly-operator dragonfly/dragonfly-operator --namespace dragonfly-operator-system --create-namespace -``` - -## Deploy - -```bash -kubectl create namespace apitemplate -kubectl apply -f dragonfly.yml -``` - -## Connection - -The API connects to DragonFly via the operator-managed service: - -``` -dragonfly.apitemplate.svc.cluster.local:6379 -``` - -Set this as the `Dragonfly__ConnectionString` environment variable in your API deployment. - -## How It Works - -The DragonFly operator manages: - -- **Automatic failover** — if the master pod fails, the operator promotes a replica to master -- **Replica management** — maintains the desired replica count -- **Rolling updates** — zero-downtime upgrades when the DragonFly image version changes - -No HAProxy is needed in Kubernetes — the operator handles service routing internally. diff --git a/monolith/API-Template-mono/infrastructure/kubernetes/dragonfly/dragonfly.yml b/monolith/API-Template-mono/infrastructure/kubernetes/dragonfly/dragonfly.yml deleted file mode 100644 index 75b2233d..00000000 --- a/monolith/API-Template-mono/infrastructure/kubernetes/dragonfly/dragonfly.yml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: dragonflydb.io/v1alpha1 -kind: Dragonfly -metadata: - name: dragonfly - namespace: apitemplate -spec: - replicas: 2 - resources: - requests: - cpu: 100m - memory: 512Mi - limits: - cpu: 500m - memory: 512Mi - args: - - "--maxmemory" - - "500mb" - - "--proactor_threads" - - "2" diff --git a/monolith/API-Template-mono/infrastructure/observability/alloy/config.alloy b/monolith/API-Template-mono/infrastructure/observability/alloy/config.alloy deleted file mode 100644 index ecf3747e..00000000 --- a/monolith/API-Template-mono/infrastructure/observability/alloy/config.alloy +++ /dev/null @@ -1,54 +0,0 @@ -logging { - level = "info" - format = "logfmt" -} - -otelcol.receiver.otlp "apitemplate" { - grpc { - endpoint = "0.0.0.0:4317" - } - - http { - endpoint = "0.0.0.0:4318" - } - - output { - metrics = [otelcol.exporter.prometheus.apitemplate.input] - logs = [otelcol.exporter.otlphttp.loki.input] - traces = [otelcol.processor.batch.traces.input] - } -} - -otelcol.processor.batch "traces" { - output { - traces = [otelcol.exporter.otlp.tempo.input] - } -} - -otelcol.exporter.otlp "tempo" { - client { - endpoint = "tempo:4317" - tls { - insecure = true - } - } -} - -otelcol.exporter.otlphttp "loki" { - client { - endpoint = "http://loki:3100/otlp" - tls { - insecure = true - } - } -} - -otelcol.exporter.prometheus "apitemplate" { - forward_to = [prometheus.remote_write.default.receiver] -} - -prometheus.remote_write "default" { - endpoint { - url = "http://prometheus:9090/api/v1/write" - } -} diff --git a/monolith/API-Template-mono/infrastructure/observability/grafana/dashboards/apitemplate-overview.json b/monolith/API-Template-mono/infrastructure/observability/grafana/dashboards/apitemplate-overview.json deleted file mode 100644 index cadbef61..00000000 --- a/monolith/API-Template-mono/infrastructure/observability/grafana/dashboards/apitemplate-overview.json +++ /dev/null @@ -1,464 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "graphTooltip": 0, - "id": null, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "orange", - "value": 0.5 - }, - { - "color": "red", - "value": 1 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - } - }, - "targets": [ - { - "expr": "sum(rate(http_server_request_duration_seconds_count[5m]))", - "refId": "A" - } - ], - "title": "Request Rate", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "orange", - "value": 0.02 - }, - { - "color": "red", - "value": 0.05 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 6, - "y": 0 - }, - "id": 2, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - } - }, - "targets": [ - { - "expr": "sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count[5m])), 1)", - "refId": "A" - } - ], - "title": "5xx Ratio", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "orange", - "value": 0.5 - }, - { - "color": "red", - "value": 0.75 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 12, - "y": 0 - }, - "id": 3, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - } - }, - "targets": [ - { - "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket[5m])))", - "refId": "A" - } - ], - "title": "p95 Latency", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 18, - "y": 0 - }, - "id": 4, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "center", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - } - }, - "targets": [ - { - "expr": "process_runtime_dotnet_gc_heap_size", - "refId": "A" - } - ], - "title": ".NET Heap Size", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 6 - }, - "id": 5, - "targets": [ - { - "expr": "sum by (http_route) (rate(http_server_request_duration_seconds_count[5m]))", - "legendFormat": "{{http_route}}", - "refId": "A" - } - ], - "title": "Requests by Route", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 6 - }, - "id": 6, - "targets": [ - { - "expr": "sum by (http_method, server_address) (rate(http_client_request_duration_seconds_count[5m]))", - "legendFormat": "{{http_method}} {{server_address}}", - "refId": "A" - } - ], - "title": "Outbound HTTP Calls", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 14 - }, - "id": 7, - "targets": [ - { - "expr": "max_over_time(apitemplate_healthcheck_status[5m])", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "Dependency Health", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 14 - }, - "id": 8, - "targets": [ - { - "expr": "{service_name=\"APITemplate\"}", - "queryType": "range", - "refId": "A" - } - ], - "title": "Recent API Logs", - "type": "logs" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "ops" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 0, - "y": 22 - }, - "id": 9, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - } - }, - "targets": [ - { - "expr": "sum(rate(apitemplate_graphql_requests[5m]))", - "refId": "A" - } - ], - "title": "GraphQL Request Rate", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "ms" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 6, - "y": 22 - }, - "id": 10, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - } - }, - "targets": [ - { - "expr": "histogram_quantile(0.95, sum by (le) (rate(apitemplate_graphql_request_duration_bucket[5m]))) * 1000", - "refId": "A" - } - ], - "title": "GraphQL p95", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "ops" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 12, - "y": 22 - }, - "id": 11, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - } - }, - "targets": [ - { - "expr": "sum(rate(apitemplate_rate_limit_rejections[5m]))", - "refId": "A" - } - ], - "title": "Rate Limit Rejections", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 18, - "y": 22 - }, - "id": 12, - "targets": [ - { - "expr": "sum(rate(apitemplate_exceptions_handled[5m])) by (error_code, http_response_status_code)", - "legendFormat": "{{error_code}} / {{http_response_status_code}}", - "refId": "A" - } - ], - "title": "Handled Exceptions", - "type": "timeseries" - } - ], - "refresh": "10s", - "schemaVersion": 41, - "style": "dark", - "tags": ["apitemplate", "observability"], - "templating": { - "list": [] - }, - "time": { - "from": "now-1h", - "to": "now" - }, - "timezone": "browser", - "title": "APITemplate Overview", - "uid": "apitemplate-overview", - "version": 1 -} diff --git a/monolith/API-Template-mono/infrastructure/observability/grafana/grafana.ini b/monolith/API-Template-mono/infrastructure/observability/grafana/grafana.ini deleted file mode 100644 index 903d2170..00000000 --- a/monolith/API-Template-mono/infrastructure/observability/grafana/grafana.ini +++ /dev/null @@ -1,8 +0,0 @@ -[security] -allow_embedding = false - -[auth.anonymous] -enabled = false - -[paths] -provisioning = /etc/grafana/provisioning diff --git a/monolith/API-Template-mono/infrastructure/observability/grafana/provisioning/dashboards/dashboards.yml b/monolith/API-Template-mono/infrastructure/observability/grafana/provisioning/dashboards/dashboards.yml deleted file mode 100644 index 17893a17..00000000 --- a/monolith/API-Template-mono/infrastructure/observability/grafana/provisioning/dashboards/dashboards.yml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: 1 - -providers: - - name: APITemplate - folder: APITemplate - type: file - disableDeletion: false - updateIntervalSeconds: 30 - options: - path: /var/lib/grafana/dashboards diff --git a/monolith/API-Template-mono/infrastructure/observability/grafana/provisioning/datasources/datasources.yml b/monolith/API-Template-mono/infrastructure/observability/grafana/provisioning/datasources/datasources.yml deleted file mode 100644 index 8c6f021d..00000000 --- a/monolith/API-Template-mono/infrastructure/observability/grafana/provisioning/datasources/datasources.yml +++ /dev/null @@ -1,34 +0,0 @@ -apiVersion: 1 - -datasources: - - name: Prometheus - uid: prometheus - type: prometheus - access: proxy - url: http://prometheus:9090 - isDefault: true - editable: false - - - name: Loki - uid: loki - type: loki - access: proxy - url: http://loki:3100 - editable: false - - - name: Tempo - uid: tempo - type: tempo - access: proxy - url: http://tempo:3200 - editable: false - jsonData: - tracesToLogsV2: - datasourceUid: loki - spanStartTimeShift: "-5m" - spanEndTimeShift: "5m" - tags: - - key: service.name - value: service_name - serviceMap: - datasourceUid: prometheus diff --git a/monolith/API-Template-mono/infrastructure/observability/loki/config.yml b/monolith/API-Template-mono/infrastructure/observability/loki/config.yml deleted file mode 100644 index e5e6aabe..00000000 --- a/monolith/API-Template-mono/infrastructure/observability/loki/config.yml +++ /dev/null @@ -1,71 +0,0 @@ -auth_enabled: false - -server: - http_listen_port: 3100 - -pattern_ingester: - enabled: true - -distributor: - otlp_config: - default_resource_attributes_as_index_labels: - - service.name - - service.namespace - - service.version - - deployment.environment.name - - host.name - -common: - path_prefix: /loki - replication_factor: 1 - ring: - kvstore: - store: inmemory - -schema_config: - configs: - - from: 2024-01-01 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h - -storage_config: - filesystem: - directory: /loki/chunks - -ruler: - alertmanager_url: http://localhost:9093 - -limits_config: - allow_structured_metadata: true - volume_enabled: true - discover_log_levels: true - discover_service_name: - - service_name - - service - - app - - application - - name - - app_kubernetes_io_name - - container - - container_name - - component - - workload - - job - otlp_config: - resource_attributes: - ignore_defaults: false - attributes_config: - - action: index_label - attributes: - - service.name - - service.namespace - - service.version - - deployment.environment.name - - host.name - -analytics: - reporting_enabled: false diff --git a/monolith/API-Template-mono/infrastructure/observability/prometheus/prometheus.yml b/monolith/API-Template-mono/infrastructure/observability/prometheus/prometheus.yml deleted file mode 100644 index f3a66c77..00000000 --- a/monolith/API-Template-mono/infrastructure/observability/prometheus/prometheus.yml +++ /dev/null @@ -1,24 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -rule_files: - - /etc/prometheus/rules/*.yml - -scrape_configs: - - job_name: prometheus - static_configs: - - targets: ["prometheus:9090"] - - - job_name: alloy - static_configs: - - targets: ["alloy:12345"] - - - job_name: tempo - static_configs: - - targets: ["tempo:3200"] - - - job_name: loki - metrics_path: /metrics - static_configs: - - targets: ["loki:3100"] diff --git a/monolith/API-Template-mono/infrastructure/observability/prometheus/rules/apitemplate-alerts.yml b/monolith/API-Template-mono/infrastructure/observability/prometheus/rules/apitemplate-alerts.yml deleted file mode 100644 index e65acd92..00000000 --- a/monolith/API-Template-mono/infrastructure/observability/prometheus/rules/apitemplate-alerts.yml +++ /dev/null @@ -1,38 +0,0 @@ -groups: - - name: apitemplate-observability - rules: - - alert: APITemplateTelemetryMissing - expr: absent(http_server_request_duration_seconds_count) - for: 10m - labels: - severity: warning - annotations: - summary: APITemplate telemetry is missing - description: Prometheus has not received request metrics from the API for 10 minutes. - - - alert: APITemplateHigh5xxRate - expr: sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count[5m])), 1) > 0.05 - for: 5m - labels: - severity: critical - annotations: - summary: APITemplate 5xx rate is elevated - description: More than 5% of requests returned 5xx responses over the last 5 minutes. - - - alert: APITemplateHighP95Latency - expr: histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket[5m]))) > 0.75 - for: 10m - labels: - severity: warning - annotations: - summary: APITemplate p95 latency is elevated - description: The API p95 latency has been above 750 ms for 10 minutes. - - - alert: APITemplateBackendHealthDegraded - expr: max_over_time(apitemplate_healthcheck_status{service=~"postgresql|mongodb|dragonfly"}[5m]) < 1 - for: 5m - labels: - severity: critical - annotations: - summary: A backend dependency health check is failing - description: One of the database or cache dependencies has reported an unhealthy state. diff --git a/monolith/API-Template-mono/infrastructure/observability/tempo/config.yml b/monolith/API-Template-mono/infrastructure/observability/tempo/config.yml deleted file mode 100644 index fdbb4945..00000000 --- a/monolith/API-Template-mono/infrastructure/observability/tempo/config.yml +++ /dev/null @@ -1,39 +0,0 @@ -server: - http_listen_port: 3200 - -distributor: - receivers: - otlp: - protocols: - grpc: - endpoint: 0.0.0.0:4317 - http: - endpoint: 0.0.0.0:4318 - -ingester: - max_block_duration: 5m - -compactor: - compaction: - block_retention: 24h - -storage: - trace: - backend: local - wal: - path: /var/tempo/wal - local: - path: /var/tempo/blocks - -metrics_generator: - storage: - path: /var/tempo/generator - processor: - local_blocks: - filter_server_spans: false - flush_to_storage: true - -overrides: - defaults: - metrics_generator: - processors: [service-graphs, span-metrics, local-blocks] diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs b/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs deleted file mode 100644 index efcc325f..00000000 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs +++ /dev/null @@ -1,147 +0,0 @@ -using APITemplate.Infrastructure.Database; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddCategory : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Categories", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - Description = table.Column( - type: "character varying(500)", - maxLength: 500, - nullable: true - ), - CreatedAt = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - }, - constraints: table => - { - table.PrimaryKey("PK_Categories", x => x.Id); - } - ); - - migrationBuilder.CreateTable( - name: "Products", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: false - ), - Description = table.Column( - type: "character varying(1000)", - maxLength: 1000, - nullable: true - ), - Price = table.Column( - type: "numeric(18,2)", - precision: 18, - scale: 2, - nullable: false - ), - CreatedAt = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CategoryId = table.Column(type: "uuid", nullable: true), - }, - constraints: table => - { - table.PrimaryKey("PK_Products", x => x.Id); - table.ForeignKey( - name: "FK_Products_Categories_CategoryId", - column: x => x.CategoryId, - principalTable: "Categories", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull - ); - } - ); - - migrationBuilder.CreateTable( - name: "ProductReviews", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - ProductId = table.Column(type: "uuid", nullable: false), - ReviewerName = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - Comment = table.Column( - type: "character varying(2000)", - maxLength: 2000, - nullable: true - ), - Rating = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - }, - constraints: table => - { - table.PrimaryKey("PK_ProductReviews", x => x.Id); - table.ForeignKey( - name: "FK_ProductReviews_Products_ProductId", - column: x => x.ProductId, - principalTable: "Products", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_ProductReviews_ProductId", - table: "ProductReviews", - column: "ProductId" - ); - - migrationBuilder.CreateIndex( - name: "IX_Products_CategoryId", - table: "Products", - column: "CategoryId" - ); - - migrationBuilder.Sql( - SqlResource.Load("Procedures.get_product_category_stats_v1_up.sql") - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql( - SqlResource.Load("Procedures.get_product_category_stats_v1_down.sql") - ); - - migrationBuilder.DropTable(name: "ProductReviews"); - migrationBuilder.DropTable(name: "Products"); - migrationBuilder.DropTable(name: "Categories"); - } - } -} diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs b/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs deleted file mode 100644 index a8b80f09..00000000 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddTenantInvitationNormalizedEmail : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "PasswordResetTokens", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - UserId = table.Column(type: "uuid", nullable: false), - TokenHash = table.Column( - type: "character varying(128)", - maxLength: 128, - nullable: false - ), - ExpiresAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false - ), - IsUsed = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_PasswordResetTokens", x => x.Id); - table.CheckConstraint( - "CK_PasswordResetTokens_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - table.ForeignKey( - name: "FK_PasswordResetTokens_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); - } - ); - - migrationBuilder.CreateTable( - name: "TenantInvitations", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Email = table.Column( - type: "character varying(320)", - maxLength: 320, - nullable: false - ), - NormalizedEmail = table.Column( - type: "character varying(320)", - maxLength: 320, - nullable: false - ), - TokenHash = table.Column( - type: "character varying(128)", - maxLength: 128, - nullable: false - ), - ExpiresAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false - ), - Status = table.Column( - type: "character varying(32)", - maxLength: 32, - nullable: false, - defaultValue: "Pending" - ), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_TenantInvitations", x => x.Id); - table.CheckConstraint( - "CK_TenantInvitations_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - table.ForeignKey( - name: "FK_TenantInvitations_Tenants_TenantId", - column: x => x.TenantId, - principalTable: "Tenants", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_PasswordResetTokens_TenantId", - table: "PasswordResetTokens", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_PasswordResetTokens_TenantId_IsDeleted", - table: "PasswordResetTokens", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_PasswordResetTokens_TokenHash", - table: "PasswordResetTokens", - column: "TokenHash" - ); - - migrationBuilder.CreateIndex( - name: "IX_PasswordResetTokens_UserId", - table: "PasswordResetTokens", - column: "UserId" - ); - - migrationBuilder.CreateIndex( - name: "IX_TenantInvitations_TenantId", - table: "TenantInvitations", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_TenantInvitations_TenantId_IsDeleted", - table: "TenantInvitations", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_TenantInvitations_TenantId_NormalizedEmail", - table: "TenantInvitations", - columns: new[] { "TenantId", "NormalizedEmail" } - ); - - migrationBuilder.CreateIndex( - name: "IX_TenantInvitations_TokenHash", - table: "TenantInvitations", - column: "TokenHash" - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "PasswordResetTokens"); - - migrationBuilder.DropTable(name: "TenantInvitations"); - } - } -} diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs b/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs deleted file mode 100644 index 4ec35f91..00000000 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class DropPasswordResetTokensTable : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "PasswordResetTokens"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "PasswordResetTokens", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - UserId = table.Column(type: "uuid", nullable: false), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - ExpiresAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - IsUsed = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - TenantId = table.Column(type: "uuid", nullable: false), - TokenHash = table.Column( - type: "character varying(128)", - maxLength: 128, - nullable: false - ), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - }, - constraints: table => - { - table.PrimaryKey("PK_PasswordResetTokens", x => x.Id); - table.CheckConstraint( - "CK_PasswordResetTokens_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - table.ForeignKey( - name: "FK_PasswordResetTokens_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_PasswordResetTokens_TenantId", - table: "PasswordResetTokens", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_PasswordResetTokens_TenantId_IsDeleted", - table: "PasswordResetTokens", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_PasswordResetTokens_TokenHash", - table: "PasswordResetTokens", - column: "TokenHash" - ); - - migrationBuilder.CreateIndex( - name: "IX_PasswordResetTokens_UserId", - table: "PasswordResetTokens", - column: "UserId" - ); - } - } -} diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs b/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs deleted file mode 100644 index 091b25a7..00000000 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs +++ /dev/null @@ -1,59 +0,0 @@ -using APITemplate.Domain.Entities; -using Kot.MongoDB.Migrations; -using MongoDB.Driver; - -namespace APITemplate.Infrastructure.Migrations; - -public sealed class M002_AddProductDataSoftDeleteIndexes : MongoMigration -{ - public M002_AddProductDataSoftDeleteIndexes() - : base("1.1.0") { } - - public override Task UpAsync( - IMongoDatabase db, - IClientSessionHandle session, - CancellationToken ct - ) - { - var collection = db.GetCollection("product_data"); - - return collection.Indexes.CreateManyAsync( - [ - new CreateIndexModel( - Builders - .IndexKeys.Ascending(x => x.TenantId) - .Ascending(x => x.IsDeleted) - .Ascending("_t"), - new CreateIndexOptions { Name = "idx_tenant_is_deleted_type" } - ), - new CreateIndexModel( - Builders - .IndexKeys.Ascending(x => x.TenantId) - .Ascending(x => x.IsDeleted) - .Descending(x => x.CreatedAt), - new CreateIndexOptions { Name = "idx_tenant_is_deleted_created" } - ), - new CreateIndexModel( - Builders - .IndexKeys.Ascending(x => x.TenantId) - .Ascending(x => x.Id) - .Ascending(x => x.IsDeleted), - new CreateIndexOptions { Name = "idx_tenant_id_is_deleted" } - ), - ], - ct - ); - } - - public override async Task DownAsync( - IMongoDatabase db, - IClientSessionHandle session, - CancellationToken ct - ) - { - var collection = db.GetCollection("product_data"); - await collection.Indexes.DropOneAsync("idx_tenant_is_deleted_type", ct); - await collection.Indexes.DropOneAsync("idx_tenant_is_deleted_created", ct); - await collection.Indexes.DropOneAsync("idx_tenant_id_is_deleted", ct); - } -} diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/UnitOfWorkMockExtensions.cs b/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/UnitOfWorkMockExtensions.cs deleted file mode 100644 index 42395dda..00000000 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/UnitOfWorkMockExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using APITemplate.Domain.Interfaces; -using APITemplate.Domain.Options; -using Moq; - -namespace APITemplate.Tests.Unit.Handlers; - -internal static class UnitOfWorkMockExtensions -{ - public static void SetupImmediateTransactionExecution(this Mock unitOfWorkMock) - { - unitOfWorkMock - .Setup(u => - u.ExecuteInTransactionAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny() - ) - ) - .Returns((Func action, CancellationToken _, TransactionOptions? _) => action()); - } - - public static void SetupImmediateTransactionExecution(this Mock unitOfWorkMock) - { - unitOfWorkMock - .Setup(u => - u.ExecuteInTransactionAsync( - It.IsAny>>(), - It.IsAny(), - It.IsAny() - ) - ) - .Returns( - (Func> action, CancellationToken _, TransactionOptions? _) => action() - ); - } -} diff --git a/monolith/API-Template-mono/src/APITemplate.Api/APITemplate.Api.csproj b/src/APITemplate.Api/APITemplate.Api.csproj similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/APITemplate.Api.csproj rename to src/APITemplate.Api/APITemplate.Api.csproj diff --git a/monolith/API-Template-mono/src/APITemplate.Api/APITemplate.http b/src/APITemplate.Api/APITemplate.http similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/APITemplate.http rename to src/APITemplate.Api/APITemplate.http diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs b/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs rename to src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs b/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs rename to src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs b/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs rename to src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs b/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs rename to src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs b/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs rename to src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/CachingOptions.cs b/src/APITemplate.Api/Api/Cache/CachingOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/CachingOptions.cs rename to src/APITemplate.Api/Api/Cache/CachingOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs b/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs rename to src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs b/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs rename to src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs b/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs rename to src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs b/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs rename to src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs b/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs rename to src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/BffController.cs b/src/APITemplate.Api/Api/Controllers/V1/BffController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/BffController.cs rename to src/APITemplate.Api/Api/Controllers/V1/BffController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs b/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs rename to src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/FilesController.cs b/src/APITemplate.Api/Api/Controllers/V1/FilesController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/FilesController.cs rename to src/APITemplate.Api/Api/Controllers/V1/FilesController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs b/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs rename to src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs b/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs rename to src/APITemplate.Api/Api/Controllers/V1/JobsController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs b/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs rename to src/APITemplate.Api/Api/Controllers/V1/PatchController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs b/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs rename to src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs b/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs rename to src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs b/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs rename to src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/SseController.cs b/src/APITemplate.Api/Api/Controllers/V1/SseController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/SseController.cs rename to src/APITemplate.Api/Api/Controllers/V1/SseController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs b/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs rename to src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs b/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs rename to src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs b/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs rename to src/APITemplate.Api/Api/Controllers/V1/UsersController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs b/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs rename to src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs b/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs rename to src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs b/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs rename to src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs similarity index 90% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs rename to src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs index 05638a78..6b77de85 100644 --- a/monolith/API-Template-mono/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs +++ b/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs @@ -19,15 +19,13 @@ internal static partial class ApiExceptionHandlerLogs [LoggerMessage( EventId = 1001, Level = LogLevel.Error, - Message = "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" - )] + Message = "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}")] public static partial void UnhandledException( this ILogger logger, Exception exception, int statusCode, [SensitiveData] string errorCode, - [PersonalData] string traceId - ); + [PersonalData] string traceId); /// /// Logs a handled application exception (typically HTTP 4xx). @@ -40,13 +38,11 @@ [PersonalData] string traceId [LoggerMessage( EventId = 1002, Level = LogLevel.Warning, - Message = "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" - )] + Message = "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}")] public static partial void HandledApplicationException( this ILogger logger, Exception exception, int statusCode, [SensitiveData] string errorCode, - [PersonalData] string traceId - ); + [PersonalData] string traceId); } diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs b/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs rename to src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs b/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs rename to src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs b/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs rename to src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs b/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs rename to src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs b/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs rename to src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Webhooks/ValidateWebhookSignatureAttribute.cs b/src/APITemplate.Api/Api/Filters/Webhooks/ValidateWebhookSignatureAttribute.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Webhooks/ValidateWebhookSignatureAttribute.cs rename to src/APITemplate.Api/Api/Filters/Webhooks/ValidateWebhookSignatureAttribute.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Webhooks/WebhookSignatureResourceFilter.cs b/src/APITemplate.Api/Api/Filters/Webhooks/WebhookSignatureResourceFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Filters/Webhooks/WebhookSignatureResourceFilter.cs rename to src/APITemplate.Api/Api/Filters/Webhooks/WebhookSignatureResourceFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs b/src/APITemplate.Api/Api/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs rename to src/APITemplate.Api/Api/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/ErrorOrGraphQLExtensions.cs b/src/APITemplate.Api/Api/GraphQL/ErrorOrGraphQLExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/ErrorOrGraphQLExtensions.cs rename to src/APITemplate.Api/Api/GraphQL/ErrorOrGraphQLExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Instrumentation/GraphQlExecutionMetricsListener.cs b/src/APITemplate.Api/Api/GraphQL/Instrumentation/GraphQlExecutionMetricsListener.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Instrumentation/GraphQlExecutionMetricsListener.cs rename to src/APITemplate.Api/Api/GraphQL/Instrumentation/GraphQlExecutionMetricsListener.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/CategoryPageResult.cs b/src/APITemplate.Api/Api/GraphQL/Models/CategoryPageResult.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/CategoryPageResult.cs rename to src/APITemplate.Api/Api/GraphQL/Models/CategoryPageResult.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/CategoryQueryInput.cs b/src/APITemplate.Api/Api/GraphQL/Models/CategoryQueryInput.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/CategoryQueryInput.cs rename to src/APITemplate.Api/Api/GraphQL/Models/CategoryQueryInput.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/ProductPageResult.cs b/src/APITemplate.Api/Api/GraphQL/Models/ProductPageResult.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/ProductPageResult.cs rename to src/APITemplate.Api/Api/GraphQL/Models/ProductPageResult.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/ProductQueryInput.cs b/src/APITemplate.Api/Api/GraphQL/Models/ProductQueryInput.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/ProductQueryInput.cs rename to src/APITemplate.Api/Api/GraphQL/Models/ProductQueryInput.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewPageResult.cs b/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewPageResult.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewPageResult.cs rename to src/APITemplate.Api/Api/GraphQL/Models/ProductReviewPageResult.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewQueryInput.cs b/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewQueryInput.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewQueryInput.cs rename to src/APITemplate.Api/Api/GraphQL/Models/ProductReviewQueryInput.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Mutations/ProductMutations.cs b/src/APITemplate.Api/Api/GraphQL/Mutations/ProductMutations.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Mutations/ProductMutations.cs rename to src/APITemplate.Api/Api/GraphQL/Mutations/ProductMutations.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Mutations/ProductReviewMutations.cs b/src/APITemplate.Api/Api/GraphQL/Mutations/ProductReviewMutations.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Mutations/ProductReviewMutations.cs rename to src/APITemplate.Api/Api/GraphQL/Mutations/ProductReviewMutations.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Queries/CategoryQueries.cs b/src/APITemplate.Api/Api/GraphQL/Queries/CategoryQueries.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Queries/CategoryQueries.cs rename to src/APITemplate.Api/Api/GraphQL/Queries/CategoryQueries.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Queries/ProductQueries.cs b/src/APITemplate.Api/Api/GraphQL/Queries/ProductQueries.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Queries/ProductQueries.cs rename to src/APITemplate.Api/Api/GraphQL/Queries/ProductQueries.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Queries/ProductReviewQueries.cs b/src/APITemplate.Api/Api/GraphQL/Queries/ProductReviewQueries.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Queries/ProductReviewQueries.cs rename to src/APITemplate.Api/Api/GraphQL/Queries/ProductReviewQueries.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Types/ProductReviewType.cs b/src/APITemplate.Api/Api/GraphQL/Types/ProductReviewType.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Types/ProductReviewType.cs rename to src/APITemplate.Api/Api/GraphQL/Types/ProductReviewType.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Types/ProductType.cs b/src/APITemplate.Api/Api/GraphQL/Types/ProductType.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Types/ProductType.cs rename to src/APITemplate.Api/Api/GraphQL/Types/ProductType.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Types/ProductTypeResolvers.cs b/src/APITemplate.Api/Api/GraphQL/Types/ProductTypeResolvers.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/GraphQL/Types/ProductTypeResolvers.cs rename to src/APITemplate.Api/Api/GraphQL/Types/ProductTypeResolvers.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Middleware/CsrfValidationMiddleware.cs b/src/APITemplate.Api/Api/Middleware/CsrfValidationMiddleware.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Middleware/CsrfValidationMiddleware.cs rename to src/APITemplate.Api/Api/Middleware/CsrfValidationMiddleware.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Middleware/RequestContextMiddleware.cs b/src/APITemplate.Api/Api/Middleware/RequestContextMiddleware.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Middleware/RequestContextMiddleware.cs rename to src/APITemplate.Api/Api/Middleware/RequestContextMiddleware.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs b/src/APITemplate.Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs rename to src/APITemplate.Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs b/src/APITemplate.Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs rename to src/APITemplate.Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs b/src/APITemplate.Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs rename to src/APITemplate.Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/OpenApiErrorResponseHelper.cs b/src/APITemplate.Api/Api/OpenApi/OpenApiErrorResponseHelper.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/OpenApiErrorResponseHelper.cs rename to src/APITemplate.Api/Api/OpenApi/OpenApiErrorResponseHelper.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs b/src/APITemplate.Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs rename to src/APITemplate.Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Api/Requests/FileUploadRequest.cs b/src/APITemplate.Api/Api/Requests/FileUploadRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Api/Requests/FileUploadRequest.cs rename to src/APITemplate.Api/Api/Requests/FileUploadRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Dockerfile b/src/APITemplate.Api/Dockerfile similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Dockerfile rename to src/APITemplate.Api/Dockerfile diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/ApiServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/ApiServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/ApiServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/ApiServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/AuthenticationServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/AuthenticationServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/AuthenticationServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/AuthenticationServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/BackgroundJobsServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/BackgroundJobsServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/BackgroundJobsServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/BackgroundJobsServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/Configuration/ConfigurationExtensions.cs b/src/APITemplate.Api/Extensions/Configuration/ConfigurationExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/Configuration/ConfigurationExtensions.cs rename to src/APITemplate.Api/Extensions/Configuration/ConfigurationExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/Configuration/ConfigurationSections.cs b/src/APITemplate.Api/Extensions/Configuration/ConfigurationSections.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/Configuration/ConfigurationSections.cs rename to src/APITemplate.Api/Extensions/Configuration/ConfigurationSections.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/Configuration/KeycloakStartupLogs.cs b/src/APITemplate.Api/Extensions/Configuration/KeycloakStartupLogs.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/Configuration/KeycloakStartupLogs.cs rename to src/APITemplate.Api/Extensions/Configuration/KeycloakStartupLogs.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/Configuration/ServiceCollectionOptionsExtensions.cs b/src/APITemplate.Api/Extensions/Configuration/ServiceCollectionOptionsExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/Configuration/ServiceCollectionOptionsExtensions.cs rename to src/APITemplate.Api/Extensions/Configuration/ServiceCollectionOptionsExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/ControllerExtensions.cs b/src/APITemplate.Api/Extensions/ControllerExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/ControllerExtensions.cs rename to src/APITemplate.Api/Extensions/ControllerExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/EmailServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/EmailServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/EmailServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/EmailServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/GraphQLServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/GraphQLServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/GraphQLServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/GraphQLServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/InfrastructureServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/InfrastructureServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/InfrastructureServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/InfrastructureServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/KeycloakAdminServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/KeycloakAdminServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/KeycloakAdminServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/KeycloakAdminServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/ObservabilityServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/ObservabilityServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/ObservabilityServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/ObservabilityServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/PersistenceServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/PersistenceServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/PersistenceServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/PersistenceServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/Resilience/ResilienceDefaults.cs b/src/APITemplate.Api/Extensions/Resilience/ResilienceDefaults.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/Resilience/ResilienceDefaults.cs rename to src/APITemplate.Api/Extensions/Resilience/ResilienceDefaults.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/ServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/ServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/ServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/ServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/Startup/ApplicationBuilderExtensions.cs b/src/APITemplate.Api/Extensions/Startup/ApplicationBuilderExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/Startup/ApplicationBuilderExtensions.cs rename to src/APITemplate.Api/Extensions/Startup/ApplicationBuilderExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/Startup/LoggingExtensions.cs b/src/APITemplate.Api/Extensions/Startup/LoggingExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/Startup/LoggingExtensions.cs rename to src/APITemplate.Api/Extensions/Startup/LoggingExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/WebhookServiceCollectionExtensions.cs b/src/APITemplate.Api/Extensions/WebhookServiceCollectionExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/WebhookServiceCollectionExtensions.cs rename to src/APITemplate.Api/Extensions/WebhookServiceCollectionExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/WolverineHandlerChainExtensions.cs b/src/APITemplate.Api/Extensions/WolverineHandlerChainExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/WolverineHandlerChainExtensions.cs rename to src/APITemplate.Api/Extensions/WolverineHandlerChainExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Extensions/WolverineTypeExtensions.cs b/src/APITemplate.Api/Extensions/WolverineTypeExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Extensions/WolverineTypeExtensions.cs rename to src/APITemplate.Api/Extensions/WolverineTypeExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/GlobalUsings.cs b/src/APITemplate.Api/GlobalUsings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/GlobalUsings.cs rename to src/APITemplate.Api/GlobalUsings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Program.cs b/src/APITemplate.Api/Program.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Program.cs rename to src/APITemplate.Api/Program.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Api/Properties/launchSettings.json b/src/APITemplate.Api/Properties/launchSettings.json similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/Properties/launchSettings.json rename to src/APITemplate.Api/Properties/launchSettings.json diff --git a/src/APITemplate.Api/appsettings.Development.json b/src/APITemplate.Api/appsettings.Development.json new file mode 100644 index 00000000..eaee0f10 --- /dev/null +++ b/src/APITemplate.Api/appsettings.Development.json @@ -0,0 +1,43 @@ +{ + "Dragonfly": { + "ConnectionString": "localhost:6379" + }, + "Keycloak": { + "realm": "api-template", + "auth-server-url": "http://localhost:8180/", + "ssl-required": "none", + "resource": "api-template", + "verify-token-audience": true, + "credentials": { + "secret": "dev-client-secret" + }, + "confidential-port": 0 + }, + "Email": { + "SmtpHost": "localhost", + "SmtpPort": 1025, + "UseSsl": false, + "SenderEmail": "noreply@apitemplate.local", + "SenderName": "APITemplate (Dev)", + "BaseUrl": "http://localhost:5000" + }, + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "HmacKey": "mV7XhO9YXNw1fGKxvRrQz6CkKUL5jvN3i8A0Jv3cL2Q=", + "HmacKeyNote": "Development/debug only. Do not use this key in Release/Production; prefer APITEMPLATE_REDACTION_HMAC_KEY environment variable.", + "KeyId": 1001 + }, + "Webhook": { + "Secret": "dev-webhook-secret-at-least-16-chars" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Information" + } + } + } +} diff --git a/monolith/API-Template-mono/src/APITemplate.Api/appsettings.Production.json b/src/APITemplate.Api/appsettings.Production.json similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/appsettings.Production.json rename to src/APITemplate.Api/appsettings.Production.json diff --git a/monolith/API-Template-mono/src/APITemplate.Api/appsettings.json b/src/APITemplate.Api/appsettings.json similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Api/appsettings.json rename to src/APITemplate.Api/appsettings.json diff --git a/monolith/API-Template-mono/src/APITemplate.Application/APITemplate.Application.csproj b/src/APITemplate.Application/APITemplate.Application.csproj similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/APITemplate.Application.csproj rename to src/APITemplate.Application/APITemplate.Application.csproj diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/ICleanupService.cs b/src/APITemplate.Application/Common/BackgroundJobs/ICleanupService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/ICleanupService.cs rename to src/APITemplate.Application/Common/BackgroundJobs/ICleanupService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IEmailRetryService.cs b/src/APITemplate.Application/Common/BackgroundJobs/IEmailRetryService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IEmailRetryService.cs rename to src/APITemplate.Application/Common/BackgroundJobs/IEmailRetryService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IExternalIntegrationSyncService.cs b/src/APITemplate.Application/Common/BackgroundJobs/IExternalIntegrationSyncService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IExternalIntegrationSyncService.cs rename to src/APITemplate.Application/Common/BackgroundJobs/IExternalIntegrationSyncService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IJobQueue.cs b/src/APITemplate.Application/Common/BackgroundJobs/IJobQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IJobQueue.cs rename to src/APITemplate.Application/Common/BackgroundJobs/IJobQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IOutgoingWebhookQueue.cs b/src/APITemplate.Application/Common/BackgroundJobs/IOutgoingWebhookQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IOutgoingWebhookQueue.cs rename to src/APITemplate.Application/Common/BackgroundJobs/IOutgoingWebhookQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IQueue.cs b/src/APITemplate.Application/Common/BackgroundJobs/IQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IQueue.cs rename to src/APITemplate.Application/Common/BackgroundJobs/IQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IQueueReader.cs b/src/APITemplate.Application/Common/BackgroundJobs/IQueueReader.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IQueueReader.cs rename to src/APITemplate.Application/Common/BackgroundJobs/IQueueReader.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IRecurringBackgroundJobRegistration.cs b/src/APITemplate.Application/Common/BackgroundJobs/IRecurringBackgroundJobRegistration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IRecurringBackgroundJobRegistration.cs rename to src/APITemplate.Application/Common/BackgroundJobs/IRecurringBackgroundJobRegistration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IReindexService.cs b/src/APITemplate.Application/Common/BackgroundJobs/IReindexService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IReindexService.cs rename to src/APITemplate.Application/Common/BackgroundJobs/IReindexService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IWebhookProcessingQueue.cs b/src/APITemplate.Application/Common/BackgroundJobs/IWebhookProcessingQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/IWebhookProcessingQueue.cs rename to src/APITemplate.Application/Common/BackgroundJobs/IWebhookProcessingQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/RecurringBackgroundJobDefinition.cs b/src/APITemplate.Application/Common/BackgroundJobs/RecurringBackgroundJobDefinition.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/BackgroundJobs/RecurringBackgroundJobDefinition.cs rename to src/APITemplate.Application/Common/BackgroundJobs/RecurringBackgroundJobDefinition.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/BatchFailureContext.cs b/src/APITemplate.Application/Common/Batch/BatchFailureContext.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/BatchFailureContext.cs rename to src/APITemplate.Application/Common/Batch/BatchFailureContext.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/BatchFailureMerge.cs b/src/APITemplate.Application/Common/Batch/BatchFailureMerge.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/BatchFailureMerge.cs rename to src/APITemplate.Application/Common/Batch/BatchFailureMerge.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/EntityLookup.cs b/src/APITemplate.Application/Common/Batch/EntityLookup.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/EntityLookup.cs rename to src/APITemplate.Application/Common/Batch/EntityLookup.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/IBatchRule.cs b/src/APITemplate.Application/Common/Batch/IBatchRule.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/IBatchRule.cs rename to src/APITemplate.Application/Common/Batch/IBatchRule.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/Rules/FluentValidationBatchRule.cs b/src/APITemplate.Application/Common/Batch/Rules/FluentValidationBatchRule.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/Rules/FluentValidationBatchRule.cs rename to src/APITemplate.Application/Common/Batch/Rules/FluentValidationBatchRule.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/Rules/MarkMissingByIdBatchRule.cs b/src/APITemplate.Application/Common/Batch/Rules/MarkMissingByIdBatchRule.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Batch/Rules/MarkMissingByIdBatchRule.cs rename to src/APITemplate.Application/Common/Batch/Rules/MarkMissingByIdBatchRule.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Context/IActorProvider.cs b/src/APITemplate.Application/Common/Context/IActorProvider.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Context/IActorProvider.cs rename to src/APITemplate.Application/Common/Context/IActorProvider.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Context/ITenantProvider.cs b/src/APITemplate.Application/Common/Context/ITenantProvider.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Context/ITenantProvider.cs rename to src/APITemplate.Application/Common/Context/ITenantProvider.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IDateRangeFilter.cs b/src/APITemplate.Application/Common/Contracts/IDateRangeFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IDateRangeFilter.cs rename to src/APITemplate.Application/Common/Contracts/IDateRangeFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IFileStorageService.cs b/src/APITemplate.Application/Common/Contracts/IFileStorageService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IFileStorageService.cs rename to src/APITemplate.Application/Common/Contracts/IFileStorageService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IIdempotencyStore.cs b/src/APITemplate.Application/Common/Contracts/IIdempotencyStore.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IIdempotencyStore.cs rename to src/APITemplate.Application/Common/Contracts/IIdempotencyStore.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IProductRequest.cs b/src/APITemplate.Application/Common/Contracts/IProductRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IProductRequest.cs rename to src/APITemplate.Application/Common/Contracts/IProductRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/ISortableFilter.cs b/src/APITemplate.Application/Common/Contracts/ISortableFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/ISortableFilter.cs rename to src/APITemplate.Application/Common/Contracts/ISortableFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IWebhookEventHandler.cs b/src/APITemplate.Application/Common/Contracts/IWebhookEventHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IWebhookEventHandler.cs rename to src/APITemplate.Application/Common/Contracts/IWebhookEventHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IWebhookPayloadSigner.cs b/src/APITemplate.Application/Common/Contracts/IWebhookPayloadSigner.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IWebhookPayloadSigner.cs rename to src/APITemplate.Application/Common/Contracts/IWebhookPayloadSigner.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IWebhookPayloadValidator.cs b/src/APITemplate.Application/Common/Contracts/IWebhookPayloadValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Contracts/IWebhookPayloadValidator.cs rename to src/APITemplate.Application/Common/Contracts/IWebhookPayloadValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/BatchDeleteRequest.cs b/src/APITemplate.Application/Common/DTOs/BatchDeleteRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/BatchDeleteRequest.cs rename to src/APITemplate.Application/Common/DTOs/BatchDeleteRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/BatchResponse.cs b/src/APITemplate.Application/Common/DTOs/BatchResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/BatchResponse.cs rename to src/APITemplate.Application/Common/DTOs/BatchResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/IHasFacets.cs b/src/APITemplate.Application/Common/DTOs/IHasFacets.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/IHasFacets.cs rename to src/APITemplate.Application/Common/DTOs/IHasFacets.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/IPagedItems.cs b/src/APITemplate.Application/Common/DTOs/IPagedItems.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/IPagedItems.cs rename to src/APITemplate.Application/Common/DTOs/IPagedItems.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/PaginationFilter.cs b/src/APITemplate.Application/Common/DTOs/PaginationFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/DTOs/PaginationFilter.cs rename to src/APITemplate.Application/Common/DTOs/PaginationFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Email/EmailMessage.cs b/src/APITemplate.Application/Common/Email/EmailMessage.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Email/EmailMessage.cs rename to src/APITemplate.Application/Common/Email/EmailMessage.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Email/EmailTemplateNames.cs b/src/APITemplate.Application/Common/Email/EmailTemplateNames.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Email/EmailTemplateNames.cs rename to src/APITemplate.Application/Common/Email/EmailTemplateNames.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Email/IEmailQueue.cs b/src/APITemplate.Application/Common/Email/IEmailQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Email/IEmailQueue.cs rename to src/APITemplate.Application/Common/Email/IEmailQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Email/IEmailSender.cs b/src/APITemplate.Application/Common/Email/IEmailSender.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Email/IEmailSender.cs rename to src/APITemplate.Application/Common/Email/IEmailSender.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Email/IEmailTemplateRenderer.cs b/src/APITemplate.Application/Common/Email/IEmailTemplateRenderer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Email/IEmailTemplateRenderer.cs rename to src/APITemplate.Application/Common/Email/IEmailTemplateRenderer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Email/IFailedEmailStore.cs b/src/APITemplate.Application/Common/Email/IFailedEmailStore.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Email/IFailedEmailStore.cs rename to src/APITemplate.Application/Common/Email/IFailedEmailStore.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Email/ISecureTokenGenerator.cs b/src/APITemplate.Application/Common/Email/ISecureTokenGenerator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Email/ISecureTokenGenerator.cs rename to src/APITemplate.Application/Common/Email/ISecureTokenGenerator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Errors/DomainErrors.cs b/src/APITemplate.Application/Common/Errors/DomainErrors.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Errors/DomainErrors.cs rename to src/APITemplate.Application/Common/Errors/DomainErrors.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Errors/ErrorCatalog.cs b/src/APITemplate.Application/Common/Errors/ErrorCatalog.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Errors/ErrorCatalog.cs rename to src/APITemplate.Application/Common/Errors/ErrorCatalog.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Events/CacheEvents.cs b/src/APITemplate.Application/Common/Events/CacheEvents.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Events/CacheEvents.cs rename to src/APITemplate.Application/Common/Events/CacheEvents.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Events/CacheTags.cs b/src/APITemplate.Application/Common/Events/CacheTags.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Events/CacheTags.cs rename to src/APITemplate.Application/Common/Events/CacheTags.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Events/EmailEvents.cs b/src/APITemplate.Application/Common/Events/EmailEvents.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Events/EmailEvents.cs rename to src/APITemplate.Application/Common/Events/EmailEvents.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Events/MessageBusExtensions.cs b/src/APITemplate.Application/Common/Events/MessageBusExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Events/MessageBusExtensions.cs rename to src/APITemplate.Application/Common/Events/MessageBusExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Events/SoftDeleteEvents.cs b/src/APITemplate.Application/Common/Events/SoftDeleteEvents.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Events/SoftDeleteEvents.cs rename to src/APITemplate.Application/Common/Events/SoftDeleteEvents.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Events/TenantInvitationEmailHandler.cs b/src/APITemplate.Application/Common/Events/TenantInvitationEmailHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Events/TenantInvitationEmailHandler.cs rename to src/APITemplate.Application/Common/Events/TenantInvitationEmailHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Events/UserRegisteredEmailHandler.cs b/src/APITemplate.Application/Common/Events/UserRegisteredEmailHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Events/UserRegisteredEmailHandler.cs rename to src/APITemplate.Application/Common/Events/UserRegisteredEmailHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Events/UserRoleChangedEmailHandler.cs b/src/APITemplate.Application/Common/Events/UserRoleChangedEmailHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Events/UserRoleChangedEmailHandler.cs rename to src/APITemplate.Application/Common/Events/UserRoleChangedEmailHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Extensions/RepositoryExtensions.cs b/src/APITemplate.Application/Common/Extensions/RepositoryExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Extensions/RepositoryExtensions.cs rename to src/APITemplate.Application/Common/Extensions/RepositoryExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Http/RateLimitPolicies.cs b/src/APITemplate.Application/Common/Http/RateLimitPolicies.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Http/RateLimitPolicies.cs rename to src/APITemplate.Application/Common/Http/RateLimitPolicies.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Http/RequestContextConstants.cs b/src/APITemplate.Application/Common/Http/RequestContextConstants.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Http/RequestContextConstants.cs rename to src/APITemplate.Application/Common/Http/RequestContextConstants.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Middleware/ErrorOrValidationMiddleware.cs b/src/APITemplate.Application/Common/Middleware/ErrorOrValidationMiddleware.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Middleware/ErrorOrValidationMiddleware.cs rename to src/APITemplate.Application/Common/Middleware/ErrorOrValidationMiddleware.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/AppOptions.cs b/src/APITemplate.Application/Common/Options/AppOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/AppOptions.cs rename to src/APITemplate.Application/Common/Options/AppOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/BackgroundJobsOptions.cs b/src/APITemplate.Application/Common/Options/BackgroundJobs/BackgroundJobsOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/BackgroundJobsOptions.cs rename to src/APITemplate.Application/Common/Options/BackgroundJobs/BackgroundJobsOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/CleanupJobOptions.cs b/src/APITemplate.Application/Common/Options/BackgroundJobs/CleanupJobOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/CleanupJobOptions.cs rename to src/APITemplate.Application/Common/Options/BackgroundJobs/CleanupJobOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/EmailRetryJobOptions.cs b/src/APITemplate.Application/Common/Options/BackgroundJobs/EmailRetryJobOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/EmailRetryJobOptions.cs rename to src/APITemplate.Application/Common/Options/BackgroundJobs/EmailRetryJobOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/ExternalSyncJobOptions.cs b/src/APITemplate.Application/Common/Options/BackgroundJobs/ExternalSyncJobOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/ExternalSyncJobOptions.cs rename to src/APITemplate.Application/Common/Options/BackgroundJobs/ExternalSyncJobOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/ReindexJobOptions.cs b/src/APITemplate.Application/Common/Options/BackgroundJobs/ReindexJobOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/ReindexJobOptions.cs rename to src/APITemplate.Application/Common/Options/BackgroundJobs/ReindexJobOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/TickerQSchedulerOptions.cs b/src/APITemplate.Application/Common/Options/BackgroundJobs/TickerQSchedulerOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BackgroundJobs/TickerQSchedulerOptions.cs rename to src/APITemplate.Application/Common/Options/BackgroundJobs/TickerQSchedulerOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BootstrapTenantOptions.cs b/src/APITemplate.Application/Common/Options/BootstrapTenantOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/BootstrapTenantOptions.cs rename to src/APITemplate.Application/Common/Options/BootstrapTenantOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/DragonflyOptions.cs b/src/APITemplate.Application/Common/Options/Infrastructure/DragonflyOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/DragonflyOptions.cs rename to src/APITemplate.Application/Common/Options/Infrastructure/DragonflyOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/EmailOptions.cs b/src/APITemplate.Application/Common/Options/Infrastructure/EmailOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/EmailOptions.cs rename to src/APITemplate.Application/Common/Options/Infrastructure/EmailOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/FileStorageOptions.cs b/src/APITemplate.Application/Common/Options/Infrastructure/FileStorageOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/FileStorageOptions.cs rename to src/APITemplate.Application/Common/Options/Infrastructure/FileStorageOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/ObservabilityOptions.cs b/src/APITemplate.Application/Common/Options/Infrastructure/ObservabilityOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/ObservabilityOptions.cs rename to src/APITemplate.Application/Common/Options/Infrastructure/ObservabilityOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/TransactionDefaultsOptions.cs b/src/APITemplate.Application/Common/Options/Infrastructure/TransactionDefaultsOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/TransactionDefaultsOptions.cs rename to src/APITemplate.Application/Common/Options/Infrastructure/TransactionDefaultsOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/WebhookOptions.cs b/src/APITemplate.Application/Common/Options/Infrastructure/WebhookOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Infrastructure/WebhookOptions.cs rename to src/APITemplate.Application/Common/Options/Infrastructure/WebhookOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/BffOptions.cs b/src/APITemplate.Application/Common/Options/Security/BffOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/BffOptions.cs rename to src/APITemplate.Application/Common/Options/Security/BffOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/CorsOptions.cs b/src/APITemplate.Application/Common/Options/Security/CorsOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/CorsOptions.cs rename to src/APITemplate.Application/Common/Options/Security/CorsOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/KeycloakOptions.cs b/src/APITemplate.Application/Common/Options/Security/KeycloakOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/KeycloakOptions.cs rename to src/APITemplate.Application/Common/Options/Security/KeycloakOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/RateLimitingOptions.cs b/src/APITemplate.Application/Common/Options/Security/RateLimitingOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/RateLimitingOptions.cs rename to src/APITemplate.Application/Common/Options/Security/RateLimitingOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/RedactionOptions.cs b/src/APITemplate.Application/Common/Options/Security/RedactionOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/RedactionOptions.cs rename to src/APITemplate.Application/Common/Options/Security/RedactionOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/SystemIdentityOptions.cs b/src/APITemplate.Application/Common/Options/Security/SystemIdentityOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Options/Security/SystemIdentityOptions.cs rename to src/APITemplate.Application/Common/Options/Security/SystemIdentityOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Resilience/ResiliencePipelineKeys.cs b/src/APITemplate.Application/Common/Resilience/ResiliencePipelineKeys.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Resilience/ResiliencePipelineKeys.cs rename to src/APITemplate.Application/Common/Resilience/ResiliencePipelineKeys.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Search/SearchDefaults.cs b/src/APITemplate.Application/Common/Search/SearchDefaults.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Search/SearchDefaults.cs rename to src/APITemplate.Application/Common/Search/SearchDefaults.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Security/AuthConstants.cs b/src/APITemplate.Application/Common/Security/AuthConstants.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Security/AuthConstants.cs rename to src/APITemplate.Application/Common/Security/AuthConstants.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Security/IKeycloakAdminService.cs b/src/APITemplate.Application/Common/Security/IKeycloakAdminService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Security/IKeycloakAdminService.cs rename to src/APITemplate.Application/Common/Security/IKeycloakAdminService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Security/IRolePermissionMap.cs b/src/APITemplate.Application/Common/Security/IRolePermissionMap.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Security/IRolePermissionMap.cs rename to src/APITemplate.Application/Common/Security/IRolePermissionMap.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Security/IUserProvisioningService.cs b/src/APITemplate.Application/Common/Security/IUserProvisioningService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Security/IUserProvisioningService.cs rename to src/APITemplate.Application/Common/Security/IUserProvisioningService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Security/Permission.cs b/src/APITemplate.Application/Common/Security/Permission.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Security/Permission.cs rename to src/APITemplate.Application/Common/Security/Permission.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Security/StaticRolePermissionMap.cs b/src/APITemplate.Application/Common/Security/StaticRolePermissionMap.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Security/StaticRolePermissionMap.cs rename to src/APITemplate.Application/Common/Security/StaticRolePermissionMap.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Sorting/SortField.cs b/src/APITemplate.Application/Common/Sorting/SortField.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Sorting/SortField.cs rename to src/APITemplate.Application/Common/Sorting/SortField.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Sorting/SortFieldMap.cs b/src/APITemplate.Application/Common/Sorting/SortFieldMap.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Sorting/SortFieldMap.cs rename to src/APITemplate.Application/Common/Sorting/SortFieldMap.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Startup/IStartupTaskCoordinator.cs b/src/APITemplate.Application/Common/Startup/IStartupTaskCoordinator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Startup/IStartupTaskCoordinator.cs rename to src/APITemplate.Application/Common/Startup/IStartupTaskCoordinator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Startup/StartupTaskNames.cs b/src/APITemplate.Application/Common/Startup/StartupTaskNames.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Startup/StartupTaskNames.cs rename to src/APITemplate.Application/Common/Startup/StartupTaskNames.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/DataAnnotationsValidator.cs b/src/APITemplate.Application/Common/Validation/DataAnnotationsValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/DataAnnotationsValidator.cs rename to src/APITemplate.Application/Common/Validation/DataAnnotationsValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/DateRangeFilterValidator.cs b/src/APITemplate.Application/Common/Validation/DateRangeFilterValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/DateRangeFilterValidator.cs rename to src/APITemplate.Application/Common/Validation/DateRangeFilterValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/FluentValidationExtensions.cs b/src/APITemplate.Application/Common/Validation/FluentValidationExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/FluentValidationExtensions.cs rename to src/APITemplate.Application/Common/Validation/FluentValidationExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/NotEmptyAttribute.cs b/src/APITemplate.Application/Common/Validation/NotEmptyAttribute.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/NotEmptyAttribute.cs rename to src/APITemplate.Application/Common/Validation/NotEmptyAttribute.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/PaginationFilterValidator.cs b/src/APITemplate.Application/Common/Validation/PaginationFilterValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/PaginationFilterValidator.cs rename to src/APITemplate.Application/Common/Validation/PaginationFilterValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/SortableFilterValidator.cs b/src/APITemplate.Application/Common/Validation/SortableFilterValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Common/Validation/SortableFilterValidator.cs rename to src/APITemplate.Application/Common/Validation/SortableFilterValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Bff/DTOs/BffUserResponse.cs b/src/APITemplate.Application/Features/Bff/DTOs/BffUserResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Bff/DTOs/BffUserResponse.cs rename to src/APITemplate.Application/Features/Bff/DTOs/BffUserResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/CategorySortFields.cs b/src/APITemplate.Application/Features/Category/CategorySortFields.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/CategorySortFields.cs rename to src/APITemplate.Application/Features/Category/CategorySortFields.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Commands/CreateCategoriesCommand.cs b/src/APITemplate.Application/Features/Category/Commands/CreateCategoriesCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Commands/CreateCategoriesCommand.cs rename to src/APITemplate.Application/Features/Category/Commands/CreateCategoriesCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Commands/DeleteCategoriesCommand.cs b/src/APITemplate.Application/Features/Category/Commands/DeleteCategoriesCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Commands/DeleteCategoriesCommand.cs rename to src/APITemplate.Application/Features/Category/Commands/DeleteCategoriesCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Commands/UpdateCategoriesCommand.cs b/src/APITemplate.Application/Features/Category/Commands/UpdateCategoriesCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Commands/UpdateCategoriesCommand.cs rename to src/APITemplate.Application/Features/Category/Commands/UpdateCategoriesCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/CategoryFilter.cs b/src/APITemplate.Application/Features/Category/DTOs/CategoryFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/CategoryFilter.cs rename to src/APITemplate.Application/Features/Category/DTOs/CategoryFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/CategoryResponse.cs b/src/APITemplate.Application/Features/Category/DTOs/CategoryResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/CategoryResponse.cs rename to src/APITemplate.Application/Features/Category/DTOs/CategoryResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/CreateCategoriesRequest.cs b/src/APITemplate.Application/Features/Category/DTOs/CreateCategoriesRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/CreateCategoriesRequest.cs rename to src/APITemplate.Application/Features/Category/DTOs/CreateCategoriesRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/CreateCategoryRequest.cs b/src/APITemplate.Application/Features/Category/DTOs/CreateCategoryRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/CreateCategoryRequest.cs rename to src/APITemplate.Application/Features/Category/DTOs/CreateCategoryRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs b/src/APITemplate.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs rename to src/APITemplate.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs b/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs rename to src/APITemplate.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoryRequest.cs b/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoryRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoryRequest.cs rename to src/APITemplate.Application/Features/Category/DTOs/UpdateCategoryRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Mappings/CategoryMappings.cs b/src/APITemplate.Application/Features/Category/Mappings/CategoryMappings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Mappings/CategoryMappings.cs rename to src/APITemplate.Application/Features/Category/Mappings/CategoryMappings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Queries/GetCategoriesQuery.cs b/src/APITemplate.Application/Features/Category/Queries/GetCategoriesQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Queries/GetCategoriesQuery.cs rename to src/APITemplate.Application/Features/Category/Queries/GetCategoriesQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Queries/GetCategoryByIdQuery.cs b/src/APITemplate.Application/Features/Category/Queries/GetCategoryByIdQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Queries/GetCategoryByIdQuery.cs rename to src/APITemplate.Application/Features/Category/Queries/GetCategoryByIdQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Queries/GetCategoryStatsQuery.cs b/src/APITemplate.Application/Features/Category/Queries/GetCategoryStatsQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Queries/GetCategoryStatsQuery.cs rename to src/APITemplate.Application/Features/Category/Queries/GetCategoryStatsQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs b/src/APITemplate.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs rename to src/APITemplate.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Specifications/CategoryByIdSpecification.cs b/src/APITemplate.Application/Features/Category/Specifications/CategoryByIdSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Specifications/CategoryByIdSpecification.cs rename to src/APITemplate.Application/Features/Category/Specifications/CategoryByIdSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Specifications/CategoryFilterCriteria.cs b/src/APITemplate.Application/Features/Category/Specifications/CategoryFilterCriteria.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Specifications/CategoryFilterCriteria.cs rename to src/APITemplate.Application/Features/Category/Specifications/CategoryFilterCriteria.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Specifications/CategorySpecification.cs b/src/APITemplate.Application/Features/Category/Specifications/CategorySpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Specifications/CategorySpecification.cs rename to src/APITemplate.Application/Features/Category/Specifications/CategorySpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Validation/CategoryFilterValidator.cs b/src/APITemplate.Application/Features/Category/Validation/CategoryFilterValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Validation/CategoryFilterValidator.cs rename to src/APITemplate.Application/Features/Category/Validation/CategoryFilterValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs b/src/APITemplate.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs rename to src/APITemplate.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs b/src/APITemplate.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs rename to src/APITemplate.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Commands/IdempotentCreateCommand.cs b/src/APITemplate.Application/Features/Examples/Commands/IdempotentCreateCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Commands/IdempotentCreateCommand.cs rename to src/APITemplate.Application/Features/Examples/Commands/IdempotentCreateCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Commands/PatchProductCommand.cs b/src/APITemplate.Application/Features/Examples/Commands/PatchProductCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Commands/PatchProductCommand.cs rename to src/APITemplate.Application/Features/Examples/Commands/PatchProductCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Commands/SubmitJobCommand.cs b/src/APITemplate.Application/Features/Examples/Commands/SubmitJobCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Commands/SubmitJobCommand.cs rename to src/APITemplate.Application/Features/Examples/Commands/SubmitJobCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Commands/UploadFileCommand.cs b/src/APITemplate.Application/Features/Examples/Commands/UploadFileCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Commands/UploadFileCommand.cs rename to src/APITemplate.Application/Features/Examples/Commands/UploadFileCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/DownloadFileRequest.cs b/src/APITemplate.Application/Features/Examples/DTOs/DownloadFileRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/DownloadFileRequest.cs rename to src/APITemplate.Application/Features/Examples/DTOs/DownloadFileRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/FileUploadResponse.cs b/src/APITemplate.Application/Features/Examples/DTOs/FileUploadResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/FileUploadResponse.cs rename to src/APITemplate.Application/Features/Examples/DTOs/FileUploadResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/GetJobStatusRequest.cs b/src/APITemplate.Application/Features/Examples/DTOs/GetJobStatusRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/GetJobStatusRequest.cs rename to src/APITemplate.Application/Features/Examples/DTOs/GetJobStatusRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateRequest.cs b/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateRequest.cs rename to src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateResponse.cs b/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateResponse.cs rename to src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/JobStatusResponse.cs b/src/APITemplate.Application/Features/Examples/DTOs/JobStatusResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/JobStatusResponse.cs rename to src/APITemplate.Application/Features/Examples/DTOs/JobStatusResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/OutgoingWebhookDTOs.cs b/src/APITemplate.Application/Features/Examples/DTOs/OutgoingWebhookDTOs.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/OutgoingWebhookDTOs.cs rename to src/APITemplate.Application/Features/Examples/DTOs/OutgoingWebhookDTOs.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/PatchableProductDto.cs b/src/APITemplate.Application/Features/Examples/DTOs/PatchableProductDto.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/PatchableProductDto.cs rename to src/APITemplate.Application/Features/Examples/DTOs/PatchableProductDto.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/SseNotificationItem.cs b/src/APITemplate.Application/Features/Examples/DTOs/SseNotificationItem.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/SseNotificationItem.cs rename to src/APITemplate.Application/Features/Examples/DTOs/SseNotificationItem.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/SseStreamRequest.cs b/src/APITemplate.Application/Features/Examples/DTOs/SseStreamRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/SseStreamRequest.cs rename to src/APITemplate.Application/Features/Examples/DTOs/SseStreamRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/SubmitJobRequest.cs b/src/APITemplate.Application/Features/Examples/DTOs/SubmitJobRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/SubmitJobRequest.cs rename to src/APITemplate.Application/Features/Examples/DTOs/SubmitJobRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/UploadFileRequest.cs b/src/APITemplate.Application/Features/Examples/DTOs/UploadFileRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/UploadFileRequest.cs rename to src/APITemplate.Application/Features/Examples/DTOs/UploadFileRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/WebhookConstants.cs b/src/APITemplate.Application/Features/Examples/DTOs/WebhookConstants.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/WebhookConstants.cs rename to src/APITemplate.Application/Features/Examples/DTOs/WebhookConstants.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/WebhookPayload.cs b/src/APITemplate.Application/Features/Examples/DTOs/WebhookPayload.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/DTOs/WebhookPayload.cs rename to src/APITemplate.Application/Features/Examples/DTOs/WebhookPayload.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Mappings/JobResponseMapper.cs b/src/APITemplate.Application/Features/Examples/Mappings/JobResponseMapper.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Mappings/JobResponseMapper.cs rename to src/APITemplate.Application/Features/Examples/Mappings/JobResponseMapper.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Queries/DownloadFileQuery.cs b/src/APITemplate.Application/Features/Examples/Queries/DownloadFileQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Queries/DownloadFileQuery.cs rename to src/APITemplate.Application/Features/Examples/Queries/DownloadFileQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Queries/GetJobStatusQuery.cs b/src/APITemplate.Application/Features/Examples/Queries/GetJobStatusQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Queries/GetJobStatusQuery.cs rename to src/APITemplate.Application/Features/Examples/Queries/GetJobStatusQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Queries/GetNotificationStreamQuery.cs b/src/APITemplate.Application/Features/Examples/Queries/GetNotificationStreamQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Queries/GetNotificationStreamQuery.cs rename to src/APITemplate.Application/Features/Examples/Queries/GetNotificationStreamQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Validation/IdempotentCreateRequestValidator.cs b/src/APITemplate.Application/Features/Examples/Validation/IdempotentCreateRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Validation/IdempotentCreateRequestValidator.cs rename to src/APITemplate.Application/Features/Examples/Validation/IdempotentCreateRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Validation/PatchableProductDtoValidator.cs b/src/APITemplate.Application/Features/Examples/Validation/PatchableProductDtoValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Validation/PatchableProductDtoValidator.cs rename to src/APITemplate.Application/Features/Examples/Validation/PatchableProductDtoValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Validation/SubmitJobRequestValidator.cs b/src/APITemplate.Application/Features/Examples/Validation/SubmitJobRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Examples/Validation/SubmitJobRequestValidator.cs rename to src/APITemplate.Application/Features/Examples/Validation/SubmitJobRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Commands/CreateProductsCommand.cs b/src/APITemplate.Application/Features/Product/Commands/CreateProductsCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Commands/CreateProductsCommand.cs rename to src/APITemplate.Application/Features/Product/Commands/CreateProductsCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Commands/DeleteProductsCommand.cs b/src/APITemplate.Application/Features/Product/Commands/DeleteProductsCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Commands/DeleteProductsCommand.cs rename to src/APITemplate.Application/Features/Product/Commands/DeleteProductsCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Commands/UpdateProductsCommand.cs b/src/APITemplate.Application/Features/Product/Commands/UpdateProductsCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Commands/UpdateProductsCommand.cs rename to src/APITemplate.Application/Features/Product/Commands/UpdateProductsCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Commands/UpdateProductsValidator.cs b/src/APITemplate.Application/Features/Product/Commands/UpdateProductsValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Commands/UpdateProductsValidator.cs rename to src/APITemplate.Application/Features/Product/Commands/UpdateProductsValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/CreateProductRequest.cs b/src/APITemplate.Application/Features/Product/DTOs/CreateProductRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/CreateProductRequest.cs rename to src/APITemplate.Application/Features/Product/DTOs/CreateProductRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/CreateProductsRequest.cs b/src/APITemplate.Application/Features/Product/DTOs/CreateProductsRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/CreateProductsRequest.cs rename to src/APITemplate.Application/Features/Product/DTOs/CreateProductsRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs b/src/APITemplate.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs rename to src/APITemplate.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductFilter.cs b/src/APITemplate.Application/Features/Product/DTOs/ProductFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductFilter.cs rename to src/APITemplate.Application/Features/Product/DTOs/ProductFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs b/src/APITemplate.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs rename to src/APITemplate.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductResponse.cs b/src/APITemplate.Application/Features/Product/DTOs/ProductResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductResponse.cs rename to src/APITemplate.Application/Features/Product/DTOs/ProductResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs b/src/APITemplate.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs rename to src/APITemplate.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductsResponse.cs b/src/APITemplate.Application/Features/Product/DTOs/ProductsResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/ProductsResponse.cs rename to src/APITemplate.Application/Features/Product/DTOs/ProductsResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/UpdateProductRequest.cs b/src/APITemplate.Application/Features/Product/DTOs/UpdateProductRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/UpdateProductRequest.cs rename to src/APITemplate.Application/Features/Product/DTOs/UpdateProductRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/UpdateProductsRequest.cs b/src/APITemplate.Application/Features/Product/DTOs/UpdateProductsRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/DTOs/UpdateProductsRequest.cs rename to src/APITemplate.Application/Features/Product/DTOs/UpdateProductsRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Mappings/ProductMappings.cs b/src/APITemplate.Application/Features/Product/Mappings/ProductMappings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Mappings/ProductMappings.cs rename to src/APITemplate.Application/Features/Product/Mappings/ProductMappings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/ProductSortFields.cs b/src/APITemplate.Application/Features/Product/ProductSortFields.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/ProductSortFields.cs rename to src/APITemplate.Application/Features/Product/ProductSortFields.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/ProductValidationHelper.cs b/src/APITemplate.Application/Features/Product/ProductValidationHelper.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/ProductValidationHelper.cs rename to src/APITemplate.Application/Features/Product/ProductValidationHelper.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Queries/GetProductByIdQuery.cs b/src/APITemplate.Application/Features/Product/Queries/GetProductByIdQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Queries/GetProductByIdQuery.cs rename to src/APITemplate.Application/Features/Product/Queries/GetProductByIdQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Queries/GetProductsQuery.cs b/src/APITemplate.Application/Features/Product/Queries/GetProductsQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Queries/GetProductsQuery.cs rename to src/APITemplate.Application/Features/Product/Queries/GetProductsQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Repositories/IProductRepository.cs b/src/APITemplate.Application/Features/Product/Repositories/IProductRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Repositories/IProductRepository.cs rename to src/APITemplate.Application/Features/Product/Repositories/IProductRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductByIdSpecification.cs b/src/APITemplate.Application/Features/Product/Specifications/ProductByIdSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductByIdSpecification.cs rename to src/APITemplate.Application/Features/Product/Specifications/ProductByIdSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs b/src/APITemplate.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs rename to src/APITemplate.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs b/src/APITemplate.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs rename to src/APITemplate.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductFilterCriteria.cs b/src/APITemplate.Application/Features/Product/Specifications/ProductFilterCriteria.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductFilterCriteria.cs rename to src/APITemplate.Application/Features/Product/Specifications/ProductFilterCriteria.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs b/src/APITemplate.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs rename to src/APITemplate.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductSpecification.cs b/src/APITemplate.Application/Features/Product/Specifications/ProductSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductSpecification.cs rename to src/APITemplate.Application/Features/Product/Specifications/ProductSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs b/src/APITemplate.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs rename to src/APITemplate.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/CreateProductRequestValidator.cs b/src/APITemplate.Application/Features/Product/Validation/CreateProductRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/CreateProductRequestValidator.cs rename to src/APITemplate.Application/Features/Product/Validation/CreateProductRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/ProductFilterValidator.cs b/src/APITemplate.Application/Features/Product/Validation/ProductFilterValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/ProductFilterValidator.cs rename to src/APITemplate.Application/Features/Product/Validation/ProductFilterValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/ProductRequestValidatorBase.cs b/src/APITemplate.Application/Features/Product/Validation/ProductRequestValidatorBase.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/ProductRequestValidatorBase.cs rename to src/APITemplate.Application/Features/Product/Validation/ProductRequestValidatorBase.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/UpdateProductItemValidator.cs b/src/APITemplate.Application/Features/Product/Validation/UpdateProductItemValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/UpdateProductItemValidator.cs rename to src/APITemplate.Application/Features/Product/Validation/UpdateProductItemValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/UpdateProductRequestValidator.cs b/src/APITemplate.Application/Features/Product/Validation/UpdateProductRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Product/Validation/UpdateProductRequestValidator.cs rename to src/APITemplate.Application/Features/Product/Validation/UpdateProductRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs b/src/APITemplate.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs rename to src/APITemplate.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs b/src/APITemplate.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs rename to src/APITemplate.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs b/src/APITemplate.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs rename to src/APITemplate.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs b/src/APITemplate.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs rename to src/APITemplate.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs b/src/APITemplate.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs rename to src/APITemplate.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/DTOs/ProductDataResponse.cs b/src/APITemplate.Application/Features/ProductData/DTOs/ProductDataResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/DTOs/ProductDataResponse.cs rename to src/APITemplate.Application/Features/ProductData/DTOs/ProductDataResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Handlers/ProductDataCascadeDeleteHandler.cs b/src/APITemplate.Application/Features/ProductData/Handlers/ProductDataCascadeDeleteHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Handlers/ProductDataCascadeDeleteHandler.cs rename to src/APITemplate.Application/Features/ProductData/Handlers/ProductDataCascadeDeleteHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Mappings/ProductDataMappings.cs b/src/APITemplate.Application/Features/ProductData/Mappings/ProductDataMappings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Mappings/ProductDataMappings.cs rename to src/APITemplate.Application/Features/ProductData/Mappings/ProductDataMappings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs b/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs rename to src/APITemplate.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataQuery.cs b/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataQuery.cs rename to src/APITemplate.Application/Features/ProductData/Queries/GetProductDataQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs b/src/APITemplate.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs rename to src/APITemplate.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs b/src/APITemplate.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs rename to src/APITemplate.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Commands/CreateProductReviewCommand.cs b/src/APITemplate.Application/Features/ProductReview/Commands/CreateProductReviewCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Commands/CreateProductReviewCommand.cs rename to src/APITemplate.Application/Features/ProductReview/Commands/CreateProductReviewCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Commands/DeleteProductReviewCommand.cs b/src/APITemplate.Application/Features/ProductReview/Commands/DeleteProductReviewCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Commands/DeleteProductReviewCommand.cs rename to src/APITemplate.Application/Features/ProductReview/Commands/DeleteProductReviewCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/DTOs/CreateProductReviewRequest.cs b/src/APITemplate.Application/Features/ProductReview/DTOs/CreateProductReviewRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/DTOs/CreateProductReviewRequest.cs rename to src/APITemplate.Application/Features/ProductReview/DTOs/CreateProductReviewRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewFilter.cs b/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewFilter.cs rename to src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewResponse.cs b/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewResponse.cs rename to src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Mappings/ProductReviewMappings.cs b/src/APITemplate.Application/Features/ProductReview/Mappings/ProductReviewMappings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Mappings/ProductReviewMappings.cs rename to src/APITemplate.Application/Features/ProductReview/Mappings/ProductReviewMappings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/ProductReviewSortFields.cs b/src/APITemplate.Application/Features/ProductReview/ProductReviewSortFields.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/ProductReviewSortFields.cs rename to src/APITemplate.Application/Features/ProductReview/ProductReviewSortFields.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewByIdQuery.cs b/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewByIdQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewByIdQuery.cs rename to src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewByIdQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdQuery.cs b/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdQuery.cs rename to src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdsQuery.cs b/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdsQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdsQuery.cs rename to src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdsQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsQuery.cs b/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsQuery.cs rename to src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdSpecification.cs b/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdSpecification.cs rename to src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdsSpecification.cs b/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdsSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdsSpecification.cs rename to src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdsSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewFilterCriteria.cs b/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewFilterCriteria.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewFilterCriteria.cs rename to src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewFilterCriteria.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewSpecification.cs b/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewSpecification.cs rename to src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Validation/CreateProductReviewRequestValidator.cs b/src/APITemplate.Application/Features/ProductReview/Validation/CreateProductReviewRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Validation/CreateProductReviewRequestValidator.cs rename to src/APITemplate.Application/Features/ProductReview/Validation/CreateProductReviewRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Validation/ProductReviewFilterValidator.cs b/src/APITemplate.Application/Features/ProductReview/Validation/ProductReviewFilterValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/ProductReview/Validation/ProductReviewFilterValidator.cs rename to src/APITemplate.Application/Features/ProductReview/Validation/ProductReviewFilterValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Commands/CreateTenantCommand.cs b/src/APITemplate.Application/Features/Tenant/Commands/CreateTenantCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Commands/CreateTenantCommand.cs rename to src/APITemplate.Application/Features/Tenant/Commands/CreateTenantCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Commands/DeleteTenantCommand.cs b/src/APITemplate.Application/Features/Tenant/Commands/DeleteTenantCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Commands/DeleteTenantCommand.cs rename to src/APITemplate.Application/Features/Tenant/Commands/DeleteTenantCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/DTOs/CreateTenantRequest.cs b/src/APITemplate.Application/Features/Tenant/DTOs/CreateTenantRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/DTOs/CreateTenantRequest.cs rename to src/APITemplate.Application/Features/Tenant/DTOs/CreateTenantRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/DTOs/TenantFilter.cs b/src/APITemplate.Application/Features/Tenant/DTOs/TenantFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/DTOs/TenantFilter.cs rename to src/APITemplate.Application/Features/Tenant/DTOs/TenantFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/DTOs/TenantResponse.cs b/src/APITemplate.Application/Features/Tenant/DTOs/TenantResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/DTOs/TenantResponse.cs rename to src/APITemplate.Application/Features/Tenant/DTOs/TenantResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Mappings/TenantMappings.cs b/src/APITemplate.Application/Features/Tenant/Mappings/TenantMappings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Mappings/TenantMappings.cs rename to src/APITemplate.Application/Features/Tenant/Mappings/TenantMappings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs b/src/APITemplate.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs rename to src/APITemplate.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Queries/GetTenantsQuery.cs b/src/APITemplate.Application/Features/Tenant/Queries/GetTenantsQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Queries/GetTenantsQuery.cs rename to src/APITemplate.Application/Features/Tenant/Queries/GetTenantsQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs b/src/APITemplate.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs rename to src/APITemplate.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs b/src/APITemplate.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs rename to src/APITemplate.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Specifications/TenantSpecification.cs b/src/APITemplate.Application/Features/Tenant/Specifications/TenantSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Specifications/TenantSpecification.cs rename to src/APITemplate.Application/Features/Tenant/Specifications/TenantSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/TenantSortFields.cs b/src/APITemplate.Application/Features/Tenant/TenantSortFields.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/TenantSortFields.cs rename to src/APITemplate.Application/Features/Tenant/TenantSortFields.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs b/src/APITemplate.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs rename to src/APITemplate.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Validation/TenantFilterValidator.cs b/src/APITemplate.Application/Features/Tenant/Validation/TenantFilterValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/Tenant/Validation/TenantFilterValidator.cs rename to src/APITemplate.Application/Features/Tenant/Validation/TenantFilterValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs b/src/APITemplate.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs rename to src/APITemplate.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs b/src/APITemplate.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs rename to src/APITemplate.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs b/src/APITemplate.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs rename to src/APITemplate.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs b/src/APITemplate.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs rename to src/APITemplate.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs b/src/APITemplate.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs rename to src/APITemplate.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs b/src/APITemplate.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs rename to src/APITemplate.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs b/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs rename to src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs b/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs rename to src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs b/src/APITemplate.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs rename to src/APITemplate.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs b/src/APITemplate.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs rename to src/APITemplate.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs b/src/APITemplate.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs rename to src/APITemplate.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs b/src/APITemplate.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs rename to src/APITemplate.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/ChangeUserRoleCommand.cs b/src/APITemplate.Application/Features/User/Commands/ChangeUserRoleCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/ChangeUserRoleCommand.cs rename to src/APITemplate.Application/Features/User/Commands/ChangeUserRoleCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/CreateUserCommand.cs b/src/APITemplate.Application/Features/User/Commands/CreateUserCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/CreateUserCommand.cs rename to src/APITemplate.Application/Features/User/Commands/CreateUserCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/DeleteUserCommand.cs b/src/APITemplate.Application/Features/User/Commands/DeleteUserCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/DeleteUserCommand.cs rename to src/APITemplate.Application/Features/User/Commands/DeleteUserCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs b/src/APITemplate.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs rename to src/APITemplate.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/SetUserActiveCommand.cs b/src/APITemplate.Application/Features/User/Commands/SetUserActiveCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/SetUserActiveCommand.cs rename to src/APITemplate.Application/Features/User/Commands/SetUserActiveCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/UpdateUserCommand.cs b/src/APITemplate.Application/Features/User/Commands/UpdateUserCommand.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Commands/UpdateUserCommand.cs rename to src/APITemplate.Application/Features/User/Commands/UpdateUserCommand.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/ChangeUserRoleRequest.cs b/src/APITemplate.Application/Features/User/DTOs/ChangeUserRoleRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/ChangeUserRoleRequest.cs rename to src/APITemplate.Application/Features/User/DTOs/ChangeUserRoleRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/CreateUserRequest.cs b/src/APITemplate.Application/Features/User/DTOs/CreateUserRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/CreateUserRequest.cs rename to src/APITemplate.Application/Features/User/DTOs/CreateUserRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/RequestPasswordResetRequest.cs b/src/APITemplate.Application/Features/User/DTOs/RequestPasswordResetRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/RequestPasswordResetRequest.cs rename to src/APITemplate.Application/Features/User/DTOs/RequestPasswordResetRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/UpdateUserRequest.cs b/src/APITemplate.Application/Features/User/DTOs/UpdateUserRequest.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/UpdateUserRequest.cs rename to src/APITemplate.Application/Features/User/DTOs/UpdateUserRequest.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/UserFilter.cs b/src/APITemplate.Application/Features/User/DTOs/UserFilter.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/UserFilter.cs rename to src/APITemplate.Application/Features/User/DTOs/UserFilter.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/UserResponse.cs b/src/APITemplate.Application/Features/User/DTOs/UserResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/DTOs/UserResponse.cs rename to src/APITemplate.Application/Features/User/DTOs/UserResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Mappings/UserMappings.cs b/src/APITemplate.Application/Features/User/Mappings/UserMappings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Mappings/UserMappings.cs rename to src/APITemplate.Application/Features/User/Mappings/UserMappings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Queries/GetUserByIdQuery.cs b/src/APITemplate.Application/Features/User/Queries/GetUserByIdQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Queries/GetUserByIdQuery.cs rename to src/APITemplate.Application/Features/User/Queries/GetUserByIdQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Queries/GetUsersQuery.cs b/src/APITemplate.Application/Features/User/Queries/GetUsersQuery.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Queries/GetUsersQuery.cs rename to src/APITemplate.Application/Features/User/Queries/GetUsersQuery.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserByEmailSpecification.cs b/src/APITemplate.Application/Features/User/Specifications/UserByEmailSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserByEmailSpecification.cs rename to src/APITemplate.Application/Features/User/Specifications/UserByEmailSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserByIdSpecification.cs b/src/APITemplate.Application/Features/User/Specifications/UserByIdSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserByIdSpecification.cs rename to src/APITemplate.Application/Features/User/Specifications/UserByIdSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserByUsernameSpecification.cs b/src/APITemplate.Application/Features/User/Specifications/UserByUsernameSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserByUsernameSpecification.cs rename to src/APITemplate.Application/Features/User/Specifications/UserByUsernameSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserFilterCriteria.cs b/src/APITemplate.Application/Features/User/Specifications/UserFilterCriteria.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserFilterCriteria.cs rename to src/APITemplate.Application/Features/User/Specifications/UserFilterCriteria.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserFilterSpecification.cs b/src/APITemplate.Application/Features/User/Specifications/UserFilterSpecification.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Specifications/UserFilterSpecification.cs rename to src/APITemplate.Application/Features/User/Specifications/UserFilterSpecification.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/UserSortFields.cs b/src/APITemplate.Application/Features/User/UserSortFields.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/UserSortFields.cs rename to src/APITemplate.Application/Features/User/UserSortFields.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/UserValidationHelper.cs b/src/APITemplate.Application/Features/User/UserValidationHelper.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/UserValidationHelper.cs rename to src/APITemplate.Application/Features/User/UserValidationHelper.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs b/src/APITemplate.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs rename to src/APITemplate.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Validation/CreateUserRequestValidator.cs b/src/APITemplate.Application/Features/User/Validation/CreateUserRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Validation/CreateUserRequestValidator.cs rename to src/APITemplate.Application/Features/User/Validation/CreateUserRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Validation/UpdateUserRequestValidator.cs b/src/APITemplate.Application/Features/User/Validation/UpdateUserRequestValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Validation/UpdateUserRequestValidator.cs rename to src/APITemplate.Application/Features/User/Validation/UpdateUserRequestValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/Features/User/Validation/UserFilterValidator.cs b/src/APITemplate.Application/Features/User/Validation/UserFilterValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/Features/User/Validation/UserFilterValidator.cs rename to src/APITemplate.Application/Features/User/Validation/UserFilterValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Application/GlobalUsings.ApplicationFeatures.cs b/src/APITemplate.Application/GlobalUsings.ApplicationFeatures.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Application/GlobalUsings.ApplicationFeatures.cs rename to src/APITemplate.Application/GlobalUsings.ApplicationFeatures.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/APITemplate.Domain.csproj b/src/APITemplate.Domain/APITemplate.Domain.csproj similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/APITemplate.Domain.csproj rename to src/APITemplate.Domain/APITemplate.Domain.csproj diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Common/PagedResponse.cs b/src/APITemplate.Domain/Common/PagedResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Common/PagedResponse.cs rename to src/APITemplate.Domain/Common/PagedResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/AppUser.cs b/src/APITemplate.Domain/Entities/AppUser.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/AppUser.cs rename to src/APITemplate.Domain/Entities/AppUser.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/AuditDefaults.cs b/src/APITemplate.Domain/Entities/AuditDefaults.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/AuditDefaults.cs rename to src/APITemplate.Domain/Entities/AuditDefaults.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/AuditInfo.cs b/src/APITemplate.Domain/Entities/AuditInfo.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/AuditInfo.cs rename to src/APITemplate.Domain/Entities/AuditInfo.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/Category.cs b/src/APITemplate.Domain/Entities/Category.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/Category.cs rename to src/APITemplate.Domain/Entities/Category.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/IAuditableEntity.cs b/src/APITemplate.Domain/Entities/Contracts/IAuditableEntity.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/IAuditableEntity.cs rename to src/APITemplate.Domain/Entities/Contracts/IAuditableEntity.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/IAuditableTenantEntity.cs b/src/APITemplate.Domain/Entities/Contracts/IAuditableTenantEntity.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/IAuditableTenantEntity.cs rename to src/APITemplate.Domain/Entities/Contracts/IAuditableTenantEntity.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/IHasId.cs b/src/APITemplate.Domain/Entities/Contracts/IHasId.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/IHasId.cs rename to src/APITemplate.Domain/Entities/Contracts/IHasId.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/ISoftDeletable.cs b/src/APITemplate.Domain/Entities/Contracts/ISoftDeletable.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/ISoftDeletable.cs rename to src/APITemplate.Domain/Entities/Contracts/ISoftDeletable.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/ITenantEntity.cs b/src/APITemplate.Domain/Entities/Contracts/ITenantEntity.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/Contracts/ITenantEntity.cs rename to src/APITemplate.Domain/Entities/Contracts/ITenantEntity.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/FailedEmail.cs b/src/APITemplate.Domain/Entities/FailedEmail.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/FailedEmail.cs rename to src/APITemplate.Domain/Entities/FailedEmail.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/JobExecution.cs b/src/APITemplate.Domain/Entities/JobExecution.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/JobExecution.cs rename to src/APITemplate.Domain/Entities/JobExecution.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/Product.cs b/src/APITemplate.Domain/Entities/Product.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/Product.cs rename to src/APITemplate.Domain/Entities/Product.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductCategoryStats.cs b/src/APITemplate.Domain/Entities/ProductCategoryStats.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductCategoryStats.cs rename to src/APITemplate.Domain/Entities/ProductCategoryStats.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductData/ImageProductData.cs b/src/APITemplate.Domain/Entities/ProductData/ImageProductData.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductData/ImageProductData.cs rename to src/APITemplate.Domain/Entities/ProductData/ImageProductData.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductData/ProductData.cs b/src/APITemplate.Domain/Entities/ProductData/ProductData.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductData/ProductData.cs rename to src/APITemplate.Domain/Entities/ProductData/ProductData.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductData/VideoProductData.cs b/src/APITemplate.Domain/Entities/ProductData/VideoProductData.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductData/VideoProductData.cs rename to src/APITemplate.Domain/Entities/ProductData/VideoProductData.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductDataLink.cs b/src/APITemplate.Domain/Entities/ProductDataLink.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductDataLink.cs rename to src/APITemplate.Domain/Entities/ProductDataLink.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductReview.cs b/src/APITemplate.Domain/Entities/ProductReview.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/ProductReview.cs rename to src/APITemplate.Domain/Entities/ProductReview.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/StoredFile.cs b/src/APITemplate.Domain/Entities/StoredFile.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/StoredFile.cs rename to src/APITemplate.Domain/Entities/StoredFile.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/Tenant.cs b/src/APITemplate.Domain/Entities/Tenant.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/Tenant.cs rename to src/APITemplate.Domain/Entities/Tenant.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Entities/TenantInvitation.cs b/src/APITemplate.Domain/Entities/TenantInvitation.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Entities/TenantInvitation.cs rename to src/APITemplate.Domain/Entities/TenantInvitation.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Enums/InvitationStatus.cs b/src/APITemplate.Domain/Enums/InvitationStatus.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Enums/InvitationStatus.cs rename to src/APITemplate.Domain/Enums/InvitationStatus.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Enums/JobStatus.cs b/src/APITemplate.Domain/Enums/JobStatus.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Enums/JobStatus.cs rename to src/APITemplate.Domain/Enums/JobStatus.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Enums/UserRole.cs b/src/APITemplate.Domain/Enums/UserRole.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Enums/UserRole.cs rename to src/APITemplate.Domain/Enums/UserRole.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/AppException.cs b/src/APITemplate.Domain/Exceptions/AppException.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/AppException.cs rename to src/APITemplate.Domain/Exceptions/AppException.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/ConflictException.cs b/src/APITemplate.Domain/Exceptions/ConflictException.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/ConflictException.cs rename to src/APITemplate.Domain/Exceptions/ConflictException.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/ForbiddenException.cs b/src/APITemplate.Domain/Exceptions/ForbiddenException.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/ForbiddenException.cs rename to src/APITemplate.Domain/Exceptions/ForbiddenException.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/NotFoundException.cs b/src/APITemplate.Domain/Exceptions/NotFoundException.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/NotFoundException.cs rename to src/APITemplate.Domain/Exceptions/NotFoundException.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/UnauthorizedException.cs b/src/APITemplate.Domain/Exceptions/UnauthorizedException.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/UnauthorizedException.cs rename to src/APITemplate.Domain/Exceptions/UnauthorizedException.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/ValidationException.cs b/src/APITemplate.Domain/Exceptions/ValidationException.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Exceptions/ValidationException.cs rename to src/APITemplate.Domain/Exceptions/ValidationException.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/GlobalUsings.cs b/src/APITemplate.Domain/GlobalUsings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/GlobalUsings.cs rename to src/APITemplate.Domain/GlobalUsings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/ICategoryRepository.cs b/src/APITemplate.Domain/Interfaces/ICategoryRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/ICategoryRepository.cs rename to src/APITemplate.Domain/Interfaces/ICategoryRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IFailedEmailRepository.cs b/src/APITemplate.Domain/Interfaces/IFailedEmailRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IFailedEmailRepository.cs rename to src/APITemplate.Domain/Interfaces/IFailedEmailRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IJobExecutionRepository.cs b/src/APITemplate.Domain/Interfaces/IJobExecutionRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IJobExecutionRepository.cs rename to src/APITemplate.Domain/Interfaces/IJobExecutionRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IProductDataLinkRepository.cs b/src/APITemplate.Domain/Interfaces/IProductDataLinkRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IProductDataLinkRepository.cs rename to src/APITemplate.Domain/Interfaces/IProductDataLinkRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IProductDataRepository.cs b/src/APITemplate.Domain/Interfaces/IProductDataRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IProductDataRepository.cs rename to src/APITemplate.Domain/Interfaces/IProductDataRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IProductReviewRepository.cs b/src/APITemplate.Domain/Interfaces/IProductReviewRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IProductReviewRepository.cs rename to src/APITemplate.Domain/Interfaces/IProductReviewRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IRepository.cs b/src/APITemplate.Domain/Interfaces/IRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IRepository.cs rename to src/APITemplate.Domain/Interfaces/IRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IStoredFileRepository.cs b/src/APITemplate.Domain/Interfaces/IStoredFileRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IStoredFileRepository.cs rename to src/APITemplate.Domain/Interfaces/IStoredFileRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IStoredProcedure.cs b/src/APITemplate.Domain/Interfaces/IStoredProcedure.cs similarity index 93% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IStoredProcedure.cs rename to src/APITemplate.Domain/Interfaces/IStoredProcedure.cs index 1da37411..10cd5123 100644 --- a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IStoredProcedure.cs +++ b/src/APITemplate.Domain/Interfaces/IStoredProcedure.cs @@ -17,8 +17,7 @@ namespace APITemplate.Domain.Interfaces; /// The keyless entity type that EF Core will materialise from the procedure result set. /// Must be registered with HasNoKey() in the DbContext. /// -public interface IStoredProcedure - where TResult : class +public interface IStoredProcedure where TResult : class { /// /// Returns an interpolated SQL string with all parameter values embedded. diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs b/src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs similarity index 92% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs rename to src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs index 17228c2b..7fdf224b 100644 --- a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs +++ b/src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs @@ -13,8 +13,7 @@ public interface IStoredProcedureExecutor /// Task QueryFirstAsync( IStoredProcedure procedure, - CancellationToken ct = default - ) + CancellationToken ct = default) where TResult : class; /// @@ -22,8 +21,7 @@ public interface IStoredProcedureExecutor /// Task> QueryManyAsync( IStoredProcedure procedure, - CancellationToken ct = default - ) + CancellationToken ct = default) where TResult : class; /// diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/ITenantInvitationRepository.cs b/src/APITemplate.Domain/Interfaces/ITenantInvitationRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/ITenantInvitationRepository.cs rename to src/APITemplate.Domain/Interfaces/ITenantInvitationRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/ITenantRepository.cs b/src/APITemplate.Domain/Interfaces/ITenantRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/ITenantRepository.cs rename to src/APITemplate.Domain/Interfaces/ITenantRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IUnitOfWork.cs b/src/APITemplate.Domain/Interfaces/IUnitOfWork.cs similarity index 97% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IUnitOfWork.cs rename to src/APITemplate.Domain/Interfaces/IUnitOfWork.cs index 30e47e49..c3c52f1d 100644 --- a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IUnitOfWork.cs +++ b/src/APITemplate.Domain/Interfaces/IUnitOfWork.cs @@ -51,8 +51,7 @@ public interface IUnitOfWork Task ExecuteInTransactionAsync( Func action, CancellationToken ct = default, - TransactionOptions? options = null - ); + TransactionOptions? options = null); /// /// Runs a multi-step relational write flow in one explicit transaction and returns a value. @@ -65,6 +64,5 @@ Task ExecuteInTransactionAsync( Task ExecuteInTransactionAsync( Func> action, CancellationToken ct = default, - TransactionOptions? options = null - ); + TransactionOptions? options = null); } diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IUserRepository.cs b/src/APITemplate.Domain/Interfaces/IUserRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Interfaces/IUserRepository.cs rename to src/APITemplate.Domain/Interfaces/IUserRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Domain/Options/TransactionOptions.cs b/src/APITemplate.Domain/Options/TransactionOptions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Domain/Options/TransactionOptions.cs rename to src/APITemplate.Domain/Options/TransactionOptions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/APITemplate.Infrastructure.csproj b/src/APITemplate.Infrastructure/APITemplate.Infrastructure.csproj similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/APITemplate.Infrastructure.csproj rename to src/APITemplate.Infrastructure/APITemplate.Infrastructure.csproj diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/ChannelJobQueue.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/ChannelJobQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/ChannelJobQueue.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/ChannelJobQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/CleanupService.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/CleanupService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/CleanupService.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/CleanupService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/EmailRetryService.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/EmailRetryService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/EmailRetryService.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/EmailRetryService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/ISoftDeleteCleanupStrategy.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/ISoftDeleteCleanupStrategy.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/ISoftDeleteCleanupStrategy.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/ISoftDeleteCleanupStrategy.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/JobProcessingBackgroundService.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/JobProcessingBackgroundService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/JobProcessingBackgroundService.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/JobProcessingBackgroundService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/ReindexService.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/ReindexService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/ReindexService.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/ReindexService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/IDistributedJobCoordinator.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/IDistributedJobCoordinator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/IDistributedJobCoordinator.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/IDistributedJobCoordinator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/EmailRetryRecurringJob.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/EmailRetryRecurringJob.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/EmailRetryRecurringJob.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/EmailRetryRecurringJob.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/EmailRetryRecurringJobRegistration.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/EmailRetryRecurringJobRegistration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/EmailRetryRecurringJobRegistration.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/EmailRetryRecurringJobRegistration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQFunctionNames.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQFunctionNames.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQFunctionNames.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQFunctionNames.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQJobIds.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQJobIds.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQJobIds.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQJobIds.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs b/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs b/src/APITemplate.Infrastructure/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs rename to src/APITemplate.Infrastructure/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_down.sql b/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_down.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_down.sql rename to src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_down.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_up.sql b/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_up.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_up.sql rename to src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_up.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_down.sql b/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_down.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_down.sql rename to src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_down.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_up.sql b/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_up.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_up.sql rename to src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_up.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_down.sql b/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_down.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_down.sql rename to src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_down.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_up.sql b/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_up.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_up.sql rename to src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_up.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_down.sql b/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_down.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_down.sql rename to src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_down.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_up.sql b/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_up.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_up.sql rename to src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_up.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_down.sql b/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_down.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_down.sql rename to src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_down.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_up.sql b/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_up.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_up.sql rename to src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_up.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_down.sql b/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_down.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_down.sql rename to src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_down.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_up.sql b/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_up.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_up.sql rename to src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_up.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/SqlResource.cs b/src/APITemplate.Infrastructure/Database/SqlResource.cs similarity index 66% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/SqlResource.cs rename to src/APITemplate.Infrastructure/Database/SqlResource.cs index 8ab276c8..c087b983 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/SqlResource.cs +++ b/src/APITemplate.Infrastructure/Database/SqlResource.cs @@ -13,14 +13,13 @@ public static class SqlResource public static string Load(string relativeResourcePath) { - var normalizedPath = relativeResourcePath.Replace('\\', '.').Replace('/', '.'); + var normalizedPath = relativeResourcePath + .Replace('\\', '.') + .Replace('/', '.'); var resourceName = $"{Namespace}.{normalizedPath}"; - var stream = - Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName) - ?? throw new FileNotFoundException( - $"Embedded SQL resource '{resourceName}' not found." - ); + var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException($"Embedded SQL resource '{resourceName}' not found."); using var reader = new StreamReader(stream); return reader.ReadToEnd(); diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_down.sql b/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_down.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_down.sql rename to src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_down.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_up.sql b/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_up.sql similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_up.sql rename to src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_up.sql diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/ChannelEmailQueue.cs b/src/APITemplate.Infrastructure/Email/ChannelEmailQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/ChannelEmailQueue.cs rename to src/APITemplate.Infrastructure/Email/ChannelEmailQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/EmailSendingBackgroundService.cs b/src/APITemplate.Infrastructure/Email/EmailSendingBackgroundService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/EmailSendingBackgroundService.cs rename to src/APITemplate.Infrastructure/Email/EmailSendingBackgroundService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/FailedEmailErrorNormalizer.cs b/src/APITemplate.Infrastructure/Email/FailedEmailErrorNormalizer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/FailedEmailErrorNormalizer.cs rename to src/APITemplate.Infrastructure/Email/FailedEmailErrorNormalizer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/FailedEmailStore.cs b/src/APITemplate.Infrastructure/Email/FailedEmailStore.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/FailedEmailStore.cs rename to src/APITemplate.Infrastructure/Email/FailedEmailStore.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/FluidEmailTemplateRenderer.cs b/src/APITemplate.Infrastructure/Email/FluidEmailTemplateRenderer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/FluidEmailTemplateRenderer.cs rename to src/APITemplate.Infrastructure/Email/FluidEmailTemplateRenderer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/MailKitEmailSender.cs b/src/APITemplate.Infrastructure/Email/MailKitEmailSender.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/MailKitEmailSender.cs rename to src/APITemplate.Infrastructure/Email/MailKitEmailSender.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/Templates/tenant-invitation.liquid b/src/APITemplate.Infrastructure/Email/Templates/tenant-invitation.liquid similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/Templates/tenant-invitation.liquid rename to src/APITemplate.Infrastructure/Email/Templates/tenant-invitation.liquid diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/Templates/user-registration.liquid b/src/APITemplate.Infrastructure/Email/Templates/user-registration.liquid similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/Templates/user-registration.liquid rename to src/APITemplate.Infrastructure/Email/Templates/user-registration.liquid diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/Templates/user-role-changed.liquid b/src/APITemplate.Infrastructure/Email/Templates/user-role-changed.liquid similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Email/Templates/user-role-changed.liquid rename to src/APITemplate.Infrastructure/Email/Templates/user-role-changed.liquid diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/FileStorage/LocalFileStorageService.cs b/src/APITemplate.Infrastructure/FileStorage/LocalFileStorageService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/FileStorage/LocalFileStorageService.cs rename to src/APITemplate.Infrastructure/FileStorage/LocalFileStorageService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/GlobalUsings.cs b/src/APITemplate.Infrastructure/GlobalUsings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/GlobalUsings.cs rename to src/APITemplate.Infrastructure/GlobalUsings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Health/HealthCheckNames.cs b/src/APITemplate.Infrastructure/Health/HealthCheckNames.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Health/HealthCheckNames.cs rename to src/APITemplate.Infrastructure/Health/HealthCheckNames.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Health/KeycloakHealthCheck.cs b/src/APITemplate.Infrastructure/Health/KeycloakHealthCheck.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Health/KeycloakHealthCheck.cs rename to src/APITemplate.Infrastructure/Health/KeycloakHealthCheck.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Health/MongoDbHealthCheck.cs b/src/APITemplate.Infrastructure/Health/MongoDbHealthCheck.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Health/MongoDbHealthCheck.cs rename to src/APITemplate.Infrastructure/Health/MongoDbHealthCheck.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs b/src/APITemplate.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs rename to src/APITemplate.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Idempotency/IdempotencyStoreConstants.cs b/src/APITemplate.Infrastructure/Idempotency/IdempotencyStoreConstants.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Idempotency/IdempotencyStoreConstants.cs rename to src/APITemplate.Infrastructure/Idempotency/IdempotencyStoreConstants.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs b/src/APITemplate.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs rename to src/APITemplate.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Logging/ActivityTraceEnricher.cs b/src/APITemplate.Infrastructure/Logging/ActivityTraceEnricher.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Logging/ActivityTraceEnricher.cs rename to src/APITemplate.Infrastructure/Logging/ActivityTraceEnricher.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Logging/LogDataClassifications.cs b/src/APITemplate.Infrastructure/Logging/LogDataClassifications.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Logging/LogDataClassifications.cs rename to src/APITemplate.Infrastructure/Logging/LogDataClassifications.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Logging/RedactionConfiguration.cs b/src/APITemplate.Infrastructure/Logging/RedactionConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Logging/RedactionConfiguration.cs rename to src/APITemplate.Infrastructure/Logging/RedactionConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.Designer.cs diff --git a/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs b/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs new file mode 100644 index 00000000..8a6af202 --- /dev/null +++ b/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs @@ -0,0 +1,95 @@ +using APITemplate.Infrastructure.Database; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace APITemplate.Migrations +{ + /// + public partial class AddCategory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + Price = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + CategoryId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + table.ForeignKey( + name: "FK_Products_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "ProductReviews", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProductId = table.Column(type: "uuid", nullable: false), + ReviewerName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Comment = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Rating = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_ProductReviews", x => x.Id); + table.ForeignKey( + name: "FK_ProductReviews_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProductReviews_ProductId", + table: "ProductReviews", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_Products_CategoryId", + table: "Products", + column: "CategoryId"); + + migrationBuilder.Sql(SqlResource.Load("Procedures.get_product_category_stats_v1_up.sql")); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(SqlResource.Load("Procedures.get_product_category_stats_v1_down.sql")); + + migrationBuilder.DropTable(name: "ProductReviews"); + migrationBuilder.DropTable(name: "Products"); + migrationBuilder.DropTable(name: "Categories"); + } + } +} diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs b/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs similarity index 51% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs rename to src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs index 0c9a702c..f697b9f4 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs @@ -17,28 +17,24 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.RenameColumn( name: "CreatedAt", table: "Products", - newName: "UpdatedAtUtc" - ); + newName: "UpdatedAtUtc"); migrationBuilder.RenameColumn( name: "CreatedAt", table: "ProductReviews", - newName: "UpdatedAtUtc" - ); + newName: "UpdatedAtUtc"); migrationBuilder.RenameColumn( name: "CreatedAt", table: "Categories", - newName: "UpdatedAtUtc" - ); + newName: "UpdatedAtUtc"); migrationBuilder.AddColumn( name: "CreatedAtUtc", table: "Products", type: "timestamp with time zone", nullable: false, - defaultValueSql: "now()" - ); + defaultValueSql: "now()"); migrationBuilder.AddColumn( name: "CreatedBy", @@ -46,31 +42,27 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "character varying(200)", maxLength: 200, nullable: false, - defaultValue: "system" - ); + defaultValue: "system"); migrationBuilder.AddColumn( name: "DeletedAtUtc", table: "Products", type: "timestamp with time zone", - nullable: true - ); + nullable: true); migrationBuilder.AddColumn( name: "DeletedBy", table: "Products", type: "character varying(200)", maxLength: 200, - nullable: true - ); + nullable: true); migrationBuilder.AddColumn( name: "IsDeleted", table: "Products", type: "boolean", nullable: false, - defaultValue: false - ); + defaultValue: false); migrationBuilder.AddColumn( name: "RowVersion", @@ -78,16 +70,14 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "bytea", rowVersion: true, nullable: false, - defaultValue: new byte[0] - ); + defaultValue: new byte[0]); migrationBuilder.AddColumn( name: "TenantId", table: "Products", type: "uuid", nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AddColumn( name: "UpdatedBy", @@ -95,16 +85,14 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "character varying(200)", maxLength: 200, nullable: false, - defaultValue: "system" - ); + defaultValue: "system"); migrationBuilder.AddColumn( name: "CreatedAtUtc", table: "ProductReviews", type: "timestamp with time zone", nullable: false, - defaultValueSql: "now()" - ); + defaultValueSql: "now()"); migrationBuilder.AddColumn( name: "CreatedBy", @@ -112,31 +100,27 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "character varying(200)", maxLength: 200, nullable: false, - defaultValue: "system" - ); + defaultValue: "system"); migrationBuilder.AddColumn( name: "DeletedAtUtc", table: "ProductReviews", type: "timestamp with time zone", - nullable: true - ); + nullable: true); migrationBuilder.AddColumn( name: "DeletedBy", table: "ProductReviews", type: "character varying(200)", maxLength: 200, - nullable: true - ); + nullable: true); migrationBuilder.AddColumn( name: "IsDeleted", table: "ProductReviews", type: "boolean", nullable: false, - defaultValue: false - ); + defaultValue: false); migrationBuilder.AddColumn( name: "RowVersion", @@ -144,16 +128,14 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "bytea", rowVersion: true, nullable: false, - defaultValue: new byte[0] - ); + defaultValue: new byte[0]); migrationBuilder.AddColumn( name: "TenantId", table: "ProductReviews", type: "uuid", nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AddColumn( name: "UpdatedBy", @@ -161,16 +143,14 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "character varying(200)", maxLength: 200, nullable: false, - defaultValue: "system" - ); + defaultValue: "system"); migrationBuilder.AddColumn( name: "CreatedAtUtc", table: "Categories", type: "timestamp with time zone", nullable: false, - defaultValueSql: "now()" - ); + defaultValueSql: "now()"); migrationBuilder.AddColumn( name: "CreatedBy", @@ -178,31 +158,27 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "character varying(200)", maxLength: 200, nullable: false, - defaultValue: "system" - ); + defaultValue: "system"); migrationBuilder.AddColumn( name: "DeletedAtUtc", table: "Categories", type: "timestamp with time zone", - nullable: true - ); + nullable: true); migrationBuilder.AddColumn( name: "DeletedBy", table: "Categories", type: "character varying(200)", maxLength: 200, - nullable: true - ); + nullable: true); migrationBuilder.AddColumn( name: "IsDeleted", table: "Categories", type: "boolean", nullable: false, - defaultValue: false - ); + defaultValue: false); migrationBuilder.AddColumn( name: "RowVersion", @@ -210,16 +186,14 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "bytea", rowVersion: true, nullable: false, - defaultValue: new byte[0] - ); + defaultValue: new byte[0]); migrationBuilder.AddColumn( name: "TenantId", table: "Categories", type: "uuid", nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AddColumn( name: "UpdatedBy", @@ -227,359 +201,206 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "character varying(200)", maxLength: 200, nullable: false, - defaultValue: "system" - ); + defaultValue: "system"); migrationBuilder.CreateTable( name: "Tenants", columns: table => new { Id = table.Column(type: "uuid", nullable: false), - Code = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - Name = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: false - ), - IsActive = table.Column( - type: "boolean", - nullable: false, - defaultValue: true - ), + Code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsActive = table.Column(type: "boolean", nullable: false, defaultValue: true), TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system" - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system" - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: true - ), - RowVersion = table.Column( - type: "bytea", - rowVersion: true, - nullable: false - ), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + CreatedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: false, defaultValue: "system"), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + UpdatedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: false, defaultValue: "system"), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + DeletedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: false) }, constraints: table => { table.PrimaryKey("PK_Tenants", x => x.Id); - table.CheckConstraint( - "CK_Tenants_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - } - ); + table.CheckConstraint("CK_Tenants_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); + }); migrationBuilder.CreateTable( name: "Users", columns: table => new { Id = table.Column(type: "uuid", nullable: false), - Username = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - Email = table.Column( - type: "character varying(320)", - maxLength: 320, - nullable: false - ), - PasswordHash = table.Column( - type: "character varying(1000)", - maxLength: 1000, - nullable: false - ), - IsActive = table.Column( - type: "boolean", - nullable: false, - defaultValue: true - ), + Username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Email = table.Column(type: "character varying(320)", maxLength: 320, nullable: false), + PasswordHash = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + IsActive = table.Column(type: "boolean", nullable: false, defaultValue: true), TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system" - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system" - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: true - ), - RowVersion = table.Column( - type: "bytea", - rowVersion: true, - nullable: false - ), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + CreatedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: false, defaultValue: "system"), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + UpdatedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: false, defaultValue: "system"), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + DeletedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: false) }, constraints: table => { table.PrimaryKey("PK_Users", x => x.Id); - table.CheckConstraint( - "CK_Users_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); + table.CheckConstraint("CK_Users_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); table.ForeignKey( name: "FK_Users_Tenants_TenantId", column: x => x.TenantId, principalTable: "Tenants", principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); - } - ); + onDelete: ReferentialAction.Restrict); + }); migrationBuilder.InsertData( table: "Tenants", columns: new[] { - "Id", - "Code", - "Name", - "IsActive", - "TenantId", - "CreatedAtUtc", - "CreatedBy", - "UpdatedAtUtc", - "UpdatedBy", - "IsDeleted", - "DeletedAtUtc", - "DeletedBy", - "RowVersion", + "Id", "Code", "Name", "IsActive", "TenantId", "CreatedAtUtc", "CreatedBy", + "UpdatedAtUtc", "UpdatedBy", "IsDeleted", "DeletedAtUtc", "DeletedBy", "RowVersion" }, values: new object[] { - defaultTenantId, - "default", - "Default Tenant", - true, - defaultTenantId, - DateTime.UtcNow, - "migration", - DateTime.UtcNow, - "migration", - false, - null, - null, - new byte[] { 1 }, - } - ); - - migrationBuilder.Sql( - $""" + defaultTenantId, "default", "Default Tenant", true, defaultTenantId, DateTime.UtcNow, "migration", + DateTime.UtcNow, "migration", false, null, null, new byte[] { 1 } + }); + + migrationBuilder.Sql($""" UPDATE "Categories" SET "CreatedAtUtc" = "UpdatedAtUtc", "TenantId" = '{defaultTenantId}', "CreatedBy" = 'migration', "UpdatedBy" = 'migration' WHERE "TenantId" = '00000000-0000-0000-0000-000000000000'; - """ - ); + """); - migrationBuilder.Sql( - $""" + migrationBuilder.Sql($""" UPDATE "Products" SET "CreatedAtUtc" = "UpdatedAtUtc", "TenantId" = '{defaultTenantId}', "CreatedBy" = 'migration', "UpdatedBy" = 'migration' WHERE "TenantId" = '00000000-0000-0000-0000-000000000000'; - """ - ); + """); - migrationBuilder.Sql( - $""" + migrationBuilder.Sql($""" UPDATE "ProductReviews" SET "CreatedAtUtc" = "UpdatedAtUtc", "TenantId" = '{defaultTenantId}', "CreatedBy" = 'migration', "UpdatedBy" = 'migration' WHERE "TenantId" = '00000000-0000-0000-0000-000000000000'; - """ - ); + """); migrationBuilder.CreateIndex( name: "IX_Products_TenantId", table: "Products", - column: "TenantId" - ); + column: "TenantId"); migrationBuilder.CreateIndex( name: "IX_Products_TenantId_IsDeleted", table: "Products", - columns: new[] { "TenantId", "IsDeleted" } - ); + columns: new[] { "TenantId", "IsDeleted" }); migrationBuilder.CreateIndex( name: "IX_Products_TenantId_Name", table: "Products", - columns: new[] { "TenantId", "Name" } - ); + columns: new[] { "TenantId", "Name" }); migrationBuilder.AddCheckConstraint( name: "CK_Products_SoftDeleteConsistency", table: "Products", - sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); + sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); migrationBuilder.CreateIndex( name: "IX_ProductReviews_TenantId", table: "ProductReviews", - column: "TenantId" - ); + column: "TenantId"); migrationBuilder.CreateIndex( name: "IX_ProductReviews_TenantId_IsDeleted", table: "ProductReviews", - columns: new[] { "TenantId", "IsDeleted" } - ); + columns: new[] { "TenantId", "IsDeleted" }); migrationBuilder.CreateIndex( name: "IX_ProductReviews_TenantId_ProductId", table: "ProductReviews", - columns: new[] { "TenantId", "ProductId" } - ); + columns: new[] { "TenantId", "ProductId" }); migrationBuilder.AddCheckConstraint( name: "CK_ProductReviews_SoftDeleteConsistency", table: "ProductReviews", - sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); + sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); migrationBuilder.CreateIndex( name: "IX_Categories_TenantId", table: "Categories", - column: "TenantId" - ); + column: "TenantId"); migrationBuilder.CreateIndex( name: "IX_Categories_TenantId_IsDeleted", table: "Categories", - columns: new[] { "TenantId", "IsDeleted" } - ); + columns: new[] { "TenantId", "IsDeleted" }); migrationBuilder.CreateIndex( name: "IX_Categories_TenantId_Name", table: "Categories", columns: new[] { "TenantId", "Name" }, - unique: true - ); + unique: true); migrationBuilder.AddCheckConstraint( name: "CK_Categories_SoftDeleteConsistency", table: "Categories", - sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); + sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); migrationBuilder.CreateIndex( name: "IX_Tenants_Code", table: "Tenants", column: "Code", - unique: true - ); + unique: true); migrationBuilder.CreateIndex( name: "IX_Tenants_IsActive", table: "Tenants", - column: "IsActive" - ); + column: "IsActive"); migrationBuilder.CreateIndex( name: "IX_Tenants_TenantId", table: "Tenants", - column: "TenantId" - ); + column: "TenantId"); migrationBuilder.CreateIndex( name: "IX_Tenants_TenantId_IsDeleted", table: "Tenants", - columns: new[] { "TenantId", "IsDeleted" } - ); + columns: new[] { "TenantId", "IsDeleted" }); migrationBuilder.CreateIndex( name: "IX_Users_TenantId", table: "Users", - column: "TenantId" - ); + column: "TenantId"); migrationBuilder.CreateIndex( name: "IX_Users_TenantId_Email", table: "Users", columns: new[] { "TenantId", "Email" }, - unique: true - ); + unique: true); migrationBuilder.CreateIndex( name: "IX_Users_TenantId_IsDeleted", table: "Users", - columns: new[] { "TenantId", "IsDeleted" } - ); + columns: new[] { "TenantId", "IsDeleted" }); migrationBuilder.CreateIndex( name: "IX_Users_TenantId_Username", table: "Users", columns: new[] { "TenantId", "Username" }, - unique: true - ); + unique: true); migrationBuilder.AddForeignKey( name: "FK_Categories_Tenants_TenantId", @@ -587,8 +408,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "TenantId", principalTable: "Tenants", principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); + onDelete: ReferentialAction.Restrict); migrationBuilder.AddForeignKey( name: "FK_ProductReviews_Tenants_TenantId", @@ -596,8 +416,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "TenantId", principalTable: "Tenants", principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); + onDelete: ReferentialAction.Restrict); migrationBuilder.AddForeignKey( name: "FK_Products_Tenants_TenantId", @@ -605,14 +424,11 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "TenantId", principalTable: "Tenants", principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); + onDelete: ReferentialAction.Restrict); migrationBuilder.Sql("DROP FUNCTION IF EXISTS get_product_category_stats(UUID);"); migrationBuilder.Sql("DROP FUNCTION IF EXISTS get_product_category_stats(UUID, UUID);"); - migrationBuilder.Sql( - SqlResource.Load("Procedures.get_product_category_stats_v2_up.sql") - ); + migrationBuilder.Sql(SqlResource.Load("Procedures.get_product_category_stats_v2_up.sql")); } /// @@ -620,134 +436,182 @@ protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropForeignKey( name: "FK_Categories_Tenants_TenantId", - table: "Categories" - ); + table: "Categories"); migrationBuilder.DropForeignKey( name: "FK_ProductReviews_Tenants_TenantId", - table: "ProductReviews" - ); + table: "ProductReviews"); migrationBuilder.DropForeignKey( name: "FK_Products_Tenants_TenantId", - table: "Products" - ); + table: "Products"); - migrationBuilder.DropTable(name: "Users"); + migrationBuilder.DropTable( + name: "Users"); - migrationBuilder.DropTable(name: "Tenants"); + migrationBuilder.DropTable( + name: "Tenants"); - migrationBuilder.DropIndex(name: "IX_Products_TenantId", table: "Products"); + migrationBuilder.DropIndex( + name: "IX_Products_TenantId", + table: "Products"); - migrationBuilder.DropIndex(name: "IX_Products_TenantId_IsDeleted", table: "Products"); + migrationBuilder.DropIndex( + name: "IX_Products_TenantId_IsDeleted", + table: "Products"); - migrationBuilder.DropIndex(name: "IX_Products_TenantId_Name", table: "Products"); + migrationBuilder.DropIndex( + name: "IX_Products_TenantId_Name", + table: "Products"); migrationBuilder.DropCheckConstraint( name: "CK_Products_SoftDeleteConsistency", - table: "Products" - ); + table: "Products"); - migrationBuilder.DropIndex(name: "IX_ProductReviews_TenantId", table: "ProductReviews"); + migrationBuilder.DropIndex( + name: "IX_ProductReviews_TenantId", + table: "ProductReviews"); migrationBuilder.DropIndex( name: "IX_ProductReviews_TenantId_IsDeleted", - table: "ProductReviews" - ); + table: "ProductReviews"); migrationBuilder.DropIndex( name: "IX_ProductReviews_TenantId_ProductId", - table: "ProductReviews" - ); + table: "ProductReviews"); migrationBuilder.DropCheckConstraint( name: "CK_ProductReviews_SoftDeleteConsistency", - table: "ProductReviews" - ); + table: "ProductReviews"); - migrationBuilder.DropIndex(name: "IX_Categories_TenantId", table: "Categories"); + migrationBuilder.DropIndex( + name: "IX_Categories_TenantId", + table: "Categories"); migrationBuilder.DropIndex( name: "IX_Categories_TenantId_IsDeleted", - table: "Categories" - ); + table: "Categories"); - migrationBuilder.DropIndex(name: "IX_Categories_TenantId_Name", table: "Categories"); + migrationBuilder.DropIndex( + name: "IX_Categories_TenantId_Name", + table: "Categories"); migrationBuilder.DropCheckConstraint( name: "CK_Categories_SoftDeleteConsistency", - table: "Categories" - ); + table: "Categories"); - migrationBuilder.DropColumn(name: "CreatedAtUtc", table: "Products"); + migrationBuilder.DropColumn( + name: "CreatedAtUtc", + table: "Products"); - migrationBuilder.DropColumn(name: "CreatedBy", table: "Products"); + migrationBuilder.DropColumn( + name: "CreatedBy", + table: "Products"); - migrationBuilder.DropColumn(name: "DeletedAtUtc", table: "Products"); + migrationBuilder.DropColumn( + name: "DeletedAtUtc", + table: "Products"); - migrationBuilder.DropColumn(name: "DeletedBy", table: "Products"); + migrationBuilder.DropColumn( + name: "DeletedBy", + table: "Products"); - migrationBuilder.DropColumn(name: "IsDeleted", table: "Products"); + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Products"); - migrationBuilder.DropColumn(name: "RowVersion", table: "Products"); + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Products"); - migrationBuilder.DropColumn(name: "TenantId", table: "Products"); + migrationBuilder.DropColumn( + name: "TenantId", + table: "Products"); - migrationBuilder.DropColumn(name: "UpdatedBy", table: "Products"); + migrationBuilder.DropColumn( + name: "UpdatedBy", + table: "Products"); - migrationBuilder.DropColumn(name: "CreatedAtUtc", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "CreatedAtUtc", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "CreatedBy", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "CreatedBy", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "DeletedAtUtc", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "DeletedAtUtc", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "DeletedBy", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "DeletedBy", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "IsDeleted", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "RowVersion", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "RowVersion", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "TenantId", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "TenantId", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "UpdatedBy", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "UpdatedBy", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "CreatedAtUtc", table: "Categories"); + migrationBuilder.DropColumn( + name: "CreatedAtUtc", + table: "Categories"); - migrationBuilder.DropColumn(name: "CreatedBy", table: "Categories"); + migrationBuilder.DropColumn( + name: "CreatedBy", + table: "Categories"); - migrationBuilder.DropColumn(name: "DeletedAtUtc", table: "Categories"); + migrationBuilder.DropColumn( + name: "DeletedAtUtc", + table: "Categories"); - migrationBuilder.DropColumn(name: "DeletedBy", table: "Categories"); + migrationBuilder.DropColumn( + name: "DeletedBy", + table: "Categories"); - migrationBuilder.DropColumn(name: "IsDeleted", table: "Categories"); + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Categories"); - migrationBuilder.DropColumn(name: "RowVersion", table: "Categories"); + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Categories"); - migrationBuilder.DropColumn(name: "TenantId", table: "Categories"); + migrationBuilder.DropColumn( + name: "TenantId", + table: "Categories"); - migrationBuilder.DropColumn(name: "UpdatedBy", table: "Categories"); + migrationBuilder.DropColumn( + name: "UpdatedBy", + table: "Categories"); migrationBuilder.RenameColumn( name: "UpdatedAtUtc", table: "Products", - newName: "CreatedAt" - ); + newName: "CreatedAt"); migrationBuilder.RenameColumn( name: "UpdatedAtUtc", table: "ProductReviews", - newName: "CreatedAt" - ); + newName: "CreatedAt"); migrationBuilder.RenameColumn( name: "UpdatedAtUtc", table: "Categories", - newName: "CreatedAt" - ); + newName: "CreatedAt"); - migrationBuilder.Sql( - SqlResource.Load("Procedures.get_product_category_stats_v2_down.sql") - ); + migrationBuilder.Sql(SqlResource.Load("Procedures.get_product_category_stats_v2_down.sql")); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs b/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs similarity index 81% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs rename to src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs index c453f0c6..128e1179 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs @@ -16,14 +16,15 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "character varying(32)", maxLength: 32, nullable: false, - defaultValue: "TenantUser" - ); + defaultValue: "TenantUser"); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn(name: "Role", table: "Users"); + migrationBuilder.DropColumn( + name: "Role", + table: "Users"); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs b/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs similarity index 70% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs rename to src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs index e7f83f62..59c0051a 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs @@ -10,23 +10,23 @@ public partial class AddNormalizedUsernameForAuth : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.DropIndex(name: "IX_Users_TenantId_Username", table: "Users"); + migrationBuilder.DropIndex( + name: "IX_Users_TenantId_Username", + table: "Users"); migrationBuilder.AddColumn( name: "NormalizedUsername", table: "Users", type: "character varying(100)", maxLength: 100, - nullable: true - ); + nullable: true); migrationBuilder.Sql( """ UPDATE "Users" SET "NormalizedUsername" = UPPER(TRIM("Username")) WHERE "NormalizedUsername" IS NULL; - """ - ); + """); migrationBuilder.AlterColumn( name: "NormalizedUsername", @@ -37,38 +37,40 @@ protected override void Up(MigrationBuilder migrationBuilder) oldClrType: typeof(string), oldType: "character varying(100)", oldMaxLength: 100, - oldNullable: true - ); + oldNullable: true); migrationBuilder.CreateIndex( name: "IX_Users_NormalizedUsername", table: "Users", column: "NormalizedUsername", - unique: true - ); + unique: true); migrationBuilder.CreateIndex( name: "IX_Users_TenantId_Username", table: "Users", - columns: new[] { "TenantId", "Username" } - ); + columns: new[] { "TenantId", "Username" }); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropIndex(name: "IX_Users_NormalizedUsername", table: "Users"); + migrationBuilder.DropIndex( + name: "IX_Users_NormalizedUsername", + table: "Users"); - migrationBuilder.DropIndex(name: "IX_Users_TenantId_Username", table: "Users"); + migrationBuilder.DropIndex( + name: "IX_Users_TenantId_Username", + table: "Users"); - migrationBuilder.DropColumn(name: "NormalizedUsername", table: "Users"); + migrationBuilder.DropColumn( + name: "NormalizedUsername", + table: "Users"); migrationBuilder.CreateIndex( name: "IX_Users_TenantId_Username", table: "Users", columns: new[] { "TenantId", "Username" }, - unique: true - ); + unique: true); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.cs b/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.cs rename to src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs b/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs similarity index 72% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs rename to src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs index e44fbbc6..2ab8ebbb 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs @@ -10,16 +10,19 @@ public partial class MakeNormalizedUsernameUniquePerTenant : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.DropIndex(name: "IX_Users_NormalizedUsername", table: "Users"); + migrationBuilder.DropIndex( + name: "IX_Users_NormalizedUsername", + table: "Users"); - migrationBuilder.DropIndex(name: "IX_Users_TenantId_Username", table: "Users"); + migrationBuilder.DropIndex( + name: "IX_Users_TenantId_Username", + table: "Users"); migrationBuilder.CreateIndex( name: "IX_Users_TenantId_NormalizedUsername", table: "Users", columns: new[] { "TenantId", "NormalizedUsername" }, - unique: true - ); + unique: true); } /// @@ -27,21 +30,18 @@ protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropIndex( name: "IX_Users_TenantId_NormalizedUsername", - table: "Users" - ); + table: "Users"); migrationBuilder.CreateIndex( name: "IX_Users_NormalizedUsername", table: "Users", column: "NormalizedUsername", - unique: true - ); + unique: true); migrationBuilder.CreateIndex( name: "IX_Users_TenantId_Username", table: "Users", - columns: new[] { "TenantId", "Username" } - ); + columns: new[] { "TenantId", "Username" }); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.cs b/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.cs rename to src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs b/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs similarity index 77% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs rename to src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs index 54087e84..87ba3c19 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs @@ -16,24 +16,23 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "UserId", table: "ProductReviews", type: "uuid", - nullable: true - ); + nullable: true); // Step 2: Backfill existing rows — assign each review to the user // who matches the old ReviewerName, falling back to the first admin user // in the same tenant. Adjust this query if your mapping differs. - migrationBuilder.Sql( - """ + migrationBuilder.Sql(""" UPDATE "ProductReviews" pr SET "UserId" = u."Id" FROM "Users" u WHERE u."Username" = pr."ReviewerName" AND u."TenantId" = pr."TenantId"; - """ - ); + """); // Step 3: Drop the old column now that data has been migrated. - migrationBuilder.DropColumn(name: "ReviewerName", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "ReviewerName", + table: "ProductReviews"); // Step 4: Make UserId non-nullable now that all rows have a value. migrationBuilder.AlterColumn( @@ -43,14 +42,12 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, oldClrType: typeof(Guid), oldType: "uuid", - oldNullable: true - ); + oldNullable: true); migrationBuilder.CreateIndex( name: "IX_ProductReviews_UserId", table: "ProductReviews", - column: "UserId" - ); + column: "UserId"); migrationBuilder.AddForeignKey( name: "FK_ProductReviews_Users_UserId", @@ -58,8 +55,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "UserId", principalTable: "Users", principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); + onDelete: ReferentialAction.Restrict); } /// @@ -67,12 +63,15 @@ protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropForeignKey( name: "FK_ProductReviews_Users_UserId", - table: "ProductReviews" - ); + table: "ProductReviews"); - migrationBuilder.DropIndex(name: "IX_ProductReviews_UserId", table: "ProductReviews"); + migrationBuilder.DropIndex( + name: "IX_ProductReviews_UserId", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "UserId", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "UserId", + table: "ProductReviews"); migrationBuilder.AddColumn( name: "ReviewerName", @@ -80,8 +79,7 @@ protected override void Down(MigrationBuilder migrationBuilder) type: "character varying(100)", maxLength: 100, nullable: false, - defaultValue: "" - ); + defaultValue: ""); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs b/src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs similarity index 82% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs rename to src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs index 4d0738bb..3604d0a8 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs @@ -10,11 +10,9 @@ public partial class RenameUserRoleTenantUserToUser : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.Sql( - """ + migrationBuilder.Sql(""" UPDATE "Users" SET "Role" = 'User' WHERE "Role" = 'TenantUser'; - """ - ); + """); migrationBuilder.AlterColumn( name: "Role", @@ -26,18 +24,15 @@ protected override void Up(MigrationBuilder migrationBuilder) oldClrType: typeof(string), oldType: "character varying(32)", oldMaxLength: 32, - oldDefaultValue: "TenantUser" - ); + oldDefaultValue: "TenantUser"); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.Sql( - """ + migrationBuilder.Sql(""" UPDATE "Users" SET "Role" = 'TenantUser' WHERE "Role" = 'User'; - """ - ); + """); migrationBuilder.AlterColumn( name: "Role", @@ -49,8 +44,7 @@ protected override void Down(MigrationBuilder migrationBuilder) oldClrType: typeof(string), oldType: "character varying(32)", oldMaxLength: 32, - oldDefaultValue: "User" - ); + oldDefaultValue: "User"); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs b/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs similarity index 63% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs rename to src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs index 5b90f592..5a34bb19 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs @@ -10,15 +10,25 @@ public partial class SwitchToXminConcurrency : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn(name: "RowVersion", table: "Users"); + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Users"); - migrationBuilder.DropColumn(name: "RowVersion", table: "Tenants"); + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Tenants"); - migrationBuilder.DropColumn(name: "RowVersion", table: "Products"); + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Products"); - migrationBuilder.DropColumn(name: "RowVersion", table: "ProductReviews"); + migrationBuilder.DropColumn( + name: "RowVersion", + table: "ProductReviews"); - migrationBuilder.DropColumn(name: "RowVersion", table: "Categories"); + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Categories"); } /// @@ -29,40 +39,35 @@ protected override void Down(MigrationBuilder migrationBuilder) table: "Users", type: "bytea", nullable: false, - defaultValue: new byte[0] - ); + defaultValue: new byte[0]); migrationBuilder.AddColumn( name: "RowVersion", table: "Tenants", type: "bytea", nullable: false, - defaultValue: new byte[0] - ); + defaultValue: new byte[0]); migrationBuilder.AddColumn( name: "RowVersion", table: "Products", type: "bytea", nullable: false, - defaultValue: new byte[0] - ); + defaultValue: new byte[0]); migrationBuilder.AddColumn( name: "RowVersion", table: "ProductReviews", type: "bytea", nullable: false, - defaultValue: new byte[0] - ); + defaultValue: new byte[0]); migrationBuilder.AddColumn( name: "RowVersion", table: "Categories", type: "bytea", nullable: false, - defaultValue: new byte[0] - ); + defaultValue: new byte[0]); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs b/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs similarity index 87% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs rename to src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs index fecedfbd..c4f4d59f 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs @@ -14,21 +14,10 @@ protected override void Up(MigrationBuilder migrationBuilder) // PostgreSQL cannot cast varchar → uuid implicitly. // First normalize any non-UUID values (e.g. "system") to the zero GUID string, // then ALTER COLUMN with an explicit USING cast. - const string uuidRegex = - "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; - foreach ( - var table in new[] - { - "Users", - "Tenants", - "Products", - "ProductReviews", - "Categories", - } - ) + const string uuidRegex = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + foreach (var table in new[] { "Users", "Tenants", "Products", "ProductReviews", "Categories" }) { - migrationBuilder.Sql( - $""" + migrationBuilder.Sql($""" UPDATE "{table}" SET "CreatedBy" = '00000000-0000-0000-0000-000000000000' WHERE "CreatedBy" !~ '{uuidRegex}'; UPDATE "{table}" SET "UpdatedBy" = '00000000-0000-0000-0000-000000000000' WHERE "UpdatedBy" !~ '{uuidRegex}'; UPDATE "{table}" SET "DeletedBy" = NULL WHERE "DeletedBy" IS NOT NULL AND "DeletedBy" !~ '{uuidRegex}'; @@ -39,8 +28,7 @@ var table in new[] ALTER TABLE "{table}" ALTER COLUMN "UpdatedBy" TYPE uuid USING "UpdatedBy"::uuid; ALTER TABLE "{table}" ALTER COLUMN "UpdatedBy" SET DEFAULT '00000000-0000-0000-0000-000000000000'; ALTER TABLE "{table}" ALTER COLUMN "DeletedBy" TYPE uuid USING "DeletedBy"::uuid; - """ - ); + """); } } @@ -56,8 +44,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AlterColumn( name: "DeletedBy", @@ -67,8 +54,7 @@ protected override void Down(MigrationBuilder migrationBuilder) nullable: true, oldClrType: typeof(Guid), oldType: "uuid", - oldNullable: true - ); + oldNullable: true); migrationBuilder.AlterColumn( name: "CreatedBy", @@ -79,8 +65,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AlterColumn( name: "UpdatedBy", @@ -91,8 +76,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AlterColumn( name: "DeletedBy", @@ -102,8 +86,7 @@ protected override void Down(MigrationBuilder migrationBuilder) nullable: true, oldClrType: typeof(Guid), oldType: "uuid", - oldNullable: true - ); + oldNullable: true); migrationBuilder.AlterColumn( name: "CreatedBy", @@ -114,8 +97,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AlterColumn( name: "UpdatedBy", @@ -126,8 +108,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AlterColumn( name: "DeletedBy", @@ -137,8 +118,7 @@ protected override void Down(MigrationBuilder migrationBuilder) nullable: true, oldClrType: typeof(Guid), oldType: "uuid", - oldNullable: true - ); + oldNullable: true); migrationBuilder.AlterColumn( name: "CreatedBy", @@ -149,8 +129,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AlterColumn( name: "UpdatedBy", @@ -161,8 +140,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AlterColumn( name: "DeletedBy", @@ -172,8 +150,7 @@ protected override void Down(MigrationBuilder migrationBuilder) nullable: true, oldClrType: typeof(Guid), oldType: "uuid", - oldNullable: true - ); + oldNullable: true); migrationBuilder.AlterColumn( name: "CreatedBy", @@ -184,8 +161,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AlterColumn( name: "UpdatedBy", @@ -196,8 +172,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AlterColumn( name: "DeletedBy", @@ -207,8 +182,7 @@ protected override void Down(MigrationBuilder migrationBuilder) nullable: true, oldClrType: typeof(Guid), oldType: "uuid", - oldNullable: true - ); + oldNullable: true); migrationBuilder.AlterColumn( name: "CreatedBy", @@ -219,8 +193,7 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: "system", oldClrType: typeof(Guid), oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs b/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs similarity index 73% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs rename to src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs index 9f88f345..4d39f7c9 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs @@ -16,35 +16,30 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { ProductId = table.Column(type: "uuid", nullable: false), - ProductDataId = table.Column(type: "uuid", nullable: false), + ProductDataId = table.Column(type: "uuid", nullable: false) }, constraints: table => { - table.PrimaryKey( - "PK_ProductDataLinks", - x => new { x.ProductId, x.ProductDataId } - ); + table.PrimaryKey("PK_ProductDataLinks", x => new { x.ProductId, x.ProductDataId }); table.ForeignKey( name: "FK_ProductDataLinks_Products_ProductId", column: x => x.ProductId, principalTable: "Products", principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); + onDelete: ReferentialAction.Cascade); + }); migrationBuilder.CreateIndex( name: "IX_ProductDataLinks_ProductDataId", table: "ProductDataLinks", - column: "ProductDataId" - ); + column: "ProductDataId"); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable(name: "ProductDataLinks"); + migrationBuilder.DropTable( + name: "ProductDataLinks"); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs b/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs similarity index 70% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs rename to src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs index 4f8985a6..6a11277e 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs @@ -13,75 +13,65 @@ protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.DropForeignKey( name: "FK_ProductDataLinks_Products_ProductId", - table: "ProductDataLinks" - ); + table: "ProductDataLinks"); migrationBuilder.DropIndex( name: "IX_ProductDataLinks_ProductDataId", - table: "ProductDataLinks" - ); + table: "ProductDataLinks"); migrationBuilder.AddColumn( name: "CreatedAtUtc", table: "ProductDataLinks", type: "timestamp with time zone", nullable: false, - defaultValueSql: "now()" - ); + defaultValueSql: "now()"); migrationBuilder.AddColumn( name: "CreatedBy", table: "ProductDataLinks", type: "uuid", nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AddColumn( name: "DeletedAtUtc", table: "ProductDataLinks", type: "timestamp with time zone", - nullable: true - ); + nullable: true); migrationBuilder.AddColumn( name: "DeletedBy", table: "ProductDataLinks", type: "uuid", - nullable: true - ); + nullable: true); migrationBuilder.AddColumn( name: "IsDeleted", table: "ProductDataLinks", type: "boolean", nullable: false, - defaultValue: false - ); + defaultValue: false); migrationBuilder.AddColumn( name: "TenantId", table: "ProductDataLinks", type: "uuid", nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AddColumn( name: "UpdatedAtUtc", table: "ProductDataLinks", type: "timestamp with time zone", nullable: false, - defaultValueSql: "now()" - ); + defaultValueSql: "now()"); migrationBuilder.AddColumn( name: "UpdatedBy", table: "ProductDataLinks", type: "uuid", nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ); + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.AddColumn( name: "xmin", @@ -89,32 +79,27 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "xid", rowVersion: true, nullable: false, - defaultValue: 0u - ); + defaultValue: 0u); migrationBuilder.CreateIndex( name: "IX_ProductDataLinks_TenantId", table: "ProductDataLinks", - column: "TenantId" - ); + column: "TenantId"); migrationBuilder.CreateIndex( name: "IX_ProductDataLinks_TenantId_IsDeleted", table: "ProductDataLinks", - columns: new[] { "TenantId", "IsDeleted" } - ); + columns: new[] { "TenantId", "IsDeleted" }); migrationBuilder.CreateIndex( name: "IX_ProductDataLinks_TenantId_ProductDataId_IsDeleted", table: "ProductDataLinks", - columns: new[] { "TenantId", "ProductDataId", "IsDeleted" } - ); + columns: new[] { "TenantId", "ProductDataId", "IsDeleted" }); migrationBuilder.AddCheckConstraint( name: "CK_ProductDataLinks_SoftDeleteConsistency", table: "ProductDataLinks", - sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); + sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); migrationBuilder.AddForeignKey( name: "FK_ProductDataLinks_Products_ProductId", @@ -122,8 +107,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "ProductId", principalTable: "Products", principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); + onDelete: ReferentialAction.Restrict); } /// @@ -131,52 +115,64 @@ protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropForeignKey( name: "FK_ProductDataLinks_Products_ProductId", - table: "ProductDataLinks" - ); + table: "ProductDataLinks"); migrationBuilder.DropIndex( name: "IX_ProductDataLinks_TenantId", - table: "ProductDataLinks" - ); + table: "ProductDataLinks"); migrationBuilder.DropIndex( name: "IX_ProductDataLinks_TenantId_IsDeleted", - table: "ProductDataLinks" - ); + table: "ProductDataLinks"); migrationBuilder.DropIndex( name: "IX_ProductDataLinks_TenantId_ProductDataId_IsDeleted", - table: "ProductDataLinks" - ); + table: "ProductDataLinks"); migrationBuilder.DropCheckConstraint( name: "CK_ProductDataLinks_SoftDeleteConsistency", - table: "ProductDataLinks" - ); + table: "ProductDataLinks"); - migrationBuilder.DropColumn(name: "CreatedAtUtc", table: "ProductDataLinks"); + migrationBuilder.DropColumn( + name: "CreatedAtUtc", + table: "ProductDataLinks"); - migrationBuilder.DropColumn(name: "CreatedBy", table: "ProductDataLinks"); + migrationBuilder.DropColumn( + name: "CreatedBy", + table: "ProductDataLinks"); - migrationBuilder.DropColumn(name: "DeletedAtUtc", table: "ProductDataLinks"); + migrationBuilder.DropColumn( + name: "DeletedAtUtc", + table: "ProductDataLinks"); - migrationBuilder.DropColumn(name: "DeletedBy", table: "ProductDataLinks"); + migrationBuilder.DropColumn( + name: "DeletedBy", + table: "ProductDataLinks"); - migrationBuilder.DropColumn(name: "IsDeleted", table: "ProductDataLinks"); + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "ProductDataLinks"); - migrationBuilder.DropColumn(name: "TenantId", table: "ProductDataLinks"); + migrationBuilder.DropColumn( + name: "TenantId", + table: "ProductDataLinks"); - migrationBuilder.DropColumn(name: "UpdatedAtUtc", table: "ProductDataLinks"); + migrationBuilder.DropColumn( + name: "UpdatedAtUtc", + table: "ProductDataLinks"); - migrationBuilder.DropColumn(name: "UpdatedBy", table: "ProductDataLinks"); + migrationBuilder.DropColumn( + name: "UpdatedBy", + table: "ProductDataLinks"); - migrationBuilder.DropColumn(name: "xmin", table: "ProductDataLinks"); + migrationBuilder.DropColumn( + name: "xmin", + table: "ProductDataLinks"); migrationBuilder.CreateIndex( name: "IX_ProductDataLinks_ProductDataId", table: "ProductDataLinks", - column: "ProductDataId" - ); + column: "ProductDataId"); migrationBuilder.AddForeignKey( name: "FK_ProductDataLinks_Products_ProductId", @@ -184,8 +180,7 @@ protected override void Down(MigrationBuilder migrationBuilder) column: "ProductId", principalTable: "Products", principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); + onDelete: ReferentialAction.Cascade); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs b/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs similarity index 50% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs rename to src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs index 7c7c9e21..879dbb43 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs @@ -10,21 +10,17 @@ public partial class AddFullTextSearchIndexes : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder - .CreateIndex( - name: "IX_Products_Name_Description", - table: "Products", - columns: new[] { "Name", "Description" } - ) + migrationBuilder.CreateIndex( + name: "IX_Products_Name_Description", + table: "Products", + columns: new[] { "Name", "Description" }) .Annotation("Npgsql:IndexMethod", "GIN") .Annotation("Npgsql:TsVectorConfig", "english"); - migrationBuilder - .CreateIndex( - name: "IX_Categories_Name_Description", - table: "Categories", - columns: new[] { "Name", "Description" } - ) + migrationBuilder.CreateIndex( + name: "IX_Categories_Name_Description", + table: "Categories", + columns: new[] { "Name", "Description" }) .Annotation("Npgsql:IndexMethod", "GIN") .Annotation("Npgsql:TsVectorConfig", "english"); } @@ -32,9 +28,13 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropIndex(name: "IX_Products_Name_Description", table: "Products"); + migrationBuilder.DropIndex( + name: "IX_Products_Name_Description", + table: "Products"); - migrationBuilder.DropIndex(name: "IX_Categories_Name_Description", table: "Categories"); + migrationBuilder.DropIndex( + name: "IX_Categories_Name_Description", + table: "Categories"); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs b/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs similarity index 69% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs rename to src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs index 7d2efb92..ca59a3eb 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs @@ -10,19 +10,18 @@ public partial class AddNormalizedEmailForUsers : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.DropIndex(name: "IX_Users_TenantId_Email", table: "Users"); + migrationBuilder.DropIndex( + name: "IX_Users_TenantId_Email", + table: "Users"); migrationBuilder.AddColumn( name: "NormalizedEmail", table: "Users", type: "character varying(320)", maxLength: 320, - nullable: true - ); + nullable: true); - migrationBuilder.Sql( - "UPDATE \"Users\" SET \"NormalizedEmail\" = UPPER(TRIM(\"Email\"));" - ); + migrationBuilder.Sql("UPDATE \"Users\" SET \"NormalizedEmail\" = UPPER(TRIM(\"Email\"));"); migrationBuilder.AlterColumn( name: "NormalizedEmail", @@ -33,30 +32,31 @@ protected override void Up(MigrationBuilder migrationBuilder) oldClrType: typeof(string), oldType: "character varying(320)", oldMaxLength: 320, - oldNullable: true - ); + oldNullable: true); migrationBuilder.CreateIndex( name: "IX_Users_TenantId_NormalizedEmail", table: "Users", columns: new[] { "TenantId", "NormalizedEmail" }, - unique: true - ); + unique: true); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropIndex(name: "IX_Users_TenantId_NormalizedEmail", table: "Users"); + migrationBuilder.DropIndex( + name: "IX_Users_TenantId_NormalizedEmail", + table: "Users"); - migrationBuilder.DropColumn(name: "NormalizedEmail", table: "Users"); + migrationBuilder.DropColumn( + name: "NormalizedEmail", + table: "Users"); migrationBuilder.CreateIndex( name: "IX_Users_TenantId_Email", table: "Users", columns: new[] { "TenantId", "Email" }, - unique: true - ); + unique: true); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.Designer.cs diff --git a/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs b/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs new file mode 100644 index 00000000..84b30e48 --- /dev/null +++ b/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs @@ -0,0 +1,128 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace APITemplate.Migrations +{ + /// + public partial class AddTenantInvitationNormalizedEmail : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasswordResetTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + IsUsed = table.Column(type: "boolean", nullable: false, defaultValue: false), + TenantId = table.Column(type: "uuid", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + CreatedBy = table.Column(type: "uuid", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000000")), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + UpdatedBy = table.Column(type: "uuid", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000000")), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + DeletedBy = table.Column(type: "uuid", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordResetTokens", x => x.Id); + table.CheckConstraint("CK_PasswordResetTokens_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); + table.ForeignKey( + name: "FK_PasswordResetTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "TenantInvitations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Email = table.Column(type: "character varying(320)", maxLength: 320, nullable: false), + NormalizedEmail = table.Column(type: "character varying(320)", maxLength: 320, nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "Pending"), + TenantId = table.Column(type: "uuid", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + CreatedBy = table.Column(type: "uuid", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000000")), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + UpdatedBy = table.Column(type: "uuid", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000000")), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + DeletedBy = table.Column(type: "uuid", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantInvitations", x => x.Id); + table.CheckConstraint("CK_TenantInvitations_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); + table.ForeignKey( + name: "FK_TenantInvitations_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TenantId", + table: "PasswordResetTokens", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TenantId_IsDeleted", + table: "PasswordResetTokens", + columns: new[] { "TenantId", "IsDeleted" }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TokenHash", + table: "PasswordResetTokens", + column: "TokenHash"); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_UserId", + table: "PasswordResetTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_TenantInvitations_TenantId", + table: "TenantInvitations", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_TenantInvitations_TenantId_IsDeleted", + table: "TenantInvitations", + columns: new[] { "TenantId", "IsDeleted" }); + + migrationBuilder.CreateIndex( + name: "IX_TenantInvitations_TenantId_NormalizedEmail", + table: "TenantInvitations", + columns: new[] { "TenantId", "NormalizedEmail" }); + + migrationBuilder.CreateIndex( + name: "IX_TenantInvitations_TokenHash", + table: "TenantInvitations", + column: "TokenHash"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PasswordResetTokens"); + + migrationBuilder.DropTable( + name: "TenantInvitations"); + } + } +} diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs b/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs similarity index 71% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs rename to src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs index 740c135f..7c1aa8ec 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs +++ b/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs @@ -10,23 +10,23 @@ public partial class RemovePasswordHashAddKeycloakUserId : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn(name: "PasswordHash", table: "Users"); + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "Users"); migrationBuilder.AddColumn( name: "KeycloakUserId", table: "Users", type: "character varying(256)", maxLength: 256, - nullable: true - ); + nullable: true); migrationBuilder.CreateIndex( name: "IX_Users_KeycloakUserId", table: "Users", column: "KeycloakUserId", unique: true, - filter: "\"KeycloakUserId\" IS NOT NULL" - ); + filter: "\"KeycloakUserId\" IS NOT NULL"); } /// @@ -36,9 +36,13 @@ protected override void Down(MigrationBuilder migrationBuilder) // when the Up() migration dropped the column. After rollback, every row in Users // will have PasswordHash = "" (empty string), which is not a valid BCrypt hash. // DO NOT roll back this migration in production without a full data recovery plan. - migrationBuilder.DropIndex(name: "IX_Users_KeycloakUserId", table: "Users"); + migrationBuilder.DropIndex( + name: "IX_Users_KeycloakUserId", + table: "Users"); - migrationBuilder.DropColumn(name: "KeycloakUserId", table: "Users"); + migrationBuilder.DropColumn( + name: "KeycloakUserId", + table: "Users"); migrationBuilder.AddColumn( name: "PasswordHash", @@ -46,12 +50,9 @@ protected override void Down(MigrationBuilder migrationBuilder) type: "character varying(1000)", maxLength: 1000, nullable: false, - defaultValue: "" - ); + defaultValue: ""); - migrationBuilder.Sql( - "ALTER TABLE \"Users\" ALTER COLUMN \"PasswordHash\" DROP DEFAULT;" - ); + migrationBuilder.Sql("ALTER TABLE \"Users\" ALTER COLUMN \"PasswordHash\" DROP DEFAULT;"); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.Designer.cs diff --git a/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs b/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs new file mode 100644 index 00000000..14fc36b3 --- /dev/null +++ b/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace APITemplate.Migrations +{ + /// + public partial class DropPasswordResetTokensTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PasswordResetTokens"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasswordResetTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + DeletedBy = table.Column(type: "uuid", nullable: true), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + IsUsed = table.Column(type: "boolean", nullable: false, defaultValue: false), + TenantId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + CreatedBy = table.Column(type: "uuid", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000000")), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + UpdatedBy = table.Column(type: "uuid", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000000")) + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordResetTokens", x => x.Id); + table.CheckConstraint("CK_PasswordResetTokens_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); + table.ForeignKey( + name: "FK_PasswordResetTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TenantId", + table: "PasswordResetTokens", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TenantId_IsDeleted", + table: "PasswordResetTokens", + columns: new[] { "TenantId", "IsDeleted" }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TokenHash", + table: "PasswordResetTokens", + column: "TokenHash"); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_UserId", + table: "PasswordResetTokens", + column: "UserId"); + } + } +} diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.cs b/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.cs rename to src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.cs b/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.cs rename to src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.cs b/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.cs rename to src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.cs b/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.cs rename to src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.cs b/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.cs rename to src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.cs b/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.cs rename to src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.Designer.cs b/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.cs b/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.cs rename to src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/APITemplate.Infrastructure/Migrations/AppDbContextModelSnapshot.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/AppDbContextModelSnapshot.cs rename to src/APITemplate.Infrastructure/Migrations/AppDbContextModelSnapshot.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs b/src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs similarity index 67% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs rename to src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs index 6bb3f35b..496caac4 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs +++ b/src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs @@ -11,14 +11,9 @@ namespace APITemplate.Infrastructure.Migrations; /// public sealed class M001_CreateProductDataIndexes : MongoMigration { - public M001_CreateProductDataIndexes() - : base("1.0.0") { } + public M001_CreateProductDataIndexes() : base("1.0.0") { } - public override async Task UpAsync( - IMongoDatabase db, - IClientSessionHandle session, - CancellationToken ct - ) + public override async Task UpAsync(IMongoDatabase db, IClientSessionHandle session, CancellationToken ct) { var collection = db.GetCollection("product_data"); @@ -26,22 +21,17 @@ CancellationToken ct { new CreateIndexModel( Builders.IndexKeys.Ascending("_t"), - new CreateIndexOptions { Name = "idx_type" } - ), + new CreateIndexOptions { Name = "idx_type" }), + new CreateIndexModel( Builders.IndexKeys.Descending(x => x.CreatedAt), - new CreateIndexOptions { Name = "idx_created" } - ), + new CreateIndexOptions { Name = "idx_created" }) }; await collection.Indexes.CreateManyAsync(indexes, ct); } - public override Task DownAsync( - IMongoDatabase db, - IClientSessionHandle session, - CancellationToken ct - ) + public override Task DownAsync(IMongoDatabase db, IClientSessionHandle session, CancellationToken ct) { var collection = db.GetCollection("product_data"); return collection.Indexes.DropAllAsync(ct); diff --git a/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs b/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs new file mode 100644 index 00000000..fadb2502 --- /dev/null +++ b/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs @@ -0,0 +1,36 @@ +using APITemplate.Domain.Entities; +using Kot.MongoDB.Migrations; +using MongoDB.Driver; + +namespace APITemplate.Infrastructure.Migrations; + +public sealed class M002_AddProductDataSoftDeleteIndexes : MongoMigration +{ + public M002_AddProductDataSoftDeleteIndexes() : base("1.1.0") { } + + public override Task UpAsync(IMongoDatabase db, IClientSessionHandle session, CancellationToken ct) + { + var collection = db.GetCollection("product_data"); + + return collection.Indexes.CreateManyAsync( + [ + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TenantId).Ascending(x => x.IsDeleted).Ascending("_t"), + new CreateIndexOptions { Name = "idx_tenant_is_deleted_type" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TenantId).Ascending(x => x.IsDeleted).Descending(x => x.CreatedAt), + new CreateIndexOptions { Name = "idx_tenant_is_deleted_created" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TenantId).Ascending(x => x.Id).Ascending(x => x.IsDeleted), + new CreateIndexOptions { Name = "idx_tenant_id_is_deleted" }) + ], ct); + } + + public override async Task DownAsync(IMongoDatabase db, IClientSessionHandle session, CancellationToken ct) + { + var collection = db.GetCollection("product_data"); + await collection.Indexes.DropOneAsync("idx_tenant_is_deleted_type", ct); + await collection.Indexes.DropOneAsync("idx_tenant_is_deleted_created", ct); + await collection.Indexes.DropOneAsync("idx_tenant_id_is_deleted", ct); + } +} diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.Designer.cs b/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.Designer.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.Designer.cs rename to src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.Designer.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.cs b/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.cs rename to src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/TickerQ/TickerQSchedulerDbContextModelSnapshot.cs b/src/APITemplate.Infrastructure/Migrations/TickerQ/TickerQSchedulerDbContextModelSnapshot.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Migrations/TickerQ/TickerQSchedulerDbContextModelSnapshot.cs rename to src/APITemplate.Infrastructure/Migrations/TickerQ/TickerQSchedulerDbContextModelSnapshot.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/ApiMetrics.cs b/src/APITemplate.Infrastructure/Observability/ApiMetrics.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/ApiMetrics.cs rename to src/APITemplate.Infrastructure/Observability/ApiMetrics.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/AuthTelemetry.cs b/src/APITemplate.Infrastructure/Observability/AuthTelemetry.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/AuthTelemetry.cs rename to src/APITemplate.Infrastructure/Observability/AuthTelemetry.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/CacheTelemetry.cs b/src/APITemplate.Infrastructure/Observability/CacheTelemetry.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/CacheTelemetry.cs rename to src/APITemplate.Infrastructure/Observability/CacheTelemetry.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/ConflictTelemetry.cs b/src/APITemplate.Infrastructure/Observability/ConflictTelemetry.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/ConflictTelemetry.cs rename to src/APITemplate.Infrastructure/Observability/ConflictTelemetry.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/GraphQlTelemetry.cs b/src/APITemplate.Infrastructure/Observability/GraphQlTelemetry.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/GraphQlTelemetry.cs rename to src/APITemplate.Infrastructure/Observability/GraphQlTelemetry.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/HealthCheckMetricsPublisher.cs b/src/APITemplate.Infrastructure/Observability/HealthCheckMetricsPublisher.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/HealthCheckMetricsPublisher.cs rename to src/APITemplate.Infrastructure/Observability/HealthCheckMetricsPublisher.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/HttpRouteResolver.cs b/src/APITemplate.Infrastructure/Observability/HttpRouteResolver.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/HttpRouteResolver.cs rename to src/APITemplate.Infrastructure/Observability/HttpRouteResolver.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/ObservabilityConventions.cs b/src/APITemplate.Infrastructure/Observability/ObservabilityConventions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/ObservabilityConventions.cs rename to src/APITemplate.Infrastructure/Observability/ObservabilityConventions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/StartupTelemetry.cs b/src/APITemplate.Infrastructure/Observability/StartupTelemetry.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/StartupTelemetry.cs rename to src/APITemplate.Infrastructure/Observability/StartupTelemetry.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/StoredProcedureTelemetry.cs b/src/APITemplate.Infrastructure/Observability/StoredProcedureTelemetry.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/StoredProcedureTelemetry.cs rename to src/APITemplate.Infrastructure/Observability/StoredProcedureTelemetry.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs b/src/APITemplate.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs rename to src/APITemplate.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/ValidationTelemetry.cs b/src/APITemplate.Infrastructure/Observability/ValidationTelemetry.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Observability/ValidationTelemetry.cs rename to src/APITemplate.Infrastructure/Observability/ValidationTelemetry.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/AppDbContext.cs b/src/APITemplate.Infrastructure/Persistence/AppDbContext.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/AppDbContext.cs rename to src/APITemplate.Infrastructure/Persistence/AppDbContext.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/AppDbContextFactory.cs b/src/APITemplate.Infrastructure/Persistence/AppDbContextFactory.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/AppDbContextFactory.cs rename to src/APITemplate.Infrastructure/Persistence/AppDbContextFactory.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs b/src/APITemplate.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs rename to src/APITemplate.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs b/src/APITemplate.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs rename to src/APITemplate.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/AuthBootstrapSeeder.cs b/src/APITemplate.Infrastructure/Persistence/AuthBootstrapSeeder.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/AuthBootstrapSeeder.cs rename to src/APITemplate.Infrastructure/Persistence/AuthBootstrapSeeder.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs similarity index 89% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs index 1862be02..e127d3ce 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs +++ b/src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs @@ -9,8 +9,7 @@ namespace APITemplate.Infrastructure.Persistence.Configurations; /// HasNoKey() tells EF Core: this type has no primary key and no backing table. /// It can only be materialised via FromSql() or raw SQL queries. /// -public sealed class ProductCategoryStatsConfiguration - : IEntityTypeConfiguration +public sealed class ProductCategoryStatsConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/ProductConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/ProductConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/ProductConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/ProductConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/TenantConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/TenantConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/TenantConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/TenantConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs b/src/APITemplate.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs rename to src/APITemplate.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/DesignTimeConfigurationHelper.cs b/src/APITemplate.Infrastructure/Persistence/DesignTimeConfigurationHelper.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/DesignTimeConfigurationHelper.cs rename to src/APITemplate.Infrastructure/Persistence/DesignTimeConfigurationHelper.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/DesignTimeDefaults.cs b/src/APITemplate.Infrastructure/Persistence/DesignTimeDefaults.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/DesignTimeDefaults.cs rename to src/APITemplate.Infrastructure/Persistence/DesignTimeDefaults.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs b/src/APITemplate.Infrastructure/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs rename to src/APITemplate.Infrastructure/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs b/src/APITemplate.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs rename to src/APITemplate.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/MongoDbContext.cs b/src/APITemplate.Infrastructure/Persistence/MongoDbContext.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/MongoDbContext.cs rename to src/APITemplate.Infrastructure/Persistence/MongoDbContext.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/MongoDbSettings.cs b/src/APITemplate.Infrastructure/Persistence/MongoDbSettings.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/MongoDbSettings.cs rename to src/APITemplate.Infrastructure/Persistence/MongoDbSettings.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs b/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs similarity index 94% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs rename to src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs index 163000c8..d3ae5b11 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs +++ b/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs @@ -24,6 +24,5 @@ public interface ISoftDeleteCascadeRule Task> GetDependentsAsync( AppDbContext dbContext, IAuditableTenantEntity entity, - CancellationToken cancellationToken = default - ); + CancellationToken cancellationToken = default); } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs b/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs rename to src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/ProductSoftDeleteCascadeRule.cs b/src/APITemplate.Infrastructure/Persistence/SoftDelete/ProductSoftDeleteCascadeRule.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/ProductSoftDeleteCascadeRule.cs rename to src/APITemplate.Infrastructure/Persistence/SoftDelete/ProductSoftDeleteCascadeRule.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs b/src/APITemplate.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs rename to src/APITemplate.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/TenantSoftDeleteCascadeRule.cs b/src/APITemplate.Infrastructure/Persistence/SoftDelete/TenantSoftDeleteCascadeRule.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/SoftDelete/TenantSoftDeleteCascadeRule.cs rename to src/APITemplate.Infrastructure/Persistence/SoftDelete/TenantSoftDeleteCascadeRule.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Startup/PostgresAdvisoryLockStartupTaskCoordinator.cs b/src/APITemplate.Infrastructure/Persistence/Startup/PostgresAdvisoryLockStartupTaskCoordinator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/Startup/PostgresAdvisoryLockStartupTaskCoordinator.cs rename to src/APITemplate.Infrastructure/Persistence/Startup/PostgresAdvisoryLockStartupTaskCoordinator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs b/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs rename to src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs b/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs rename to src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs b/src/APITemplate.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs rename to src/APITemplate.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs b/src/APITemplate.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs rename to src/APITemplate.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs b/src/APITemplate.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs rename to src/APITemplate.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs b/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs similarity index 91% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs rename to src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs index 7ede6050..5d92fa43 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs +++ b/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs @@ -41,8 +41,7 @@ public UnitOfWork( AppDbContext dbContext, IOptions transactionDefaults, ILogger logger, - IDbTransactionProvider transactionProvider - ) + IDbTransactionProvider transactionProvider) { _dbContext = dbContext; _transactionDefaults = transactionDefaults.Value; @@ -72,10 +71,7 @@ public Task CommitAsync(CancellationToken ct = default) } var effectiveOptions = _transactionDefaults.Resolve(null); - _logger.CommitStarted( - effectiveOptions.RetryEnabled ?? true, - effectiveOptions.TimeoutSeconds - ); + _logger.CommitStarted(effectiveOptions.RetryEnabled ?? true, effectiveOptions.TimeoutSeconds); var strategy = _transactionProvider.CreateExecutionStrategy(effectiveOptions); return strategy.ExecuteAsync( async cancellationToken => @@ -83,8 +79,7 @@ public Task CommitAsync(CancellationToken ct = default) await _dbContext.SaveChangesAsync(cancellationToken); _logger.CommitCompleted(); }, - ct - ); + ct); } /// @@ -104,17 +99,15 @@ public Task CommitAsync(CancellationToken ct = default) public async Task ExecuteInTransactionAsync( Func action, CancellationToken ct = default, - TransactionOptions? options = null - ) => - await ExecuteInTransactionAsync( + TransactionOptions? options = null) + => await ExecuteInTransactionAsync( async () => { await action(); return true; }, ct, - options - ); + options); /// /// Executes a write delegate inside an explicit relational transaction and returns a value created by that flow. @@ -137,8 +130,7 @@ await ExecuteInTransactionAsync( public async Task ExecuteInTransactionAsync( Func> action, CancellationToken ct = default, - TransactionOptions? options = null - ) + TransactionOptions? options = null) { var currentTransaction = _transactionProvider.CurrentTransaction; if (currentTransaction is not null) @@ -165,8 +157,7 @@ private async Task ExecuteWithinSavepointAsync( IDbContextTransaction transaction, Func> action, TransactionOptions? options, - CancellationToken ct - ) + CancellationToken ct) { ValidateNestedTransactionOptions(options); var savepointName = $"uow_sp_{Interlocked.Increment(ref _savepointCounter)}"; @@ -204,8 +195,7 @@ CancellationToken ct private async Task ExecuteAsOutermostTransactionAsync( Func> action, TransactionOptions effectiveOptions, - CancellationToken ct - ) + CancellationToken ct) { var strategy = _transactionProvider.CreateExecutionStrategy(effectiveOptions); var previousActiveOptions = _activeTransactionOptions; @@ -215,22 +205,18 @@ CancellationToken ct operation: async (_, transactionalAction, cancellationToken) => { _activeTransactionOptions = effectiveOptions; - using var timeoutScope = _commandTimeoutScope.Apply( - effectiveOptions.TimeoutSeconds - ); + using var timeoutScope = _commandTimeoutScope.Apply(effectiveOptions.TimeoutSeconds); _logger.OutermostTransactionStarted( effectiveOptions.IsolationLevel!.Value, effectiveOptions.TimeoutSeconds, - effectiveOptions.RetryEnabled ?? true - ); + effectiveOptions.RetryEnabled ?? true); IDbContextTransaction? transaction = null; try { transaction = await _transactionProvider.BeginTransactionAsync( effectiveOptions.IsolationLevel!.Value, - cancellationToken - ); + cancellationToken); _logger.DatabaseTransactionOpened(); } catch (Exception ex) when (IsTransactionNotSupported(ex)) @@ -277,8 +263,7 @@ CancellationToken ct } }, verifySucceeded: null, - ct - ); + ct); } /// @@ -288,9 +273,7 @@ CancellationToken ct private void ValidateNestedTransactionOptions(TransactionOptions? options) { if (_activeTransactionOptions is null) - throw new InvalidOperationException( - "Nested transaction execution requires an active outer transaction policy." - ); + throw new InvalidOperationException("Nested transaction execution requires an active outer transaction policy."); if (options is null || options.IsEmpty()) return; @@ -299,9 +282,8 @@ private void ValidateNestedTransactionOptions(TransactionOptions? options) if (effectiveOptions != _activeTransactionOptions) { throw new InvalidOperationException( - "Nested transactions inherit the active outer transaction options. " - + "Pass null/default options inside nested ExecuteInTransactionAsync calls." - ); + "Nested transactions inherit the active outer transaction options. " + + "Pass null/default options inside nested ExecuteInTransactionAsync calls."); } } @@ -315,16 +297,17 @@ private void ValidateNestedTransactionOptions(TransactionOptions? options) private async Task ReleaseSavepointIfSupportedAsync( IDbContextTransaction transaction, string savepointName, - CancellationToken ct - ) + CancellationToken ct) { try { await transaction.ReleaseSavepointAsync(savepointName, ct); } - catch (NotSupportedException) { } + catch (NotSupportedException) + { + } } - private static bool IsTransactionNotSupported(Exception ex) => - ex is InvalidOperationException or NotSupportedException; + private static bool IsTransactionNotSupported(Exception ex) + => ex is InvalidOperationException or NotSupportedException; } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs b/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs rename to src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs b/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs rename to src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/CategoryRepository.cs b/src/APITemplate.Infrastructure/Repositories/CategoryRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/CategoryRepository.cs rename to src/APITemplate.Infrastructure/Repositories/CategoryRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/FailedEmailRepository.cs b/src/APITemplate.Infrastructure/Repositories/FailedEmailRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/FailedEmailRepository.cs rename to src/APITemplate.Infrastructure/Repositories/FailedEmailRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/JobExecutionRepository.cs b/src/APITemplate.Infrastructure/Repositories/JobExecutionRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/JobExecutionRepository.cs rename to src/APITemplate.Infrastructure/Repositories/JobExecutionRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs b/src/APITemplate.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs rename to src/APITemplate.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/Pagination/PagedRow.cs b/src/APITemplate.Infrastructure/Repositories/Pagination/PagedRow.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/Pagination/PagedRow.cs rename to src/APITemplate.Infrastructure/Repositories/Pagination/PagedRow.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/ProductDataLinkRepository.cs b/src/APITemplate.Infrastructure/Repositories/ProductDataLinkRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/ProductDataLinkRepository.cs rename to src/APITemplate.Infrastructure/Repositories/ProductDataLinkRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/ProductDataRepository.cs b/src/APITemplate.Infrastructure/Repositories/ProductDataRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/ProductDataRepository.cs rename to src/APITemplate.Infrastructure/Repositories/ProductDataRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/ProductRepository.cs b/src/APITemplate.Infrastructure/Repositories/ProductRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/ProductRepository.cs rename to src/APITemplate.Infrastructure/Repositories/ProductRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/ProductReviewRepository.cs b/src/APITemplate.Infrastructure/Repositories/ProductReviewRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/ProductReviewRepository.cs rename to src/APITemplate.Infrastructure/Repositories/ProductReviewRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/RepositoryBase.cs b/src/APITemplate.Infrastructure/Repositories/RepositoryBase.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/RepositoryBase.cs rename to src/APITemplate.Infrastructure/Repositories/RepositoryBase.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/StoredFileRepository.cs b/src/APITemplate.Infrastructure/Repositories/StoredFileRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/StoredFileRepository.cs rename to src/APITemplate.Infrastructure/Repositories/StoredFileRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/TenantInvitationRepository.cs b/src/APITemplate.Infrastructure/Repositories/TenantInvitationRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/TenantInvitationRepository.cs rename to src/APITemplate.Infrastructure/Repositories/TenantInvitationRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/TenantRepository.cs b/src/APITemplate.Infrastructure/Repositories/TenantRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/TenantRepository.cs rename to src/APITemplate.Infrastructure/Repositories/TenantRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/UserRepository.cs b/src/APITemplate.Infrastructure/Repositories/UserRepository.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Repositories/UserRepository.cs rename to src/APITemplate.Infrastructure/Repositories/UserRepository.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/DragonflyTicketStore.cs b/src/APITemplate.Infrastructure/Security/DragonflyTicketStore.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/DragonflyTicketStore.cs rename to src/APITemplate.Infrastructure/Security/DragonflyTicketStore.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/HttpActorProvider.cs b/src/APITemplate.Infrastructure/Security/HttpActorProvider.cs similarity index 90% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/HttpActorProvider.cs rename to src/APITemplate.Infrastructure/Security/HttpActorProvider.cs index 634df95e..b7c9ed02 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/HttpActorProvider.cs +++ b/src/APITemplate.Infrastructure/Security/HttpActorProvider.cs @@ -21,8 +21,7 @@ public sealed class HttpActorProvider : IActorProvider public HttpActorProvider( IHttpContextAccessor httpContextAccessor, - IOptions systemIdentityOptions - ) + IOptions systemIdentityOptions) { _httpContextAccessor = httpContextAccessor; _systemIdentity = systemIdentityOptions.Value; @@ -34,8 +33,7 @@ public Guid ActorId { var user = _httpContextAccessor.HttpContext?.User; // Prefer stable subject-style identifiers first, then name-like claims, then configured system fallback. - var raw = - user?.FindFirstValue(ClaimTypes.NameIdentifier) + var raw = user?.FindFirstValue(ClaimTypes.NameIdentifier) ?? user?.FindFirstValue(AuthConstants.Claims.Subject) ?? user?.FindFirstValue(ClaimTypes.Name); diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/HttpTenantProvider.cs b/src/APITemplate.Infrastructure/Security/HttpTenantProvider.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/HttpTenantProvider.cs rename to src/APITemplate.Infrastructure/Security/HttpTenantProvider.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs b/src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs similarity index 76% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs rename to src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs index 08a93c12..b5f62648 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs +++ b/src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs @@ -45,8 +45,7 @@ public static async Task OnValidatePrincipal(CookieValidatePrincipalContext cont private static bool TryCreateRefreshRequest( CookieValidatePrincipalContext context, - out RefreshRequest refreshRequest - ) + out RefreshRequest refreshRequest) { refreshRequest = default; @@ -69,24 +68,20 @@ out RefreshRequest refreshRequest private static bool TryGetExpiration( CookieValidatePrincipalContext context, - out DateTimeOffset expiresAt - ) + out DateTimeOffset expiresAt) { expiresAt = default; - var expiresAtStr = context.Properties.GetTokenValue( - AuthConstants.CookieTokenNames.ExpiresAt - ); - return expiresAtStr is not null && DateTimeOffset.TryParse(expiresAtStr, out expiresAt); + var expiresAtStr = context.Properties.GetTokenValue(AuthConstants.CookieTokenNames.ExpiresAt); + return expiresAtStr is not null + && DateTimeOffset.TryParse(expiresAtStr, out expiresAt); } private static bool IsRefreshRequired( CookieValidatePrincipalContext context, - DateTimeOffset expiresAt - ) + DateTimeOffset expiresAt) { - var bffOptions = context - .HttpContext.RequestServices.GetRequiredService>() - .Value; + var bffOptions = context.HttpContext.RequestServices + .GetRequiredService>().Value; return expiresAt - DateTimeOffset.UtcNow <= TimeSpan.FromMinutes(bffOptions.TokenRefreshThresholdMinutes); @@ -94,24 +89,19 @@ DateTimeOffset expiresAt private static bool TryGetRefreshToken( CookieValidatePrincipalContext context, - out string refreshToken - ) + out string refreshToken) { - refreshToken = - context.Properties.GetTokenValue(AuthConstants.CookieTokenNames.RefreshToken) - ?? string.Empty; + refreshToken = context.Properties.GetTokenValue(AuthConstants.CookieTokenNames.RefreshToken) ?? string.Empty; return !string.IsNullOrEmpty(refreshToken); } private static async Task TryRefreshSessionAsync( CookieValidatePrincipalContext context, - RefreshRequest refreshRequest - ) + RefreshRequest refreshRequest) { var tokenEndpoint = KeycloakUrlHelper.BuildTokenEndpoint( refreshRequest.KeycloakOptions.AuthServerUrl, - refreshRequest.KeycloakOptions.Realm - ); + refreshRequest.KeycloakOptions.Realm); using var client = CreateTokenClient(context); try @@ -120,8 +110,7 @@ RefreshRequest refreshRequest context, client, tokenEndpoint, - refreshRequest - ); + refreshRequest); if (!response.IsSuccessStatusCode) { @@ -129,9 +118,7 @@ RefreshRequest refreshRequest return null; } - return await response.Content.ReadFromJsonAsync( - context.HttpContext.RequestAborted - ); + return await response.Content.ReadFromJsonAsync(context.HttpContext.RequestAborted); } catch (Exception ex) { @@ -143,8 +130,8 @@ RefreshRequest refreshRequest private static HttpClient CreateTokenClient(CookieValidatePrincipalContext context) { - return context - .HttpContext.RequestServices.GetRequiredService() + return context.HttpContext.RequestServices + .GetRequiredService() .CreateClient(AuthConstants.HttpClients.KeycloakToken); } @@ -152,41 +139,34 @@ private static Task SendRefreshRequestAsync( CookieValidatePrincipalContext context, HttpClient client, string tokenEndpoint, - RefreshRequest refreshRequest - ) + RefreshRequest refreshRequest) { return client.PostAsync( tokenEndpoint, BuildRefreshRequestContent(refreshRequest.KeycloakOptions, refreshRequest.RefreshToken), - context.HttpContext.RequestAborted - ); + context.HttpContext.RequestAborted); } private static KeycloakOptions GetKeycloakOptions(CookieValidatePrincipalContext context) { - return context - .HttpContext.RequestServices.GetRequiredService>() - .Value; + return context.HttpContext.RequestServices + .GetRequiredService>().Value; } + private static FormUrlEncodedContent BuildRefreshRequestContent( KeycloakOptions keycloakOptions, - string refreshToken - ) + string refreshToken) { var formParams = new Dictionary { - [AuthConstants.OAuth2FormParameters.GrantType] = AuthConstants - .OAuth2GrantTypes - .RefreshToken, + [AuthConstants.OAuth2FormParameters.GrantType] = AuthConstants.OAuth2GrantTypes.RefreshToken, [AuthConstants.OAuth2FormParameters.ClientId] = keycloakOptions.Resource, - [AuthConstants.OAuth2FormParameters.RefreshToken] = refreshToken, + [AuthConstants.OAuth2FormParameters.RefreshToken] = refreshToken }; if (!string.IsNullOrEmpty(keycloakOptions.Credentials.Secret)) - formParams[AuthConstants.OAuth2FormParameters.ClientSecret] = keycloakOptions - .Credentials - .Secret; + formParams[AuthConstants.OAuth2FormParameters.ClientSecret] = keycloakOptions.Credentials.Secret; return new FormUrlEncodedContent(formParams); } @@ -194,33 +174,26 @@ string refreshToken private static void ApplyRefreshedSession( CookieValidatePrincipalContext context, KeycloakTokenResponse tokenResponse, - string refreshToken - ) + string refreshToken) { - context.Properties.UpdateTokenValue( - AuthConstants.CookieTokenNames.AccessToken, - tokenResponse.AccessToken - ); + context.Properties.UpdateTokenValue(AuthConstants.CookieTokenNames.AccessToken, tokenResponse.AccessToken); context.Properties.UpdateTokenValue( AuthConstants.CookieTokenNames.RefreshToken, - tokenResponse.RefreshToken ?? refreshToken - ); + tokenResponse.RefreshToken ?? refreshToken); context.Properties.UpdateTokenValue( AuthConstants.CookieTokenNames.ExpiresAt, - DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString("o") - ); + DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString("o")); context.ShouldRenew = true; } private static ILogger GetLogger(CookieValidatePrincipalContext context) { - return context - .HttpContext.RequestServices.GetRequiredService() + return context.HttpContext.RequestServices + .GetRequiredService() .CreateLogger(nameof(CookieSessionRefresher)); } private readonly record struct RefreshRequest( KeycloakOptions KeycloakOptions, - string RefreshToken - ); + string RefreshToken); } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminService.cs b/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminService.cs rename to src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs b/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs rename to src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs b/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs rename to src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs b/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs rename to src/APITemplate.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs b/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs rename to src/APITemplate.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs b/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs rename to src/APITemplate.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/SecureTokenGenerator.cs b/src/APITemplate.Infrastructure/Security/SecureTokenGenerator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/SecureTokenGenerator.cs rename to src/APITemplate.Infrastructure/Security/SecureTokenGenerator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidator.cs b/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidator.cs rename to src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs b/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs rename to src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs b/src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs similarity index 81% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs rename to src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs index 9e1df6cb..6b20b475 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs +++ b/src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs @@ -27,8 +27,7 @@ public sealed class UserProvisioningService : IUserProvisioningService public UserProvisioningService( AppDbContext db, IUnitOfWork unitOfWork, - ILogger logger - ) + ILogger logger) { _db = db; _unitOfWork = unitOfWork; @@ -40,21 +39,19 @@ ILogger logger string keycloakUserId, string email, string username, - CancellationToken ct = default - ) + CancellationToken ct = default) { // 1. Check if the user is already provisioned — bypass tenant filter because // we only have the Keycloak subject ID, not a tenant context yet. - var existing = await _db - .Users.IgnoreQueryFilters() + var existing = await _db.Users + .IgnoreQueryFilters() .FirstOrDefaultAsync(u => u.KeycloakUserId == keycloakUserId, ct); if (existing is not null) { _logger.LogDebug( "User provisioning skipped — AppUser already exists for KeycloakUserId={KeycloakUserId}", - keycloakUserId - ); + keycloakUserId); return existing; } @@ -62,19 +59,17 @@ ILogger logger // Bypass tenant filter — at this point no tenant context is active. var normalizedEmail = AppUser.NormalizeEmail(email); - var invitation = await _db - .TenantInvitations.IgnoreQueryFilters() + var invitation = await _db.TenantInvitations + .IgnoreQueryFilters() .FirstOrDefaultAsync( i => i.NormalizedEmail == normalizedEmail && i.Status == InvitationStatus.Accepted, - ct - ); + ct); if (invitation is null) { _logger.LogInformation( "User provisioning skipped — no accepted invitation found for email={NormalizedEmail}", - normalizedEmail - ); + normalizedEmail); return null; } @@ -101,26 +96,18 @@ ILogger logger "Provisioned new AppUser={UserId} for KeycloakUserId={KeycloakUserId}, TenantId={TenantId}", user.Id, keycloakUserId, - invitation.TenantId - ); + invitation.TenantId); return user; } catch (DbUpdateException ex) { // Concurrent request may have provisioned this user — re-fetch the winner. - _logger.LogWarning( - ex, - "DbUpdateException during provisioning for {KeycloakUserId}. Re-fetching.", - keycloakUserId - ); + _logger.LogWarning(ex, "DbUpdateException during provisioning for {KeycloakUserId}. Re-fetching.", keycloakUserId); - return await _db - .Users.IgnoreQueryFilters() - .FirstOrDefaultAsync(u => u.KeycloakUserId == keycloakUserId, ct) + return await _db.Users.IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.KeycloakUserId == keycloakUserId, ct) ?? throw new InvalidOperationException( - $"Provisioning failed for KeycloakUserId={keycloakUserId} and no existing user was found.", - ex - ); + $"Provisioning failed for KeycloakUserId={keycloakUserId} and no existing user was found.", ex); } } } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs b/src/APITemplate.Infrastructure/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs rename to src/APITemplate.Infrastructure/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs b/src/APITemplate.Infrastructure/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs rename to src/APITemplate.Infrastructure/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs b/src/APITemplate.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs rename to src/APITemplate.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs b/src/APITemplate.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs rename to src/APITemplate.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs b/src/APITemplate.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs rename to src/APITemplate.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs b/src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs similarity index 68% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs rename to src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs index 4072edf3..d502be85 100644 --- a/monolith/API-Template-mono/src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs +++ b/src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs @@ -29,27 +29,26 @@ public StoredProcedureExecutor(AppDbContext dbContext) public Task QueryFirstAsync( IStoredProcedure procedure, - CancellationToken ct = default - ) - where TResult : class => - StoredProcedureTelemetry.TraceQueryFirstAsync( + CancellationToken ct = default) + where TResult : class + => StoredProcedureTelemetry.TraceQueryFirstAsync( procedure, - () => _dbContext.Set().FromSql(procedure.ToSql()).FirstOrDefaultAsync(ct) - ); + () => _dbContext.Set() + .FromSql(procedure.ToSql()) + .FirstOrDefaultAsync(ct)); public Task> QueryManyAsync( IStoredProcedure procedure, - CancellationToken ct = default - ) - where TResult : class => - StoredProcedureTelemetry.TraceQueryManyAsync( + CancellationToken ct = default) + where TResult : class + => StoredProcedureTelemetry.TraceQueryManyAsync( procedure, - async () => await _dbContext.Set().FromSql(procedure.ToSql()).ToListAsync(ct) - ); + async () => await _dbContext.Set() + .FromSql(procedure.ToSql()) + .ToListAsync(ct)); - public Task ExecuteAsync(FormattableString sql, CancellationToken ct = default) => - StoredProcedureTelemetry.TraceExecuteAsync( + public Task ExecuteAsync(FormattableString sql, CancellationToken ct = default) + => StoredProcedureTelemetry.TraceExecuteAsync( sql, - () => _dbContext.Database.ExecuteSqlAsync(sql, ct) - ); + () => _dbContext.Database.ExecuteSqlAsync(sql, ct)); } diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/ChannelOutgoingWebhookQueue.cs b/src/APITemplate.Infrastructure/Webhooks/ChannelOutgoingWebhookQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/ChannelOutgoingWebhookQueue.cs rename to src/APITemplate.Infrastructure/Webhooks/ChannelOutgoingWebhookQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/ChannelWebhookQueue.cs b/src/APITemplate.Infrastructure/Webhooks/ChannelWebhookQueue.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/ChannelWebhookQueue.cs rename to src/APITemplate.Infrastructure/Webhooks/ChannelWebhookQueue.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/HmacHelper.cs b/src/APITemplate.Infrastructure/Webhooks/HmacHelper.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/HmacHelper.cs rename to src/APITemplate.Infrastructure/Webhooks/HmacHelper.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadSigner.cs b/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadSigner.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadSigner.cs rename to src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadSigner.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadValidator.cs b/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadValidator.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadValidator.cs rename to src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadValidator.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/LoggingWebhookEventHandler.cs b/src/APITemplate.Infrastructure/Webhooks/LoggingWebhookEventHandler.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/LoggingWebhookEventHandler.cs rename to src/APITemplate.Infrastructure/Webhooks/LoggingWebhookEventHandler.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/OutgoingWebhookBackgroundService.cs b/src/APITemplate.Infrastructure/Webhooks/OutgoingWebhookBackgroundService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/OutgoingWebhookBackgroundService.cs rename to src/APITemplate.Infrastructure/Webhooks/OutgoingWebhookBackgroundService.cs diff --git a/monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/WebhookProcessingBackgroundService.cs b/src/APITemplate.Infrastructure/Webhooks/WebhookProcessingBackgroundService.cs similarity index 100% rename from monolith/API-Template-mono/src/APITemplate.Infrastructure/Webhooks/WebhookProcessingBackgroundService.cs rename to src/APITemplate.Infrastructure/Webhooks/WebhookProcessingBackgroundService.cs diff --git a/src/Contracts/Contracts.IntegrationEvents/Contracts.IntegrationEvents.csproj b/src/Contracts/Contracts.IntegrationEvents/Contracts.IntegrationEvents.csproj deleted file mode 100644 index b48cc6b7..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/Contracts.IntegrationEvents.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - diff --git a/src/Contracts/Contracts.IntegrationEvents/Identity/TenantDeactivatedIntegrationEvent.cs b/src/Contracts/Contracts.IntegrationEvents/Identity/TenantDeactivatedIntegrationEvent.cs deleted file mode 100644 index f1e4c536..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/Identity/TenantDeactivatedIntegrationEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Contracts.IntegrationEvents.Identity; - -/// -/// Published by Identity service when a tenant is deactivated (soft-deleted). -/// All dependent services should cascade their cleanup. -/// CorrelationId links replies back to the originating TenantDeactivationSaga instance. -/// -public sealed record TenantDeactivatedIntegrationEvent( - Guid CorrelationId, - Guid TenantId, - Guid ActorId, - DateTime OccurredAtUtc -); diff --git a/src/Contracts/Contracts.IntegrationEvents/Identity/TenantInvitationCreatedIntegrationEvent.cs b/src/Contracts/Contracts.IntegrationEvents/Identity/TenantInvitationCreatedIntegrationEvent.cs deleted file mode 100644 index 3eb957bf..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/Identity/TenantInvitationCreatedIntegrationEvent.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Contracts.IntegrationEvents.Identity; - -/// -/// Published by Identity service when a tenant invitation is created. -/// -public sealed record TenantInvitationCreatedIntegrationEvent( - Guid InvitationId, - string Email, - string TenantName, - string Token, - DateTime OccurredAtUtc -); diff --git a/src/Contracts/Contracts.IntegrationEvents/Identity/UserRegisteredIntegrationEvent.cs b/src/Contracts/Contracts.IntegrationEvents/Identity/UserRegisteredIntegrationEvent.cs deleted file mode 100644 index ecf6f70c..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/Identity/UserRegisteredIntegrationEvent.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Contracts.IntegrationEvents.Identity; - -/// -/// Published by Identity service when a new user successfully registers. -/// -public sealed record UserRegisteredIntegrationEvent( - Guid UserId, - Guid TenantId, - string Email, - string Username, - DateTime OccurredAtUtc -); diff --git a/src/Contracts/Contracts.IntegrationEvents/Identity/UserRoleChangedIntegrationEvent.cs b/src/Contracts/Contracts.IntegrationEvents/Identity/UserRoleChangedIntegrationEvent.cs deleted file mode 100644 index 5ebd4ffc..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/Identity/UserRoleChangedIntegrationEvent.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Contracts.IntegrationEvents.Identity; - -/// -/// Published by Identity service when a user's role is changed. -/// -public sealed record UserRoleChangedIntegrationEvent( - Guid UserId, - Guid TenantId, - string Email, - string Username, - string OldRole, - string NewRole, - DateTime OccurredAtUtc -); diff --git a/src/Contracts/Contracts.IntegrationEvents/ProductCatalog/CategoryDeletedIntegrationEvent.cs b/src/Contracts/Contracts.IntegrationEvents/ProductCatalog/CategoryDeletedIntegrationEvent.cs deleted file mode 100644 index 3033f076..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/ProductCatalog/CategoryDeletedIntegrationEvent.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Contracts.IntegrationEvents.ProductCatalog; - -/// -/// Published by Product Catalog service when a category is soft-deleted. -/// -public sealed record CategoryDeletedIntegrationEvent( - Guid CategoryId, - Guid TenantId, - DateTime OccurredAtUtc -); diff --git a/src/Contracts/Contracts.IntegrationEvents/ProductCatalog/ProductCreatedIntegrationEvent.cs b/src/Contracts/Contracts.IntegrationEvents/ProductCatalog/ProductCreatedIntegrationEvent.cs deleted file mode 100644 index 9ce2cd89..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/ProductCatalog/ProductCreatedIntegrationEvent.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Contracts.IntegrationEvents.ProductCatalog; - -/// -/// Published by Product Catalog service when a new product is created. -/// -public sealed record ProductCreatedIntegrationEvent( - Guid ProductId, - Guid TenantId, - string Name, - DateTime OccurredAtUtc -); diff --git a/src/Contracts/Contracts.IntegrationEvents/ProductCatalog/ProductDeletedIntegrationEvent.cs b/src/Contracts/Contracts.IntegrationEvents/ProductCatalog/ProductDeletedIntegrationEvent.cs deleted file mode 100644 index 65b15f1d..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/ProductCatalog/ProductDeletedIntegrationEvent.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Contracts.IntegrationEvents.ProductCatalog; - -/// -/// Published by Product Catalog service when products are soft-deleted. -/// Triggers cascade in Reviews (delete reviews) and File Storage (orphan files). -/// -public sealed record ProductDeletedIntegrationEvent( - Guid CorrelationId, - IReadOnlyList ProductIds, - Guid TenantId, - DateTime OccurredAtUtc -); diff --git a/src/Contracts/Contracts.IntegrationEvents/Reviews/ReviewCreatedIntegrationEvent.cs b/src/Contracts/Contracts.IntegrationEvents/Reviews/ReviewCreatedIntegrationEvent.cs deleted file mode 100644 index 861f0b0b..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/Reviews/ReviewCreatedIntegrationEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Contracts.IntegrationEvents.Reviews; - -/// -/// Published by Reviews service when a new product review is created. -/// -public sealed record ReviewCreatedIntegrationEvent( - Guid ReviewId, - Guid ProductId, - Guid UserId, - Guid TenantId, - int Rating, - DateTime OccurredAtUtc -); diff --git a/src/Contracts/Contracts.IntegrationEvents/Sagas/ProductDeletionSagaMessages.cs b/src/Contracts/Contracts.IntegrationEvents/Sagas/ProductDeletionSagaMessages.cs deleted file mode 100644 index 143dd26d..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/Sagas/ProductDeletionSagaMessages.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Wolverine; -using Wolverine.Persistence.Sagas; - -namespace Contracts.IntegrationEvents.Sagas; - -/// -/// Command to start the product deletion cascade saga. -/// -public sealed record StartProductDeletionSaga( - Guid CorrelationId, - IReadOnlyList ProductIds, - Guid TenantId, - Guid ActorId -); - -/// -/// Confirmation that reviews have been cascade-deleted for the given products. -/// -public sealed record ReviewsCascadeCompleted( - [property: SagaIdentity] Guid CorrelationId, - int DeletedCount -); - -/// -/// Confirmation that files have been orphaned for the given products. -/// -public sealed record FilesCascadeCompleted( - [property: SagaIdentity] Guid CorrelationId, - int DeletedCount -); - -/// -/// Timeout message for product deletion saga reliability. -/// -public sealed record ProductDeletionSagaTimeout([property: SagaIdentity] Guid CorrelationId) - : TimeoutMessage(TimeSpan.FromMinutes(5)); diff --git a/src/Contracts/Contracts.IntegrationEvents/Sagas/TenantDeactivationSagaMessages.cs b/src/Contracts/Contracts.IntegrationEvents/Sagas/TenantDeactivationSagaMessages.cs deleted file mode 100644 index 134e58c2..00000000 --- a/src/Contracts/Contracts.IntegrationEvents/Sagas/TenantDeactivationSagaMessages.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Wolverine; -using Wolverine.Persistence.Sagas; - -namespace Contracts.IntegrationEvents.Sagas; - -/// -/// Command to start the tenant deactivation cascade saga. -/// -public sealed record StartTenantDeactivationSaga(Guid CorrelationId, Guid TenantId, Guid ActorId); - -/// -/// Confirmation that users have been deactivated for the given tenant. -/// -public sealed record UsersCascadeCompleted( - [property: SagaIdentity] Guid CorrelationId, - Guid TenantId, - int DeactivatedCount -); - -/// -/// Confirmation that products have been cascade-deleted for the given tenant. -/// -public sealed record ProductsCascadeCompleted( - [property: SagaIdentity] Guid CorrelationId, - Guid TenantId, - int DeletedCount -); - -/// -/// Confirmation that categories have been cascade-deleted for the given tenant. -/// -public sealed record CategoriesCascadeCompleted( - [property: SagaIdentity] Guid CorrelationId, - Guid TenantId, - int DeletedCount -); - -/// -/// Timeout message for tenant deactivation saga reliability. -/// -public sealed record TenantDeactivationSagaTimeout([property: SagaIdentity] Guid CorrelationId) - : TimeoutMessage(TimeSpan.FromMinutes(5)); diff --git a/src/Gateway/Gateway.Api/Dockerfile b/src/Gateway/Gateway.Api/Dockerfile deleted file mode 100644 index df330be0..00000000 --- a/src/Gateway/Gateway.Api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build -WORKDIR /src -COPY . . -RUN dotnet restore "src/Gateway/Gateway.Api/Gateway.Api.csproj" -RUN dotnet publish "src/Gateway/Gateway.Api/Gateway.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "Gateway.Api.dll"] diff --git a/src/Gateway/Gateway.Api/Gateway.Api.csproj b/src/Gateway/Gateway.Api/Gateway.Api.csproj deleted file mode 100644 index f86cef64..00000000 --- a/src/Gateway/Gateway.Api/Gateway.Api.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Gateway/Gateway.Api/Program.cs b/src/Gateway/Gateway.Api/Program.cs deleted file mode 100644 index 35e36be1..00000000 --- a/src/Gateway/Gateway.Api/Program.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Scalar.AspNetCore; -using SharedKernel.Api.Extensions; -using SharedKernel.Application.Security; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Host.UseSharedSerilog(); -builder.Services.AddSharedObservability(builder.Configuration, builder.Environment, "gateway"); - -builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); - -builder.Services.AddHealthChecks(); - -WebApplication app = builder.Build(); - -app.MapReverseProxy(); -app.MapHealthChecks("/health"); -app.MapGatewayScalarUi(); - -app.Run(); - -public static class GatewayDocumentationExtensions -{ - public static WebApplication MapGatewayScalarUi(this WebApplication app) - { - if (!app.Environment.IsDevelopment()) - { - return app; - } - - IConfigurationSection keycloak = app.Configuration.GetRequiredSection("Keycloak"); - string authority = KeycloakAuthExtensions.BuildAuthority( - keycloak["auth-server-url"] - ?? throw new InvalidOperationException( - "Configuration key 'Keycloak:auth-server-url' is required." - ), - keycloak["realm"] - ?? throw new InvalidOperationException( - "Configuration key 'Keycloak:realm' is required." - ) - ); - - app.MapScalarApiReference( - "/scalar", - (options, httpContext) => - { - string redirectUri = BuildScalarRedirectUri(httpContext.Request); - - options.WithTitle("Gateway"); - options - .AddDocument("identity", "Identity API", "/openapi/identity.json") - .AddDocument( - "product-catalog", - "Product Catalog API", - "/openapi/product-catalog.json" - ) - .AddDocument("reviews", "Reviews API", "/openapi/reviews.json") - .AddDocument( - "file-storage", - "File Storage API", - "/openapi/file-storage.json" - ) - .AddDocument( - "background-jobs", - "Background Jobs API", - "/openapi/background-jobs.json" - ) - .AddDocument( - "notifications", - "Notifications API", - "/openapi/notifications.json" - ) - .AddDocument("webhooks", "Webhooks API", "/openapi/webhooks.json") - .AddPreferredSecuritySchemes(SharedAuthConstants.OpenApi.OAuth2Scheme) - .AddAuthorizationCodeFlow( - SharedAuthConstants.OpenApi.OAuth2Scheme, - flow => - { - flow.ClientId = SharedAuthConstants.OpenApi.ScalarClientId; - flow.SelectedScopes = [.. SharedAuthConstants.Scopes.Default]; - flow.AuthorizationUrl = - $"{authority}/{SharedAuthConstants.OpenIdConnect.AuthorizationEndpointPath}"; - flow.TokenUrl = - $"{authority}/{SharedAuthConstants.OpenIdConnect.TokenEndpointPath}"; - flow.RedirectUri = redirectUri; - flow.Pkce = Pkce.Sha256; - } - ); - } - ) - .AllowAnonymous(); - - return app; - } - - private static string BuildScalarRedirectUri(HttpRequest request) => - $"{request.Scheme}://{request.Host}{request.PathBase}{request.Path}"; -} - -public partial class Program; diff --git a/src/Gateway/Gateway.Api/Properties/launchSettings.json b/src/Gateway/Gateway.Api/Properties/launchSettings.json deleted file mode 100644 index 615f9b93..00000000 --- a/src/Gateway/Gateway.Api/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5012", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7077;http://localhost:5012", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Gateway/Gateway.Api/appsettings.json b/src/Gateway/Gateway.Api/appsettings.json deleted file mode 100644 index 570f9e99..00000000 --- a/src/Gateway/Gateway.Api/appsettings.json +++ /dev/null @@ -1,234 +0,0 @@ -{ - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "WriteTo": [ - { "Name": "Console" } - ] - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "Keycloak": { - "realm": "api-template", - "auth-server-url": "http://localhost:8180/", - "resource": "api-template", - "credentials": { - "secret": "change-me" - }, - "SkipReadinessCheck": true - }, - "ReverseProxy": { - "Routes": { - "products-route": { - "ClusterId": "product-catalog", - "Match": { - "Path": "/api/v1/products/{**catch-all}" - } - }, - "categories-route": { - "ClusterId": "product-catalog", - "Match": { - "Path": "/api/v1/categories/{**catch-all}" - } - }, - "productreviews-route": { - "ClusterId": "reviews", - "Match": { - "Path": "/api/v1/productreviews/{**catch-all}" - } - }, - "users-route": { - "ClusterId": "identity", - "Match": { - "Path": "/api/v1/users/{**catch-all}" - } - }, - "tenants-route": { - "ClusterId": "identity", - "Match": { - "Path": "/api/v1/tenants/{**catch-all}" - } - }, - "tenantinvitations-route": { - "ClusterId": "identity", - "Match": { - "Path": "/api/v1/tenantinvitations/{**catch-all}" - } - }, - "notifications-route": { - "ClusterId": "notifications", - "Match": { - "Path": "/api/v1/notifications/{**catch-all}" - } - }, - "files-route": { - "ClusterId": "file-storage", - "Match": { - "Path": "/api/v1/files/{**catch-all}" - } - }, - "jobs-route": { - "ClusterId": "background-jobs", - "Match": { - "Path": "/api/v1/jobs/{**catch-all}" - } - }, - "webhooks-route": { - "ClusterId": "webhooks", - "Match": { - "Path": "/api/v1/webhooks/{**catch-all}" - } - }, - "product-data-route": { - "ClusterId": "product-catalog", - "Match": { - "Path": "/api/v1/productdata/{**catch-all}" - } - }, - "bff-route": { - "ClusterId": "identity", - "Match": { - "Path": "/api/v1/bff/{**catch-all}" - } - }, - "openapi-identity-route": { - "ClusterId": "identity", - "Match": { - "Path": "/openapi/identity.json" - }, - "Transforms": [ - { - "PathSet": "/openapi/v1.json" - } - ] - }, - "openapi-product-catalog-route": { - "ClusterId": "product-catalog", - "Match": { - "Path": "/openapi/product-catalog.json" - }, - "Transforms": [ - { - "PathSet": "/openapi/v1.json" - } - ] - }, - "openapi-reviews-route": { - "ClusterId": "reviews", - "Match": { - "Path": "/openapi/reviews.json" - }, - "Transforms": [ - { - "PathSet": "/openapi/v1.json" - } - ] - }, - "openapi-notifications-route": { - "ClusterId": "notifications", - "Match": { - "Path": "/openapi/notifications.json" - }, - "Transforms": [ - { - "PathSet": "/openapi/v1.json" - } - ] - }, - "openapi-file-storage-route": { - "ClusterId": "file-storage", - "Match": { - "Path": "/openapi/file-storage.json" - }, - "Transforms": [ - { - "PathSet": "/openapi/v1.json" - } - ] - }, - "openapi-background-jobs-route": { - "ClusterId": "background-jobs", - "Match": { - "Path": "/openapi/background-jobs.json" - }, - "Transforms": [ - { - "PathSet": "/openapi/v1.json" - } - ] - }, - "openapi-webhooks-route": { - "ClusterId": "webhooks", - "Match": { - "Path": "/openapi/webhooks.json" - }, - "Transforms": [ - { - "PathSet": "/openapi/v1.json" - } - ] - } - }, - "Clusters": { - "product-catalog": { - "Destinations": { - "destination1": { - "Address": "http://product-catalog:8080" - } - } - }, - "reviews": { - "Destinations": { - "destination1": { - "Address": "http://reviews:8080" - } - } - }, - "identity": { - "Destinations": { - "destination1": { - "Address": "http://identity:8080" - } - } - }, - "notifications": { - "Destinations": { - "destination1": { - "Address": "http://notifications:8080" - } - } - }, - "file-storage": { - "Destinations": { - "destination1": { - "Address": "http://file-storage:8080" - } - } - }, - "background-jobs": { - "Destinations": { - "destination1": { - "Address": "http://background-jobs:8080" - } - } - }, - "webhooks": { - "Destinations": { - "destination1": { - "Address": "http://webhooks:8080" - } - } - } - } - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/BackgroundJobs.Api.csproj b/src/Services/BackgroundJobs/BackgroundJobs.Api/BackgroundJobs.Api.csproj deleted file mode 100644 index c9894496..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/BackgroundJobs.Api.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - - diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/Controllers/JobsController.cs b/src/Services/BackgroundJobs/BackgroundJobs.Api/Controllers/JobsController.cs deleted file mode 100644 index 1729f554..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/Controllers/JobsController.cs +++ /dev/null @@ -1,46 +0,0 @@ -using BackgroundJobs.Application.Features.Jobs.Commands; -using BackgroundJobs.Application.Features.Jobs.DTOs; -using BackgroundJobs.Application.Features.Jobs.Queries; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using SharedKernel.Api.Controllers; -using SharedKernel.Api.ErrorOrMapping; -using Wolverine; - -namespace BackgroundJobs.Api.Controllers; - -/// -/// Presentation-layer controller for long-running job submission and -/// asynchronous status polling using a channel-based job queue. -/// -public sealed class JobsController(IMessageBus bus) : ApiControllerBase -{ - /// - /// Enqueues a new job and returns 202 Accepted with a Location header pointing to the - /// status endpoint so the caller can poll for completion. - /// - [HttpPost] - public async Task Submit(SubmitJobRequest request, CancellationToken ct) - { - ErrorOr result = await bus.InvokeAsync>( - new SubmitJobCommand(request), - ct - ); - if (result.IsError) - return result.ToErrorResult(this); - - return AcceptedAtAction(nameof(GetStatus), new { id = result.Value.Id }, result.Value); - } - - /// Returns the current execution status of a previously submitted job, or 404 if not found. - [HttpGet("{id:guid}")] - public Task> GetStatus( - [FromRoute] Guid id, - CancellationToken ct - ) => - InvokeToActionResultAsync( - bus, - new GetJobStatusQuery(new GetJobStatusRequest(id)), - ct - ); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/Dockerfile b/src/Services/BackgroundJobs/BackgroundJobs.Api/Dockerfile deleted file mode 100644 index 988a6d63..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build -WORKDIR /src -COPY . . -RUN dotnet restore "src/Services/BackgroundJobs/BackgroundJobs.Api/BackgroundJobs.Api.csproj" -RUN dotnet publish "src/Services/BackgroundJobs/BackgroundJobs.Api/BackgroundJobs.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "BackgroundJobs.Api.dll"] diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs b/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs deleted file mode 100644 index 56c69b09..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs +++ /dev/null @@ -1,171 +0,0 @@ -using BackgroundJobs.Application.Common; -using BackgroundJobs.Application.Features.Jobs.Commands; -using BackgroundJobs.Application.Options; -using BackgroundJobs.Domain.Interfaces; -using BackgroundJobs.Infrastructure.Persistence; -using BackgroundJobs.Infrastructure.Queue; -using BackgroundJobs.Infrastructure.Repositories; -using BackgroundJobs.Infrastructure.Services; -using BackgroundJobs.Infrastructure.TickerQ; -using BackgroundJobs.Infrastructure.TickerQ.Coordination; -using BackgroundJobs.Infrastructure.TickerQ.Jobs; -using BackgroundJobs.Infrastructure.TickerQ.RecurringJobRegistrations; -using Microsoft.EntityFrameworkCore; -using SharedKernel.Api.Extensions; -using SharedKernel.Application.Security; -using SharedKernel.Messaging.Conventions; -using SharedKernel.Messaging.Topology; -using StackExchange.Redis; -using TickerQ.DependencyInjection; -using TickerQ.EntityFrameworkCore.Customizer; -using TickerQ.EntityFrameworkCore.DependencyInjection; -using TickerQ.Utilities; -using TickerQ.Utilities.Entities; -using Wolverine; -using Wolverine.Postgresql; -using Wolverine.RabbitMQ; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Host.UseSharedSerilog(); -builder.Services.AddSharedObservability( - builder.Configuration, - builder.Environment, - "background-jobs" -); - -// Database -string connectionString = builder.Configuration.GetRequiredConnectionString("DefaultConnection"); - -builder.Services.AddDbContext(options => - options.UseNpgsql(connectionString) -); -builder.Services.AddScoped(sp => sp.GetRequiredService()); - -// Options -builder.Services.AddValidatedOptions( - builder.Configuration, - BackgroundJobsOptions.SectionName -); - -BackgroundJobsOptions backgroundJobsOptions = - builder.Configuration.GetRequiredOptions( - BackgroundJobsOptions.SectionName - ); - -builder.Services.AddSharedInfrastructure(builder.Configuration); -builder.Services.AddSharedKeycloakJwtBearer(builder.Configuration, builder.Environment); -builder.Services.AddSharedAuthorization(); - -// Repository -builder.Services.AddScoped(); - -// Job queue (singleton: both producer and consumer share the same channel) -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddHostedService(); - -// Services -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// TickerQ (when enabled) -if (backgroundJobsOptions.TickerQ.Enabled) -{ - string? dragonflyConnectionString = builder.Configuration.GetConnectionString( - backgroundJobsOptions.TickerQ.CoordinationConnection - ); - - if (!string.IsNullOrWhiteSpace(dragonflyConnectionString)) - { - builder.Services.AddSingleton( - ConnectionMultiplexer.Connect(dragonflyConnectionString) - ); - } - - builder.Services.AddSingleton(); - - string schemaName = TickerQSchedulerOptions.DefaultSchemaName; - - builder.Services.AddDbContext(dbOptions => - dbOptions.UseNpgsql( - connectionString, - npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName) - ) - ); - - builder.Services.AddScoped(); - builder.Services.AddScoped< - IRecurringBackgroundJobRegistration, - CleanupRecurringJobRegistration - >(); - builder.Services.AddScoped< - IRecurringBackgroundJobRegistration, - ReindexRecurringJobRegistration - >(); - - builder.Services.AddTickerQ(tickerOptions => - { - tickerOptions - .AddOperationalStore(store => - store - .UseApplicationDbContext( - ConfigurationType.IgnoreModelCustomizer - ) - .SetSchema(schemaName) - ) - .ConfigureScheduler(scheduler => - { - scheduler.NodeIdentifier = - $"{backgroundJobsOptions.TickerQ.InstanceNamePrefix}-{Environment.MachineName}-{Environment.ProcessId}"; - scheduler.MaxConcurrency = 1; - }) - .AddTickerQDiscovery([typeof(CleanupRecurringJob).Assembly]); - }); -} - -// Health checks -builder.Services.AddHealthChecks(); - -// Controllers -builder.Services.AddControllers(); -builder.Services.AddSharedOpenApiDocumentation(); - -// Wolverine with RabbitMQ -builder.Host.UseWolverine(opts => -{ - opts.Discovery.IncludeAssembly( - typeof(BackgroundJobs.Infrastructure.EventHandlers.TenantDeactivatedHandler).Assembly - ); - opts.Discovery.IncludeAssembly(typeof(SubmitJobCommand).Assembly); - - // Shared conventions - opts.ApplySharedConventions(); - opts.ApplySharedRetryPolicies(); - opts.PersistMessagesWithPostgresql(connectionString, "wolverine"); - - // RabbitMQ transport - opts.UseSharedRabbitMq(builder.Configuration); - - // Listen to background-jobs queues - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.BackgroundJobs.TenantDeactivated, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.Identity); - } - ); -}); - -WebApplication app = builder.Build(); - -await app.MigrateDbAsync(); - -app.UseSharedExceptionHandlerAndAuthentication(); -app.UseSharedAuthorizationCachingAndInfrastructure(useOutputCaching: false); -app.MapControllers(); - -await app.RunAsync(); - -public partial class Program; diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/Properties/launchSettings.json b/src/Services/BackgroundJobs/BackgroundJobs.Api/Properties/launchSettings.json deleted file mode 100644 index b447dfd3..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5203", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7257;http://localhost:5203", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json b/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json deleted file mode 100644 index 482403e3..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "Host=postgres;Database=backgroundjobs_db;Username=postgres;Password=postgres", - "Dragonfly": "dragonfly:6379" - }, - "Keycloak": { - "realm": "api-template", - "auth-server-url": "http://localhost:8180/", - "resource": "api-template" - }, - "RabbitMQ": { - "HostName": "rabbitmq" - }, - "BackgroundJobs": { - "TickerQ": { - "Enabled": false, - "FailClosed": true, - "InstanceNamePrefix": "BackgroundJobs", - "CoordinationConnection": "Dragonfly" - }, - "Cleanup": { - "Enabled": false, - "Cron": "0 * * * *", - "SoftDeleteRetentionDays": 30, - "StaleJobRetentionDays": 90, - "BatchSize": 100 - }, - "Reindex": { - "Enabled": false, - "Cron": "0 */6 * * *" - } - }, - "TransactionDefaults": { - "IsolationLevel": "ReadCommitted", - "TimeoutSeconds": 30, - "RetryEnabled": true, - "RetryCount": 3, - "RetryDelaySeconds": 5 - }, - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "WriteTo": [ - { "Name": "Console" } - ] - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/BackgroundJobs.Application.csproj b/src/Services/BackgroundJobs/BackgroundJobs.Application/BackgroundJobs.Application.csproj deleted file mode 100644 index f60dc3f5..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/BackgroundJobs.Application.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/ICleanupService.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/ICleanupService.cs deleted file mode 100644 index 1db2bd5e..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/ICleanupService.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace BackgroundJobs.Application.Common; - -/// -/// Application-layer contract for scheduled data-cleanup operations. -/// Implementations live in the Infrastructure layer and are invoked by recurring background jobs. -/// -public interface ICleanupService -{ - /// - /// Permanently purges soft-deleted job execution records that exceeded the retention window, - /// processed in batches of . - /// - Task CleanupSoftDeletedRecordsAsync( - int retentionDays, - int batchSize, - CancellationToken ct = default - ); - - /// - /// Removes completed or failed job executions older than days, - /// processed in batches of . - /// - Task CleanupStaleJobExecutionsAsync( - int retentionDays, - int batchSize, - CancellationToken ct = default - ); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/IJobQueue.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/IJobQueue.cs deleted file mode 100644 index 38eebb19..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/IJobQueue.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SharedKernel.Application.Queue; - -namespace BackgroundJobs.Application.Common; - -/// -/// Write-side contract for enqueuing generic background job identifiers (as s). -/// -public interface IJobQueue : IQueue; - -/// -/// Read-side contract for consuming job identifiers from the generic job queue. -/// -public interface IJobQueueReader : IQueueReader; diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/IRecurringBackgroundJobRegistration.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/IRecurringBackgroundJobRegistration.cs deleted file mode 100644 index fc046aaa..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/IRecurringBackgroundJobRegistration.cs +++ /dev/null @@ -1,16 +0,0 @@ -using BackgroundJobs.Application.Options; - -namespace BackgroundJobs.Application.Common; - -/// -/// Marker interface that each recurring background job implements to self-describe its schedule. -/// The Infrastructure bootstrapper discovers all registrations via DI and registers them with the scheduler. -/// -public interface IRecurringBackgroundJobRegistration -{ - /// - /// Produces the for this job using values - /// from (e.g. cron expressions, retry counts). - /// - RecurringBackgroundJobDefinition Build(BackgroundJobsOptions options); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/IReindexService.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/IReindexService.cs deleted file mode 100644 index 2821a118..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/IReindexService.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace BackgroundJobs.Application.Common; - -/// -/// Application-layer contract for rebuilding full-text search indexes. -/// Implementations are provided by the Infrastructure layer and scheduled as recurring background jobs. -/// -public interface IReindexService -{ - /// - /// Triggers a full rebuild of the full-text search index for all indexed entities. - /// - Task ReindexFullTextSearchAsync(CancellationToken ct = default); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/RecurringBackgroundJobDefinition.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/RecurringBackgroundJobDefinition.cs deleted file mode 100644 index c5e04f2b..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Common/RecurringBackgroundJobDefinition.cs +++ /dev/null @@ -1,25 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace BackgroundJobs.Application.Common; - -/// -/// Immutable descriptor for a recurring background job passed from the Application layer to the -/// Infrastructure scheduler. Each -/// produces one instance of this record. -/// -/// Stable identifier for the job, used to upsert the schedule in the scheduler. -/// The scheduler entry-point function name. -/// Cron expression that controls the execution frequency. -/// When false the scheduler should skip or remove this job without error. -/// Human-readable description shown in the scheduler dashboard. -/// Number of automatic retry attempts on failure. -/// Optional delay intervals (in seconds) between consecutive retry attempts. -public sealed record RecurringBackgroundJobDefinition( - Guid Id, - string FunctionName, - string CronExpression, - bool Enabled, - string Description, - int Retries = 0, - int[]? RetryIntervals = null -) : IHasId; diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Commands/SubmitJobCommand.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Commands/SubmitJobCommand.cs deleted file mode 100644 index 6b1de8bd..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Commands/SubmitJobCommand.cs +++ /dev/null @@ -1,48 +0,0 @@ -using BackgroundJobs.Application.Common; -using BackgroundJobs.Application.Features.Jobs.DTOs; -using BackgroundJobs.Application.Features.Jobs.Mappings; -using BackgroundJobs.Domain.Entities; -using BackgroundJobs.Domain.Interfaces; -using ErrorOr; -using SharedKernel.Application.Context; -using SharedKernel.Domain.Interfaces; - -namespace BackgroundJobs.Application.Features.Jobs.Commands; - -public sealed record SubmitJobCommand(SubmitJobRequest Request); - -public sealed class SubmitJobCommandHandler -{ - public static async Task> HandleAsync( - SubmitJobCommand command, - IJobExecutionRepository repository, - IJobQueue jobQueue, - IUnitOfWork unitOfWork, - ITenantProvider tenantProvider, - TimeProvider timeProvider, - CancellationToken ct - ) - { - JobExecution entity = new() - { - Id = Guid.NewGuid(), - JobType = command.Request.JobType, - Parameters = command.Request.Parameters, - CallbackUrl = command.Request.CallbackUrl, - SubmittedAtUtc = timeProvider.GetUtcNow().UtcDateTime, - TenantId = tenantProvider.TenantId, - }; - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.AddAsync(entity, ct); - }, - ct - ); - - await jobQueue.EnqueueAsync(entity.Id, ct); - - return JobResponseMapper.MapToResponse(entity); - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/DTOs/GetJobStatusRequest.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/DTOs/GetJobStatusRequest.cs deleted file mode 100644 index 6f6f3a0b..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/DTOs/GetJobStatusRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace BackgroundJobs.Application.Features.Jobs.DTOs; - -/// -/// Carries the unique identifier of the background job whose status is being queried. -/// -public sealed record GetJobStatusRequest(Guid Id) : IHasId; diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/DTOs/JobStatusResponse.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/DTOs/JobStatusResponse.cs deleted file mode 100644 index e4a1f3af..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/DTOs/JobStatusResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -using BackgroundJobs.Domain.Enums; -using SharedKernel.Domain.Entities.Contracts; - -namespace BackgroundJobs.Application.Features.Jobs.DTOs; - -/// -/// Represents the full runtime state of a background job, including progress, result payload, error information, and optional webhook callback URL. -/// -public sealed record JobStatusResponse( - Guid Id, - string JobType, - JobStatus Status, - int ProgressPercent, - string? Parameters, - string? ResultPayload, - string? ErrorMessage, - DateTime SubmittedAtUtc, - DateTime? StartedAtUtc, - DateTime? CompletedAtUtc, - string? CallbackUrl -) : IHasId; diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/DTOs/SubmitJobRequest.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/DTOs/SubmitJobRequest.cs deleted file mode 100644 index 4897099f..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/DTOs/SubmitJobRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; - -namespace BackgroundJobs.Application.Features.Jobs.DTOs; - -/// -/// Carries the parameters needed to enqueue a new background job, including an optional JSON parameters string and an optional webhook callback URL. -/// -public sealed record SubmitJobRequest( - [NotEmpty(ErrorMessage = "Job type is required.")] [MaxLength(100)] string JobType, - string? Parameters = null, - [Url] [MaxLength(2048)] string? CallbackUrl = null -); diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Mappings/JobResponseMapper.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Mappings/JobResponseMapper.cs deleted file mode 100644 index c6ed2fbf..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Mappings/JobResponseMapper.cs +++ /dev/null @@ -1,22 +0,0 @@ -using BackgroundJobs.Application.Features.Jobs.DTOs; -using BackgroundJobs.Domain.Entities; - -namespace BackgroundJobs.Application.Features.Jobs.Mappings; - -internal static class JobResponseMapper -{ - internal static JobStatusResponse MapToResponse(JobExecution entity) => - new( - entity.Id, - entity.JobType, - entity.Status, - entity.ProgressPercent, - entity.Parameters, - entity.ResultPayload, - entity.ErrorMessage, - entity.SubmittedAtUtc, - entity.StartedAtUtc, - entity.CompletedAtUtc, - entity.CallbackUrl - ); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Queries/GetJobStatusQuery.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Queries/GetJobStatusQuery.cs deleted file mode 100644 index ce1ddacd..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Queries/GetJobStatusQuery.cs +++ /dev/null @@ -1,25 +0,0 @@ -using BackgroundJobs.Application.Features.Jobs.DTOs; -using BackgroundJobs.Application.Features.Jobs.Mappings; -using BackgroundJobs.Domain.Entities; -using BackgroundJobs.Domain.Interfaces; -using ErrorOr; -using SharedKernel.Application.Errors; - -namespace BackgroundJobs.Application.Features.Jobs.Queries; - -public sealed record GetJobStatusQuery(GetJobStatusRequest Request); - -public sealed class GetJobStatusQueryHandler -{ - public static async Task> HandleAsync( - GetJobStatusQuery query, - IJobExecutionRepository repository, - CancellationToken ct - ) - { - JobExecution? entity = await repository.GetByIdAsync(query.Request.Id, ct); - return entity is null - ? DomainErrors.General.NotFound("JobExecution", query.Request.Id) - : JobResponseMapper.MapToResponse(entity); - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Validation/SubmitJobRequestValidator.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Validation/SubmitJobRequestValidator.cs deleted file mode 100644 index 7b2583f7..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Features/Jobs/Validation/SubmitJobRequestValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using BackgroundJobs.Application.Features.Jobs.DTOs; -using SharedKernel.Application.Validation; - -namespace BackgroundJobs.Application.Features.Jobs.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints, -/// including required job type and optional URL format for the callback. -/// -public sealed class SubmitJobRequestValidator : DataAnnotationsValidator; diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/BackgroundJobsOptions.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/BackgroundJobsOptions.cs deleted file mode 100644 index f487b1ec..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/BackgroundJobsOptions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Microsoft.Extensions.Options; - -namespace BackgroundJobs.Application.Options; - -/// -/// Aggregates per-job configuration options for all registered background jobs in the service. -/// -public sealed class BackgroundJobsOptions -{ - public const string SectionName = "BackgroundJobs"; - - [Required] - [ValidateObjectMembers] - public TickerQSchedulerOptions TickerQ { get; init; } = new(); - - [Required] - [ValidateObjectMembers] - public CleanupJobOptions Cleanup { get; init; } = new(); - - [Required] - [ValidateObjectMembers] - public ReindexJobOptions Reindex { get; init; } = new(); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/CleanupJobOptions.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/CleanupJobOptions.cs deleted file mode 100644 index bb360190..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/CleanupJobOptions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace BackgroundJobs.Application.Options; - -/// -/// Configuration for the periodic cleanup job that purges soft-deleted records -/// and stale job executions according to the configured retention windows. -/// -public sealed class CleanupJobOptions -{ - public bool Enabled { get; init; } - - [Required] - public string Cron { get; init; } = "0 * * * *"; - - [Range(1, int.MaxValue)] - public int SoftDeleteRetentionDays { get; init; } = 30; - - [Range(1, int.MaxValue)] - public int StaleJobRetentionDays { get; init; } = 90; - - [Range(1, int.MaxValue)] - public int BatchSize { get; init; } = 100; -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/ReindexJobOptions.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/ReindexJobOptions.cs deleted file mode 100644 index 84c4b9d0..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/ReindexJobOptions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace BackgroundJobs.Application.Options; - -/// -/// Configuration for the scheduled job that rebuilds search indexes on a periodic basis. -/// -public sealed class ReindexJobOptions -{ - public bool Enabled { get; init; } - - [Required] - public string Cron { get; init; } = "0 */6 * * *"; -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/TickerQSchedulerOptions.cs b/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/TickerQSchedulerOptions.cs deleted file mode 100644 index 616e4eae..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Application/Options/TickerQSchedulerOptions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace BackgroundJobs.Application.Options; - -/// -/// Configuration for the TickerQ scheduler, including distributed coordination and fail-safe behaviour. -/// -public sealed class TickerQSchedulerOptions -{ - public const string DefaultSchemaName = "tickerq"; - public const string DefaultCoordinationConnection = "Dragonfly"; - - public bool Enabled { get; init; } - public bool FailClosed { get; init; } = true; - - [Required] - public string InstanceNamePrefix { get; init; } = "BackgroundJobs"; - - [Required] - public string CoordinationConnection { get; init; } = DefaultCoordinationConnection; -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Domain/BackgroundJobs.Domain.csproj b/src/Services/BackgroundJobs/BackgroundJobs.Domain/BackgroundJobs.Domain.csproj deleted file mode 100644 index e5effa8b..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Domain/BackgroundJobs.Domain.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Domain/Entities/JobExecution.cs b/src/Services/BackgroundJobs/BackgroundJobs.Domain/Entities/JobExecution.cs deleted file mode 100644 index 9b1a8155..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Domain/Entities/JobExecution.cs +++ /dev/null @@ -1,67 +0,0 @@ -using BackgroundJobs.Domain.Enums; -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace BackgroundJobs.Domain.Entities; - -/// -/// Domain entity that tracks the lifecycle of a background job from submission through completion or failure. -/// Exposes domain methods to advance the job's while keeping state transitions encapsulated. -/// -public sealed class JobExecution : IAuditableTenantEntity, IHasId -{ - public Guid Id { get; set; } - public required string JobType { get; init; } - public JobStatus Status { get; private set; } = JobStatus.Pending; - public int ProgressPercent { get; private set; } - public string? Parameters { get; init; } - public string? CallbackUrl { get; init; } - public string? ResultPayload { get; private set; } - public string? ErrorMessage { get; private set; } - public DateTime SubmittedAtUtc { get; init; } - public DateTime? StartedAtUtc { get; private set; } - public DateTime? CompletedAtUtc { get; private set; } - public Guid TenantId { get; set; } - public AuditInfo Audit { get; set; } = new(); - public bool IsDeleted { get; set; } - public DateTime? DeletedAtUtc { get; set; } - public Guid? DeletedBy { get; set; } - - /// - /// Transitions the job to and records the start timestamp. - /// - public void MarkProcessing(TimeProvider timeProvider) - { - Status = JobStatus.Processing; - StartedAtUtc = timeProvider.GetUtcNow().UtcDateTime; - } - - /// - /// Transitions the job to , sets progress to 100%, stores the optional result payload, and records the completion timestamp. - /// - public void MarkCompleted(string? resultPayload, TimeProvider timeProvider) - { - Status = JobStatus.Completed; - ProgressPercent = 100; - ResultPayload = resultPayload; - CompletedAtUtc = timeProvider.GetUtcNow().UtcDateTime; - } - - /// - /// Transitions the job to , stores the error message, and records the completion timestamp. - /// - public void MarkFailed(string errorMessage, TimeProvider timeProvider) - { - Status = JobStatus.Failed; - ErrorMessage = errorMessage; - CompletedAtUtc = timeProvider.GetUtcNow().UtcDateTime; - } - - /// - /// Updates the job's progress percentage, clamping the value to the valid range [0, 100]. - /// - public void UpdateProgress(int percent) - { - ProgressPercent = Math.Clamp(percent, 0, 100); - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Domain/Enums/JobStatus.cs b/src/Services/BackgroundJobs/BackgroundJobs.Domain/Enums/JobStatus.cs deleted file mode 100644 index 17d444af..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Domain/Enums/JobStatus.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace BackgroundJobs.Domain.Enums; - -/// -/// Represents the execution state of a background . -/// -public enum JobStatus -{ - /// The job has been submitted and is waiting to be picked up by a worker. - Pending, - - /// A worker has claimed the job and is actively executing it. - Processing, - - /// The job finished successfully. - Completed, - - /// The job terminated with an error and will not be retried automatically. - Failed, -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Domain/Interfaces/IJobExecutionRepository.cs b/src/Services/BackgroundJobs/BackgroundJobs.Domain/Interfaces/IJobExecutionRepository.cs deleted file mode 100644 index 1227dcd9..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Domain/Interfaces/IJobExecutionRepository.cs +++ /dev/null @@ -1,9 +0,0 @@ -using BackgroundJobs.Domain.Entities; -using SharedKernel.Domain.Interfaces; - -namespace BackgroundJobs.Domain.Interfaces; - -/// -/// Repository contract for entities, inheriting all generic CRUD operations from . -/// -public interface IJobExecutionRepository : IRepository; diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/BackgroundJobs.Infrastructure.csproj b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/BackgroundJobs.Infrastructure.csproj deleted file mode 100644 index 6204d810..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/BackgroundJobs.Infrastructure.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/EventHandlers/TenantDeactivatedHandler.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/EventHandlers/TenantDeactivatedHandler.cs deleted file mode 100644 index aa8f501e..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/EventHandlers/TenantDeactivatedHandler.cs +++ /dev/null @@ -1,45 +0,0 @@ -using BackgroundJobs.Domain.Entities; -using BackgroundJobs.Domain.Enums; -using BackgroundJobs.Infrastructure.Persistence; -using Contracts.IntegrationEvents.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace BackgroundJobs.Infrastructure.EventHandlers; - -/// -/// Handles by cancelling all pending/processing -/// job executions for the deactivated tenant using a single bulk update. -/// -public sealed class TenantDeactivatedHandler -{ - public static async Task HandleAsync( - TenantDeactivatedIntegrationEvent message, - BackgroundJobsDbContext dbContext, - ILogger logger, - CancellationToken ct - ) - { - int cancelled = await dbContext - .JobExecutions.Where(j => - j.TenantId == message.TenantId - && (j.Status == JobStatus.Pending || j.Status == JobStatus.Processing) - ) - .ExecuteUpdateAsync( - setters => - setters - .SetProperty(j => j.Status, JobStatus.Failed) - .SetProperty(j => j.ErrorMessage, "Tenant deactivated"), - ct - ); - - if (cancelled > 0) - { - logger.LogInformation( - "Cancelled {Count} pending/processing jobs for deactivated tenant {TenantId}.", - cancelled, - message.TenantId - ); - } - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContext.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContext.cs deleted file mode 100644 index cd44f7db..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContext.cs +++ /dev/null @@ -1,20 +0,0 @@ -using BackgroundJobs.Domain.Entities; -using Microsoft.EntityFrameworkCore; - -namespace BackgroundJobs.Infrastructure.Persistence; - -/// -/// EF Core DbContext scoped to the BackgroundJobs microservice, managing only job-related entities. -/// -public sealed class BackgroundJobsDbContext : DbContext -{ - public DbSet JobExecutions => Set(); - - public BackgroundJobsDbContext(DbContextOptions options) - : base(options) { } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfigurationsFromAssembly(typeof(BackgroundJobsDbContext).Assembly); - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs deleted file mode 100644 index 8a62170f..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace BackgroundJobs.Infrastructure.Persistence; - -/// -/// Design-time factory used by EF Core tooling to create the DbContext -/// for migration scaffolding without requiring the full runtime DI container. -/// -public sealed class BackgroundJobsDbContextDesignTimeFactory - : IDesignTimeDbContextFactory -{ - public BackgroundJobsDbContext CreateDbContext(string[] args) - { - DbContextOptionsBuilder optionsBuilder = new(); - optionsBuilder.UseNpgsql( - "Host=localhost;Database=background_jobs_db;Username=postgres;Password=postgres" - ); - - return new BackgroundJobsDbContext(optionsBuilder.Options); - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs deleted file mode 100644 index 31084d84..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs +++ /dev/null @@ -1,36 +0,0 @@ -using BackgroundJobs.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace BackgroundJobs.Infrastructure.Persistence.Configurations; - -/// -/// EF Core configuration for entity. -/// -public sealed class JobExecutionConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(e => e.Id); - - builder.Property(e => e.JobType).IsRequired().HasMaxLength(100); - - builder.Property(e => e.Status).IsRequired().HasConversion().HasMaxLength(20); - - builder.Property(e => e.Parameters).HasColumnType("text"); - - builder.Property(e => e.CallbackUrl).HasMaxLength(2048); - - builder.Property(e => e.ResultPayload).HasColumnType("text"); - - builder.Property(e => e.ErrorMessage).HasColumnType("text"); - - builder.HasIndex(e => e.TenantId); - builder.HasIndex(e => e.Status); - builder.HasIndex(e => e.SubmittedAtUtc); - - builder.HasQueryFilter(e => !e.IsDeleted); - - builder.OwnsOne(e => e.Audit); - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Migrations/20260326232803_InitialCreate.Designer.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Migrations/20260326232803_InitialCreate.Designer.cs deleted file mode 100644 index 033670d8..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Migrations/20260326232803_InitialCreate.Designer.cs +++ /dev/null @@ -1,125 +0,0 @@ -// -using System; -using BackgroundJobs.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace BackgroundJobs.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(BackgroundJobsDbContext))] - [Migration("20260326232803_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("BackgroundJobs.Domain.Entities.JobExecution", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CallbackUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("CompletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("ErrorMessage") - .HasColumnType("text"); - - b.Property("IsDeleted") - .HasColumnType("boolean"); - - b.Property("JobType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Parameters") - .HasColumnType("text"); - - b.Property("ProgressPercent") - .HasColumnType("integer"); - - b.Property("ResultPayload") - .HasColumnType("text"); - - b.Property("StartedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SubmittedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("Status"); - - b.HasIndex("SubmittedAtUtc"); - - b.HasIndex("TenantId"); - - b.ToTable("JobExecutions"); - }); - - modelBuilder.Entity("BackgroundJobs.Domain.Entities.JobExecution", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("JobExecutionId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b1.Property("CreatedBy") - .HasColumnType("uuid"); - - b1.Property("UpdatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b1.Property("UpdatedBy") - .HasColumnType("uuid"); - - b1.HasKey("JobExecutionId"); - - b1.ToTable("JobExecutions"); - - b1.WithOwner() - .HasForeignKey("JobExecutionId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Migrations/20260326232803_InitialCreate.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Migrations/20260326232803_InitialCreate.cs deleted file mode 100644 index c3150d4d..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Migrations/20260326232803_InitialCreate.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace BackgroundJobs.Infrastructure.Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "JobExecutions", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - JobType = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - Status = table.Column( - type: "character varying(20)", - maxLength: 20, - nullable: false - ), - ProgressPercent = table.Column(type: "integer", nullable: false), - Parameters = table.Column(type: "text", nullable: true), - CallbackUrl = table.Column( - type: "character varying(2048)", - maxLength: 2048, - nullable: true - ), - ResultPayload = table.Column(type: "text", nullable: true), - ErrorMessage = table.Column(type: "text", nullable: true), - SubmittedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false - ), - StartedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - CompletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - TenantId = table.Column(type: "uuid", nullable: false), - Audit_CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false - ), - Audit_CreatedBy = table.Column(type: "uuid", nullable: false), - Audit_UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false - ), - Audit_UpdatedBy = table.Column(type: "uuid", nullable: false), - IsDeleted = table.Column(type: "boolean", nullable: false), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - }, - constraints: table => - { - table.PrimaryKey("PK_JobExecutions", x => x.Id); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_JobExecutions_Status", - table: "JobExecutions", - column: "Status" - ); - - migrationBuilder.CreateIndex( - name: "IX_JobExecutions_SubmittedAtUtc", - table: "JobExecutions", - column: "SubmittedAtUtc" - ); - - migrationBuilder.CreateIndex( - name: "IX_JobExecutions_TenantId", - table: "JobExecutions", - column: "TenantId" - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "JobExecutions"); - } - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Migrations/BackgroundJobsDbContextModelSnapshot.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Migrations/BackgroundJobsDbContextModelSnapshot.cs deleted file mode 100644 index 767b60a5..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/Migrations/BackgroundJobsDbContextModelSnapshot.cs +++ /dev/null @@ -1,122 +0,0 @@ -// -using System; -using BackgroundJobs.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace BackgroundJobs.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(BackgroundJobsDbContext))] - partial class BackgroundJobsDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("BackgroundJobs.Domain.Entities.JobExecution", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CallbackUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("CompletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("ErrorMessage") - .HasColumnType("text"); - - b.Property("IsDeleted") - .HasColumnType("boolean"); - - b.Property("JobType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Parameters") - .HasColumnType("text"); - - b.Property("ProgressPercent") - .HasColumnType("integer"); - - b.Property("ResultPayload") - .HasColumnType("text"); - - b.Property("StartedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SubmittedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("Status"); - - b.HasIndex("SubmittedAtUtc"); - - b.HasIndex("TenantId"); - - b.ToTable("JobExecutions"); - }); - - modelBuilder.Entity("BackgroundJobs.Domain.Entities.JobExecution", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("JobExecutionId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b1.Property("CreatedBy") - .HasColumnType("uuid"); - - b1.Property("UpdatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b1.Property("UpdatedBy") - .HasColumnType("uuid"); - - b1.HasKey("JobExecutionId"); - - b1.ToTable("JobExecutions"); - - b1.WithOwner() - .HasForeignKey("JobExecutionId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/ChannelJobQueue.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/ChannelJobQueue.cs deleted file mode 100644 index de95ac42..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/ChannelJobQueue.cs +++ /dev/null @@ -1,17 +0,0 @@ -using BackgroundJobs.Application.Common; -using SharedKernel.Infrastructure.Queue; - -namespace BackgroundJobs.Infrastructure.Queue; - -/// -/// Bounded in-process job queue backed by a . -/// Registered as a singleton and implements both (producer) and -/// (consumer) so that writers and readers stay decoupled. -/// -public sealed class ChannelJobQueue : BoundedChannelQueue, IJobQueue, IJobQueueReader -{ - private const int DefaultCapacity = 100; - - public ChannelJobQueue() - : base(DefaultCapacity) { } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs deleted file mode 100644 index 40b38e52..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Text.Json; -using BackgroundJobs.Application.Common; -using BackgroundJobs.Domain.Interfaces; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using SharedKernel.Domain.Interfaces; -using SharedKernel.Infrastructure.Queue; - -namespace BackgroundJobs.Infrastructure.Queue; - -/// -/// Hosted background service that dequeues job IDs from , simulates -/// multi-step processing with progress updates, and persists completion or failure state. -/// Each job is processed in its own DI scope to ensure repository and unit-of-work isolation. -/// -public sealed class JobProcessingBackgroundService : QueueConsumerBackgroundService -{ - private const int SimulatedStepCount = 5; - private const int SimulatedStepDelayMs = 200; - private const int ProgressPerStep = 20; - private const string CompletedResultSummary = "Job completed successfully"; - - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - private readonly TimeProvider _timeProvider; - - public JobProcessingBackgroundService( - IJobQueueReader queue, - IServiceScopeFactory scopeFactory, - ILogger logger, - TimeProvider timeProvider - ) - : base(queue) - { - _scopeFactory = scopeFactory; - _logger = logger; - _timeProvider = timeProvider; - } - - /// - /// Marks the job as processing, simulates five incremental progress steps, and marks it completed. - /// - protected override async Task ProcessItemAsync(Guid jobId, CancellationToken ct) - { - await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - IJobExecutionRepository repo = - scope.ServiceProvider.GetRequiredService(); - IUnitOfWork uow = scope.ServiceProvider.GetRequiredService(); - - Domain.Entities.JobExecution? job = await repo.GetByIdAsync(jobId, ct); - if (job is null) - return; - - job.MarkProcessing(_timeProvider); - await uow.CommitAsync(ct); - - for (int step = 1; step <= SimulatedStepCount; step++) - { - await Task.Delay(SimulatedStepDelayMs, ct); - job.UpdateProgress(step * ProgressPerStep); - await uow.CommitAsync(ct); - } - - job.MarkCompleted( - JsonSerializer.Serialize(new { summary = CompletedResultSummary }), - _timeProvider - ); - await uow.CommitAsync(ct); - } - - /// Logs the error and attempts to persist the failed state within a 30-second timeout. - protected override async Task HandleErrorAsync(Guid jobId, Exception ex, CancellationToken ct) - { - _logger.LogError(ex, "Job {JobId} failed", jobId); - await TryMarkFailedAsync(jobId, ex.Message, ct); - } - - private async Task TryMarkFailedAsync(Guid jobId, string errorMessage, CancellationToken ct) - { - try - { - using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); - using CancellationTokenSource linkedCts = - CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); - CancellationToken token = linkedCts.Token; - - await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - IJobExecutionRepository repo = - scope.ServiceProvider.GetRequiredService(); - IUnitOfWork uow = scope.ServiceProvider.GetRequiredService(); - - Domain.Entities.JobExecution? job = await repo.GetByIdAsync(jobId, token); - if (job is not null) - { - job.MarkFailed(errorMessage, _timeProvider); - await uow.CommitAsync(token); - } - } - catch (Exception failEx) - { - _logger.LogError(failEx, "Failed to mark job {JobId} as failed", jobId); - } - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Repositories/JobExecutionRepository.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Repositories/JobExecutionRepository.cs deleted file mode 100644 index f4a11dbd..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Repositories/JobExecutionRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using BackgroundJobs.Domain.Entities; -using BackgroundJobs.Domain.Interfaces; -using BackgroundJobs.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Repositories; - -namespace BackgroundJobs.Infrastructure.Repositories; - -/// -/// EF Core repository for entities. -/// -public sealed class JobExecutionRepository : RepositoryBase, IJobExecutionRepository -{ - public JobExecutionRepository(BackgroundJobsDbContext dbContext) - : base(dbContext) { } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Services/CleanupService.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Services/CleanupService.cs deleted file mode 100644 index d49c5522..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Services/CleanupService.cs +++ /dev/null @@ -1,99 +0,0 @@ -using BackgroundJobs.Application.Common; -using BackgroundJobs.Domain.Entities; -using BackgroundJobs.Domain.Enums; -using BackgroundJobs.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace BackgroundJobs.Infrastructure.Services; - -/// -/// Infrastructure implementation of that performs -/// scheduled data-hygiene tasks: soft-deleted records and stale job executions. -/// -public sealed class CleanupService : ICleanupService -{ - private readonly BackgroundJobsDbContext _dbContext; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - - public CleanupService( - BackgroundJobsDbContext dbContext, - TimeProvider timeProvider, - ILogger logger - ) - { - _dbContext = dbContext; - _timeProvider = timeProvider; - _logger = logger; - } - - /// - /// Permanently deletes soft-deleted job execution records older than days, - /// processed in batches of . - /// - public async Task CleanupSoftDeletedRecordsAsync( - int retentionDays, - int batchSize, - CancellationToken ct = default - ) - { - DateTime cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-retentionDays); - int totalDeleted = 0; - int deleted; - - do - { - deleted = await _dbContext - .JobExecutions.IgnoreQueryFilters() - .Where(e => e.IsDeleted && e.DeletedAtUtc < cutoff) - .OrderBy(e => e.DeletedAtUtc) - .Take(batchSize) - .ExecuteDeleteAsync(ct); - - totalDeleted += deleted; - } while (deleted == batchSize); - - if (totalDeleted > 0) - { - _logger.LogInformation( - "Cleaned up {Count} soft-deleted job execution records.", - totalDeleted - ); - } - } - - /// - /// Removes completed or failed job executions older than days, - /// processed in batches of . - /// - public async Task CleanupStaleJobExecutionsAsync( - int retentionDays, - int batchSize, - CancellationToken ct = default - ) - { - DateTime cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-retentionDays); - int totalDeleted = 0; - int deleted; - - do - { - deleted = await _dbContext - .JobExecutions.Where(e => - (e.Status == JobStatus.Completed || e.Status == JobStatus.Failed) - && e.CompletedAtUtc < cutoff - ) - .OrderBy(e => e.CompletedAtUtc) - .Take(batchSize) - .ExecuteDeleteAsync(ct); - - totalDeleted += deleted; - } while (deleted == batchSize); - - if (totalDeleted > 0) - { - _logger.LogInformation("Cleaned up {Count} stale job execution records.", totalDeleted); - } - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Services/ReindexService.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Services/ReindexService.cs deleted file mode 100644 index a428b6a3..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Services/ReindexService.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.Text.RegularExpressions; -using BackgroundJobs.Application.Common; -using BackgroundJobs.Infrastructure.Persistence; -using BackgroundJobs.Infrastructure.StoredProcedures; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace BackgroundJobs.Infrastructure.Services; - -/// -/// Infrastructure implementation of that rebuilds bloated -/// PostgreSQL full-text search indexes using REINDEX INDEX CONCURRENTLY. -/// Only indexes exceeding the configured bloat threshold are reindexed to minimise disruption. -/// -public sealed partial class ReindexService : IReindexService -{ - private const double BloatThresholdPercent = 30.0; - - private readonly BackgroundJobsDbContext _dbContext; - private readonly ILogger _logger; - - public ReindexService(BackgroundJobsDbContext dbContext, ILogger logger) - { - _dbContext = dbContext; - _logger = logger; - } - - /// - /// Safety net for FTS index bloat after heavy write activity. PostgreSQL autovacuum - /// handles routine maintenance, but cannot reclaim index bloat -- only REINDEX can. - /// This method checks actual bloat ratio before reindexing to avoid unnecessary work. - /// Scoped to the current database's public schema to avoid touching other schemas. - /// - public async Task ReindexFullTextSearchAsync(CancellationToken ct = default) - { - GetFtsIndexNamesProcedure procedure = new(); - List ftsIndexes = await _dbContext - .Database.SqlQuery(procedure.ToSql()) - .ToListAsync(ct); - - foreach (string index in ftsIndexes) - { - if (!ValidIndexNameRegex().IsMatch(index)) - { - _logger.LogWarning("Skipping invalid FTS index name: {IndexName}.", index); - continue; - } - - double bloatPercent = await GetIndexBloatPercentAsync(index, ct); - - if (bloatPercent < BloatThresholdPercent) - { - _logger.LogDebug( - "FTS index {IndexName} bloat {BloatPercent:F1}% is below threshold {Threshold}%, skipping.", - index, - bloatPercent, - BloatThresholdPercent - ); - continue; - } - - _logger.LogInformation( - "FTS index {IndexName} bloat {BloatPercent:F1}% exceeds threshold {Threshold}%, reindexing.", - index, - bloatPercent, - BloatThresholdPercent - ); - - // REINDEX INDEX CONCURRENTLY is DDL -- cannot be wrapped in a PostgreSQL function. - // Identifier names cannot be parameterized here; regex validation above constrains - // the value to PostgreSQL-safe identifier characters before interpolation. -#pragma warning disable EF1002 - await _dbContext.Database.ExecuteSqlRawAsync( - $"REINDEX INDEX CONCURRENTLY \"{index}\"", - ct - ); -#pragma warning restore EF1002 - - _logger.LogInformation("Reindexed FTS index {IndexName}.", index); - } - } - - /// Queries the stored procedure for the bloat percentage of the named index. - private async Task GetIndexBloatPercentAsync(string indexName, CancellationToken ct) - { - GetIndexBloatPercentProcedure procedure = new(indexName); - return await _dbContext - .Database.SqlQuery(procedure.ToSql()) - .FirstOrDefaultAsync(ct); - } - - [GeneratedRegex(@"^[a-zA-Z_][a-zA-Z0-9_]*$")] - private static partial Regex ValidIndexNameRegex(); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs deleted file mode 100644 index 17bf86a1..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace BackgroundJobs.Infrastructure.StoredProcedures; - -/// -/// Calls the get_fts_index_names() PostgreSQL function. -/// Returns full-text search index names from the public schema. -/// -/// Result: single text column aliased as "Value". -/// Used via Database.SqlQuery<string> (primitive return type). -/// -public sealed record GetFtsIndexNamesProcedure -{ - public FormattableString ToSql() => $"SELECT * FROM get_fts_index_names()"; -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs deleted file mode 100644 index 6e062571..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace BackgroundJobs.Infrastructure.StoredProcedures; - -/// -/// Calls the get_index_bloat_percent(p_index_name) PostgreSQL function. -/// Estimates index bloat by comparing actual size to ideal size derived from -/// live table rows and average row width. -/// -/// Result: single double precision column aliased as "Value". -/// Used via Database.SqlQuery<double> (primitive return type). -/// -public sealed record GetIndexBloatPercentProcedure(string IndexName) -{ - public FormattableString ToSql() => $"SELECT * FROM get_index_bloat_percent({IndexName})"; -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs deleted file mode 100644 index 5fbe23bf..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs +++ /dev/null @@ -1,218 +0,0 @@ -using BackgroundJobs.Application.Options; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StackExchange.Redis; - -namespace BackgroundJobs.Infrastructure.TickerQ.Coordination; - -/// -/// Dragonfly/Redis-backed implementation of that uses -/// a SET NX distributed lock with periodic lease renewal to guarantee single-leader job execution. -/// When FailClosed is enabled, any Redis unavailability throws rather than running without coordination. -/// -public sealed class DragonflyDistributedJobCoordinator : IDistributedJobCoordinator -{ - private const int LeaseSeconds = 300; - private const double LeaseRenewalDivider = 3.0; - private static readonly LuaScript RenewLeaseScript = LuaScript.Prepare( - """ - if redis.call('get', @key) == @value then - return redis.call('expire', @key, @leaseSeconds) - end - return 0 - """ - ); - private static readonly LuaScript ReleaseLockScript = LuaScript.Prepare( - "if redis.call('get', @key) == @value then return redis.call('del', @key) else return 0 end" - ); - - private readonly IConnectionMultiplexer _connectionMultiplexer; - private readonly BackgroundJobsOptions _options; - private readonly ILogger _logger; - - public DragonflyDistributedJobCoordinator( - IConnectionMultiplexer connectionMultiplexer, - IOptions options, - ILogger logger - ) - { - _connectionMultiplexer = connectionMultiplexer; - _options = options.Value; - _logger = logger; - } - - /// - /// Attempts to acquire a SET NX lock in Dragonfly for , then - /// runs while a background timer renews the lease every third of the - /// lease window; releases the lock unconditionally on completion or failure. - /// - public async Task ExecuteIfLeaderAsync( - string jobName, - Func action, - CancellationToken ct = default - ) - { - IDatabase? database = RequireCoordination(jobName); - if (database is null) - { - await action(ct); - return; - } - - string lockKey = $"TickerQ:Leader:{_options.TickerQ.InstanceNamePrefix}:{jobName}"; - string lockValue = $"{Environment.MachineName}:{Environment.ProcessId}:{Guid.NewGuid():N}"; - - bool acquired = await database.StringSetAsync( - lockKey, - lockValue, - TimeSpan.FromSeconds(LeaseSeconds), - when: When.NotExists - ); - - if (!acquired) - { - _logger.LogDebug( - "Skipped background job {JobName} because another instance currently owns the coordination lease.", - jobName - ); - return; - } - - using CancellationTokenSource executionCts = - CancellationTokenSource.CreateLinkedTokenSource(ct); - Task renewalTask = RenewLeaseAsync(database, lockKey, lockValue, jobName, executionCts); - - try - { - await action(executionCts.Token); - } - finally - { - executionCts.Cancel(); - await AwaitRenewalAsync(renewalTask); - await ReleaseAsync(database, lockKey, lockValue); - } - } - - /// - /// Returns the active Redis , or when - /// coordination is unavailable and FailClosed is disabled (fail-open mode). - /// Throws when FailClosed is enabled. - /// - private IDatabase? RequireCoordination(string jobName) - { - if (!_connectionMultiplexer.IsConnected) - { - return HandleUnavailable(jobName, "DragonFly connection is not established."); - } - - try - { - return _connectionMultiplexer.GetDatabase(); - } - catch (Exception ex) - { - return HandleUnavailable(jobName, "DragonFly coordination is unavailable.", ex); - } - } - - private IDatabase? HandleUnavailable( - string jobName, - string message, - Exception? innerException = null - ) - { - if (!_options.TickerQ.FailClosed) - { - _logger.LogWarning( - innerException, - "DragonFly coordination is unavailable for background job {JobName}; continuing because fail-closed is disabled. {Message}", - jobName, - message - ); - return null; - } - - throw CreateFailClosedException(jobName, message, innerException); - } - - private InvalidOperationException CreateFailClosedException( - string jobName, - string message, - Exception? innerException = null - ) - { - _logger.LogWarning( - innerException, - "Fail-closed coordination stopped background job {JobName}: {Message}", - jobName, - message - ); - - return new InvalidOperationException( - $"Background job '{jobName}' did not start because DragonFly coordination is unavailable. {message}", - innerException - ); - } - - private static async Task AwaitRenewalAsync(Task renewalTask) - { - try - { - await renewalTask; - } - catch (OperationCanceledException) - { - // Expected when the owner finishes and stops renewing the lease. - } - } - - /// - /// Runs a periodic loop that extends the lock TTL using an atomic Lua compare-and-expire script. - /// Cancels and throws - /// if the renewal fails, indicating another node has taken ownership. - /// - private async Task RenewLeaseAsync( - IDatabase database, - string key, - string value, - string jobName, - CancellationTokenSource executionCts - ) - { - using PeriodicTimer timer = new(TimeSpan.FromSeconds(LeaseSeconds / LeaseRenewalDivider)); - while (await timer.WaitForNextTickAsync(executionCts.Token)) - { - long renewed = (long) - await database.ScriptEvaluateAsync( - RenewLeaseScript, - new - { - key, - value, - leaseSeconds = LeaseSeconds, - } - ); - - if (renewed != 0) - { - continue; - } - - _logger.LogWarning( - "Lost DragonFly coordination lease for background job {JobName}; cancelling the in-flight execution.", - jobName - ); - executionCts.Cancel(); - throw new LeadershipLeaseLostException(jobName); - } - } - - private static Task ReleaseAsync(IDatabase database, string key, string value) => - database.ScriptEvaluateAsync(ReleaseLockScript, new { key, value }); - - private sealed class LeadershipLeaseLostException(string jobName) - : InvalidOperationException( - $"Background job '{jobName}' lost its DragonFly coordination lease while still running." - ); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Coordination/IDistributedJobCoordinator.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Coordination/IDistributedJobCoordinator.cs deleted file mode 100644 index c7689b47..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Coordination/IDistributedJobCoordinator.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace BackgroundJobs.Infrastructure.TickerQ.Coordination; - -/// -/// Provides leader-election semantics for distributed recurring jobs so that only one -/// application instance executes a given job at a time in a multi-node deployment. -/// -public interface IDistributedJobCoordinator -{ - /// - /// Acquires a distributed lease for and, if successful, - /// invokes ; otherwise skips execution silently. - /// The lease is released automatically when completes or faults. - /// - Task ExecuteIfLeaderAsync( - string jobName, - Func action, - CancellationToken ct = default - ); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Jobs/CleanupRecurringJob.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Jobs/CleanupRecurringJob.cs deleted file mode 100644 index 47a3b703..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Jobs/CleanupRecurringJob.cs +++ /dev/null @@ -1,63 +0,0 @@ -using BackgroundJobs.Application.Common; -using BackgroundJobs.Application.Options; -using BackgroundJobs.Infrastructure.TickerQ.Coordination; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using TickerQ.Utilities.Base; - -namespace BackgroundJobs.Infrastructure.TickerQ.Jobs; - -/// -/// TickerQ recurring job that orchestrates all data-hygiene cleanup tasks (soft-deleted records, -/// stale job executions) through . -/// Execution is gated by to prevent multi-node duplication. -/// -public sealed class CleanupRecurringJob -{ - private readonly ICleanupService _cleanupService; - private readonly IDistributedJobCoordinator _coordinator; - private readonly CleanupJobOptions _options; - private readonly ILogger _logger; - - public CleanupRecurringJob( - ICleanupService cleanupService, - IDistributedJobCoordinator coordinator, - IOptions options, - ILogger logger - ) - { - _cleanupService = cleanupService; - _coordinator = coordinator; - _options = options.Value.Cleanup; - _logger = logger; - } - - /// - /// TickerQ entry-point that acquires the distributed leader lease and sequentially runs - /// all cleanup operations defined in . - /// - [TickerFunction(TickerQFunctionNames.Cleanup)] - public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => - _coordinator.ExecuteIfLeaderAsync( - TickerQFunctionNames.Cleanup, - async token => - { - _logger.LogInformation( - "Executing cleanup recurring job for ticker {TickerId}.", - context.Id - ); - - await _cleanupService.CleanupSoftDeletedRecordsAsync( - _options.SoftDeleteRetentionDays, - _options.BatchSize, - token - ); - await _cleanupService.CleanupStaleJobExecutionsAsync( - _options.StaleJobRetentionDays, - _options.BatchSize, - token - ); - }, - ct - ); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Jobs/ReindexRecurringJob.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Jobs/ReindexRecurringJob.cs deleted file mode 100644 index 98066b3e..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/Jobs/ReindexRecurringJob.cs +++ /dev/null @@ -1,44 +0,0 @@ -using BackgroundJobs.Application.Common; -using BackgroundJobs.Infrastructure.TickerQ.Coordination; -using Microsoft.Extensions.Logging; -using TickerQ.Utilities.Base; - -namespace BackgroundJobs.Infrastructure.TickerQ.Jobs; - -/// -/// TickerQ recurring job that triggers a full-text search index rebuild through . -/// Execution is gated by to prevent multi-node duplication. -/// -public sealed class ReindexRecurringJob -{ - private readonly IReindexService _reindexService; - private readonly IDistributedJobCoordinator _coordinator; - private readonly ILogger _logger; - - public ReindexRecurringJob( - IReindexService reindexService, - IDistributedJobCoordinator coordinator, - ILogger logger - ) - { - _reindexService = reindexService; - _coordinator = coordinator; - _logger = logger; - } - - /// TickerQ entry-point that acquires the distributed leader lease and invokes the reindex service. - [TickerFunction(TickerQFunctionNames.Reindex)] - public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => - _coordinator.ExecuteIfLeaderAsync( - TickerQFunctionNames.Reindex, - async token => - { - _logger.LogInformation( - "Executing reindex recurring job for ticker {TickerId}.", - context.Id - ); - await _reindexService.ReindexFullTextSearchAsync(token); - }, - ct - ); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs deleted file mode 100644 index f330fe85..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs +++ /dev/null @@ -1,21 +0,0 @@ -using BackgroundJobs.Application.Common; -using BackgroundJobs.Application.Options; - -namespace BackgroundJobs.Infrastructure.TickerQ.RecurringJobRegistrations; - -/// -/// Provides the for the cleanup recurring job, -/// sourcing schedule and enablement from . -/// -public sealed class CleanupRecurringJobRegistration : IRecurringBackgroundJobRegistration -{ - /// Builds the cleanup job definition from the supplied options. - public RecurringBackgroundJobDefinition Build(BackgroundJobsOptions options) => - new( - TickerQJobIds.Cleanup, - TickerQFunctionNames.Cleanup, - options.Cleanup.Cron, - options.Cleanup.Enabled, - "Runs soft-delete and stale job execution cleanup." - ); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs deleted file mode 100644 index aecbdfca..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs +++ /dev/null @@ -1,21 +0,0 @@ -using BackgroundJobs.Application.Common; -using BackgroundJobs.Application.Options; - -namespace BackgroundJobs.Infrastructure.TickerQ.RecurringJobRegistrations; - -/// -/// Provides the for the reindex recurring job, -/// sourcing schedule and enablement from . -/// -public sealed class ReindexRecurringJobRegistration : IRecurringBackgroundJobRegistration -{ - /// Builds the reindex job definition from the supplied options. - public RecurringBackgroundJobDefinition Build(BackgroundJobsOptions options) => - new( - TickerQJobIds.Reindex, - TickerQFunctionNames.Reindex, - options.Reindex.Cron, - options.Reindex.Enabled, - "Rebuilds the PostgreSQL full-text search indexes." - ); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQFunctionNames.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQFunctionNames.cs deleted file mode 100644 index 8c718c8b..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQFunctionNames.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace BackgroundJobs.Infrastructure.TickerQ; - -/// -/// String constants used as TickerQ function identifiers in [TickerFunction] attributes -/// and coordinator calls, ensuring consistent naming between registration and execution. -/// -internal static class TickerQFunctionNames -{ - public const string Cleanup = "cleanup-recurring-job"; - public const string Reindex = "reindex-recurring-job"; -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQJobIds.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQJobIds.cs deleted file mode 100644 index 0ec743b7..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQJobIds.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace BackgroundJobs.Infrastructure.TickerQ; - -/// -/// Stable GUIDs that uniquely identify each recurring TickerQ job in the scheduler database. -/// These values must never change once the jobs have been seeded. -/// -internal static class TickerQJobIds -{ - public static readonly Guid Cleanup = new("4bc6790c-c877-43ed-8a32-85d5fa2dad95"); - public static readonly Guid Reindex = new("9cf4e6ef-a2dd-4ff7-8968-174a6236a59f"); -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQRecurringJobRegistrar.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQRecurringJobRegistrar.cs deleted file mode 100644 index 741496af..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQRecurringJobRegistrar.cs +++ /dev/null @@ -1,116 +0,0 @@ -using BackgroundJobs.Application.Common; -using BackgroundJobs.Application.Options; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using TickerQ.Utilities.Entities; - -namespace BackgroundJobs.Infrastructure.TickerQ; - -/// -/// Upserts all registered recurring job definitions into the TickerQ scheduler database at -/// application startup, keeping cron expressions, enablement flags, and metadata in sync -/// with the current configuration without requiring manual database edits. -/// -public sealed class TickerQRecurringJobRegistrar -{ - private const string SeedIdentifier = "BackgroundJobs:TickerQ:Recurring"; - private const string InitIdentifierProperty = "InitIdentifier"; - private const string CreatedAtProperty = "CreatedAt"; - private const string UpdatedAtProperty = "UpdatedAt"; - - private readonly TickerQSchedulerDbContext _dbContext; - private readonly IEnumerable _registrations; - private readonly BackgroundJobsOptions _options; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - - public TickerQRecurringJobRegistrar( - TickerQSchedulerDbContext dbContext, - IEnumerable registrations, - IOptions options, - TimeProvider timeProvider, - ILogger logger - ) - { - _dbContext = dbContext; - _registrations = registrations; - _options = options.Value; - _timeProvider = timeProvider; - _logger = logger; - } - - /// - /// Loads all rows from the database, inserts new ones and updates - /// existing ones to match the current definitions, - /// then saves changes in a single call. - /// - public async Task SyncAsync(CancellationToken ct = default) - { - DateTime now = _timeProvider.GetUtcNow().UtcDateTime; - List definitions = _registrations - .Select(x => x.Build(_options)) - .ToList(); - Dictionary tickersById = ( - await _dbContext.Set().ToListAsync(ct) - ).ToDictionary(x => x.Id); - - foreach (RecurringBackgroundJobDefinition definition in definitions) - { - if (!tickersById.TryGetValue(definition.Id, out CronTickerEntity? existing)) - { - CronTickerEntity entity = new() - { - Id = definition.Id, - Function = definition.FunctionName, - Description = definition.Description, - Expression = definition.CronExpression, - IsEnabled = definition.Enabled, - Retries = definition.Retries, - RetryIntervals = definition.RetryIntervals ?? [], - }; - _dbContext.Set().Add(entity); - Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry = - _dbContext.Entry(entity); - StampMetadata(entry, now); - continue; - } - - existing.Function = definition.FunctionName; - existing.Description = definition.Description; - existing.Expression = definition.CronExpression; - existing.IsEnabled = definition.Enabled; - existing.Retries = definition.Retries; - existing.RetryIntervals = definition.RetryIntervals ?? []; - StampUpdatedMetadata(_dbContext.Entry(existing), now); - } - - await _dbContext.SaveChangesAsync(ct); - - _logger.LogInformation( - "Synchronized {Count} recurring TickerQ job definitions.", - definitions.Count - ); - } - - /// Sets InitIdentifier, CreatedAt, and UpdatedAt shadow properties for a new entity. - private static void StampMetadata( - Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry, - DateTime now - ) - { - entry.Property(InitIdentifierProperty).CurrentValue = SeedIdentifier; - entry.Property(CreatedAtProperty).CurrentValue = now; - entry.Property(UpdatedAtProperty).CurrentValue = now; - } - - /// Refreshes UpdatedAt and initialises InitIdentifier if not already set on an existing entity. - private static void StampUpdatedMetadata( - Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry, - DateTime now - ) - { - entry.Property(InitIdentifierProperty).CurrentValue ??= SeedIdentifier; - entry.Property(UpdatedAtProperty).CurrentValue = now; - } -} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQSchedulerDbContext.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQSchedulerDbContext.cs deleted file mode 100644 index b99fa303..00000000 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/TickerQ/TickerQSchedulerDbContext.cs +++ /dev/null @@ -1,30 +0,0 @@ -using BackgroundJobs.Application.Options; -using Microsoft.EntityFrameworkCore; -using TickerQ.EntityFrameworkCore.DbContextFactory; -using TickerQ.Utilities.Entities; - -namespace BackgroundJobs.Infrastructure.TickerQ; - -/// -/// EF Core that hosts the TickerQ scheduler tables -/// (TimeTickers and CronTickers) in the dedicated TickerQ schema. -/// Used exclusively by TickerQ internals and the job registrar. -/// -public sealed class TickerQSchedulerDbContext : TickerQDbContext -{ - public TickerQSchedulerDbContext(DbContextOptions options) - : base(options) { } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema(TickerQSchedulerOptions.DefaultSchemaName); - base.OnModelCreating(modelBuilder); - - foreach ( - Microsoft.EntityFrameworkCore.Metadata.IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes() - ) - { - entityType.SetSchema(TickerQSchedulerOptions.DefaultSchemaName); - } - } -} diff --git a/src/Services/FileStorage/FileStorage.Api/Dockerfile b/src/Services/FileStorage/FileStorage.Api/Dockerfile deleted file mode 100644 index cad5e264..00000000 --- a/src/Services/FileStorage/FileStorage.Api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build -WORKDIR /src -COPY . . -RUN dotnet restore "src/Services/FileStorage/FileStorage.Api/FileStorage.Api.csproj" -RUN dotnet publish "src/Services/FileStorage/FileStorage.Api/FileStorage.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "FileStorage.Api.dll"] diff --git a/src/Services/FileStorage/FileStorage.Api/Endpoints/FilesHttpEndpoints.cs b/src/Services/FileStorage/FileStorage.Api/Endpoints/FilesHttpEndpoints.cs deleted file mode 100644 index 978e65a9..00000000 --- a/src/Services/FileStorage/FileStorage.Api/Endpoints/FilesHttpEndpoints.cs +++ /dev/null @@ -1,94 +0,0 @@ -using ErrorOr; -using FileStorage.Application.Common.Contracts; -using FileStorage.Application.Features.Files.Commands; -using FileStorage.Application.Features.Files.DTOs; -using FileStorage.Application.Features.Files.Queries; -using FileStorage.Domain.Interfaces; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using Microsoft.Extensions.DependencyInjection; -using SharedKernel.Api.Authorization; -using SharedKernel.Api.ErrorOrMapping; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.DTOs; -using SharedKernel.Application.Security; -using Wolverine; -using Wolverine.Http; - -namespace FileStorage.Api.Endpoints; - -/// Wolverine HTTP endpoints for multipart upload and file download (local storage). -public static class FilesHttpEndpoints -{ - [WolverinePost("/api/v1/files/upload")] - [RequirePermission(Permission.Files.Upload)] - [RequestSizeLimit(10 * 1024 * 1024)] - public static async Task UploadAsync( - [FromForm(Name = "File")] IFormFile file, - [FromForm(Name = "Description")] string? description, - IMessageBus bus, - HttpContext httpContext, - CancellationToken ct - ) - { - await using Stream stream = file.OpenReadStream(); - ErrorOr result = await bus.InvokeAsync>( - new UploadFileCommand( - new UploadFileRequest( - stream, - file.FileName, - file.ContentType, - file.Length, - description - ) - ), - ct - ); - - return result.ToIResult( - httpContext, - value => Results.Created($"/api/v1/files/{value.Id}/download", value) - ); - } - - [WolverineGet("/api/v1/files/{id:guid}/download")] - [RequirePermission(Permission.Files.Download)] - [OutputCache(PolicyName = CacheTags.Files)] - public static async Task DownloadAsync( - Guid id, - HttpContext httpContext, - CancellationToken ct - ) - { - // Resolve dependencies inside the method body so Wolverine.HTTP codegen does not inject domain types into generated frames. - IStoredFileRepository repository = - httpContext.RequestServices.GetRequiredService(); - IFileStorageService fileStorage = - httpContext.RequestServices.GetRequiredService(); - - ErrorOr meta = await StoredFileDownloadMetadata.ResolveAsync( - id, - repository, - ct - ); - if (meta.IsError) - return meta.Errors.ToProblemDetailsIResult(httpContext); - - FileDownloadInfo info = meta.Value; - - Stream? stream = await fileStorage.OpenReadAsync(info.StoragePath, ct); - if (stream is null) - return Results.NotFound(); - - try - { - return Results.File(stream, info.ContentType, info.FileName); - } - catch - { - await stream.DisposeAsync(); - throw; - } - } -} diff --git a/src/Services/FileStorage/FileStorage.Api/FileStorage.Api.csproj b/src/Services/FileStorage/FileStorage.Api/FileStorage.Api.csproj deleted file mode 100644 index 94878203..00000000 --- a/src/Services/FileStorage/FileStorage.Api/FileStorage.Api.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/src/Services/FileStorage/FileStorage.Api/Program.cs b/src/Services/FileStorage/FileStorage.Api/Program.cs deleted file mode 100644 index 4e42df5f..00000000 --- a/src/Services/FileStorage/FileStorage.Api/Program.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Contracts.IntegrationEvents.Sagas; -using FileStorage.Application.Common.Contracts; -using FileStorage.Application.Common.Options; -using FileStorage.Application.Features.Files.Commands; -using FileStorage.Domain.Interfaces; -using FileStorage.Infrastructure.FileStorage; -using FileStorage.Infrastructure.Persistence; -using FileStorage.Infrastructure.Repositories; -using Microsoft.EntityFrameworkCore; -using SharedKernel.Api.Extensions; -using SharedKernel.Api.OutputCaching; -using SharedKernel.Application.Security; -using SharedKernel.Messaging.Conventions; -using SharedKernel.Messaging.Topology; -using Wolverine; -using Wolverine.EntityFrameworkCore; -using Wolverine.Http; -using Wolverine.Postgresql; -using Wolverine.RabbitMQ; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Host.UseSharedSerilog(); -builder.Services.AddSharedObservability(builder.Configuration, builder.Environment, "file-storage"); - -builder.Services.AddValidatedOptions( - builder.Configuration, - FileStorageOptions.SectionName -); - -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetRequiredConnectionString("FileStorageDb")) -); - -builder.Services.AddScoped(sp => sp.GetRequiredService()); - -builder.Services.AddSharedInfrastructure(builder.Configuration); - -builder.Services.AddScoped(); -builder.Services.AddSingleton(); - -builder.Services.AddScoped(); -builder.Services.AddSharedKeycloakJwtBearer(builder.Configuration, builder.Environment); -builder.Services.AddSharedAuthorization(enablePermissionPolicies: true); - -builder.Services.AddSharedOpenApiDocumentation(); -builder.Services.AddSharedOutputCaching(builder.Configuration); -builder.Services.AddWolverineHttp(); - -builder.Services.AddHealthChecks(); - -builder.Host.UseWolverine(opts => -{ - opts.ApplicationAssembly = typeof(Program).Assembly; - - opts.ApplySharedConventions(); - opts.ApplySharedRetryPolicies(); - - opts.Discovery.IncludeAssembly(typeof(UploadFileCommand).Assembly); - opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); - - opts.PersistMessagesWithPostgresql( - builder.Configuration.GetRequiredConnectionString("FileStorageDb"), - "wolverine" - ); - opts.UseEntityFrameworkCoreTransactions(); - - opts.UseSharedRabbitMq(builder.Configuration); - - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.FileStorage.ProductDeleted, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.ProductCatalog); - } - ); - - // Route product deletion cascade completion back to ProductCatalog saga queue. - opts.PublishMessage() - .ToRabbitQueue(RabbitMqTopology.Queues.ProductCatalog.FilesCascadeCompleted); -}); - -WebApplication app = builder.Build(); - -await app.MigrateDbAsync(); - -app.UseSharedExceptionHandlerAndAuthentication(); -app.UseSharedAuthorizationCachingAndInfrastructure(useOutputCaching: true); -app.MapWolverineEndpoints(); - -await app.RunAsync(); - -public partial class Program; diff --git a/src/Services/FileStorage/FileStorage.Api/Properties/launchSettings.json b/src/Services/FileStorage/FileStorage.Api/Properties/launchSettings.json deleted file mode 100644 index 23443b6e..00000000 --- a/src/Services/FileStorage/FileStorage.Api/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5087", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7274;http://localhost:5087", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Services/FileStorage/FileStorage.Api/appsettings.json b/src/Services/FileStorage/FileStorage.Api/appsettings.json deleted file mode 100644 index a05ee5f8..00000000 --- a/src/Services/FileStorage/FileStorage.Api/appsettings.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "WriteTo": [ - { "Name": "Console" } - ] - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "FileStorageDb": "Host=localhost;Port=5432;Database=filestorage_db;Username=postgres;Password=postgres", - "RabbitMQ": "amqp://guest:guest@localhost:5672", - "Dragonfly": "localhost:6379" - }, - "Keycloak": { - "realm": "api-template", - "auth-server-url": "http://localhost:8180/", - "resource": "api-template" - }, - "FileStorage": { - "BasePath": "", - "MaxFileSizeBytes": 10485760, - "AllowedExtensions": [".jpg", ".png", ".gif", ".pdf", ".csv", ".txt"] - }, - "TransactionDefaults": { - "IsolationLevel": "ReadCommitted", - "TimeoutSeconds": 30, - "RetryEnabled": true, - "RetryCount": 3, - "RetryDelaySeconds": 5 - } -} diff --git a/src/Services/FileStorage/FileStorage.Application/Common/Contracts/IFileStorageService.cs b/src/Services/FileStorage/FileStorage.Application/Common/Contracts/IFileStorageService.cs deleted file mode 100644 index a01c0e08..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Common/Contracts/IFileStorageService.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace FileStorage.Application.Common.Contracts; - -/// -/// Application-layer abstraction for binary file storage, decoupling handlers from -/// the concrete storage backend (local disk, blob storage, S3, etc.). -/// -public interface IFileStorageService -{ - /// - /// Persists the contents of under the given - /// and returns a containing the resolved storage path and file size. - /// - Task SaveAsync( - Stream fileStream, - string fileName, - CancellationToken ct = default - ); - - /// - /// Opens a readable stream for the file at , - /// or returns null if the file does not exist. - /// - Task OpenReadAsync(string storagePath, CancellationToken ct = default); - - /// - /// Permanently removes the file at from the storage backend. - /// - Task DeleteAsync(string storagePath, CancellationToken ct = default); -} - -/// -/// Value object returned by describing where -/// the file was stored and how large it is. -/// -public sealed record FileStorageResult(string StoragePath, long SizeBytes); diff --git a/src/Services/FileStorage/FileStorage.Application/Common/Errors/DomainErrors.cs b/src/Services/FileStorage/FileStorage.Application/Common/Errors/DomainErrors.cs deleted file mode 100644 index 6606f055..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Common/Errors/DomainErrors.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ErrorOr; -using SharedDomainErrors = SharedKernel.Application.Errors.DomainErrors; - -namespace FileStorage.Application.Common.Errors; - -/// -/// Factory methods producing instances for the FileStorage bounded context. -/// -public static class DomainErrors -{ - public static class Files - { - public static Error NotFound(string fileName) => - SharedDomainErrors.General.NotFound( - FileStorageErrorCatalog.Files.NotFound, - "File", - fileName - ); - - public static Error InvalidFileType(string extension) => - Error.Validation( - code: FileStorageErrorCatalog.Files.InvalidFileType, - description: $"File type '{extension}' is not allowed." - ); - - public static Error FileTooLarge(long maxSize) => - Error.Validation( - code: FileStorageErrorCatalog.Files.FileTooLarge, - description: $"File exceeds maximum size of {maxSize} bytes." - ); - } -} diff --git a/src/Services/FileStorage/FileStorage.Application/Common/Errors/FileStorageErrorCatalog.cs b/src/Services/FileStorage/FileStorage.Application/Common/Errors/FileStorageErrorCatalog.cs deleted file mode 100644 index b8812a18..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Common/Errors/FileStorageErrorCatalog.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace FileStorage.Application.Common.Errors; - -/// -/// Central catalog of structured error codes for the FileStorage microservice. -/// -public static class FileStorageErrorCatalog -{ - public static class Files - { - public const string NotFound = "FILE-0404"; - public const string InvalidFileType = "FILE-0400-TYPE"; - public const string FileTooLarge = "FILE-0400-SIZE"; - } -} diff --git a/src/Services/FileStorage/FileStorage.Application/Common/Options/FileStorageOptions.cs b/src/Services/FileStorage/FileStorage.Application/Common/Options/FileStorageOptions.cs deleted file mode 100644 index 65d4749a..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Common/Options/FileStorageOptions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace FileStorage.Application.Common.Options; - -/// -/// Configuration for the local file-storage provider, including the base directory, upload size limit, -/// and allowed file extensions. -/// -public sealed class FileStorageOptions -{ - public const string SectionName = "FileStorage"; - - [Required] - public string BasePath { get; init; } = Path.Combine(Path.GetTempPath(), "filestorage-files"); - - [Range(1, long.MaxValue)] - public long MaxFileSizeBytes { get; init; } = 10 * 1024 * 1024; // 10 MB - - [MinLength(1)] - public string[] AllowedExtensions { get; init; } = - [".jpg", ".png", ".gif", ".pdf", ".csv", ".txt"]; -} diff --git a/src/Services/FileStorage/FileStorage.Application/Features/Files/Commands/UploadFileCommand.cs b/src/Services/FileStorage/FileStorage.Application/Features/Files/Commands/UploadFileCommand.cs deleted file mode 100644 index cb798337..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Features/Files/Commands/UploadFileCommand.cs +++ /dev/null @@ -1,72 +0,0 @@ -using ErrorOr; -using FileStorage.Application.Common.Contracts; -using FileStorage.Application.Common.Errors; -using FileStorage.Application.Common.Options; -using FileStorage.Application.Features.Files.DTOs; -using FileStorage.Domain.Entities; -using FileStorage.Domain.Interfaces; -using Microsoft.Extensions.Options; -using SharedKernel.Domain.Interfaces; - -namespace FileStorage.Application.Features.Files.Commands; - -public sealed record UploadFileCommand(UploadFileRequest Request); - -public sealed class UploadFileCommandHandler -{ - public static async Task> HandleAsync( - UploadFileCommand command, - IStoredFileRepository repository, - IFileStorageService storage, - IUnitOfWork unitOfWork, - IOptions options, - CancellationToken ct - ) - { - UploadFileRequest req = command.Request; - FileStorageOptions opts = options.Value; - string? extension = Path.GetExtension(req.FileName)?.ToLowerInvariant(); - if (string.IsNullOrEmpty(extension) || !opts.AllowedExtensions.Contains(extension)) - return DomainErrors.Files.InvalidFileType(extension ?? "none"); - - if (req.SizeBytes > opts.MaxFileSizeBytes) - return DomainErrors.Files.FileTooLarge(opts.MaxFileSizeBytes); - - FileStorageResult storageResult = await storage.SaveAsync(req.FileStream, req.FileName, ct); - - try - { - StoredFile entity = new StoredFile - { - Id = Guid.NewGuid(), - OriginalFileName = req.FileName, - StoragePath = storageResult.StoragePath, - ContentType = req.ContentType, - SizeBytes = storageResult.SizeBytes, - Description = req.Description, - }; - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.AddAsync(entity, ct); - }, - ct - ); - - return new FileUploadResponse( - entity.Id, - entity.OriginalFileName, - entity.ContentType, - entity.SizeBytes, - entity.Description, - entity.Audit.CreatedAtUtc - ); - } - catch - { - await storage.DeleteAsync(storageResult.StoragePath, CancellationToken.None); - throw; - } - } -} diff --git a/src/Services/FileStorage/FileStorage.Application/Features/Files/DTOs/DownloadFileRequest.cs b/src/Services/FileStorage/FileStorage.Application/Features/Files/DTOs/DownloadFileRequest.cs deleted file mode 100644 index fb0ec14d..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Features/Files/DTOs/DownloadFileRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace FileStorage.Application.Features.Files.DTOs; - -/// -/// Carries the unique identifier of the stored file to be downloaded. -/// -public sealed record DownloadFileRequest(Guid Id) : IHasId; diff --git a/src/Services/FileStorage/FileStorage.Application/Features/Files/DTOs/FileUploadResponse.cs b/src/Services/FileStorage/FileStorage.Application/Features/Files/DTOs/FileUploadResponse.cs deleted file mode 100644 index 4659c895..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Features/Files/DTOs/FileUploadResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace FileStorage.Application.Features.Files.DTOs; - -/// -/// Represents the metadata of a successfully uploaded file as returned to the API consumer. -/// -public sealed record FileUploadResponse( - Guid Id, - string OriginalFileName, - string ContentType, - long SizeBytes, - string? Description, - DateTime CreatedAtUtc -) : IHasId; diff --git a/src/Services/FileStorage/FileStorage.Application/Features/Files/DTOs/UploadFileRequest.cs b/src/Services/FileStorage/FileStorage.Application/Features/Files/DTOs/UploadFileRequest.cs deleted file mode 100644 index 8a5a2498..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Features/Files/DTOs/UploadFileRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace FileStorage.Application.Features.Files.DTOs; - -/// -/// Carries all data needed to store an uploaded file, including the raw stream, original file name, content type, size, and optional description. -/// -public sealed record UploadFileRequest( - Stream FileStream, - string FileName, - string ContentType, - long SizeBytes, - string? Description -); diff --git a/src/Services/FileStorage/FileStorage.Application/Features/Files/EventHandlers/ProductDeletedEventHandler.cs b/src/Services/FileStorage/FileStorage.Application/Features/Files/EventHandlers/ProductDeletedEventHandler.cs deleted file mode 100644 index f52034ed..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Features/Files/EventHandlers/ProductDeletedEventHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using Contracts.IntegrationEvents.Sagas; -using Microsoft.Extensions.Logging; -using Wolverine; - -namespace FileStorage.Application.Features.Files.EventHandlers; - -/// -/// Handles by logging the event -/// for future file orphaning logic (e.g., marking files associated with deleted products). -/// -public sealed class ProductDeletedEventHandler -{ - public static async Task HandleAsync( - ProductDeletedIntegrationEvent @event, - IMessageBus bus, - ILogger logger, - CancellationToken ct - ) - { - logger.LogInformation( - "Received ProductDeletedIntegrationEvent for {Count} product(s) in tenant {TenantId}. " - + "File orphaning logic can be implemented here.", - @event.ProductIds.Count, - @event.TenantId - ); - - await bus.PublishAsync(new FilesCascadeCompleted(@event.CorrelationId, 0)); - } -} diff --git a/src/Services/FileStorage/FileStorage.Application/Features/Files/Queries/DownloadFileQuery.cs b/src/Services/FileStorage/FileStorage.Application/Features/Files/Queries/DownloadFileQuery.cs deleted file mode 100644 index f7e5c5b1..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Features/Files/Queries/DownloadFileQuery.cs +++ /dev/null @@ -1,16 +0,0 @@ -using ErrorOr; -using FileStorage.Domain.Interfaces; -using SharedKernel.Application.DTOs; - -namespace FileStorage.Application.Features.Files.Queries; - -public sealed record DownloadFileQuery(Guid Id); - -public sealed class DownloadFileQueryHandler -{ - public static Task> HandleAsync( - DownloadFileQuery query, - IStoredFileRepository repository, - CancellationToken ct - ) => StoredFileDownloadMetadata.ResolveAsync(query.Id, repository, ct); -} diff --git a/src/Services/FileStorage/FileStorage.Application/Features/Files/Queries/StoredFileDownloadMetadata.cs b/src/Services/FileStorage/FileStorage.Application/Features/Files/Queries/StoredFileDownloadMetadata.cs deleted file mode 100644 index 0f627778..00000000 --- a/src/Services/FileStorage/FileStorage.Application/Features/Files/Queries/StoredFileDownloadMetadata.cs +++ /dev/null @@ -1,33 +0,0 @@ -using ErrorOr; -using FileStorage.Application.Common.Errors; -using FileStorage.Domain.Interfaces; -using SharedKernel.Application.DTOs; -using SharedKernel.Application.Extensions; - -namespace FileStorage.Application.Features.Files.Queries; - -/// Shared lookup of stored-file metadata for HTTP and message handlers. -public static class StoredFileDownloadMetadata -{ - public static async Task> ResolveAsync( - Guid id, - IStoredFileRepository repository, - CancellationToken ct - ) - { - ErrorOr entityResult = await repository.GetByIdOrError( - id, - DomainErrors.Files.NotFound(id.ToString()), - ct - ); - if (entityResult.IsError) - return entityResult.Errors; - - Domain.Entities.StoredFile entity = entityResult.Value; - return new FileDownloadInfo( - entity.StoragePath, - entity.ContentType, - entity.OriginalFileName - ); - } -} diff --git a/src/Services/FileStorage/FileStorage.Application/FileStorage.Application.csproj b/src/Services/FileStorage/FileStorage.Application/FileStorage.Application.csproj deleted file mode 100644 index db329fc8..00000000 --- a/src/Services/FileStorage/FileStorage.Application/FileStorage.Application.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/FileStorage/FileStorage.Domain/Entities/StoredFile.cs b/src/Services/FileStorage/FileStorage.Domain/Entities/StoredFile.cs deleted file mode 100644 index b923240f..00000000 --- a/src/Services/FileStorage/FileStorage.Domain/Entities/StoredFile.cs +++ /dev/null @@ -1,23 +0,0 @@ -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace FileStorage.Domain.Entities; - -/// -/// Domain entity representing metadata for a file uploaded to blob storage. -/// The actual binary content is stored externally; this entity tracks the reference and descriptive metadata. -/// -public sealed class StoredFile : IAuditableTenantEntity, IHasId -{ - public Guid Id { get; set; } - public required string OriginalFileName { get; init; } - public required string StoragePath { get; init; } - public required string ContentType { get; init; } - public long SizeBytes { get; init; } - public string? Description { get; init; } - public Guid TenantId { get; set; } - public AuditInfo Audit { get; set; } = new(); - public bool IsDeleted { get; set; } - public DateTime? DeletedAtUtc { get; set; } - public Guid? DeletedBy { get; set; } -} diff --git a/src/Services/FileStorage/FileStorage.Domain/FileStorage.Domain.csproj b/src/Services/FileStorage/FileStorage.Domain/FileStorage.Domain.csproj deleted file mode 100644 index e5effa8b..00000000 --- a/src/Services/FileStorage/FileStorage.Domain/FileStorage.Domain.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/FileStorage/FileStorage.Domain/Interfaces/IStoredFileRepository.cs b/src/Services/FileStorage/FileStorage.Domain/Interfaces/IStoredFileRepository.cs deleted file mode 100644 index c6c6da57..00000000 --- a/src/Services/FileStorage/FileStorage.Domain/Interfaces/IStoredFileRepository.cs +++ /dev/null @@ -1,9 +0,0 @@ -using FileStorage.Domain.Entities; -using SharedKernel.Domain.Interfaces; - -namespace FileStorage.Domain.Interfaces; - -/// -/// Repository contract for entities, inheriting all generic CRUD operations from . -/// -public interface IStoredFileRepository : IRepository; diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/FileStorage.Infrastructure.csproj b/src/Services/FileStorage/FileStorage.Infrastructure/FileStorage.Infrastructure.csproj deleted file mode 100644 index 66196252..00000000 --- a/src/Services/FileStorage/FileStorage.Infrastructure/FileStorage.Infrastructure.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - - - - - - - diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/FileStorage/LocalFileStorageService.cs b/src/Services/FileStorage/FileStorage.Infrastructure/FileStorage/LocalFileStorageService.cs deleted file mode 100644 index a9031f36..00000000 --- a/src/Services/FileStorage/FileStorage.Infrastructure/FileStorage/LocalFileStorageService.cs +++ /dev/null @@ -1,110 +0,0 @@ -using FileStorage.Application.Common.Contracts; -using FileStorage.Application.Common.Options; -using Microsoft.Extensions.Options; -using SharedKernel.Application.Context; - -namespace FileStorage.Infrastructure.FileStorage; - -/// -/// Infrastructure implementation of that persists files to the -/// local file system under a tenant-scoped subdirectory within the configured base path. -/// All path operations include path-traversal validation to prevent directory escape attacks. -/// -public sealed class LocalFileStorageService : IFileStorageService -{ - private readonly FileStorageOptions _options; - private readonly ITenantProvider _tenantProvider; - - public LocalFileStorageService( - IOptions options, - ITenantProvider tenantProvider - ) - { - _options = options.Value; - _tenantProvider = tenantProvider; - } - - /// - /// Saves to the tenant directory using a UUID-based file name - /// that retains the original extension, validates the resolved path, and returns the storage path and size. - /// - public async Task SaveAsync( - Stream fileStream, - string fileName, - CancellationToken ct = default - ) - { - string tenantDir = Path.Combine(_options.BasePath, _tenantProvider.TenantId.ToString()); - Directory.CreateDirectory(tenantDir); - - string safeExtension = Path.GetExtension(Path.GetFileName(fileName)); - string storedFileName = $"{Guid.NewGuid()}{safeExtension}"; - string storagePath = Path.Combine(tenantDir, storedFileName); - - ValidatePathWithinBasePath(storagePath); - - long sizeBytes; - await using ( - FileStream output = new FileStream( - storagePath, - FileMode.Create, - FileAccess.Write, - FileShare.None, - 4096, - FileOptions.Asynchronous - ) - ) - { - await fileStream.CopyToAsync(output, ct); - sizeBytes = output.Length; - } - - return new FileStorageResult(storagePath, sizeBytes); - } - - /// Opens the file at for reading after path validation; returns if the file does not exist. - public Task OpenReadAsync(string storagePath, CancellationToken ct = default) - { - ValidatePathWithinBasePath(storagePath); - - if (!File.Exists(storagePath)) - return Task.FromResult(null); - - return Task.FromResult( - new FileStream( - storagePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - 4096, - FileOptions.Asynchronous - ) - ); - } - - /// Deletes the file at after path validation; silently succeeds if the file does not exist. - public Task DeleteAsync(string storagePath, CancellationToken ct = default) - { - ValidatePathWithinBasePath(storagePath); - - if (File.Exists(storagePath)) - File.Delete(storagePath); - - return Task.CompletedTask; - } - - /// - /// Throws if the fully resolved - /// does not reside within the configured base path, preventing path-traversal attacks. - /// - private void ValidatePathWithinBasePath(string path) - { - string fullPath = Path.GetFullPath(path); - string fullBasePath = - Path.GetFullPath(_options.BasePath).TrimEnd(Path.DirectorySeparatorChar) - + Path.DirectorySeparatorChar; - - if (!fullPath.StartsWith(fullBasePath, StringComparison.OrdinalIgnoreCase)) - throw new UnauthorizedAccessException("Path traversal detected: access denied."); - } -} diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs deleted file mode 100644 index acd52d98..00000000 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FileStorage.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SharedKernel.Infrastructure.Persistence.Configurations; - -namespace FileStorage.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity, mapped to the StoredFiles table. -public sealed class StoredFileConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("StoredFiles"); - - builder.HasKey(e => e.Id); - builder.ConfigureTenantAuditable(); - - builder.Property(e => e.OriginalFileName).IsRequired().HasMaxLength(255); - - builder.Property(e => e.StoragePath).IsRequired().HasMaxLength(500); - - builder.Property(e => e.ContentType).IsRequired().HasMaxLength(100); - - builder.Property(e => e.Description).HasMaxLength(1000); - } -} diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs deleted file mode 100644 index 293eda45..00000000 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs +++ /dev/null @@ -1,50 +0,0 @@ -using FileStorage.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Context; -using SharedKernel.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Persistence.Auditing; -using SharedKernel.Infrastructure.Persistence.EntityNormalization; -using SharedKernel.Infrastructure.Persistence.SoftDelete; - -namespace FileStorage.Infrastructure.Persistence; - -/// -/// EF Core context for FileStorage microservice. -/// Enforces multi-tenancy, audit stamping, soft delete, and optimistic concurrency. -/// -public sealed class FileStorageDbContext : TenantAuditableDbContext -{ - public FileStorageDbContext( - DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor, - IEntityNormalizationService? entityNormalizationService = null - ) - : base( - options, - tenantProvider, - actorProvider, - timeProvider, - softDeleteCascadeRules, - entityStateManager, - softDeleteProcessor, - entityNormalizationService - ) { } - - public DbSet StoredFiles => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(FileStorageDbContext).Assembly); - - // Global query filters for multi-tenancy and soft-delete - modelBuilder - .Entity() - .HasQueryFilter(e => (!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted); - } -} diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs deleted file mode 100644 index 94bd616e..00000000 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace FileStorage.Infrastructure.Persistence; - -/// -/// Design-time factory used by EF Core tooling to create the DbContext -/// for migration scaffolding without requiring the full runtime DI container. -/// -public sealed class FileStorageDbContextDesignTimeFactory - : IDesignTimeDbContextFactory -{ - public FileStorageDbContext CreateDbContext(string[] args) - { - DbContextOptionsBuilder optionsBuilder = new(); - optionsBuilder.UseNpgsql( - "Host=localhost;Database=file_storage_db;Username=postgres;Password=postgres" - ); - - return new FileStorageDbContext( - optionsBuilder.Options, - tenantProvider: null!, - actorProvider: null!, - timeProvider: TimeProvider.System, - softDeleteCascadeRules: [], - entityStateManager: null!, - softDeleteProcessor: null! - ); - } -} diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Migrations/20260326232841_InitialCreate.Designer.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Migrations/20260326232841_InitialCreate.Designer.cs deleted file mode 100644 index 62256a44..00000000 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Migrations/20260326232841_InitialCreate.Designer.cs +++ /dev/null @@ -1,133 +0,0 @@ -// -using System; -using FileStorage.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace FileStorage.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(FileStorageDbContext))] - [Migration("20260326232841_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FileStorage.Domain.Entities.StoredFile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("ContentType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("OriginalFileName") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("SizeBytes") - .HasColumnType("bigint"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("StoredFiles", null, t => - { - t.HasCheckConstraint("CK_StoredFiles_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("FileStorage.Domain.Entities.StoredFile", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("StoredFileId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("StoredFileId"); - - b1.ToTable("StoredFiles"); - - b1.WithOwner() - .HasForeignKey("StoredFileId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Migrations/20260326232841_InitialCreate.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Migrations/20260326232841_InitialCreate.cs deleted file mode 100644 index 9f734d88..00000000 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Migrations/20260326232841_InitialCreate.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FileStorage.Infrastructure.Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "StoredFiles", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - OriginalFileName = table.Column( - type: "character varying(255)", - maxLength: 255, - nullable: false - ), - StoragePath = table.Column( - type: "character varying(500)", - maxLength: 500, - nullable: false - ), - ContentType = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - SizeBytes = table.Column(type: "bigint", nullable: false), - Description = table.Column( - type: "character varying(1000)", - maxLength: 1000, - nullable: true - ), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_StoredFiles", x => x.Id); - table.CheckConstraint( - "CK_StoredFiles_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_StoredFiles_TenantId", - table: "StoredFiles", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_StoredFiles_TenantId_IsDeleted", - table: "StoredFiles", - columns: new[] { "TenantId", "IsDeleted" } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "StoredFiles"); - } - } -} diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Migrations/FileStorageDbContextModelSnapshot.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Migrations/FileStorageDbContextModelSnapshot.cs deleted file mode 100644 index e832e330..00000000 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/Migrations/FileStorageDbContextModelSnapshot.cs +++ /dev/null @@ -1,130 +0,0 @@ -// -using System; -using FileStorage.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace FileStorage.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(FileStorageDbContext))] - partial class FileStorageDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FileStorage.Domain.Entities.StoredFile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("ContentType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("OriginalFileName") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("SizeBytes") - .HasColumnType("bigint"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("StoredFiles", null, t => - { - t.HasCheckConstraint("CK_StoredFiles_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("FileStorage.Domain.Entities.StoredFile", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("StoredFileId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("StoredFileId"); - - b1.ToTable("StoredFiles"); - - b1.WithOwner() - .HasForeignKey("StoredFileId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Repositories/StoredFileRepository.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Repositories/StoredFileRepository.cs deleted file mode 100644 index 40f63aa5..00000000 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Repositories/StoredFileRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FileStorage.Domain.Entities; -using FileStorage.Domain.Interfaces; -using FileStorage.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Repositories; - -namespace FileStorage.Infrastructure.Repositories; - -/// EF Core repository for , inheriting all standard CRUD and specification query support from . -public sealed class StoredFileRepository : RepositoryBase, IStoredFileRepository -{ - public StoredFileRepository(FileStorageDbContext dbContext) - : base(dbContext) { } -} diff --git a/src/Services/Identity/Identity.Api/Controllers/V1/BffController.cs b/src/Services/Identity/Identity.Api/Controllers/V1/BffController.cs deleted file mode 100644 index e4eda92c..00000000 --- a/src/Services/Identity/Identity.Api/Controllers/V1/BffController.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Security.Claims; -using Asp.Versioning; -using Identity.Application.Features.Bff.DTOs; -using Identity.Application.Options; -using Identity.Application.Security; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using SharedKernel.Api.Controllers; - -namespace Identity.Api.Controllers.V1; - -[ApiVersion(1.0)] -[Authorize(AuthenticationSchemes = AuthConstants.BffSchemes.Cookie)] -public sealed class BffController : ApiControllerBase -{ - private readonly BffOptions _bffOptions; - - public BffController(IOptions bffOptions) - { - _bffOptions = bffOptions.Value; - } - - [HttpGet("login")] - [AllowAnonymous] - public IActionResult Login([FromQuery] string? returnUrl = null) - { - string redirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/"; - return Challenge( - new AuthenticationProperties { RedirectUri = redirectUri }, - AuthConstants.BffSchemes.Oidc - ); - } - - [HttpPost("logout")] - public IActionResult Logout() - { - return SignOut( - new AuthenticationProperties { RedirectUri = _bffOptions.PostLogoutRedirectUri }, - AuthConstants.BffSchemes.Cookie, - AuthConstants.BffSchemes.Oidc - ); - } - - [HttpGet("csrf")] - [AllowAnonymous] - public IActionResult GetCsrf() => - Ok( - new - { - headerName = AuthConstants.Csrf.HeaderName, - headerValue = AuthConstants.Csrf.HeaderValue, - } - ); - - [HttpGet("user")] - public IActionResult GetUser() - { - ClaimsPrincipal user = HttpContext.User; - - BffUserResponse result = new( - UserId: user.FindFirstValue(ClaimTypes.NameIdentifier), - Username: user.FindFirstValue(ClaimTypes.Name), - Email: user.FindFirstValue(ClaimTypes.Email), - TenantId: user.FindFirstValue(AuthConstants.Claims.TenantId), - Roles: user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray() - ); - - return Ok(result); - } -} diff --git a/src/Services/Identity/Identity.Api/Controllers/V1/TenantInvitationsController.cs b/src/Services/Identity/Identity.Api/Controllers/V1/TenantInvitationsController.cs deleted file mode 100644 index f58df919..00000000 --- a/src/Services/Identity/Identity.Api/Controllers/V1/TenantInvitationsController.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Asp.Versioning; -using ErrorOr; -using Identity.Application.Features.TenantInvitation.Commands; -using Identity.Application.Features.TenantInvitation.DTOs; -using Identity.Application.Features.TenantInvitation.Queries; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using SharedKernel.Api.Authorization; -using SharedKernel.Api.Controllers; -using SharedKernel.Api.ErrorOrMapping; -using SharedKernel.Api.Extensions; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Security; -using SharedKernel.Domain.Common; -using Wolverine; - -namespace Identity.Api.Controllers.V1; - -[ApiVersion(1.0)] -[Route("api/v{version:apiVersion}/[controller]")] -public sealed class TenantInvitationsController(IMessageBus bus) : ApiControllerBase -{ - [HttpGet] - [RequirePermission(Permission.Invitations.Read)] - [OutputCache(PolicyName = CacheTags.TenantInvitations)] - public Task>> GetAll( - [FromQuery] TenantInvitationFilter filter, - CancellationToken ct - ) => - InvokeToActionResultAsync>( - bus, - new GetTenantInvitationsQuery(filter), - ct - ); - - [HttpPost] - [RequirePermission(Permission.Invitations.Create)] - public async Task> Create( - CreateTenantInvitationRequest request, - CancellationToken ct - ) - { - ErrorOr result = await bus.InvokeAsync< - ErrorOr - >(new CreateTenantInvitationCommand(request), ct); - if (result.IsError) - return result.ToActionResult(this); - - return CreatedAtAction( - nameof(GetAll), - new { version = this.GetApiVersion() }, - result.Value - ); - } - - [HttpPost("accept")] - [AllowAnonymous] - public Task Accept( - [FromBody] AcceptInvitationRequest request, - CancellationToken ct - ) => InvokeToOkResultAsync(bus, new AcceptTenantInvitationCommand(request.Token), ct); - - [HttpPatch("{id:guid}/revoke")] - [RequirePermission(Permission.Invitations.Revoke)] - public Task Revoke(Guid id, CancellationToken ct) => - InvokeToNoContentResultAsync(bus, new RevokeTenantInvitationCommand(id), ct); - - [HttpPost("{id:guid}/resend")] - [RequirePermission(Permission.Invitations.Create)] - public Task Resend(Guid id, CancellationToken ct) => - InvokeToOkResultAsync(bus, new ResendTenantInvitationCommand(id), ct); -} diff --git a/src/Services/Identity/Identity.Api/Controllers/V1/TenantsController.cs b/src/Services/Identity/Identity.Api/Controllers/V1/TenantsController.cs deleted file mode 100644 index 87ee6dfb..00000000 --- a/src/Services/Identity/Identity.Api/Controllers/V1/TenantsController.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Asp.Versioning; -using Identity.Application.Features.Tenant.Commands; -using Identity.Application.Features.Tenant.DTOs; -using Identity.Application.Features.Tenant.Queries; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using SharedKernel.Api.Authorization; -using SharedKernel.Api.Controllers; -using SharedKernel.Api.Extensions; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Security; -using SharedKernel.Domain.Common; -using Wolverine; - -namespace Identity.Api.Controllers.V1; - -[ApiVersion(1.0)] -public sealed class TenantsController(IMessageBus bus) : ApiControllerBase -{ - [HttpGet] - [RequirePermission(Permission.Tenants.Read)] - [OutputCache(PolicyName = CacheTags.Tenants)] - public Task>> GetAll( - [FromQuery] TenantFilter filter, - CancellationToken ct - ) => - InvokeToActionResultAsync>( - bus, - new GetTenantsQuery(filter), - ct - ); - - [HttpGet("{id:guid}")] - [RequirePermission(Permission.Tenants.Read)] - [OutputCache(PolicyName = CacheTags.Tenants)] - public Task> GetById(Guid id, CancellationToken ct) => - InvokeToActionResultAsync(bus, new GetTenantByIdQuery(id), ct); - - [HttpPost] - [RequirePermission(Permission.Tenants.Create)] - public Task> Create( - CreateTenantRequest request, - CancellationToken ct - ) => - InvokeToCreatedResultAsync( - bus, - new CreateTenantCommand(request), - v => new { id = v.Id, version = this.GetApiVersion() }, - ct - ); - - [HttpDelete("{id:guid}")] - [RequirePermission(Permission.Tenants.Delete)] - public Task Delete(Guid id, CancellationToken ct) => - InvokeToNoContentResultAsync(bus, new DeleteTenantCommand(id), ct); -} diff --git a/src/Services/Identity/Identity.Api/Controllers/V1/UsersController.cs b/src/Services/Identity/Identity.Api/Controllers/V1/UsersController.cs deleted file mode 100644 index c8e12ef4..00000000 --- a/src/Services/Identity/Identity.Api/Controllers/V1/UsersController.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Security.Claims; -using Asp.Versioning; -using Identity.Application.Features.User.Commands; -using Identity.Application.Features.User.DTOs; -using Identity.Application.Features.User.Queries; -using Identity.Application.Security; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using SharedKernel.Api.Authorization; -using SharedKernel.Api.Controllers; -using SharedKernel.Api.Extensions; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Security; -using SharedKernel.Domain.Common; -using Wolverine; - -namespace Identity.Api.Controllers.V1; - -[ApiVersion(1.0)] -public sealed class UsersController(IMessageBus bus) : ApiControllerBase -{ - [HttpGet] - [RequirePermission(Permission.Users.Read)] - [OutputCache(PolicyName = CacheTags.Users)] - public Task>> GetAll( - [FromQuery] UserFilter filter, - CancellationToken ct - ) => InvokeToActionResultAsync>(bus, new GetUsersQuery(filter), ct); - - [HttpGet("{id:guid}")] - [RequirePermission(Permission.Users.Read)] - [OutputCache(PolicyName = CacheTags.Users)] - public Task> GetById(Guid id, CancellationToken ct) => - InvokeToActionResultAsync(bus, new GetUserByIdQuery(id), ct); - - [HttpGet("me")] - public async Task> GetMe(CancellationToken ct) - { - string? userId = - User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? User.FindFirstValue(AuthConstants.Claims.Subject); - - if (userId is null || !Guid.TryParse(userId, out Guid id)) - return Unauthorized(); - - return await InvokeToActionResultAsync(bus, new GetUserByIdQuery(id), ct); - } - - [HttpPost] - [RequirePermission(Permission.Users.Create)] - public Task> Create( - CreateUserRequest request, - CancellationToken ct - ) => - InvokeToCreatedResultAsync( - bus, - new CreateUserCommand(request), - v => new { id = v.Id, version = this.GetApiVersion() }, - ct - ); - - [HttpPut("{id:guid}")] - [RequirePermission(Permission.Users.Update)] - public Task Update(Guid id, UpdateUserRequest request, CancellationToken ct) => - InvokeToNoContentResultAsync(bus, new UpdateUserCommand(id, request), ct); - - [HttpPatch("{id:guid}/activate")] - [RequirePermission(Permission.Users.Update)] - public Task Activate(Guid id, CancellationToken ct) => - InvokeToNoContentResultAsync(bus, new SetUserActiveCommand(id, IsActive: true), ct); - - [HttpPatch("{id:guid}/deactivate")] - [RequirePermission(Permission.Users.Update)] - public Task Deactivate(Guid id, CancellationToken ct) => - InvokeToNoContentResultAsync(bus, new SetUserActiveCommand(id, IsActive: false), ct); - - [HttpPatch("{id:guid}/role")] - [RequirePermission(Permission.Users.Update)] - public Task ChangeRole( - Guid id, - ChangeUserRoleRequest request, - CancellationToken ct - ) => InvokeToNoContentResultAsync(bus, new ChangeUserRoleCommand(id, request), ct); - - [HttpDelete("{id:guid}")] - [RequirePermission(Permission.Users.Delete)] - public Task Delete(Guid id, CancellationToken ct) => - InvokeToNoContentResultAsync(bus, new DeleteUserCommand(id), ct); - - [HttpPost("password-reset")] - [AllowAnonymous] - public Task RequestPasswordReset( - RequestPasswordResetRequest request, - CancellationToken ct - ) => InvokeToOkResultAsync(bus, new KeycloakPasswordResetCommand(request), ct); -} diff --git a/src/Services/Identity/Identity.Api/Dockerfile b/src/Services/Identity/Identity.Api/Dockerfile deleted file mode 100644 index 97b21697..00000000 --- a/src/Services/Identity/Identity.Api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build -WORKDIR /src -COPY . . -RUN dotnet restore "src/Services/Identity/Identity.Api/Identity.Api.csproj" -RUN dotnet publish "src/Services/Identity/Identity.Api/Identity.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "Identity.Api.dll"] diff --git a/src/Services/Identity/Identity.Api/Extensions/BffAuthenticationExtensions.cs b/src/Services/Identity/Identity.Api/Extensions/BffAuthenticationExtensions.cs deleted file mode 100644 index c8cd0317..00000000 --- a/src/Services/Identity/Identity.Api/Extensions/BffAuthenticationExtensions.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Identity.Application.Options; -using Identity.Application.Security; -using Identity.Infrastructure.Security.Tenant; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using SharedKernel.Api.Extensions; - -namespace Identity.Api.Extensions; - -public static class BffAuthenticationExtensions -{ - public static AuthenticationBuilder AddIdentityBffAuthentication( - this AuthenticationBuilder authBuilder, - IConfiguration configuration, - IHostEnvironment environment - ) - { - KeycloakOptions keycloak = - configuration.GetRequiredSection(KeycloakOptions.SectionName).Get() - ?? throw new InvalidOperationException("Keycloak configuration is missing."); - - BffOptions bff = - configuration.GetRequiredSection(BffOptions.SectionName).Get() - ?? throw new InvalidOperationException("Bff configuration is missing."); - - string authority = KeycloakAuthExtensions.BuildAuthority( - keycloak.AuthServerUrl, - keycloak.Realm - ); - - return authBuilder - .AddCookie( - AuthConstants.BffSchemes.Cookie, - options => - { - options.Cookie.Name = bff.CookieName; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.SecurePolicy = environment.IsDevelopment() - ? CookieSecurePolicy.SameAsRequest - : CookieSecurePolicy.Always; - options.ExpireTimeSpan = TimeSpan.FromMinutes(bff.SessionTimeoutMinutes); - options.SlidingExpiration = true; - } - ) - .AddOpenIdConnect( - AuthConstants.BffSchemes.Oidc, - options => - { - options.Authority = authority; - options.RequireHttpsMetadata = !environment.IsDevelopment(); - options.ClientId = keycloak.Resource; - options.ClientSecret = keycloak.Credentials.Secret; - options.ResponseType = OpenIdConnectResponseType.Code; - options.SaveTokens = true; - options.SignInScheme = AuthConstants.BffSchemes.Cookie; - - options.Scope.Clear(); - foreach (string scope in bff.Scopes) - options.Scope.Add(scope); - - options.Events = new OpenIdConnectEvents - { - OnTokenValidated = TenantClaimValidator.OnTokenValidated, - }; - } - ); - } -} diff --git a/src/Services/Identity/Identity.Api/Identity.Api.csproj b/src/Services/Identity/Identity.Api/Identity.Api.csproj deleted file mode 100644 index 94b51ae9..00000000 --- a/src/Services/Identity/Identity.Api/Identity.Api.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - - - diff --git a/src/Services/Identity/Identity.Api/Middleware/CsrfValidationMiddleware.cs b/src/Services/Identity/Identity.Api/Middleware/CsrfValidationMiddleware.cs deleted file mode 100644 index e86840de..00000000 --- a/src/Services/Identity/Identity.Api/Middleware/CsrfValidationMiddleware.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Identity.Application.Security; -using Microsoft.AspNetCore.Authentication; -using SharedKernel.Application.Errors; - -namespace Identity.Api.Middleware; - -/// -/// Middleware that enforces CSRF protection for cookie-authenticated requests. -/// -/// -/// Only mutating HTTP methods (POST, PUT, PATCH, DELETE, …) are checked. -/// Safe methods (GET, HEAD, OPTIONS) and JWT Bearer-authenticated requests are -/// unconditionally allowed through — the header is required only when the -/// session cookie is the active authentication mechanism. -/// -/// Clients must include X-CSRF: 1 on every non-safe request. -/// The required header name and value are exposed via GET /api/v1/bff/csrf -/// so that SPAs can discover the contract at runtime. -/// -public sealed class CsrfValidationMiddleware( - RequestDelegate next, - IProblemDetailsService problemDetailsService -) -{ - /// - /// Processes the request and enforces the CSRF header requirement for cookie-authenticated - /// mutating requests, returning HTTP 403 with problem details when the check fails. - /// - public async Task InvokeAsync(HttpContext context) - { - // Safe methods cannot cause state changes, so CSRF is not a concern. - if ( - HttpMethods.IsGet(context.Request.Method) - || HttpMethods.IsHead(context.Request.Method) - || HttpMethods.IsOptions(context.Request.Method) - ) - { - await next(context); - return; - } - - // Explicit bearer tokens carry their own proof of origin; skip CSRF checks even if - // a browser also happens to send a session cookie on the same request. - string? authorization = context.Request.Headers.Authorization; - if ( - !string.IsNullOrEmpty(authorization) - && authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) - ) - { - await next(context); - return; - } - - // The default auth scheme is JWT Bearer, so UseAuthentication does not automatically - // populate HttpContext.User from the BFF cookie scheme. Check both the current user - // and the cookie scheme explicitly so cookie-authenticated requests cannot bypass CSRF. - bool isCookieAuthenticated = context.User.Identities.Any(i => - i.AuthenticationType == AuthConstants.BffSchemes.Cookie - ); - - if (!isCookieAuthenticated) - { - AuthenticateResult cookieAuthResult = await context.AuthenticateAsync( - AuthConstants.BffSchemes.Cookie - ); - if (!cookieAuthResult.Succeeded) - { - await next(context); - return; - } - } - - // Cookie-authenticated mutating request — require the custom CSRF header. - string? csrfHeader = context.Request.Headers[AuthConstants.Csrf.HeaderName]; - if (csrfHeader == AuthConstants.Csrf.HeaderValue) - { - await next(context); - return; - } - - context.Response.StatusCode = StatusCodes.Status403Forbidden; - await problemDetailsService.TryWriteAsync( - new ProblemDetailsContext - { - HttpContext = context, - ProblemDetails = - { - Title = "Forbidden", - Status = StatusCodes.Status403Forbidden, - Detail = - $"Cookie-authenticated requests must include the '{AuthConstants.Csrf.HeaderName}: {AuthConstants.Csrf.HeaderValue}' header.", - Extensions = { ["errorCode"] = ErrorCatalog.Auth.CsrfHeaderMissing }, - }, - } - ); - } -} diff --git a/src/Services/Identity/Identity.Api/Program.cs b/src/Services/Identity/Identity.Api/Program.cs deleted file mode 100644 index 9fd4f554..00000000 --- a/src/Services/Identity/Identity.Api/Program.cs +++ /dev/null @@ -1,176 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Contracts.IntegrationEvents.Sagas; -using FluentValidation; -using Identity.Api.Extensions; -using Identity.Api.Middleware; -using Identity.Application.Options; -using Identity.Application.Sagas; -using Identity.Application.Security; -using Identity.Domain.Interfaces; -using Identity.Infrastructure.Persistence; -using Identity.Infrastructure.Repositories; -using Identity.Infrastructure.Security; -using Identity.Infrastructure.Security.Keycloak; -using Identity.Infrastructure.Security.Tenant; -using Keycloak.AuthServices.Sdk; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using SharedKernel.Api.Extensions; -using SharedKernel.Api.OutputCaching; -using SharedKernel.Application.Security; -using SharedKernel.Messaging.Conventions; -using SharedKernel.Messaging.Topology; -using Wolverine; -using Wolverine.EntityFrameworkCore; -using Wolverine.FluentValidation; -using Wolverine.Postgresql; -using Wolverine.RabbitMQ; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Host.UseSharedSerilog(); -builder.Services.AddSharedObservability(builder.Configuration, builder.Environment, "identity"); - -builder.Services.AddValidatedOptions( - builder.Configuration, - KeycloakOptions.SectionName -); -builder.Services.AddValidatedOptions(builder.Configuration, BffOptions.SectionName); -builder.Services.AddValidatedOptions( - builder.Configuration, - BootstrapTenantOptions.SectionName -); -builder.Services.AddValidatedOptions( - builder.Configuration, - SystemIdentityOptions.SectionName -); -builder.Services.AddValidatedOptions( - builder.Configuration, - InvitationOptions.SectionName -); - -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetRequiredConnectionString("IdentityDb")) -); - -builder.Services.AddScoped(sp => sp.GetRequiredService()); -builder.Services.AddSharedInfrastructure(builder.Configuration); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -builder.Services.AddSingleton(); -builder.Services.AddHttpClient(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddTransient(); -builder - .Services.AddOptions() - .Configure>( - (adminOpts, keycloakOpts) => - { - adminOpts.AuthServerUrl = keycloakOpts.Value.AuthServerUrl; - adminOpts.Realm = keycloakOpts.Value.Realm; - } - ); -builder - .Services.AddKeycloakAdminHttpClient(_ => { }) - .AddHttpMessageHandler(); - -builder - .Services.AddSharedKeycloakJwtBearer( - builder.Configuration, - builder.Environment, - requireTenantClaim: true, - options => - { - options.Events = new JwtBearerEvents - { - OnTokenValidated = TenantClaimValidator.OnTokenValidated, - }; - } - ) - .AddIdentityBffAuthentication(builder.Configuration, builder.Environment); - -builder.Services.AddSharedAuthorization( - [JwtBearerDefaults.AuthenticationScheme, AuthConstants.BffSchemes.Cookie], - enablePermissionPolicies: true -); - -builder.Services.AddValidatorsFromAssemblyContaining(); - -builder.Services.AddControllers(); -builder.Services.AddSharedOpenApiDocumentation(); -builder.Services.AddSharedOutputCaching(builder.Configuration); - -builder.Services.AddHealthChecks(); - -builder.Host.UseWolverine(opts => -{ - opts.ApplySharedConventions(); - opts.ApplySharedRetryPolicies(); - - opts.UseFluentValidation(); - - opts.Discovery.IncludeAssembly(typeof(IKeycloakAdminService).Assembly); - opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); - - opts.PersistMessagesWithPostgresql( - builder.Configuration.GetRequiredConnectionString("IdentityDb"), - "wolverine" - ); - opts.UseEntityFrameworkCoreTransactions(); - - opts.UseSharedRabbitMq(builder.Configuration); - - opts.PublishMessage() - .ToRabbitExchange( - RabbitMqTopology.Exchanges.Identity, - exchange => - { - exchange.ExchangeType = Wolverine.RabbitMQ.ExchangeType.Fanout; - exchange.IsDurable = true; - } - ); - opts.PublishMessage() - .ToRabbitExchange(RabbitMqTopology.Exchanges.Identity); - opts.PublishMessage() - .ToRabbitExchange(RabbitMqTopology.Exchanges.Identity); - opts.PublishMessage() - .ToRabbitExchange(RabbitMqTopology.Exchanges.Identity); - - // Handle the TenantDeactivated event for user cascade - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Identity.TenantDeactivated, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.Identity); - } - ); - - // Listen for saga completion messages routed back to this service - opts.ListenToRabbitQueue(RabbitMqTopology.Queues.Identity.UsersCascadeCompleted); - opts.ListenToRabbitQueue(RabbitMqTopology.Queues.Identity.ProductsCascadeCompleted); - opts.ListenToRabbitQueue(RabbitMqTopology.Queues.Identity.CategoriesCascadeCompleted); - - // Route completion messages to their designated queues so the saga receives them - opts.PublishMessage() - .ToRabbitQueue(RabbitMqTopology.Queues.Identity.UsersCascadeCompleted); -}); - -WebApplication app = builder.Build(); - -await app.MigrateDbAsync(); - -app.UseSharedExceptionHandlerAndAuthentication(); -app.UseMiddleware(); -app.UseSharedAuthorizationCachingAndInfrastructure(useOutputCaching: true); -app.MapControllers(); - -await app.RunAsync(); - -public partial class Program; diff --git a/src/Services/Identity/Identity.Api/Properties/launchSettings.json b/src/Services/Identity/Identity.Api/Properties/launchSettings.json deleted file mode 100644 index c71a2bfb..00000000 --- a/src/Services/Identity/Identity.Api/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5143", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7277;http://localhost:5143", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Services/Identity/Identity.Api/appsettings.json b/src/Services/Identity/Identity.Api/appsettings.json deleted file mode 100644 index 24180a1d..00000000 --- a/src/Services/Identity/Identity.Api/appsettings.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "WriteTo": [ - { "Name": "Console" } - ] - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "IdentityDb": "Host=localhost;Port=5432;Database=identity_db;Username=postgres;Password=postgres", - "RabbitMQ": "amqp://guest:guest@localhost:5672", - "Dragonfly": "localhost:6379" - }, - "Keycloak": { - "realm": "api-template", - "auth-server-url": "http://localhost:8180/", - "resource": "api-template", - "credentials": { - "secret": "change-me" - }, - "SkipReadinessCheck": true - }, - "Bff": { - "CookieName": ".Identity.Auth", - "PostLogoutRedirectUri": "/", - "SessionTimeoutMinutes": 60, - "TokenRefreshThresholdMinutes": 2 - }, - "BootstrapTenant": { - "Code": "default", - "Name": "Default Tenant" - }, - "SystemIdentity": { - "DefaultActorId": "00000000-0000-0000-0000-000000000000" - }, - "Invitation": { - "InvitationTokenExpiryHours": 72 - }, - "TransactionDefaults": { - "IsolationLevel": "ReadCommitted", - "TimeoutSeconds": 30, - "RetryEnabled": true, - "RetryCount": 3, - "RetryDelaySeconds": 5 - } -} diff --git a/src/Services/Identity/Identity.Application/Errors/DomainErrors.cs b/src/Services/Identity/Identity.Application/Errors/DomainErrors.cs deleted file mode 100644 index 9a1ff7a8..00000000 --- a/src/Services/Identity/Identity.Application/Errors/DomainErrors.cs +++ /dev/null @@ -1,90 +0,0 @@ -using ErrorOr; -using SharedDomainErrors = SharedKernel.Application.Errors.DomainErrors; - -namespace Identity.Application.Errors; - -/// -/// Factory methods producing instances for the Identity & Tenancy domain. -/// -public static class DomainErrors -{ - public static class Users - { - public static Error NotFound(Guid id) => - SharedDomainErrors.General.NotFound(IdentityErrorCatalog.Users.NotFound, "User", id); - - public static Error EmailAlreadyExists(string email) => - SharedDomainErrors.General.Conflict( - IdentityErrorCatalog.Users.EmailAlreadyExists, - $"Email '{email}' is already in use." - ); - - public static Error UsernameAlreadyExists(string username) => - SharedDomainErrors.General.Conflict( - IdentityErrorCatalog.Users.UsernameAlreadyExists, - $"Username '{username}' is already in use." - ); - } - - public static class Tenants - { - public static Error NotFound(Guid id) => - SharedDomainErrors.General.NotFound( - IdentityErrorCatalog.Tenants.NotFound, - "Tenant", - id - ); - - public static Error CodeAlreadyExists(string code) => - SharedDomainErrors.General.Conflict( - IdentityErrorCatalog.Tenants.CodeAlreadyExists, - string.Format(IdentityErrorCatalog.Tenants.CodeAlreadyExistsMessage, code) - ); - } - - public static class Invitations - { - public static Error NotFound(Guid id) => - SharedDomainErrors.General.NotFound( - IdentityErrorCatalog.Invitations.NotFound, - "Invitation", - id - ); - - public static Error AlreadyPending(string email) => - SharedDomainErrors.General.Conflict( - IdentityErrorCatalog.Invitations.AlreadyPending, - $"A pending invitation for '{email}' already exists." - ); - - public static Error Expired() => - SharedDomainErrors.General.Conflict( - IdentityErrorCatalog.Invitations.Expired, - IdentityErrorCatalog.Invitations.ExpiredMessage - ); - - public static Error ExpiredCreateNew() => - SharedDomainErrors.General.Conflict( - IdentityErrorCatalog.Invitations.Expired, - IdentityErrorCatalog.Invitations.ExpiredCreateNewMessage - ); - - public static Error AlreadyAccepted() => - SharedDomainErrors.General.Conflict( - IdentityErrorCatalog.Invitations.AlreadyAccepted, - IdentityErrorCatalog.Invitations.AlreadyAcceptedMessage - ); - - public static Error NotPending() => - SharedDomainErrors.General.Conflict( - IdentityErrorCatalog.Invitations.NotPending, - IdentityErrorCatalog.Invitations.NotPendingMessage - ); - - public static Error NotFoundOrExpired() => - Error.NotFound( - code: IdentityErrorCatalog.Invitations.NotFound, - description: IdentityErrorCatalog.Invitations.NotFoundOrExpiredMessage - ); - } -} diff --git a/src/Services/Identity/Identity.Application/Errors/IdentityErrorCatalog.cs b/src/Services/Identity/Identity.Application/Errors/IdentityErrorCatalog.cs deleted file mode 100644 index 33026d5a..00000000 --- a/src/Services/Identity/Identity.Application/Errors/IdentityErrorCatalog.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Identity.Application.Errors; - -/// -/// Structured error codes specific to the Identity & Tenancy domain. -/// -public static class IdentityErrorCatalog -{ - /// Error codes specific to the Users domain. - public static class Users - { - public const string NotFound = "USR-0404"; - public const string EmailAlreadyExists = "USR-0409-EMAIL"; - public const string UsernameAlreadyExists = "USR-0409-USERNAME"; - } - - /// Error codes specific to the Tenants domain. - public static class Tenants - { - public const string NotFound = "TNT-0404"; - public const string CodeAlreadyExists = "TNT-0409-CODE"; - public const string CodeAlreadyExistsMessage = "Tenant with code '{0}' already exists."; - } - - /// Error codes specific to the Invitations domain. - public static class Invitations - { - public const string NotFound = "INV-0404"; - public const string AlreadyPending = "INV-0409-PENDING"; - public const string Expired = "INV-0410"; - public const string AlreadyAccepted = "INV-0409-ACCEPTED"; - public const string NotPending = "INV-0409-NOT-PENDING"; - - public const string NotFoundOrExpiredMessage = "Invitation not found or expired."; - public const string ExpiredMessage = "Invitation has expired."; - public const string AlreadyAcceptedMessage = "Invitation has already been accepted."; - public const string NotPendingMessage = "Only pending invitations can be resent."; - public const string ExpiredCreateNewMessage = - "Invitation has expired. Create a new one instead."; - } -} diff --git a/src/Services/Identity/Identity.Application/EventHandlers/TenantDeactivatedEventHandler.cs b/src/Services/Identity/Identity.Application/EventHandlers/TenantDeactivatedEventHandler.cs deleted file mode 100644 index 5b6a06d0..00000000 --- a/src/Services/Identity/Identity.Application/EventHandlers/TenantDeactivatedEventHandler.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Contracts.IntegrationEvents.Sagas; -using Identity.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SharedKernel.Infrastructure.Persistence.SoftDelete; -using Wolverine; - -namespace Identity.Application.EventHandlers; - -/// -/// Handles by cascade soft-deleting all -/// active users belonging to the tenant, then publishing -/// so the TenantDeactivationSaga can track progress. -/// -public sealed class TenantDeactivatedEventHandler -{ - public static async Task HandleAsync( - TenantDeactivatedIntegrationEvent @event, - DbContext dbContext, - IMessageBus bus, - TimeProvider timeProvider, - ILogger logger, - CancellationToken ct - ) - { - DateTime now = timeProvider.GetUtcNow().UtcDateTime; - - int deactivatedCount = await dbContext - .Set() - .IgnoreQueryFilters() - .Where(u => u.TenantId == @event.TenantId && !u.IsDeleted) - .BulkSoftDeleteAsync(@event.ActorId, now, ct); - - // ExecuteUpdateAsync bypasses the change tracker; clear stale cached entities - // so any subsequent reads in the same unit of work see the updated state. - dbContext.ChangeTracker.Clear(); - - logger.LogInformation( - "Tenant {TenantId} deactivated: soft-deleted {UserCount} users", - @event.TenantId, - deactivatedCount - ); - - await bus.PublishAsync( - new UsersCascadeCompleted(@event.CorrelationId, @event.TenantId, deactivatedCount) - ); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/Bff/DTOs/BffUserResponse.cs b/src/Services/Identity/Identity.Application/Features/Bff/DTOs/BffUserResponse.cs deleted file mode 100644 index d8752454..00000000 --- a/src/Services/Identity/Identity.Application/Features/Bff/DTOs/BffUserResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Identity.Application.Features.Bff.DTOs; - -/// -/// Represents the authenticated user's identity and role information returned by the Backend-for-Frontend (BFF) user endpoint. -/// -public sealed record BffUserResponse( - string? UserId, - string? Username, - string? Email, - string? TenantId, - string[] Roles -); diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Commands/CreateTenantCommand.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Commands/CreateTenantCommand.cs deleted file mode 100644 index 00a9314b..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Commands/CreateTenantCommand.cs +++ /dev/null @@ -1,50 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Features.Tenant.DTOs; -using Identity.Application.Features.Tenant.Mappings; -using Identity.Domain.Interfaces; -using SharedKernel.Application.Common.Events; -using SharedKernel.Domain.Interfaces; -using Wolverine; -using TenantEntity = Identity.Domain.Entities.Tenant; - -namespace Identity.Application.Features.Tenant.Commands; - -public sealed record CreateTenantCommand(CreateTenantRequest Request); - -public sealed class CreateTenantCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - CreateTenantCommand command, - ITenantRepository repository, - IUnitOfWork unitOfWork, - CancellationToken ct - ) - { - if (await repository.CodeExistsAsync(command.Request.Code, ct)) - return ( - DomainErrors.Tenants.CodeAlreadyExists(command.Request.Code), - CacheInvalidationCascades.None - ); - - TenantEntity tenant = await unitOfWork.ExecuteInTransactionAsync( - async () => - { - Guid id = Guid.NewGuid(); - TenantEntity entity = new() - { - Id = id, - TenantId = id, - Code = command.Request.Code, - Name = command.Request.Name, - }; - - await repository.AddAsync(entity, ct); - return entity; - }, - ct - ); - - return (tenant.ToResponse(), CacheInvalidationCascades.ForTag(CacheTags.Tenants)); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Commands/DeleteTenantCommand.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Commands/DeleteTenantCommand.cs deleted file mode 100644 index dc723281..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Commands/DeleteTenantCommand.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Contracts.IntegrationEvents.Sagas; -using ErrorOr; -using Identity.Application.Errors; -using Identity.Domain.Interfaces; -using Microsoft.Extensions.Logging; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using SharedKernel.Application.Extensions; -using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace Identity.Application.Features.Tenant.Commands; - -public sealed record DeleteTenantCommand(Guid Id) : IHasId; - -public sealed class DeleteTenantCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - DeleteTenantCommand command, - ITenantRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - IActorProvider actorProvider, - ILogger logger, - CancellationToken ct - ) - { - ErrorOr tenantResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Tenants.NotFound(command.Id), - ct - ); - if (tenantResult.IsError) - return (tenantResult.Errors, CacheInvalidationCascades.None); - - Guid correlationId = Guid.NewGuid(); - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.DeleteAsync(tenantResult.Value, ct); - await bus.PublishAsync( - new StartTenantDeactivationSaga( - correlationId, - command.Id, - actorProvider.ActorId - ) - ); - }, - ct - ); - - return ( - Result.Success, - CacheInvalidationCascades.ForTags( - CacheTags.Tenants, - CacheTags.Users, - CacheTags.TenantInvitations - ) - ); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/DTOs/CreateTenantRequest.cs b/src/Services/Identity/Identity.Application/Features/Tenant/DTOs/CreateTenantRequest.cs deleted file mode 100644 index d51b2e3f..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/DTOs/CreateTenantRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Identity.Application.Features.Tenant.DTOs; - -/// -/// Represents the request payload for creating a new tenant. -/// -public sealed record CreateTenantRequest( - [Required, MaxLength(100)] string Code, - [Required, MaxLength(200)] string Name -); diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/DTOs/TenantFilter.cs b/src/Services/Identity/Identity.Application/Features/Tenant/DTOs/TenantFilter.cs deleted file mode 100644 index c7fb0855..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/DTOs/TenantFilter.cs +++ /dev/null @@ -1,15 +0,0 @@ -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; - -namespace Identity.Application.Features.Tenant.DTOs; - -/// -/// Pagination and filtering parameters for querying tenants, including optional full-text search and sorting. -/// -public sealed record TenantFilter( - string? Query = null, - string? SortBy = null, - string? SortDirection = null, - int PageNumber = 1, - int PageSize = PaginationFilter.DefaultPageSize -) : PaginationFilter(PageNumber, PageSize), ISortableFilter; diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/DTOs/TenantResponse.cs b/src/Services/Identity/Identity.Application/Features/Tenant/DTOs/TenantResponse.cs deleted file mode 100644 index 12cd96b8..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/DTOs/TenantResponse.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace Identity.Application.Features.Tenant.DTOs; - -/// -/// Read model returned to callers after a tenant query or creation. -/// -public sealed record TenantResponse( - Guid Id, - string Code, - string Name, - bool IsActive, - DateTime CreatedAtUtc -) : IHasId; diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Mappings/TenantMappings.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Mappings/TenantMappings.cs deleted file mode 100644 index f9f868c9..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Mappings/TenantMappings.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Linq.Expressions; -using Identity.Application.Features.Tenant.DTOs; -using TenantEntity = Identity.Domain.Entities.Tenant; - -namespace Identity.Application.Features.Tenant.Mappings; - -/// -/// Provides LINQ-compatible projection expressions and in-process mapping helpers for Tenant entities. -/// -public static class TenantMappings -{ - /// - /// Expression tree used by EF Core to project a Tenant entity directly to a in the database query. - /// - public static readonly Expression> Projection = - tenant => new TenantResponse( - tenant.Id, - tenant.Code, - tenant.Name, - tenant.IsActive, - tenant.Audit.CreatedAtUtc - ); - - private static readonly Func CompiledProjection = - Projection.Compile(); - - /// - /// Maps a Tenant entity to a using the pre-compiled projection. - /// - public static TenantResponse ToResponse(this TenantEntity tenant) => CompiledProjection(tenant); -} diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs deleted file mode 100644 index eace355b..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs +++ /dev/null @@ -1,29 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Features.Tenant.DTOs; -using Identity.Application.Features.Tenant.Specifications; -using Identity.Domain.Interfaces; -using SharedKernel.Domain.Entities.Contracts; - -namespace Identity.Application.Features.Tenant.Queries; - -public sealed record GetTenantByIdQuery(Guid Id) : IHasId; - -public sealed class GetTenantByIdQueryHandler -{ - public static async Task> HandleAsync( - GetTenantByIdQuery request, - ITenantRepository repository, - CancellationToken ct - ) - { - TenantResponse? result = await repository.FirstOrDefaultAsync( - new TenantByIdSpecification(request.Id), - ct - ); - if (result is null) - return DomainErrors.Tenants.NotFound(request.Id); - - return result; - } -} diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Queries/GetTenantsQuery.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Queries/GetTenantsQuery.cs deleted file mode 100644 index 019d77d4..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Queries/GetTenantsQuery.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ErrorOr; -using Identity.Application.Features.Tenant.DTOs; -using Identity.Application.Features.Tenant.Specifications; -using Identity.Domain.Interfaces; -using SharedKernel.Domain.Common; - -namespace Identity.Application.Features.Tenant.Queries; - -public sealed record GetTenantsQuery(TenantFilter Filter); - -public sealed class GetTenantsQueryHandler -{ - public static async Task>> HandleAsync( - GetTenantsQuery request, - ITenantRepository repository, - CancellationToken ct - ) - { - return await repository.GetPagedAsync( - new TenantSpecification(request.Filter), - request.Filter.PageNumber, - request.Filter.PageSize, - ct - ); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs deleted file mode 100644 index 7caf7cde..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Ardalis.Specification; -using Identity.Application.Features.Tenant.DTOs; -using Identity.Application.Features.Tenant.Mappings; -using TenantEntity = Identity.Domain.Entities.Tenant; - -namespace Identity.Application.Features.Tenant.Specifications; - -/// -/// Ardalis specification that fetches a single tenant by ID and projects it to . -/// -public sealed class TenantByIdSpecification : Specification -{ - public TenantByIdSpecification(Guid id) - { - Query.Where(tenant => tenant.Id == id).AsNoTracking().Select(TenantMappings.Projection); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs deleted file mode 100644 index 34fa725d..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Ardalis.Specification; -using Identity.Application.Features.Tenant.DTOs; -using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Search; -using TenantEntity = Identity.Domain.Entities.Tenant; - -namespace Identity.Application.Features.Tenant.Specifications; - -/// -/// Internal extension that applies shared criteria to an Ardalis specification builder. -/// -internal static class TenantFilterCriteria -{ - /// - /// Adds a PostgreSQL full-text search predicate on Code and Name when is provided. - /// - internal static void ApplyFilter( - this ISpecificationBuilder query, - TenantFilter filter - ) - { - if (string.IsNullOrWhiteSpace(filter.Query)) - return; - - query.Where(tenant => - EF.Functions.ToTsVector( - SearchDefaults.TextSearchConfiguration, - tenant.Code + " " + tenant.Name - ) - .Matches( - EF.Functions.WebSearchToTsQuery( - SearchDefaults.TextSearchConfiguration, - filter.Query - ) - ) - ); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Specifications/TenantSpecification.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Specifications/TenantSpecification.cs deleted file mode 100644 index 56619512..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Specifications/TenantSpecification.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Ardalis.Specification; -using Identity.Application.Features.Tenant.DTOs; -using Identity.Application.Features.Tenant.Mappings; -using TenantEntity = Identity.Domain.Entities.Tenant; - -namespace Identity.Application.Features.Tenant.Specifications; - -/// -/// Ardalis specification that retrieves a filtered and sorted list of tenants projected to . -/// -public sealed class TenantSpecification : Specification -{ - public TenantSpecification(TenantFilter filter) - { - Query.ApplyFilter(filter); - Query.AsNoTracking(); - TenantSortFields.Map.ApplySort(Query, filter.SortBy, filter.SortDirection); - Query.Select(TenantMappings.Projection); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/TenantSortFields.cs b/src/Services/Identity/Identity.Application/Features/Tenant/TenantSortFields.cs deleted file mode 100644 index 116e9318..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/TenantSortFields.cs +++ /dev/null @@ -1,20 +0,0 @@ -using SharedKernel.Application.Sorting; -using TenantEntity = Identity.Domain.Entities.Tenant; - -namespace Identity.Application.Features.Tenant; - -/// -/// Defines the sortable fields available for tenant queries and maps them to entity property expressions. -/// -public static class TenantSortFields -{ - public static readonly SortField Code = new("code"); - public static readonly SortField Name = new("name"); - public static readonly SortField CreatedAt = new("createdAt"); - - public static readonly SortFieldMap Map = new SortFieldMap() - .Add(Code, t => t.Code) - .Add(Name, t => t.Name) - .Add(CreatedAt, t => t.Audit.CreatedAtUtc) - .Default(t => t.Audit.CreatedAtUtc); -} diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs deleted file mode 100644 index 1eee143a..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Identity.Application.Features.Tenant.DTOs; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.Tenant.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class CreateTenantRequestValidator : DataAnnotationsValidator; diff --git a/src/Services/Identity/Identity.Application/Features/Tenant/Validation/TenantFilterValidator.cs b/src/Services/Identity/Identity.Application/Features/Tenant/Validation/TenantFilterValidator.cs deleted file mode 100644 index 11029810..00000000 --- a/src/Services/Identity/Identity.Application/Features/Tenant/Validation/TenantFilterValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentValidation; -using Identity.Application.Features.Tenant.DTOs; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.Tenant.Validation; - -/// -/// FluentValidation validator for that composes pagination and sort-field rules. -/// -public sealed class TenantFilterValidator : AbstractValidator -{ - /// - /// Registers pagination and sortable-field validation rules by including shared sub-validators. - /// - public TenantFilterValidator() - { - Include(new PaginationFilterValidator()); - Include(new SortableFilterValidator(TenantSortFields.Map.AllowedNames)); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs deleted file mode 100644 index 93d3d259..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Security; -using Identity.Domain.Enums; -using Identity.Domain.Interfaces; -using SharedKernel.Domain.Interfaces; - -namespace Identity.Application.Features.TenantInvitation.Commands; - -public sealed record AcceptTenantInvitationCommand(string Token); - -public sealed class AcceptTenantInvitationCommandHandler -{ - public static async Task> HandleAsync( - AcceptTenantInvitationCommand command, - ITenantInvitationRepository invitationRepository, - IUnitOfWork unitOfWork, - ISecureTokenGenerator tokenGenerator, - TimeProvider timeProvider, - CancellationToken ct - ) - { - string tokenHash = tokenGenerator.HashToken(command.Token); - Domain.Entities.TenantInvitation? invitation = - await invitationRepository.GetValidByTokenHashAsync(tokenHash, ct); - - if (invitation is null) - return DomainErrors.Invitations.NotFoundOrExpired(); - - DateTime now = timeProvider.GetUtcNow().UtcDateTime; - - if (invitation.ExpiresAtUtc < now) - return DomainErrors.Invitations.Expired(); - - if (invitation.Status == InvitationStatus.Accepted) - return DomainErrors.Invitations.AlreadyAccepted(); - - invitation.Status = InvitationStatus.Accepted; - await invitationRepository.UpdateAsync(invitation, ct); - await unitOfWork.CommitAsync(ct); - - return Result.Success; - } -} diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs deleted file mode 100644 index 93a8a76c..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Features.TenantInvitation.DTOs; -using Identity.Application.Features.TenantInvitation.Mappings; -using Identity.Application.Options; -using Identity.Application.Security; -using Identity.Domain.Entities; -using Identity.Domain.Interfaces; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using SharedKernel.Application.Extensions; -using SharedKernel.Domain.Interfaces; -using Wolverine; -using TenantInvitationEntity = Identity.Domain.Entities.TenantInvitation; - -namespace Identity.Application.Features.TenantInvitation.Commands; - -public sealed record CreateTenantInvitationCommand(CreateTenantInvitationRequest Request); - -public sealed class CreateTenantInvitationCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - CreateTenantInvitationCommand command, - ITenantInvitationRepository invitationRepository, - ITenantRepository tenantRepository, - IUnitOfWork unitOfWork, - ISecureTokenGenerator tokenGenerator, - IMessageBus bus, - ITenantProvider tenantProvider, - TimeProvider timeProvider, - IOptions invitationOptions, - ILogger logger, - CancellationToken ct - ) - { - InvitationOptions opts = invitationOptions.Value; - string normalizedEmail = AppUser.NormalizeEmail(command.Request.Email); - - if (await invitationRepository.HasPendingInvitationAsync(normalizedEmail, ct)) - return ( - DomainErrors.Invitations.AlreadyPending(command.Request.Email), - CacheInvalidationCascades.None - ); - - ErrorOr tenantResult = await tenantRepository.GetByIdOrError( - tenantProvider.TenantId, - DomainErrors.Tenants.NotFound(tenantProvider.TenantId), - ct - ); - if (tenantResult.IsError) - return (tenantResult.Errors, CacheInvalidationCascades.None); - Domain.Entities.Tenant tenant = tenantResult.Value; - - string rawToken = tokenGenerator.GenerateToken(); - string tokenHash = tokenGenerator.HashToken(rawToken); - - TenantInvitationEntity invitation = new() - { - Id = Guid.NewGuid(), - Email = command.Request.Email.Trim(), - NormalizedEmail = normalizedEmail, - TokenHash = tokenHash, - ExpiresAtUtc = timeProvider - .GetUtcNow() - .UtcDateTime.AddHours(opts.InvitationTokenExpiryHours), - }; - - await invitationRepository.AddAsync(invitation, ct); - await unitOfWork.CommitAsync(ct); - - try - { - await bus.PublishAsync( - new TenantInvitationCreatedIntegrationEvent( - invitation.Id, - invitation.Email, - tenant.Name, - rawToken, - timeProvider.GetUtcNow().UtcDateTime - ) - ); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogWarning( - ex, - "Failed to publish {EventType}.", - nameof(TenantInvitationCreatedIntegrationEvent) - ); - } - - return ( - invitation.ToResponse(), - CacheInvalidationCascades.ForTag(CacheTags.TenantInvitations) - ); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs deleted file mode 100644 index da9888f9..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Security; -using Identity.Domain.Enums; -using Identity.Domain.Interfaces; -using Microsoft.Extensions.Logging; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using SharedKernel.Application.Extensions; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace Identity.Application.Features.TenantInvitation.Commands; - -public sealed record ResendTenantInvitationCommand(Guid InvitationId); - -public sealed class ResendTenantInvitationCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - ResendTenantInvitationCommand command, - ITenantInvitationRepository invitationRepository, - ITenantRepository tenantRepository, - IUnitOfWork unitOfWork, - ISecureTokenGenerator tokenGenerator, - IMessageBus bus, - ITenantProvider tenantProvider, - TimeProvider timeProvider, - ILogger logger, - CancellationToken ct - ) - { - ErrorOr invitationResult = - await invitationRepository.GetByIdOrError( - command.InvitationId, - DomainErrors.Invitations.NotFound(command.InvitationId), - ct - ); - if (invitationResult.IsError) - return (invitationResult.Errors, CacheInvalidationCascades.None); - Domain.Entities.TenantInvitation invitation = invitationResult.Value; - - if (invitation.Status != InvitationStatus.Pending) - return (DomainErrors.Invitations.NotPending(), CacheInvalidationCascades.None); - - DateTime now = timeProvider.GetUtcNow().UtcDateTime; - if (invitation.ExpiresAtUtc < now) - return (DomainErrors.Invitations.ExpiredCreateNew(), CacheInvalidationCascades.None); - - ErrorOr tenantResult = await tenantRepository.GetByIdOrError( - tenantProvider.TenantId, - DomainErrors.Tenants.NotFound(tenantProvider.TenantId), - ct - ); - if (tenantResult.IsError) - return (tenantResult.Errors, CacheInvalidationCascades.None); - Domain.Entities.Tenant tenant = tenantResult.Value; - - string rawToken = tokenGenerator.GenerateToken(); - invitation.TokenHash = tokenGenerator.HashToken(rawToken); - - await invitationRepository.UpdateAsync(invitation, ct); - await unitOfWork.CommitAsync(ct); - - try - { - await bus.PublishAsync( - new TenantInvitationCreatedIntegrationEvent( - invitation.Id, - invitation.Email, - tenant.Name, - rawToken, - timeProvider.GetUtcNow().UtcDateTime - ) - ); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogWarning( - ex, - "Failed to publish {EventType}.", - nameof(TenantInvitationCreatedIntegrationEvent) - ); - } - - return (Result.Success, CacheInvalidationCascades.ForTag(CacheTags.TenantInvitations)); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs deleted file mode 100644 index d58e90ea..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs +++ /dev/null @@ -1,38 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Domain.Enums; -using Identity.Domain.Interfaces; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Extensions; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace Identity.Application.Features.TenantInvitation.Commands; - -public sealed record RevokeTenantInvitationCommand(Guid InvitationId); - -public sealed class RevokeTenantInvitationCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - RevokeTenantInvitationCommand command, - ITenantInvitationRepository invitationRepository, - IUnitOfWork unitOfWork, - CancellationToken ct - ) - { - ErrorOr invitationResult = - await invitationRepository.GetByIdOrError( - command.InvitationId, - DomainErrors.Invitations.NotFound(command.InvitationId), - ct - ); - if (invitationResult.IsError) - return (invitationResult.Errors, CacheInvalidationCascades.None); - Domain.Entities.TenantInvitation invitation = invitationResult.Value; - - invitation.Status = InvitationStatus.Revoked; - await invitationRepository.UpdateAsync(invitation, ct); - await unitOfWork.CommitAsync(ct); - return (Result.Success, CacheInvalidationCascades.ForTag(CacheTags.TenantInvitations)); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs deleted file mode 100644 index 6f8b8803..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.TenantInvitation.DTOs; - -/// -/// Represents the request payload for accepting a tenant invitation using a secure token. -/// -public sealed record AcceptInvitationRequest([NotEmpty] string Token); diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs deleted file mode 100644 index 00b0f85b..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.TenantInvitation.DTOs; - -/// -/// Represents the request payload for inviting a user to the current tenant by email address. -/// -public sealed record CreateTenantInvitationRequest( - [NotEmpty] [MaxLength(320)] [EmailAddress] string Email -); diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs deleted file mode 100644 index 6fea2c48..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Identity.Domain.Enums; -using SharedKernel.Application.DTOs; - -namespace Identity.Application.Features.TenantInvitation.DTOs; - -/// -/// Pagination and filtering parameters for querying tenant invitations, supporting optional email and status filters. -/// -public sealed record TenantInvitationFilter( - string? Email = null, - InvitationStatus? Status = null, - int PageNumber = 1, - int PageSize = PaginationFilter.DefaultPageSize -) : PaginationFilter(PageNumber, PageSize); diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs deleted file mode 100644 index 7af9bdf3..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Identity.Domain.Enums; -using SharedKernel.Domain.Entities.Contracts; - -namespace Identity.Application.Features.TenantInvitation.DTOs; - -/// -/// Read model returned to callers for tenant invitation queries. -/// -public sealed record TenantInvitationResponse( - Guid Id, - string Email, - InvitationStatus Status, - DateTime ExpiresAtUtc, - DateTime CreatedAtUtc -) : IHasId; diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs deleted file mode 100644 index 07dd5081..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Linq.Expressions; -using Identity.Application.Features.TenantInvitation.DTOs; -using TenantInvitationEntity = Identity.Domain.Entities.TenantInvitation; - -namespace Identity.Application.Features.TenantInvitation.Mappings; - -/// -/// Provides LINQ-compatible projection expressions and in-process mapping helpers for TenantInvitation entities. -/// -public static class TenantInvitationMappings -{ - /// - /// Expression tree used by EF Core to project a TenantInvitation entity directly to a in the database query. - /// - public static readonly Expression< - Func - > Projection = i => new TenantInvitationResponse( - i.Id, - i.Email, - i.Status, - i.ExpiresAtUtc, - i.Audit.CreatedAtUtc - ); - - private static readonly Func< - TenantInvitationEntity, - TenantInvitationResponse - > CompiledProjection = Projection.Compile(); - - /// - /// Maps a TenantInvitation entity to a using the pre-compiled projection. - /// - public static TenantInvitationResponse ToResponse(this TenantInvitationEntity invitation) => - CompiledProjection(invitation); -} diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs deleted file mode 100644 index 1387baca..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ErrorOr; -using Identity.Application.Features.TenantInvitation.DTOs; -using Identity.Application.Features.TenantInvitation.Specifications; -using Identity.Domain.Interfaces; -using SharedKernel.Domain.Common; - -namespace Identity.Application.Features.TenantInvitation.Queries; - -public sealed record GetTenantInvitationsQuery(TenantInvitationFilter Filter); - -public sealed class GetTenantInvitationsQueryHandler -{ - public static async Task>> HandleAsync( - GetTenantInvitationsQuery request, - ITenantInvitationRepository invitationRepository, - CancellationToken ct - ) - { - return await invitationRepository.GetPagedAsync( - new TenantInvitationFilterSpecification(request.Filter), - request.Filter.PageNumber, - request.Filter.PageSize, - ct - ); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs deleted file mode 100644 index ac762df9..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Ardalis.Specification; -using Identity.Application.Features.TenantInvitation.DTOs; -using Identity.Application.Features.TenantInvitation.Mappings; -using Identity.Domain.Entities; -using TenantInvitationEntity = Identity.Domain.Entities.TenantInvitation; - -namespace Identity.Application.Features.TenantInvitation.Specifications; - -/// -/// Ardalis specification that retrieves a filtered list of tenant invitations projected to . -/// -public sealed class TenantInvitationFilterSpecification - : Specification -{ - public TenantInvitationFilterSpecification(TenantInvitationFilter filter) - { - Query.ApplyFilter(filter); - Query.AsNoTracking(); - Query.OrderByDescending(i => i.Audit.CreatedAtUtc); - Query.Select(TenantInvitationMappings.Projection); - } -} - -/// -/// Internal extension that applies shared criteria to an Ardalis specification builder. -/// -internal static class TenantInvitationFilterCriteria -{ - public static void ApplyFilter( - this ISpecificationBuilder query, - TenantInvitationFilter filter - ) - { - if (!string.IsNullOrWhiteSpace(filter.Email)) - { - string normalized = AppUser.NormalizeEmail(filter.Email); - query.Where(i => i.NormalizedEmail.Contains(normalized)); - } - - if (filter.Status.HasValue) - query.Where(i => i.Status == filter.Status.Value); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs b/src/Services/Identity/Identity.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs deleted file mode 100644 index f8c3ca75..00000000 --- a/src/Services/Identity/Identity.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Identity.Application.Features.TenantInvitation.DTOs; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.TenantInvitation.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class CreateTenantInvitationRequestValidator - : DataAnnotationsValidator; diff --git a/src/Services/Identity/Identity.Application/Features/User/Commands/ChangeUserRoleCommand.cs b/src/Services/Identity/Identity.Application/Features/User/Commands/ChangeUserRoleCommand.cs deleted file mode 100644 index 9a840405..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Commands/ChangeUserRoleCommand.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Features.User.DTOs; -using Identity.Domain.Interfaces; -using Microsoft.Extensions.Logging; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using SharedKernel.Application.Extensions; -using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace Identity.Application.Features.User.Commands; - -public sealed record ChangeUserRoleCommand(Guid Id, ChangeUserRoleRequest Request) : IHasId; - -public sealed class ChangeUserRoleCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - ChangeUserRoleCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - ITenantProvider tenantProvider, - TimeProvider timeProvider, - ILogger logger, - CancellationToken ct - ) - { - ErrorOr userResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Users.NotFound(command.Id), - ct - ); - if (userResult.IsError) - return (userResult.Errors, CacheInvalidationCascades.None); - Domain.Entities.AppUser user = userResult.Value; - - string oldRole = user.Role.ToString(); - - user.Role = command.Request.Role; - await repository.UpdateAsync(user, ct); - await unitOfWork.CommitAsync(ct); - - try - { - await bus.PublishAsync( - new UserRoleChangedIntegrationEvent( - user.Id, - tenantProvider.TenantId, - user.Email, - user.Username, - oldRole, - command.Request.Role.ToString(), - timeProvider.GetUtcNow().UtcDateTime - ) - ); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogWarning( - ex, - "Failed to publish {EventType}.", - nameof(UserRoleChangedIntegrationEvent) - ); - } - - return (Result.Success, CacheInvalidationCascades.ForTag(CacheTags.Users)); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Commands/CreateUserCommand.cs b/src/Services/Identity/Identity.Application/Features/User/Commands/CreateUserCommand.cs deleted file mode 100644 index 6cd96c22..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Commands/CreateUserCommand.cs +++ /dev/null @@ -1,113 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Features.User.DTOs; -using Identity.Application.Features.User.Mappings; -using Identity.Application.Security; -using Identity.Domain.Entities; -using Identity.Domain.Interfaces; -using Microsoft.Extensions.Logging; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace Identity.Application.Features.User.Commands; - -public sealed record CreateUserCommand(CreateUserRequest Request); - -public sealed class CreateUserCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - CreateUserCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - ILogger logger, - IKeycloakAdminService keycloakAdmin, - ITenantProvider tenantProvider, - TimeProvider timeProvider, - CancellationToken ct - ) - { - ErrorOr emailResult = await UserValidationHelper.ValidateEmailUniqueAsync( - repository, - command.Request.Email, - ct - ); - if (emailResult.IsError) - return (emailResult.Errors, CacheInvalidationCascades.None); - - ErrorOr usernameResult = await UserValidationHelper.ValidateUsernameUniqueAsync( - repository, - command.Request.Username, - ct - ); - if (usernameResult.IsError) - return (usernameResult.Errors, CacheInvalidationCascades.None); - - string keycloakUserId = await keycloakAdmin.CreateUserAsync( - command.Request.Username, - command.Request.Email, - ct - ); - - try - { - AppUser user = new() - { - Id = Guid.NewGuid(), - Username = command.Request.Username, - Email = command.Request.Email, - KeycloakUserId = keycloakUserId, - }; - - await repository.AddAsync(user, ct); - await unitOfWork.CommitAsync(ct); - - try - { - await bus.PublishAsync( - new UserRegisteredIntegrationEvent( - user.Id, - tenantProvider.TenantId, - user.Email, - user.Username, - timeProvider.GetUtcNow().UtcDateTime - ) - ); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogWarning( - ex, - "Failed to publish {EventType}.", - nameof(UserRegisteredIntegrationEvent) - ); - } - - return (user.ToResponse(), CacheInvalidationCascades.ForTag(CacheTags.Users)); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError( - ex, - "DB save failed after creating Keycloak user {KeycloakUserId}. Attempting compensating delete.", - keycloakUserId - ); - try - { - await keycloakAdmin.DeleteUserAsync(keycloakUserId, CancellationToken.None); - } - catch (Exception compensationEx) - { - logger.LogError( - compensationEx, - "Compensating Keycloak delete failed for user {KeycloakUserId}. Manual cleanup required.", - keycloakUserId - ); - } - throw; - } - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Commands/DeleteUserCommand.cs b/src/Services/Identity/Identity.Application/Features/User/Commands/DeleteUserCommand.cs deleted file mode 100644 index 10d12590..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Commands/DeleteUserCommand.cs +++ /dev/null @@ -1,56 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Security; -using Identity.Domain.Interfaces; -using Microsoft.Extensions.Logging; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Extensions; -using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace Identity.Application.Features.User.Commands; - -public sealed record DeleteUserCommand(Guid Id) : IHasId; - -public sealed class DeleteUserCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - DeleteUserCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - IKeycloakAdminService keycloakAdmin, - ILogger logger, - CancellationToken ct - ) - { - ErrorOr userResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Users.NotFound(command.Id), - ct - ); - if (userResult.IsError) - return (userResult.Errors, CacheInvalidationCascades.None); - Domain.Entities.AppUser user = userResult.Value; - - if (user.KeycloakUserId is not null) - await keycloakAdmin.DeleteUserAsync(user.KeycloakUserId, ct); - - try - { - await repository.DeleteAsync(user, ct); - await unitOfWork.CommitAsync(ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogCritical( - ex, - "DB delete failed after Keycloak user {KeycloakUserId} was already deleted. Manual cleanup required.", - user.KeycloakUserId - ); - throw; - } - - return (Result.Success, CacheInvalidationCascades.ForTag(CacheTags.Users)); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs b/src/Services/Identity/Identity.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs deleted file mode 100644 index 42431a34..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ErrorOr; -using Identity.Application.Features.User.DTOs; -using Identity.Application.Security; -using Identity.Domain.Interfaces; -using Microsoft.Extensions.Logging; - -namespace Identity.Application.Features.User.Commands; - -public sealed record KeycloakPasswordResetCommand(RequestPasswordResetRequest Request); - -public sealed class KeycloakPasswordResetCommandHandler -{ - public static async Task> HandleAsync( - KeycloakPasswordResetCommand command, - IUserRepository repository, - IKeycloakAdminService keycloakAdmin, - ILogger logger, - CancellationToken ct - ) - { - Domain.Entities.AppUser? user = await repository.FindByEmailAsync( - command.Request.Email, - ct - ); - - if (user is null || user.KeycloakUserId is null) - return Result.Success; - - try - { - await keycloakAdmin.SendPasswordResetEmailAsync(user.KeycloakUserId, ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogWarning( - ex, - "Failed to send password reset email for user {UserId}.", - user.Id - ); - } - - return Result.Success; - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Commands/SetUserActiveCommand.cs b/src/Services/Identity/Identity.Application/Features/User/Commands/SetUserActiveCommand.cs deleted file mode 100644 index 40343da1..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Commands/SetUserActiveCommand.cs +++ /dev/null @@ -1,42 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Security; -using Identity.Domain.Interfaces; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Extensions; -using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace Identity.Application.Features.User.Commands; - -public sealed record SetUserActiveCommand(Guid Id, bool IsActive) : IHasId; - -public sealed class SetUserActiveCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - SetUserActiveCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - IKeycloakAdminService keycloakAdmin, - CancellationToken ct - ) - { - ErrorOr userResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Users.NotFound(command.Id), - ct - ); - if (userResult.IsError) - return (userResult.Errors, CacheInvalidationCascades.None); - Domain.Entities.AppUser user = userResult.Value; - - if (user.KeycloakUserId is not null) - await keycloakAdmin.SetUserEnabledAsync(user.KeycloakUserId, command.IsActive, ct); - - user.IsActive = command.IsActive; - await repository.UpdateAsync(user, ct); - await unitOfWork.CommitAsync(ct); - return (Result.Success, CacheInvalidationCascades.ForTag(CacheTags.Users)); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Commands/UpdateUserCommand.cs b/src/Services/Identity/Identity.Application/Features/User/Commands/UpdateUserCommand.cs deleted file mode 100644 index 84bcc814..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Commands/UpdateUserCommand.cs +++ /dev/null @@ -1,65 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Features.User.DTOs; -using Identity.Domain.Entities; -using Identity.Domain.Interfaces; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Extensions; -using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace Identity.Application.Features.User.Commands; - -public sealed record UpdateUserCommand(Guid Id, UpdateUserRequest Request) : IHasId; - -public sealed class UpdateUserCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - UpdateUserCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - CancellationToken ct - ) - { - ErrorOr userResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Users.NotFound(command.Id), - ct - ); - if (userResult.IsError) - return (userResult.Errors, CacheInvalidationCascades.None); - AppUser user = userResult.Value; - - if (!string.Equals(user.Email, command.Request.Email, StringComparison.OrdinalIgnoreCase)) - { - ErrorOr emailResult = await UserValidationHelper.ValidateEmailUniqueAsync( - repository, - command.Request.Email, - ct - ); - if (emailResult.IsError) - return (emailResult.Errors, CacheInvalidationCascades.None); - } - - string normalizedNew = AppUser.NormalizeUsername(command.Request.Username); - if (!string.Equals(user.NormalizedUsername, normalizedNew, StringComparison.Ordinal)) - { - ErrorOr usernameResult = - await UserValidationHelper.ValidateUsernameUniqueAsync( - repository, - command.Request.Username, - ct - ); - if (usernameResult.IsError) - return (usernameResult.Errors, CacheInvalidationCascades.None); - } - - user.Username = command.Request.Username; - user.Email = command.Request.Email; - - await repository.UpdateAsync(user, ct); - await unitOfWork.CommitAsync(ct); - return (Result.Success, CacheInvalidationCascades.ForTag(CacheTags.Users)); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/DTOs/ChangeUserRoleRequest.cs b/src/Services/Identity/Identity.Application/Features/User/DTOs/ChangeUserRoleRequest.cs deleted file mode 100644 index dee9b268..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/DTOs/ChangeUserRoleRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Identity.Domain.Enums; - -namespace Identity.Application.Features.User.DTOs; - -/// -/// Represents the request payload for changing a user's role. -/// -public sealed record ChangeUserRoleRequest(UserRole Role); diff --git a/src/Services/Identity/Identity.Application/Features/User/DTOs/CreateUserRequest.cs b/src/Services/Identity/Identity.Application/Features/User/DTOs/CreateUserRequest.cs deleted file mode 100644 index dda17c84..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/DTOs/CreateUserRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.User.DTOs; - -/// -/// Represents the request payload for creating a new user account. -/// -public sealed record CreateUserRequest( - [NotEmpty] [MaxLength(100)] string Username, - [NotEmpty] [MaxLength(320)] [EmailAddress] string Email -); diff --git a/src/Services/Identity/Identity.Application/Features/User/DTOs/RequestPasswordResetRequest.cs b/src/Services/Identity/Identity.Application/Features/User/DTOs/RequestPasswordResetRequest.cs deleted file mode 100644 index c3f3341a..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/DTOs/RequestPasswordResetRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.User.DTOs; - -/// -/// Represents the request payload for triggering a Keycloak password-reset email for the given email address. -/// -public sealed record RequestPasswordResetRequest( - [NotEmpty] [MaxLength(320)] [EmailAddress] string Email -); diff --git a/src/Services/Identity/Identity.Application/Features/User/DTOs/UpdateUserRequest.cs b/src/Services/Identity/Identity.Application/Features/User/DTOs/UpdateUserRequest.cs deleted file mode 100644 index fbb51d10..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/DTOs/UpdateUserRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.User.DTOs; - -/// -/// Represents the request payload for updating an existing user's username and email. -/// -public sealed record UpdateUserRequest( - [NotEmpty] [MaxLength(100)] string Username, - [NotEmpty] [MaxLength(320)] [EmailAddress] string Email -); diff --git a/src/Services/Identity/Identity.Application/Features/User/DTOs/UserFilter.cs b/src/Services/Identity/Identity.Application/Features/User/DTOs/UserFilter.cs deleted file mode 100644 index abca61d5..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/DTOs/UserFilter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Identity.Domain.Enums; -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; - -namespace Identity.Application.Features.User.DTOs; - -/// -/// Pagination and filtering parameters for querying users, with optional username, email, active-status, role, and sort fields. -/// -public sealed record UserFilter( - string? Username = null, - string? Email = null, - bool? IsActive = null, - UserRole? Role = null, - string? SortBy = null, - string? SortDirection = null, - int PageNumber = 1, - int PageSize = PaginationFilter.DefaultPageSize -) : PaginationFilter(PageNumber, PageSize), ISortableFilter; diff --git a/src/Services/Identity/Identity.Application/Features/User/DTOs/UserResponse.cs b/src/Services/Identity/Identity.Application/Features/User/DTOs/UserResponse.cs deleted file mode 100644 index 26bfa919..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/DTOs/UserResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Identity.Domain.Enums; -using SharedKernel.Domain.Entities.Contracts; - -namespace Identity.Application.Features.User.DTOs; - -/// -/// Read model returned to callers after a user query or creation. -/// -public sealed record UserResponse( - Guid Id, - string Username, - string Email, - bool IsActive, - UserRole Role, - DateTime CreatedAtUtc -) : IHasId; diff --git a/src/Services/Identity/Identity.Application/Features/User/Mappings/UserMappings.cs b/src/Services/Identity/Identity.Application/Features/User/Mappings/UserMappings.cs deleted file mode 100644 index 02eeb131..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Mappings/UserMappings.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Linq.Expressions; -using Identity.Application.Features.User.DTOs; -using Identity.Domain.Entities; - -namespace Identity.Application.Features.User.Mappings; - -/// -/// Provides LINQ-compatible projection expressions and in-process mapping helpers for entities. -/// -public static class UserMappings -{ - /// - /// Expression tree used by EF Core to project an entity directly to a in the database query. - /// - public static readonly Expression> Projection = - u => new UserResponse(u.Id, u.Username, u.Email, u.IsActive, u.Role, u.Audit.CreatedAtUtc); - - private static readonly Func CompiledProjection = Projection.Compile(); - - /// - /// Maps an entity to a using the pre-compiled projection. - /// - public static UserResponse ToResponse(this AppUser user) => CompiledProjection(user); -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Queries/GetUserByIdQuery.cs b/src/Services/Identity/Identity.Application/Features/User/Queries/GetUserByIdQuery.cs deleted file mode 100644 index 9c82bd1e..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Queries/GetUserByIdQuery.cs +++ /dev/null @@ -1,29 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Features.User.DTOs; -using Identity.Application.Features.User.Specifications; -using Identity.Domain.Interfaces; -using SharedKernel.Domain.Entities.Contracts; - -namespace Identity.Application.Features.User.Queries; - -public sealed record GetUserByIdQuery(Guid Id) : IHasId; - -public sealed class GetUserByIdQueryHandler -{ - public static async Task> HandleAsync( - GetUserByIdQuery request, - IUserRepository repository, - CancellationToken ct - ) - { - UserResponse? result = await repository.FirstOrDefaultAsync( - new UserByIdSpecification(request.Id), - ct - ); - if (result is null) - return DomainErrors.Users.NotFound(request.Id); - - return result; - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Queries/GetUsersQuery.cs b/src/Services/Identity/Identity.Application/Features/User/Queries/GetUsersQuery.cs deleted file mode 100644 index 97413e63..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Queries/GetUsersQuery.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ErrorOr; -using Identity.Application.Features.User.DTOs; -using Identity.Application.Features.User.Specifications; -using Identity.Domain.Interfaces; -using SharedKernel.Domain.Common; - -namespace Identity.Application.Features.User.Queries; - -public sealed record GetUsersQuery(UserFilter Filter); - -public sealed class GetUsersQueryHandler -{ - public static async Task>> HandleAsync( - GetUsersQuery request, - IUserRepository repository, - CancellationToken ct - ) - { - return await repository.GetPagedAsync( - new UserFilterSpecification(request.Filter), - request.Filter.PageNumber, - request.Filter.PageSize, - ct - ); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserByEmailSpecification.cs b/src/Services/Identity/Identity.Application/Features/User/Specifications/UserByEmailSpecification.cs deleted file mode 100644 index 97ffdedc..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserByEmailSpecification.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Ardalis.Specification; -using Identity.Domain.Entities; - -namespace Identity.Application.Features.User.Specifications; - -/// -/// Ardalis specification that filters users by a case-insensitive exact email match. -/// -public sealed class UserByEmailSpecification : Specification -{ - /// - /// Initialises the specification to match users whose normalised email equals the normalised form of . - /// - public UserByEmailSpecification(string email) - { - string normalizedEmail = AppUser.NormalizeEmail(email); - Query.Where(u => u.NormalizedEmail == normalizedEmail); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserByIdSpecification.cs b/src/Services/Identity/Identity.Application/Features/User/Specifications/UserByIdSpecification.cs deleted file mode 100644 index 0bb85bb9..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserByIdSpecification.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Ardalis.Specification; -using Identity.Application.Features.User.DTOs; -using Identity.Application.Features.User.Mappings; -using Identity.Domain.Entities; - -namespace Identity.Application.Features.User.Specifications; - -/// -/// Ardalis specification that fetches a single user by ID and projects it to . -/// -public sealed class UserByIdSpecification : Specification -{ - /// - /// Initialises the specification to match the user with the given and apply the response projection. - /// - public UserByIdSpecification(Guid id) - { - Query.Where(u => u.Id == id).Select(UserMappings.Projection); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserByUsernameSpecification.cs b/src/Services/Identity/Identity.Application/Features/User/Specifications/UserByUsernameSpecification.cs deleted file mode 100644 index a8be230d..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserByUsernameSpecification.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Ardalis.Specification; -using Identity.Domain.Entities; - -namespace Identity.Application.Features.User.Specifications; - -/// -/// Ardalis specification that filters users by their pre-normalised username. -/// -public sealed class UserByUsernameSpecification : Specification -{ - /// - /// Initialises the specification to match the user with the given . - /// - public UserByUsernameSpecification(string normalizedUsername) - { - Query.Where(u => u.NormalizedUsername == normalizedUsername); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserFilterCriteria.cs b/src/Services/Identity/Identity.Application/Features/User/Specifications/UserFilterCriteria.cs deleted file mode 100644 index ea90c9ee..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserFilterCriteria.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Ardalis.Specification; -using Identity.Application.Features.User.DTOs; -using Identity.Domain.Entities; - -namespace Identity.Application.Features.User.Specifications; - -/// -/// Internal extension that applies shared criteria to an Ardalis specification builder. -/// -internal static class UserFilterCriteria -{ - /// - /// Adds optional normalised-username contains, email exact-match, active-status, and role predicates to the query. - /// - internal static void ApplyFilter(this ISpecificationBuilder query, UserFilter filter) - { - if (!string.IsNullOrWhiteSpace(filter.Username)) - { - string normalizedUsername = AppUser.NormalizeUsername(filter.Username); - query.Where(u => u.NormalizedUsername.Contains(normalizedUsername)); - } - - if (!string.IsNullOrWhiteSpace(filter.Email)) - { - string normalizedEmail = AppUser.NormalizeEmail(filter.Email); - query.Where(u => u.NormalizedEmail == normalizedEmail); - } - - if (filter.IsActive.HasValue) - query.Where(u => u.IsActive == filter.IsActive.Value); - - if (filter.Role.HasValue) - query.Where(u => u.Role == filter.Role.Value); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserFilterSpecification.cs b/src/Services/Identity/Identity.Application/Features/User/Specifications/UserFilterSpecification.cs deleted file mode 100644 index cc3bf63c..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Specifications/UserFilterSpecification.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Ardalis.Specification; -using Identity.Application.Features.User.DTOs; -using Identity.Application.Features.User.Mappings; -using Identity.Domain.Entities; - -namespace Identity.Application.Features.User.Specifications; - -/// -/// Ardalis specification that retrieves a filtered and sorted list of users projected to . -/// -public sealed class UserFilterSpecification : Specification -{ - /// - /// Initialises the specification by applying filter criteria, sort order, and projection from the given . - /// - public UserFilterSpecification(UserFilter filter) - { - Query.ApplyFilter(filter); - Query.AsNoTracking(); - - UserSortFields.Map.ApplySort(Query, filter.SortBy, filter.SortDirection); - - Query.Select(UserMappings.Projection); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/UserSortFields.cs b/src/Services/Identity/Identity.Application/Features/User/UserSortFields.cs deleted file mode 100644 index 8cf1bce4..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/UserSortFields.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Identity.Domain.Entities; -using SharedKernel.Application.Sorting; - -namespace Identity.Application.Features.User; - -/// -/// Defines the sortable fields available for user queries and maps them to entity property expressions. -/// -public static class UserSortFields -{ - public static readonly SortField Username = new("username"); - public static readonly SortField Email = new("email"); - public static readonly SortField CreatedAt = new("createdAt"); - - public static readonly SortFieldMap Map = new SortFieldMap() - .Add(Username, u => u.Username) - .Add(Email, u => u.Email) - .Add(CreatedAt, u => u.Audit.CreatedAtUtc) - .Default(u => u.Audit.CreatedAtUtc); -} diff --git a/src/Services/Identity/Identity.Application/Features/User/UserValidationHelper.cs b/src/Services/Identity/Identity.Application/Features/User/UserValidationHelper.cs deleted file mode 100644 index a6d33689..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/UserValidationHelper.cs +++ /dev/null @@ -1,34 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Domain.Entities; -using Identity.Domain.Interfaces; - -namespace Identity.Application.Features.User; - -internal static class UserValidationHelper -{ - internal static async Task> ValidateEmailUniqueAsync( - IUserRepository repository, - string email, - CancellationToken ct - ) - { - if (await repository.ExistsByEmailAsync(email, ct)) - return DomainErrors.Users.EmailAlreadyExists(email); - - return Result.Success; - } - - internal static async Task> ValidateUsernameUniqueAsync( - IUserRepository repository, - string username, - CancellationToken ct - ) - { - string normalized = AppUser.NormalizeUsername(username); - if (await repository.ExistsByUsernameAsync(normalized, ct)) - return DomainErrors.Users.UsernameAlreadyExists(username); - - return Result.Success; - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs b/src/Services/Identity/Identity.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs deleted file mode 100644 index 5d9e319b..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FluentValidation; -using Identity.Application.Features.User.DTOs; -using Identity.Domain.Enums; - -namespace Identity.Application.Features.User.Validation; - -/// -/// FluentValidation validator for that ensures the role value is a valid enum member. -/// -public sealed class ChangeUserRoleRequestValidator : AbstractValidator -{ - /// - /// Registers the enum-range rule for the Role property. - /// - public ChangeUserRoleRequestValidator() - { - RuleFor(x => x.Role).IsInEnum().WithMessage("Role must be a valid UserRole value."); - } -} diff --git a/src/Services/Identity/Identity.Application/Features/User/Validation/CreateUserRequestValidator.cs b/src/Services/Identity/Identity.Application/Features/User/Validation/CreateUserRequestValidator.cs deleted file mode 100644 index 9d130e13..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Validation/CreateUserRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Identity.Application.Features.User.DTOs; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.User.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class CreateUserRequestValidator : DataAnnotationsValidator { } diff --git a/src/Services/Identity/Identity.Application/Features/User/Validation/UpdateUserRequestValidator.cs b/src/Services/Identity/Identity.Application/Features/User/Validation/UpdateUserRequestValidator.cs deleted file mode 100644 index befd310f..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Validation/UpdateUserRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Identity.Application.Features.User.DTOs; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.User.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class UpdateUserRequestValidator : DataAnnotationsValidator; diff --git a/src/Services/Identity/Identity.Application/Features/User/Validation/UserFilterValidator.cs b/src/Services/Identity/Identity.Application/Features/User/Validation/UserFilterValidator.cs deleted file mode 100644 index 2e2468d9..00000000 --- a/src/Services/Identity/Identity.Application/Features/User/Validation/UserFilterValidator.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FluentValidation; -using Identity.Application.Features.User.DTOs; -using Identity.Domain.Enums; -using SharedKernel.Application.Validation; - -namespace Identity.Application.Features.User.Validation; - -/// -/// FluentValidation validator for that composes sort-field rules and validates the optional role enum. -/// -public sealed class UserFilterValidator : DataAnnotationsValidator -{ - /// - /// Registers sort-field and optional role enum-range validation rules. - /// - public UserFilterValidator() - { - Include(new SortableFilterValidator(UserSortFields.Map.AllowedNames)); - - RuleFor(x => x.Role) - .IsInEnum() - .When(x => x.Role.HasValue) - .WithMessage("Role must be a valid UserRole value."); - } -} diff --git a/src/Services/Identity/Identity.Application/Identity.Application.csproj b/src/Services/Identity/Identity.Application/Identity.Application.csproj deleted file mode 100644 index b8653374..00000000 --- a/src/Services/Identity/Identity.Application/Identity.Application.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - diff --git a/src/Services/Identity/Identity.Application/Options/BffOptions.cs b/src/Services/Identity/Identity.Application/Options/BffOptions.cs deleted file mode 100644 index 4ae1c50f..00000000 --- a/src/Services/Identity/Identity.Application/Options/BffOptions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Identity.Application.Security; - -namespace Identity.Application.Options; - -/// -/// Configuration for the Backend-for-Frontend (BFF) session layer, including cookie settings, -/// requested OIDC scopes, and token refresh thresholds. -/// -public sealed class BffOptions -{ - public const string SectionName = "Bff"; - - [Required] - public string CookieName { get; init; } = ".Identity.Auth"; - - [Required] - public string PostLogoutRedirectUri { get; init; } = "/"; - - [Range(1, 1440)] - public int SessionTimeoutMinutes { get; init; } = 60; - - [MinLength(1)] - public string[] Scopes { get; init; } = [.. AuthConstants.Scopes.Default]; - - [Range(1, 60)] - public int TokenRefreshThresholdMinutes { get; init; } = 2; -} diff --git a/src/Services/Identity/Identity.Application/Options/BootstrapTenantOptions.cs b/src/Services/Identity/Identity.Application/Options/BootstrapTenantOptions.cs deleted file mode 100644 index b4e01dba..00000000 --- a/src/Services/Identity/Identity.Application/Options/BootstrapTenantOptions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Identity.Application.Options; - -/// -/// Configuration for the default tenant that is seeded when the application bootstraps for the first time. -/// -public sealed class BootstrapTenantOptions -{ - public const string SectionName = "BootstrapTenant"; - - [Required] - public string Code { get; init; } = "default"; - - [Required] - public string Name { get; init; } = "Default Tenant"; -} diff --git a/src/Services/Identity/Identity.Application/Options/InvitationOptions.cs b/src/Services/Identity/Identity.Application/Options/InvitationOptions.cs deleted file mode 100644 index 13f49422..00000000 --- a/src/Services/Identity/Identity.Application/Options/InvitationOptions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Identity.Application.Options; - -/// -/// Configuration for tenant invitation token expiry. -/// -public sealed class InvitationOptions -{ - public const string SectionName = "Invitation"; - - [Range(1, 720)] - public int InvitationTokenExpiryHours { get; init; } = 72; -} diff --git a/src/Services/Identity/Identity.Application/Options/KeycloakOptions.cs b/src/Services/Identity/Identity.Application/Options/KeycloakOptions.cs deleted file mode 100644 index 5ce7576e..00000000 --- a/src/Services/Identity/Identity.Application/Options/KeycloakOptions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; - -namespace Identity.Application.Options; - -/// -/// Configuration for the Keycloak identity provider, covering realm, server URL, client credentials, -/// and startup readiness-check behaviour. -/// -public sealed class KeycloakOptions -{ - public const string SectionName = "Keycloak"; - - [Required] - [ConfigurationKeyName("realm")] - public string Realm { get; init; } = string.Empty; - - [Required] - [ConfigurationKeyName("auth-server-url")] - public string AuthServerUrl { get; init; } = string.Empty; - - [ConfigurationKeyName("resource")] - public string Resource { get; init; } = string.Empty; - - [ConfigurationKeyName("SkipReadinessCheck")] - public bool SkipReadinessCheck { get; init; } - - [Range(1, 100)] - [ConfigurationKeyName("ReadinessMaxRetries")] - public int ReadinessMaxRetries { get; init; } = 30; - - [Required] - [ValidateObjectMembers] - [ConfigurationKeyName("credentials")] - public KeycloakCredentialsOptions Credentials { get; init; } = new(); -} - -/// -/// Client-secret credentials used when authenticating against the Keycloak Admin REST API. -/// -public sealed class KeycloakCredentialsOptions -{ - [Required] - [ConfigurationKeyName("secret")] - public string Secret { get; init; } = string.Empty; -} diff --git a/src/Services/Identity/Identity.Application/Options/SystemIdentityOptions.cs b/src/Services/Identity/Identity.Application/Options/SystemIdentityOptions.cs deleted file mode 100644 index 73ffa4da..00000000 --- a/src/Services/Identity/Identity.Application/Options/SystemIdentityOptions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SharedKernel.Domain.Entities; - -namespace Identity.Application.Options; - -/// -/// Configuration that defines the well-known actor identity used when the system performs -/// automated actions without an associated human user. -/// -public sealed class SystemIdentityOptions -{ - public const string SectionName = "SystemIdentity"; - - public Guid DefaultActorId { get; init; } = AuditDefaults.SystemActorId; -} diff --git a/src/Services/Identity/Identity.Application/Sagas/TenantDeactivationSaga.cs b/src/Services/Identity/Identity.Application/Sagas/TenantDeactivationSaga.cs deleted file mode 100644 index 4770485b..00000000 --- a/src/Services/Identity/Identity.Application/Sagas/TenantDeactivationSaga.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Contracts.IntegrationEvents.Sagas; -using Microsoft.Extensions.Logging; -using Wolverine; - -namespace Identity.Application.Sagas; - -public class TenantDeactivationSaga : Saga -{ - public Guid Id { get; set; } - public Guid TenantId { get; set; } - public bool UsersCascaded { get; set; } - public bool ProductsCascaded { get; set; } - public bool CategoriesCascaded { get; set; } - - public static ( - TenantDeactivationSaga, - TenantDeactivatedIntegrationEvent, - TenantDeactivationSagaTimeout - ) Start(StartTenantDeactivationSaga command, TimeProvider timeProvider) - { - TenantDeactivationSaga saga = new() - { - Id = command.CorrelationId, - TenantId = command.TenantId, - }; - TenantDeactivatedIntegrationEvent @event = new( - command.CorrelationId, - command.TenantId, - command.ActorId, - timeProvider.GetUtcNow().UtcDateTime - ); - - TenantDeactivationSagaTimeout timeout = new(command.CorrelationId); - return (saga, @event, timeout); - } - - public void Handle(UsersCascadeCompleted _) - { - UsersCascaded = true; - TryComplete(); - } - - public void Handle(ProductsCascadeCompleted _) - { - ProductsCascaded = true; - TryComplete(); - } - - public void Handle(CategoriesCascadeCompleted _) - { - CategoriesCascaded = true; - TryComplete(); - } - - public void Handle( - TenantDeactivationSagaTimeout timeout, - ILogger logger - ) - { - if (UsersCascaded && ProductsCascaded && CategoriesCascaded) - return; - - logger.LogWarning( - "TenantDeactivationSaga timed out for {SagaId}. Pending confirmations: UsersCascaded={UsersCascaded}, ProductsCascaded={ProductsCascaded}, CategoriesCascaded={CategoriesCascaded}, TenantId={TenantId}", - timeout.CorrelationId, - UsersCascaded, - ProductsCascaded, - CategoriesCascaded, - TenantId - ); - - MarkCompleted(); - } - - private void TryComplete() - { - if (UsersCascaded && ProductsCascaded && CategoriesCascaded) - MarkCompleted(); - } - - public static void NotFound( - UsersCascadeCompleted msg, - ILogger logger - ) => - logger.LogWarning( - "Received {MessageType} for unknown saga {SagaId}", - nameof(UsersCascadeCompleted), - msg.CorrelationId - ); - - public static void NotFound( - ProductsCascadeCompleted msg, - ILogger logger - ) => - logger.LogWarning( - "Received {MessageType} for unknown saga {SagaId}", - nameof(ProductsCascadeCompleted), - msg.CorrelationId - ); - - public static void NotFound( - CategoriesCascadeCompleted msg, - ILogger logger - ) => - logger.LogWarning( - "Received {MessageType} for unknown saga {SagaId}", - nameof(CategoriesCascadeCompleted), - msg.CorrelationId - ); - - public static void NotFound( - TenantDeactivationSagaTimeout msg, - ILogger logger - ) => - logger.LogInformation( - "Received {MessageType} for already-completed or missing saga {SagaId}", - nameof(TenantDeactivationSagaTimeout), - msg.CorrelationId - ); -} diff --git a/src/Services/Identity/Identity.Application/Security/AuthConstants.cs b/src/Services/Identity/Identity.Application/Security/AuthConstants.cs deleted file mode 100644 index 2323d35b..00000000 --- a/src/Services/Identity/Identity.Application/Security/AuthConstants.cs +++ /dev/null @@ -1,109 +0,0 @@ -using SharedKernel.Application.Security; - -namespace Identity.Application.Security; - -/// -/// Shared constants for authentication, OpenID Connect, and OAuth2 token payload names. -/// -public static class AuthConstants -{ - /// Named HTTP client identifiers for Keycloak communication. - public static class HttpClients - { - public const string KeycloakToken = "KeycloakTokenClient"; - public const string KeycloakAdmin = "KeycloakAdminClient"; - } - - /// Relative path segments for the Keycloak OpenID Connect endpoints. - public static class OpenIdConnect - { - public const string AuthorizationEndpointPath = "protocol/openid-connect/auth"; - public const string TokenEndpointPath = "protocol/openid-connect/token"; - } - - /// OpenAPI / Scalar UI security scheme and client identifiers. - public static class OpenApi - { - public const string OAuth2Scheme = "OAuth2"; - public const string ScalarClientId = "api-template-scalar"; - } - - /// Standard OAuth2 / OIDC scope names requested during authentication. - public static class Scopes - { - public const string OpenId = "openid"; - public const string Profile = "profile"; - public const string Email = "email"; - - public static readonly string[] Default = [OpenId, Profile, Email]; - } - - /// Cookie field names used to persist session token data in the BFF layer. - public static class CookieTokenNames - { - public const string AccessToken = "access_token"; - public const string RefreshToken = "refresh_token"; - public const string ExpiresAt = "expires_at"; - public const string ExpiresIn = "expires_in"; - } - - /// Form parameter names used in OAuth2 token endpoint requests. - public static class OAuth2FormParameters - { - public const string GrantType = "grant_type"; - public const string ClientId = "client_id"; - public const string ClientSecret = "client_secret"; - public const string RefreshToken = "refresh_token"; - } - - /// OAuth2 grant type string values used in token requests. - public static class OAuth2GrantTypes - { - public const string ClientCredentials = "client_credentials"; - public const string RefreshToken = "refresh_token"; - } - - /// Keycloak required-action identifiers sent during user lifecycle operations. - public static class KeycloakActions - { - public const string VerifyEmail = "VERIFY_EMAIL"; - public const string UpdatePassword = "UPDATE_PASSWORD"; - } - - /// JWT claim names used to extract identity and role information from tokens. - public static class Claims - { - public const string Subject = SharedAuthConstants.Claims.Subject; - public const string RealmAccess = "realm_access"; - public const string Roles = "roles"; - public const string PreferredUsername = "preferred_username"; - public const string ServiceAccountUsernamePrefix = "service-account-"; - public const string TenantId = SharedAuthConstants.Claims.TenantId; - } - - /// - /// Constants for the custom CSRF header contract used by CsrfValidationMiddleware. - /// - public static class Csrf - { - /// Name of the required anti-CSRF request header. - public const string HeaderName = "X-CSRF"; - - /// Expected value of the anti-CSRF header. - public const string HeaderValue = "1"; - } - - /// Authentication scheme names registered for the BFF cookie and OIDC flows. - public static class BffSchemes - { - public const string Cookie = "BffCookie"; - public const string Oidc = "BffOidc"; - } - - /// Named authorization policy identifiers registered in the ASP.NET Core policy store. - public static class Policies - { - public const string PlatformAdmin = "PlatformAdmin"; - public const string TenantAdmin = "TenantAdmin"; - } -} diff --git a/src/Services/Identity/Identity.Application/Security/IKeycloakAdminService.cs b/src/Services/Identity/Identity.Application/Security/IKeycloakAdminService.cs deleted file mode 100644 index 367a0c9a..00000000 --- a/src/Services/Identity/Identity.Application/Security/IKeycloakAdminService.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Identity.Application.Security; - -/// -/// Application-layer port for managing Keycloak users via the Admin REST API. -/// Implementations live in the Infrastructure layer and communicate with Keycloak on behalf of the application. -/// -public interface IKeycloakAdminService -{ - /// Creates a new Keycloak user and returns the assigned Keycloak user ID. - Task CreateUserAsync(string username, string email, CancellationToken ct = default); - - /// Triggers a password-reset email for the specified Keycloak user. - Task SendPasswordResetEmailAsync(string keycloakUserId, CancellationToken ct = default); - - /// Enables or disables the specified Keycloak user account. - Task SetUserEnabledAsync(string keycloakUserId, bool enabled, CancellationToken ct = default); - - /// Permanently deletes the specified Keycloak user. - Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = default); -} diff --git a/src/Services/Identity/Identity.Application/Security/ISecureTokenGenerator.cs b/src/Services/Identity/Identity.Application/Security/ISecureTokenGenerator.cs deleted file mode 100644 index 5e56c1ea..00000000 --- a/src/Services/Identity/Identity.Application/Security/ISecureTokenGenerator.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Identity.Application.Security; - -/// -/// Application-layer abstraction for generating and hashing cryptographically secure tokens -/// used in email verification flows (e.g. invitation acceptance, password reset). -/// -public interface ISecureTokenGenerator -{ - /// Generates a new cryptographically random token suitable for use in email links. - string GenerateToken(); - - /// - /// Returns a one-way hash of for safe storage in the database, - /// allowing verification without storing the raw token. - /// - string HashToken(string token); -} diff --git a/src/Services/Identity/Identity.Application/Security/IUserProvisioningService.cs b/src/Services/Identity/Identity.Application/Security/IUserProvisioningService.cs deleted file mode 100644 index 79a61a45..00000000 --- a/src/Services/Identity/Identity.Application/Security/IUserProvisioningService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Identity.Domain.Entities; - -namespace Identity.Application.Security; - -/// -/// Application-layer port that ensures a local record exists for an authenticated -/// Keycloak identity, creating one on first login if necessary. -/// -public interface IUserProvisioningService -{ - /// - /// Looks up the local user record for the given Keycloak identity and provisions it if it does not - /// yet exist. Returns when provisioning cannot be completed. - /// - Task ProvisionIfNeededAsync( - string keycloakUserId, - string email, - string username, - CancellationToken ct = default - ); -} diff --git a/src/Services/Identity/Identity.Application/Security/StaticRolePermissionMap.cs b/src/Services/Identity/Identity.Application/Security/StaticRolePermissionMap.cs deleted file mode 100644 index 0db7b26c..00000000 --- a/src/Services/Identity/Identity.Application/Security/StaticRolePermissionMap.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Identity.Domain.Enums; -using SharedKernel.Application.Security; - -namespace Identity.Application.Security; - -/// -/// Compile-time implementation of that maps each -/// to a fixed set of permission strings. -/// The mapping is built once and cached for the lifetime of the application. -/// -public sealed class StaticRolePermissionMap : IRolePermissionMap -{ - private static readonly IReadOnlySet Empty = new HashSet( - StringComparer.Ordinal - ); - - private static readonly IReadOnlyDictionary> Map = BuildMap(); - - public IReadOnlySet GetPermissions(string role) => - Map.TryGetValue(role, out IReadOnlySet? permissions) ? permissions : Empty; - - public bool HasPermission(string role, string permission) => - GetPermissions(role).Contains(permission); - - /// - /// Constructs the static role-to-permissions dictionary used for all permission lookups. - /// - private static Dictionary> BuildMap() - { - HashSet tenantAdminPermissions = new(StringComparer.Ordinal) - { - Permission.Products.Read, - Permission.Products.Create, - Permission.Products.Update, - Permission.Products.Delete, - Permission.Categories.Read, - Permission.Categories.Create, - Permission.Categories.Update, - Permission.Categories.Delete, - Permission.ProductReviews.Read, - Permission.ProductReviews.Create, - Permission.ProductReviews.Delete, - Permission.ProductData.Read, - Permission.ProductData.Create, - Permission.ProductData.Delete, - Permission.Users.Read, - Permission.Invitations.Read, - Permission.Invitations.Create, - Permission.Invitations.Revoke, - Permission.Examples.Read, - Permission.Examples.Create, - Permission.Examples.Update, - Permission.Examples.Execute, - Permission.Examples.Upload, - Permission.Examples.Download, - }; - - HashSet userPermissions = new(StringComparer.Ordinal) - { - Permission.Products.Read, - Permission.Categories.Read, - Permission.ProductReviews.Read, - Permission.ProductReviews.Create, - Permission.ProductData.Read, - Permission.Examples.Read, - Permission.Examples.Download, - }; - - return new Dictionary>(StringComparer.Ordinal) - { - [UserRole.PlatformAdmin.ToString()] = Permission.All, - [UserRole.TenantAdmin.ToString()] = tenantAdminPermissions, - [UserRole.User.ToString()] = userPermissions, - }; - } -} diff --git a/src/Services/Identity/Identity.Domain/Entities/AppUser.cs b/src/Services/Identity/Identity.Domain/Entities/AppUser.cs deleted file mode 100644 index 473f0b6f..00000000 --- a/src/Services/Identity/Identity.Domain/Entities/AppUser.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Identity.Domain.Enums; -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace Identity.Domain.Entities; - -/// -/// Domain entity representing an application user belonging to a tenant. -/// Tracks identity information, Keycloak linkage, role, and soft-delete state. -/// -public sealed class AppUser : IAuditableTenantEntity, IHasId -{ - public Guid Id { get; set; } - - /// - /// Original username exactly as entered by the user (preserves casing and formatting). - /// - public required string Username { get; set; } - - /// - /// Uppercase, trimmed version of the username. - /// Used for fast database indexing, case-insensitive uniqueness checks (preventing impersonation), and reliable logins. - /// - public string NormalizedUsername { get; set; } = string.Empty; - - /// - /// Original email exactly as entered by the user. Required for correct email delivery (RFC compliance). - /// - public required string Email { get; set; } - - /// - /// Uppercase, trimmed version of the email. - /// Used for fast database indexing, case-insensitive uniqueness checks (preventing impersonation), and reliable logins. - /// - public string NormalizedEmail { get; set; } = string.Empty; - - /// - /// The user's subject ID in Keycloak. Nullable — existing users may not have one yet. - /// - public string? KeycloakUserId { get; set; } - - public bool IsActive { get; set; } = true; - public UserRole Role { get; set; } = UserRole.User; - - public Tenant Tenant { get; set; } = null!; - - public Guid TenantId { get; set; } - public AuditInfo Audit { get; set; } = new(); - public bool IsDeleted { get; set; } - public DateTime? DeletedAtUtc { get; set; } - public Guid? DeletedBy { get; set; } - - /// - /// Returns the canonical form of a username: trimmed and converted to uppercase invariant. - /// - public static string NormalizeUsername(string username) => username.Trim().ToUpperInvariant(); - - /// - /// Returns the canonical form of an email address: trimmed and converted to uppercase invariant. - /// - public static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant(); -} diff --git a/src/Services/Identity/Identity.Domain/Entities/Tenant.cs b/src/Services/Identity/Identity.Domain/Entities/Tenant.cs deleted file mode 100644 index 4fd2b5d3..00000000 --- a/src/Services/Identity/Identity.Domain/Entities/Tenant.cs +++ /dev/null @@ -1,41 +0,0 @@ -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace Identity.Domain.Entities; - -/// -/// Aggregate root representing a tenant (organisation) in the multi-tenant system. -/// All other tenant-scoped entities reference this entity through . -/// -public sealed class Tenant : IAuditableTenantEntity, IHasId -{ - public Guid Id { get; set; } - - public required string Code - { - get => field; - set => - field = string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Tenant code cannot be empty.", nameof(Code)) - : value.Trim(); - } - - public required string Name - { - get => field; - set => - field = string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Tenant name cannot be empty.", nameof(Name)) - : value.Trim(); - } - - public bool IsActive { get; set; } = true; - - public ICollection Users { get; set; } = []; - - public Guid TenantId { get; set; } - public AuditInfo Audit { get; set; } = new(); - public bool IsDeleted { get; set; } - public DateTime? DeletedAtUtc { get; set; } - public Guid? DeletedBy { get; set; } -} diff --git a/src/Services/Identity/Identity.Domain/Entities/TenantInvitation.cs b/src/Services/Identity/Identity.Domain/Entities/TenantInvitation.cs deleted file mode 100644 index 09731863..00000000 --- a/src/Services/Identity/Identity.Domain/Entities/TenantInvitation.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Identity.Domain.Enums; -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace Identity.Domain.Entities; - -/// -/// Domain entity representing an email invitation for a user to join a tenant. -/// Holds a hashed token used for secure acceptance and tracks the invitation lifecycle via . -/// -public sealed class TenantInvitation : IAuditableTenantEntity, IHasId -{ - public Guid Id { get; set; } - public required string Email { get; set; } - public required string NormalizedEmail { get; set; } - public required string TokenHash { get; set; } - public DateTime ExpiresAtUtc { get; set; } - public InvitationStatus Status { get; set; } = InvitationStatus.Pending; - - public Tenant Tenant { get; set; } = null!; - - public Guid TenantId { get; set; } - public AuditInfo Audit { get; set; } = new(); - public bool IsDeleted { get; set; } - public DateTime? DeletedAtUtc { get; set; } - public Guid? DeletedBy { get; set; } -} diff --git a/src/Services/Identity/Identity.Domain/Enums/InvitationStatus.cs b/src/Services/Identity/Identity.Domain/Enums/InvitationStatus.cs deleted file mode 100644 index 14e7314d..00000000 --- a/src/Services/Identity/Identity.Domain/Enums/InvitationStatus.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Identity.Domain.Enums; - -/// -/// Represents the lifecycle state of a . -/// -public enum InvitationStatus -{ - /// The invitation has been sent and is awaiting a response. - Pending = 0, - - /// The invitee accepted the invitation and joined the tenant. - Accepted = 1, - - /// The invitation passed its expiry date without being accepted. - Expired = 2, - - /// The invitation was revoked by a tenant administrator before it could be accepted. - Revoked = 3, -} diff --git a/src/Services/Identity/Identity.Domain/Enums/UserRole.cs b/src/Services/Identity/Identity.Domain/Enums/UserRole.cs deleted file mode 100644 index 63884985..00000000 --- a/src/Services/Identity/Identity.Domain/Enums/UserRole.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Identity.Domain.Enums; - -/// -/// Defines the authorization role assigned to an . -/// -public enum UserRole -{ - /// A regular user with standard access within their tenant. - User = 0, - - /// A super-administrator with platform-wide access across all tenants. - PlatformAdmin = 1, - - /// An administrator with elevated access scoped to a single tenant. - TenantAdmin = 2, -} diff --git a/src/Services/Identity/Identity.Domain/Identity.Domain.csproj b/src/Services/Identity/Identity.Domain/Identity.Domain.csproj deleted file mode 100644 index e5effa8b..00000000 --- a/src/Services/Identity/Identity.Domain/Identity.Domain.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/Identity/Identity.Domain/Interfaces/ITenantInvitationRepository.cs b/src/Services/Identity/Identity.Domain/Interfaces/ITenantInvitationRepository.cs deleted file mode 100644 index 41cea45b..00000000 --- a/src/Services/Identity/Identity.Domain/Interfaces/ITenantInvitationRepository.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Identity.Domain.Entities; -using SharedKernel.Domain.Interfaces; - -namespace Identity.Domain.Interfaces; - -/// -/// Repository contract for entities with invitation-specific lookup operations. -/// -public interface ITenantInvitationRepository : IRepository -{ - /// - /// Returns the non-expired, non-revoked invitation that matches the given hashed token, or null if none exists. - /// - Task GetValidByTokenHashAsync( - string tokenHash, - CancellationToken ct = default - ); - - /// - /// Returns true if there is already a pending invitation for the given normalised email address. - /// - Task HasPendingInvitationAsync(string normalizedEmail, CancellationToken ct = default); -} diff --git a/src/Services/Identity/Identity.Domain/Interfaces/ITenantRepository.cs b/src/Services/Identity/Identity.Domain/Interfaces/ITenantRepository.cs deleted file mode 100644 index 35ab6a72..00000000 --- a/src/Services/Identity/Identity.Domain/Interfaces/ITenantRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Identity.Domain.Entities; -using SharedKernel.Domain.Interfaces; - -namespace Identity.Domain.Interfaces; - -/// -/// Repository contract for entities with tenant-specific lookup operations. -/// -public interface ITenantRepository : IRepository -{ - /// - /// Returns true if a tenant with the given code already exists, enabling uniqueness validation before creation. - /// - Task CodeExistsAsync(string code, CancellationToken ct = default); -} diff --git a/src/Services/Identity/Identity.Domain/Interfaces/IUserRepository.cs b/src/Services/Identity/Identity.Domain/Interfaces/IUserRepository.cs deleted file mode 100644 index 66b0d7fa..00000000 --- a/src/Services/Identity/Identity.Domain/Interfaces/IUserRepository.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Identity.Domain.Entities; -using SharedKernel.Domain.Interfaces; - -namespace Identity.Domain.Interfaces; - -/// -/// Repository contract for entities with user-specific lookup operations. -/// -public interface IUserRepository : IRepository -{ - /// Returns true if a user with the given email (case-insensitive) already exists. - Task ExistsByEmailAsync(string email, CancellationToken ct = default); - - /// Returns true if a user with the given normalised username already exists. - Task ExistsByUsernameAsync(string normalizedUsername, CancellationToken ct = default); - - /// Returns the user whose normalised email matches the given address, or null if not found. - Task FindByEmailAsync(string email, CancellationToken ct = default); -} diff --git a/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj b/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj deleted file mode 100644 index d60cdd68..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs deleted file mode 100644 index a8948036..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Identity.Domain.Entities; -using Identity.Domain.Enums; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SharedKernel.Infrastructure.Persistence.Configurations; - -namespace Identity.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity, defining constraints, indexes, and enum persistence. -public sealed class AppUserConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(u => u.Id); - builder.ConfigureTenantAuditable(); - - builder.Property(u => u.Username).IsRequired().HasMaxLength(100); - - builder.Property(u => u.NormalizedUsername).IsRequired().HasMaxLength(100); - - builder.Property(u => u.Email).IsRequired().HasMaxLength(320); - - builder.Property(u => u.NormalizedEmail).IsRequired().HasMaxLength(320); - - builder.Property(u => u.KeycloakUserId).HasMaxLength(256); - - builder - .HasIndex(u => u.KeycloakUserId) - .IsUnique() - .HasFilter("\"KeycloakUserId\" IS NOT NULL"); - - builder.Property(u => u.IsActive).IsRequired().HasDefaultValue(true); - - builder - .Property(u => u.Role) - .HasConversion() - .IsRequired() - .HasMaxLength(32) - .HasDefaultValue(UserRole.User) - .HasSentinel((UserRole)(-1)); - - builder - .HasOne(u => u.Tenant) - .WithMany(t => t.Users) - .HasForeignKey(u => u.TenantId) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasIndex(u => new { u.TenantId, u.NormalizedUsername }).IsUnique(); - builder.HasIndex(u => new { u.TenantId, u.NormalizedEmail }).IsUnique(); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/TenantConfiguration.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/TenantConfiguration.cs deleted file mode 100644 index a461c128..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/TenantConfiguration.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Identity.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SharedKernel.Infrastructure.Persistence.Configurations; - -namespace Identity.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity, including a unique index on the tenant code. -public sealed class TenantConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(t => t.Id); - builder.ConfigureTenantAuditable(); - - builder.Property(t => t.Code).IsRequired().HasMaxLength(100); - - builder.Property(t => t.Name).IsRequired().HasMaxLength(200); - - builder.Property(t => t.IsActive).IsRequired().HasDefaultValue(true); - - builder.HasIndex(t => t.Code).IsUnique(); - builder.HasIndex(t => t.IsActive); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/TenantDeactivationSagaConfiguration.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/TenantDeactivationSagaConfiguration.cs deleted file mode 100644 index 373aa12c..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/TenantDeactivationSagaConfiguration.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Identity.Application.Sagas; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Identity.Infrastructure.Persistence.Configurations; - -/// -/// EF Core mapping for TenantDeactivationSaga persisted state. -/// Sagas are orchestration state and are not tenant-filtered. -/// -public sealed class TenantDeactivationSagaConfiguration - : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("TenantDeactivationSagas", "sagas"); - - builder.HasKey(s => s.Id); - - builder.Property(s => s.Id).IsRequired(); - builder.Property(s => s.TenantId).IsRequired(); - builder.Property(s => s.UsersCascaded).IsRequired(); - builder.Property(s => s.ProductsCascaded).IsRequired(); - builder.Property(s => s.CategoriesCascaded).IsRequired(); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs deleted file mode 100644 index e0f27688..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Identity.Domain.Entities; -using Identity.Domain.Enums; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SharedKernel.Infrastructure.Persistence.Configurations; - -namespace Identity.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity, including token hash and normalized email indexes. -public sealed class TenantInvitationConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(i => i.Id); - builder.ConfigureTenantAuditable(); - - builder.Property(i => i.Email).IsRequired().HasMaxLength(320); - builder.Property(i => i.NormalizedEmail).IsRequired().HasMaxLength(320); - - builder.Property(i => i.TokenHash).IsRequired().HasMaxLength(128); - - builder - .Property(i => i.ExpiresAtUtc) - .IsRequired() - .HasColumnType("timestamp with time zone"); - - builder - .Property(i => i.Status) - .HasConversion() - .IsRequired() - .HasMaxLength(32) - .HasDefaultValue(InvitationStatus.Pending) - .HasSentinel((InvitationStatus)(-1)); - - builder - .HasOne(i => i.Tenant) - .WithMany() - .HasForeignKey(i => i.TenantId) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasIndex(i => i.TokenHash); - builder.HasIndex(i => new { i.TenantId, i.NormalizedEmail }); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs deleted file mode 100644 index f42d49e5..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Identity.Application.Sagas; -using Identity.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Context; -using SharedKernel.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Persistence.Auditing; -using SharedKernel.Infrastructure.Persistence.SoftDelete; - -namespace Identity.Infrastructure.Persistence; - -/// -/// EF Core context for Identity & Tenancy microservice. -/// Enforces multi-tenancy, audit stamping, soft delete, and optimistic concurrency. -/// -public sealed class IdentityDbContext : TenantAuditableDbContext -{ - public IdentityDbContext( - DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor - ) - : base( - options, - tenantProvider, - actorProvider, - timeProvider, - softDeleteCascadeRules, - entityStateManager, - softDeleteProcessor - ) { } - - public DbSet Tenants => Set(); - public DbSet Users => Set(); - public DbSet TenantInvitations => Set(); - public DbSet TenantDeactivationSagas => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(IdentityDbContext).Assembly); - - // Global query filters for multi-tenancy and soft-delete - modelBuilder - .Entity() - .HasQueryFilter(e => (!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted); - - modelBuilder - .Entity() - .HasQueryFilter(e => (!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted); - - modelBuilder - .Entity() - .HasQueryFilter(e => (!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs deleted file mode 100644 index c42bd74e..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace Identity.Infrastructure.Persistence; - -/// -/// Design-time factory used by EF Core tooling to create the DbContext -/// for migration scaffolding without requiring the full runtime DI container. -/// -public sealed class IdentityDbContextDesignTimeFactory - : IDesignTimeDbContextFactory -{ - public IdentityDbContext CreateDbContext(string[] args) - { - DbContextOptionsBuilder optionsBuilder = new(); - optionsBuilder.UseNpgsql( - "Host=localhost;Database=identity_db;Username=postgres;Password=postgres" - ); - - return new IdentityDbContext( - optionsBuilder.Options, - tenantProvider: null!, - actorProvider: null!, - timeProvider: TimeProvider.System, - softDeleteCascadeRules: [], - entityStateManager: null!, - softDeleteProcessor: null! - ); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260326232831_InitialCreate.Designer.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260326232831_InitialCreate.Designer.cs deleted file mode 100644 index a444587a..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260326232831_InitialCreate.Designer.cs +++ /dev/null @@ -1,389 +0,0 @@ -// -using System; -using Identity.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Identity.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(IdentityDbContext))] - [Migration("20260326232831_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Identity.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("KeycloakUserId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("User"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("KeycloakUserId") - .IsUnique() - .HasFilter("\"KeycloakUserId\" IS NOT NULL"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "NormalizedEmail") - .IsUnique(); - - b.HasIndex("TenantId", "NormalizedUsername") - .IsUnique(); - - b.ToTable("Users", t => - { - t.HasCheckConstraint("CK_Users_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("IsActive"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("Tenants", t => - { - t.HasCheckConstraint("CK_Tenants_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.TenantInvitation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("ExpiresAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("Pending"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TokenHash"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "NormalizedEmail"); - - b.ToTable("TenantInvitations", t => - { - t.HasCheckConstraint("CK_TenantInvitations_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.AppUser", b => - { - b.HasOne("Identity.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("AppUserId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("TenantId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("Identity.Domain.Entities.TenantInvitation", b => - { - b.HasOne("Identity.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("TenantInvitationId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantInvitationId"); - - b1.ToTable("TenantInvitations"); - - b1.WithOwner() - .HasForeignKey("TenantInvitationId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260326232831_InitialCreate.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260326232831_InitialCreate.cs deleted file mode 100644 index d725d31c..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260326232831_InitialCreate.cs +++ /dev/null @@ -1,342 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Identity.Infrastructure.Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Tenants", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Code = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - Name = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: false - ), - IsActive = table.Column( - type: "boolean", - nullable: false, - defaultValue: true - ), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_Tenants", x => x.Id); - table.CheckConstraint( - "CK_Tenants_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - } - ); - - migrationBuilder.CreateTable( - name: "TenantInvitations", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Email = table.Column( - type: "character varying(320)", - maxLength: 320, - nullable: false - ), - NormalizedEmail = table.Column( - type: "character varying(320)", - maxLength: 320, - nullable: false - ), - TokenHash = table.Column( - type: "character varying(128)", - maxLength: 128, - nullable: false - ), - ExpiresAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false - ), - Status = table.Column( - type: "character varying(32)", - maxLength: 32, - nullable: false, - defaultValue: "Pending" - ), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_TenantInvitations", x => x.Id); - table.CheckConstraint( - "CK_TenantInvitations_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - table.ForeignKey( - name: "FK_TenantInvitations_Tenants_TenantId", - column: x => x.TenantId, - principalTable: "Tenants", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); - } - ); - - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Username = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - NormalizedUsername = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - Email = table.Column( - type: "character varying(320)", - maxLength: 320, - nullable: false - ), - NormalizedEmail = table.Column( - type: "character varying(320)", - maxLength: 320, - nullable: false - ), - KeycloakUserId = table.Column( - type: "character varying(256)", - maxLength: 256, - nullable: true - ), - IsActive = table.Column( - type: "boolean", - nullable: false, - defaultValue: true - ), - Role = table.Column( - type: "character varying(32)", - maxLength: 32, - nullable: false, - defaultValue: "User" - ), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - table.CheckConstraint( - "CK_Users_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - table.ForeignKey( - name: "FK_Users_Tenants_TenantId", - column: x => x.TenantId, - principalTable: "Tenants", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_TenantInvitations_TenantId", - table: "TenantInvitations", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_TenantInvitations_TenantId_IsDeleted", - table: "TenantInvitations", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_TenantInvitations_TenantId_NormalizedEmail", - table: "TenantInvitations", - columns: new[] { "TenantId", "NormalizedEmail" } - ); - - migrationBuilder.CreateIndex( - name: "IX_TenantInvitations_TokenHash", - table: "TenantInvitations", - column: "TokenHash" - ); - - migrationBuilder.CreateIndex( - name: "IX_Tenants_Code", - table: "Tenants", - column: "Code", - unique: true - ); - - migrationBuilder.CreateIndex( - name: "IX_Tenants_IsActive", - table: "Tenants", - column: "IsActive" - ); - - migrationBuilder.CreateIndex( - name: "IX_Tenants_TenantId", - table: "Tenants", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_Tenants_TenantId_IsDeleted", - table: "Tenants", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_Users_KeycloakUserId", - table: "Users", - column: "KeycloakUserId", - unique: true, - filter: "\"KeycloakUserId\" IS NOT NULL" - ); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId", - table: "Users", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_IsDeleted", - table: "Users", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_NormalizedEmail", - table: "Users", - columns: new[] { "TenantId", "NormalizedEmail" }, - unique: true - ); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_NormalizedUsername", - table: "Users", - columns: new[] { "TenantId", "NormalizedUsername" }, - unique: true - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "TenantInvitations"); - - migrationBuilder.DropTable(name: "Users"); - - migrationBuilder.DropTable(name: "Tenants"); - } - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329104558_AddTenantDeactivationSaga.Designer.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329104558_AddTenantDeactivationSaga.Designer.cs deleted file mode 100644 index db112a8b..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329104558_AddTenantDeactivationSaga.Designer.cs +++ /dev/null @@ -1,414 +0,0 @@ -// -using System; -using Identity.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Identity.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(IdentityDbContext))] - [Migration("20260329104558_AddTenantDeactivationSaga")] - partial class AddTenantDeactivationSaga - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Identity.Application.Sagas.TenantDeactivationSaga", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("CategoriesCascaded") - .HasColumnType("boolean"); - - b.Property("ProductsCascaded") - .HasColumnType("boolean"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UsersCascaded") - .HasColumnType("boolean"); - - b.Property("Version") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("TenantDeactivationSagas", (string)null); - }); - - modelBuilder.Entity("Identity.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("KeycloakUserId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("User"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("KeycloakUserId") - .IsUnique() - .HasFilter("\"KeycloakUserId\" IS NOT NULL"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "NormalizedEmail") - .IsUnique(); - - b.HasIndex("TenantId", "NormalizedUsername") - .IsUnique(); - - b.ToTable("Users", t => - { - t.HasCheckConstraint("CK_Users_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("IsActive"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("Tenants", t => - { - t.HasCheckConstraint("CK_Tenants_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.TenantInvitation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("ExpiresAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("Pending"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TokenHash"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "NormalizedEmail"); - - b.ToTable("TenantInvitations", t => - { - t.HasCheckConstraint("CK_TenantInvitations_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.AppUser", b => - { - b.HasOne("Identity.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("AppUserId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("TenantId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("Identity.Domain.Entities.TenantInvitation", b => - { - b.HasOne("Identity.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("TenantInvitationId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantInvitationId"); - - b1.ToTable("TenantInvitations"); - - b1.WithOwner() - .HasForeignKey("TenantInvitationId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329104558_AddTenantDeactivationSaga.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329104558_AddTenantDeactivationSaga.cs deleted file mode 100644 index 30ee2cc2..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329104558_AddTenantDeactivationSaga.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Identity.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddTenantDeactivationSaga : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "TenantDeactivationSagas", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - TenantId = table.Column(type: "uuid", nullable: false), - UsersCascaded = table.Column(type: "boolean", nullable: false), - ProductsCascaded = table.Column(type: "boolean", nullable: false), - CategoriesCascaded = table.Column(type: "boolean", nullable: false), - Version = table.Column(type: "integer", nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_TenantDeactivationSagas", x => x.Id); - } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "TenantDeactivationSagas"); - } - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329142114_MoveTenantDeactivationSagaToSagasSchema.Designer.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329142114_MoveTenantDeactivationSagaToSagasSchema.Designer.cs deleted file mode 100644 index b7f92f6f..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329142114_MoveTenantDeactivationSagaToSagasSchema.Designer.cs +++ /dev/null @@ -1,414 +0,0 @@ -// -using System; -using Identity.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Identity.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(IdentityDbContext))] - [Migration("20260329142114_MoveTenantDeactivationSagaToSagasSchema")] - partial class MoveTenantDeactivationSagaToSagasSchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Identity.Application.Sagas.TenantDeactivationSaga", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("CategoriesCascaded") - .HasColumnType("boolean"); - - b.Property("ProductsCascaded") - .HasColumnType("boolean"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UsersCascaded") - .HasColumnType("boolean"); - - b.Property("Version") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("TenantDeactivationSagas", "sagas"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("KeycloakUserId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("User"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("KeycloakUserId") - .IsUnique() - .HasFilter("\"KeycloakUserId\" IS NOT NULL"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "NormalizedEmail") - .IsUnique(); - - b.HasIndex("TenantId", "NormalizedUsername") - .IsUnique(); - - b.ToTable("Users", t => - { - t.HasCheckConstraint("CK_Users_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("IsActive"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("Tenants", t => - { - t.HasCheckConstraint("CK_Tenants_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.TenantInvitation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("ExpiresAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("Pending"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TokenHash"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "NormalizedEmail"); - - b.ToTable("TenantInvitations", t => - { - t.HasCheckConstraint("CK_TenantInvitations_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.AppUser", b => - { - b.HasOne("Identity.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("AppUserId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("TenantId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("Identity.Domain.Entities.TenantInvitation", b => - { - b.HasOne("Identity.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("TenantInvitationId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantInvitationId"); - - b1.ToTable("TenantInvitations"); - - b1.WithOwner() - .HasForeignKey("TenantInvitationId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329142114_MoveTenantDeactivationSagaToSagasSchema.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329142114_MoveTenantDeactivationSagaToSagasSchema.cs deleted file mode 100644 index e39f8b4e..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/20260329142114_MoveTenantDeactivationSagaToSagasSchema.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Identity.Infrastructure.Persistence.Migrations -{ - /// - public partial class MoveTenantDeactivationSagaToSagasSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema(name: "sagas"); - - migrationBuilder.RenameTable( - name: "TenantDeactivationSagas", - newName: "TenantDeactivationSagas", - newSchema: "sagas" - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameTable( - name: "TenantDeactivationSagas", - schema: "sagas", - newName: "TenantDeactivationSagas" - ); - } - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs deleted file mode 100644 index 38753559..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs +++ /dev/null @@ -1,411 +0,0 @@ -// -using System; -using Identity.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Identity.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(IdentityDbContext))] - partial class IdentityDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Identity.Application.Sagas.TenantDeactivationSaga", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("CategoriesCascaded") - .HasColumnType("boolean"); - - b.Property("ProductsCascaded") - .HasColumnType("boolean"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UsersCascaded") - .HasColumnType("boolean"); - - b.Property("Version") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("TenantDeactivationSagas", "sagas"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("KeycloakUserId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("User"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("KeycloakUserId") - .IsUnique() - .HasFilter("\"KeycloakUserId\" IS NOT NULL"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "NormalizedEmail") - .IsUnique(); - - b.HasIndex("TenantId", "NormalizedUsername") - .IsUnique(); - - b.ToTable("Users", t => - { - t.HasCheckConstraint("CK_Users_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("IsActive"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("Tenants", t => - { - t.HasCheckConstraint("CK_Tenants_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.TenantInvitation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("ExpiresAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("Pending"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TokenHash"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "NormalizedEmail"); - - b.ToTable("TenantInvitations", t => - { - t.HasCheckConstraint("CK_TenantInvitations_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Identity.Domain.Entities.AppUser", b => - { - b.HasOne("Identity.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("AppUserId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("TenantId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("Identity.Domain.Entities.TenantInvitation", b => - { - b.HasOne("Identity.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("TenantInvitationId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantInvitationId"); - - b1.ToTable("TenantInvitations"); - - b1.WithOwner() - .HasForeignKey("TenantInvitationId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("Identity.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Repositories/TenantInvitationRepository.cs b/src/Services/Identity/Identity.Infrastructure/Repositories/TenantInvitationRepository.cs deleted file mode 100644 index 634f2dc7..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Repositories/TenantInvitationRepository.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Identity.Domain.Entities; -using Identity.Domain.Enums; -using Identity.Domain.Interfaces; -using Identity.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using SharedKernel.Infrastructure.Repositories; - -namespace Identity.Infrastructure.Repositories; - -/// -/// EF Core repository for with token hash and pending-invitation lookup methods. -/// -public sealed class TenantInvitationRepository - : RepositoryBase, - ITenantInvitationRepository -{ - private readonly IdentityDbContext _identityDb; - - public TenantInvitationRepository(IdentityDbContext dbContext) - : base(dbContext) - { - _identityDb = dbContext; - } - - /// Returns a pending invitation matching the given token hash, or null if none is found. - public Task GetValidByTokenHashAsync( - string tokenHash, - CancellationToken ct = default - ) => - _identityDb.TenantInvitations.FirstOrDefaultAsync( - i => i.TokenHash == tokenHash && i.Status == InvitationStatus.Pending, - ct - ); - - /// Returns true when a pending invitation already exists for the given normalized email address. - public Task HasPendingInvitationAsync( - string normalizedEmail, - CancellationToken ct = default - ) => - _identityDb.TenantInvitations.AnyAsync( - i => i.NormalizedEmail == normalizedEmail && i.Status == InvitationStatus.Pending, - ct - ); -} diff --git a/src/Services/Identity/Identity.Infrastructure/Repositories/TenantRepository.cs b/src/Services/Identity/Identity.Infrastructure/Repositories/TenantRepository.cs deleted file mode 100644 index 0d835baf..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Repositories/TenantRepository.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Ardalis.Specification; -using Identity.Domain.Entities; -using Identity.Domain.Interfaces; -using Identity.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using SharedKernel.Infrastructure.Repositories; - -namespace Identity.Infrastructure.Repositories; - -/// -/// EF Core repository for that bypasses the tenant global query filter -/// so tenants can be looked up by ID or code without an active tenant context. -/// -public sealed class TenantRepository : RepositoryBase, ITenantRepository -{ - private readonly IdentityDbContext _identityDb; - - public TenantRepository(IdentityDbContext dbContext) - : base(dbContext) - { - _identityDb = dbContext; - } - - private IQueryable UnfilteredTenants => - _identityDb.Tenants.IgnoreQueryFilters(["Tenant"]); - - /// Applies the specification to the tenant-filter-bypassed queryable so specifications work across all tenants. - protected override IQueryable ApplySpecification( - ISpecification specification, - bool evaluateCriteriaOnly = false - ) - { - return SpecificationEvaluator.GetQuery( - UnfilteredTenants, - specification, - evaluateCriteriaOnly - ); - } - - protected override IQueryable ApplySpecification( - ISpecification specification - ) - { - return SpecificationEvaluator.GetQuery(UnfilteredTenants, specification); - } - - public override async Task GetByIdAsync( - TId id, - CancellationToken cancellationToken = default - ) - where TId : default - { - if (id is not Guid guid) - throw new ArgumentException( - $"Expected Guid but received {typeof(TId).Name}.", - nameof(id) - ); - - return await UnfilteredTenants.FirstOrDefaultAsync(t => t.Id == guid, cancellationToken); - } - - /// - /// Checks whether a tenant with the given code exists, bypassing both tenant and soft-delete - /// filters to prevent reuse of codes from deleted tenants. - /// - public Task CodeExistsAsync(string code, CancellationToken ct = default) - { - return _identityDb - .Tenants.IgnoreQueryFilters(["Tenant", "SoftDelete"]) - .AnyAsync(t => t.Code == code, ct); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Repositories/UserRepository.cs b/src/Services/Identity/Identity.Infrastructure/Repositories/UserRepository.cs deleted file mode 100644 index 3cc5a2d7..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Repositories/UserRepository.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Identity.Application.Features.User.Specifications; -using Identity.Domain.Entities; -using Identity.Domain.Interfaces; -using Identity.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Repositories; - -namespace Identity.Infrastructure.Repositories; - -/// EF Core repository for with specification-based lookup by email and username. -public sealed class UserRepository : RepositoryBase, IUserRepository -{ - public UserRepository(IdentityDbContext dbContext) - : base(dbContext) { } - - public Task ExistsByEmailAsync(string email, CancellationToken ct = default) => - AnyAsync(new UserByEmailSpecification(email), ct); - - public Task ExistsByUsernameAsync( - string normalizedUsername, - CancellationToken ct = default - ) => AnyAsync(new UserByUsernameSpecification(normalizedUsername), ct); - - public Task FindByEmailAsync(string email, CancellationToken ct = default) => - FirstOrDefaultAsync(new UserByEmailSpecification(email), ct); -} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/HttpActorProvider.cs b/src/Services/Identity/Identity.Infrastructure/Security/HttpActorProvider.cs deleted file mode 100644 index 49c105d9..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/HttpActorProvider.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Security.Claims; -using Identity.Application.Options; -using Identity.Application.Security; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; -using SharedKernel.Application.Context; - -namespace Identity.Infrastructure.Security; - -/// -/// Resolves actor identity for auditing from the current HTTP principal. -/// -public sealed class HttpActorProvider : IActorProvider -{ - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly SystemIdentityOptions _systemIdentity; - - public HttpActorProvider( - IHttpContextAccessor httpContextAccessor, - IOptions systemIdentityOptions - ) - { - _httpContextAccessor = httpContextAccessor; - _systemIdentity = systemIdentityOptions.Value; - } - - public Guid ActorId - { - get - { - ClaimsPrincipal? user = _httpContextAccessor.HttpContext?.User; - string? raw = - user?.FindFirstValue(ClaimTypes.NameIdentifier) - ?? user?.FindFirstValue(AuthConstants.Claims.Subject) - ?? user?.FindFirstValue(ClaimTypes.Name); - - return Guid.TryParse(raw, out Guid id) ? id : _systemIdentity.DefaultActorId; - } - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs deleted file mode 100644 index 9676ebb5..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs +++ /dev/null @@ -1,161 +0,0 @@ -using Identity.Application.Options; -using Identity.Application.Security; -using Keycloak.AuthServices.Sdk.Admin; -using Keycloak.AuthServices.Sdk.Admin.Models; -using Keycloak.AuthServices.Sdk.Admin.Requests.Users; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Identity.Infrastructure.Security.Keycloak; - -/// -/// Keycloak Admin REST API client facade that wraps user lifecycle operations -/// (create, enable/disable, password reset, delete) using the Keycloak SDK. -/// -public sealed class KeycloakAdminService : IKeycloakAdminService -{ - private readonly IKeycloakUserClient _userClient; - private readonly string _realm; - private readonly ILogger _logger; - - public KeycloakAdminService( - IKeycloakUserClient userClient, - IOptions keycloakOptions, - ILogger logger - ) - { - _userClient = userClient; - _realm = keycloakOptions.Value.Realm; - _logger = logger; - } - - public async Task CreateUserAsync( - string username, - string email, - CancellationToken ct = default - ) - { - UserRepresentation user = new() - { - Username = username, - Email = email, - Enabled = true, - EmailVerified = false, - }; - - using HttpResponseMessage response = await _userClient.CreateUserWithResponseAsync( - _realm, - user, - ct - ); - response.EnsureSuccessStatusCode(); - - string keycloakUserId = ExtractUserIdFromLocation(response); - - _logger.LogInformation( - "Created Keycloak user {Username} with id {KeycloakUserId}", - username, - keycloakUserId - ); - - try - { - await _userClient.ExecuteActionsEmailAsync( - _realm, - keycloakUserId, - new ExecuteActionsEmailRequest - { - Actions = - [ - AuthConstants.KeycloakActions.VerifyEmail, - AuthConstants.KeycloakActions.UpdatePassword, - ], - }, - ct - ); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning( - ex, - "Failed to send setup email for Keycloak user {KeycloakUserId}. User was created but has no setup email.", - keycloakUserId - ); - } - - return keycloakUserId; - } - - public async Task SendPasswordResetEmailAsync( - string keycloakUserId, - CancellationToken ct = default - ) - { - await _userClient.ExecuteActionsEmailAsync( - _realm, - keycloakUserId, - new ExecuteActionsEmailRequest - { - Actions = [AuthConstants.KeycloakActions.UpdatePassword], - }, - ct - ); - - _logger.LogInformation( - "Sent password reset email to Keycloak user {KeycloakUserId}", - keycloakUserId - ); - } - - public async Task SetUserEnabledAsync( - string keycloakUserId, - bool enabled, - CancellationToken ct = default - ) - { - UserRepresentation patch = new() { Enabled = enabled }; - await _userClient.UpdateUserAsync(_realm, keycloakUserId, patch, ct); - - _logger.LogInformation( - "Set Keycloak user {KeycloakUserId} enabled={Enabled}", - keycloakUserId, - enabled - ); - } - - public async Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = default) - { - try - { - await _userClient.DeleteUserAsync(_realm, keycloakUserId, ct); - } - catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) - { - _logger.LogWarning( - "Keycloak user {KeycloakUserId} was not found during delete — treating as already deleted.", - keycloakUserId - ); - return; - } - - _logger.LogInformation("Deleted Keycloak user {KeycloakUserId}", keycloakUserId); - } - - private static string ExtractUserIdFromLocation(HttpResponseMessage response) - { - Uri location = - response.Headers.Location - ?? throw new InvalidOperationException( - "Keycloak CreateUser response did not include a Location header." - ); - - string userId = location.Segments[^1].TrimEnd('/'); - - if (string.IsNullOrWhiteSpace(userId)) - throw new InvalidOperationException( - $"Could not extract user ID from Keycloak Location header: {location}" - ); - - return userId; - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs deleted file mode 100644 index f19ed32a..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Net.Http.Headers; - -namespace Identity.Infrastructure.Security.Keycloak; - -/// -/// A transient that attaches a cached Keycloak -/// service-account Bearer token to every outbound admin API request. -/// -public sealed class KeycloakAdminTokenHandler : DelegatingHandler -{ - private readonly KeycloakAdminTokenProvider _tokenProvider; - - public KeycloakAdminTokenHandler(KeycloakAdminTokenProvider tokenProvider) - { - _tokenProvider = tokenProvider; - } - - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken - ) - { - string token = await _tokenProvider.GetTokenAsync(cancellationToken); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - return await base.SendAsync(request, cancellationToken); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs deleted file mode 100644 index c2aac104..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Net.Http.Json; -using Identity.Application.Options; -using Identity.Application.Security; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Identity.Infrastructure.Security.Keycloak; - -/// -/// Singleton service that acquires and caches a Keycloak service-account (client credentials) token. -/// -public sealed class KeycloakAdminTokenProvider : IDisposable -{ - private static readonly TimeSpan ExpiryMargin = TimeSpan.FromSeconds(30); - - private readonly IHttpClientFactory _httpClientFactory; - private readonly IOptions _keycloakOptions; - private readonly ILogger _logger; - private readonly TimeProvider _timeProvider; - - private string? _cachedToken; - private DateTimeOffset _tokenExpiresAt = DateTimeOffset.MinValue; - private readonly SemaphoreSlim _lock = new(1, 1); - - public KeycloakAdminTokenProvider( - IHttpClientFactory httpClientFactory, - IOptions keycloakOptions, - ILogger logger, - TimeProvider timeProvider - ) - { - _httpClientFactory = httpClientFactory; - _keycloakOptions = keycloakOptions; - _logger = logger; - _timeProvider = timeProvider; - } - - public async Task GetTokenAsync(CancellationToken cancellationToken) - { - if (IsTokenValid()) - return _cachedToken!; - - await _lock.WaitAsync(cancellationToken); - try - { - if (IsTokenValid()) - return _cachedToken!; - - KeycloakTokenResponse response = await FetchTokenAsync(cancellationToken); - - if (string.IsNullOrWhiteSpace(response.AccessToken)) - throw new InvalidOperationException( - "Keycloak token endpoint returned a response with an empty access_token." - ); - - _cachedToken = response.AccessToken; - _tokenExpiresAt = _timeProvider.GetUtcNow().AddSeconds(response.ExpiresIn); - return _cachedToken; - } - finally - { - _lock.Release(); - } - } - - private async Task FetchTokenAsync(CancellationToken cancellationToken) - { - KeycloakOptions keycloak = _keycloakOptions.Value; - string tokenEndpoint = KeycloakUrlHelper.BuildTokenEndpoint( - keycloak.AuthServerUrl, - keycloak.Realm - ); - - using HttpClient client = _httpClientFactory.CreateClient( - AuthConstants.HttpClients.KeycloakToken - ); - using FormUrlEncodedContent content = new( - new Dictionary - { - [AuthConstants.OAuth2FormParameters.GrantType] = AuthConstants - .OAuth2GrantTypes - .ClientCredentials, - [AuthConstants.OAuth2FormParameters.ClientId] = keycloak.Resource, - [AuthConstants.OAuth2FormParameters.ClientSecret] = keycloak.Credentials.Secret, - } - ); - - using HttpResponseMessage response = await client.PostAsync( - tokenEndpoint, - content, - cancellationToken - ); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError( - "Failed to acquire Keycloak admin token. Status: {Status}. Body: {Body}", - (int)response.StatusCode, - body - ); - response.EnsureSuccessStatusCode(); - } - - KeycloakTokenResponse token = - await response.Content.ReadFromJsonAsync(cancellationToken) - ?? throw new InvalidOperationException("Keycloak token endpoint returned empty body."); - - return token; - } - - private bool IsTokenValid() => - _cachedToken is not null && _timeProvider.GetUtcNow() < _tokenExpiresAt - ExpiryMargin; - - public void Dispose() => _lock.Dispose(); -} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs deleted file mode 100644 index a2e5e0a3..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SharedKernel.Application.Security; - -namespace Identity.Infrastructure.Security.Keycloak; - -/// -/// Maps Keycloak-specific JWT claims into standard .NET claim types. -/// -public static class KeycloakClaimMapper -{ - public static void MapKeycloakClaims(System.Security.Claims.ClaimsIdentity identity) => - KeycloakClaimsPrincipalMapper.MapClaims( - new System.Security.Claims.ClaimsPrincipal(identity) - ); -} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs deleted file mode 100644 index 199d6913..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Identity.Infrastructure.Security.Keycloak; - -/// Represents the token response body returned by the Keycloak token endpoint. -internal sealed record KeycloakTokenResponse( - [property: JsonPropertyName("access_token")] string AccessToken, - [property: JsonPropertyName("refresh_token")] string? RefreshToken, - [property: JsonPropertyName("expires_in")] int ExpiresIn -); diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs deleted file mode 100644 index c57002fc..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Identity.Application.Security; - -namespace Identity.Infrastructure.Security.Keycloak; - -/// -/// Builds well-known Keycloak URL components (authority, discovery, token endpoint) from -/// configured base URL and realm name. -/// -public static class KeycloakUrlHelper -{ - public static string BuildAuthority(string authServerUrl, string realm) => - $"{authServerUrl.TrimEnd('/')}/realms/{realm}"; - - public static string BuildDiscoveryUrl(string authServerUrl, string realm) => - $"{BuildAuthority(authServerUrl, realm)}/.well-known/openid-configuration"; - - public static string BuildTokenEndpoint(string authServerUrl, string realm) => - $"{BuildAuthority(authServerUrl, realm)}/{AuthConstants.OpenIdConnect.TokenEndpointPath}"; -} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/SecureTokenGenerator.cs b/src/Services/Identity/Identity.Infrastructure/Security/SecureTokenGenerator.cs deleted file mode 100644 index c9aaa9bb..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/SecureTokenGenerator.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Security.Cryptography; -using Identity.Application.Security; - -namespace Identity.Infrastructure.Security; - -/// -/// Generates cryptographically random tokens and produces their SHA-256 hex digest -/// for safe storage in the database. -/// -public sealed class SecureTokenGenerator : ISecureTokenGenerator -{ - public string GenerateToken() - { - byte[] bytes = RandomNumberGenerator.GetBytes(32); - return Convert.ToBase64String(bytes); - } - - public string HashToken(string token) - { - byte[] bytes = System.Text.Encoding.UTF8.GetBytes(token); - byte[] hash = SHA256.HashData(bytes); - return Convert.ToHexStringLower(hash); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs deleted file mode 100644 index cd52798c..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Security.Claims; -using Identity.Application.Security; -using Identity.Infrastructure.Security.Keycloak; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using JwtTokenValidatedContext = Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext; -using OidcTokenValidatedContext = Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext; - -namespace Identity.Infrastructure.Security.Tenant; - -/// -/// Validates tenant-related claims after JWT/OIDC token validation and normalizes -/// Keycloak claims into standard .NET claim types used by authorization policies. -/// -public static class TenantClaimValidator -{ - public static async Task OnTokenValidated(JwtTokenValidatedContext context) - { - await ValidateTokenAsync( - context.Principal, - context.HttpContext, - JwtBearerDefaults.AuthenticationScheme, - reason => context.Fail(reason) - ); - } - - public static async Task OnTokenValidated(OidcTokenValidatedContext context) - { - await ValidateTokenAsync( - context.Principal, - context.HttpContext, - OpenIdConnectDefaults.AuthenticationScheme, - reason => context.Fail(reason) - ); - } - - private static async Task ValidateTokenAsync( - ClaimsPrincipal? principal, - HttpContext httpContext, - string schemeName, - Action fail - ) - { - ClaimsIdentity? identity = principal?.Identity as ClaimsIdentity; - if (identity != null) - KeycloakClaimMapper.MapKeycloakClaims(identity); - - Domain.Entities.AppUser? user = null; - if (!IsServiceAccount(principal)) - user = await TryProvisionUserAsync(httpContext, principal); - - if (identity is not null && user is not null) - EnrichIdentity(identity, user); - - if (!HasValidTenantClaim(principal) && !IsServiceAccount(principal)) - { - fail($"Missing required {AuthConstants.Claims.TenantId} claim."); - } - - LogTokenValidated(httpContext, principal, schemeName); - } - - public static bool HasValidTenantClaim(ClaimsPrincipal? principal) - { - return principal?.HasClaim(c => - c.Type == AuthConstants.Claims.TenantId - && Guid.TryParse(c.Value, out Guid tenantId) - && tenantId != Guid.Empty - ) == true; - } - - private static async Task TryProvisionUserAsync( - HttpContext httpContext, - ClaimsPrincipal? principal - ) - { - try - { - string? sub = principal?.FindFirstValue(AuthConstants.Claims.Subject); - string? email = principal?.FindFirstValue(ClaimTypes.Email); - string? username = principal?.FindFirstValue(AuthConstants.Claims.PreferredUsername); - - if ( - string.IsNullOrEmpty(sub) - || string.IsNullOrEmpty(email) - || string.IsNullOrEmpty(username) - ) - return null; - - IUserProvisioningService provisioningService = - httpContext.RequestServices.GetRequiredService(); - - return await provisioningService.ProvisionIfNeededAsync(sub, email, username); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - ILogger logger = httpContext - .RequestServices.GetRequiredService() - .CreateLogger(typeof(TenantClaimValidator)); - - logger.LogWarning( - ex, - "User provisioning failed during token validation — authentication will continue" - ); - - return null; - } - } - - private static void EnrichIdentity(ClaimsIdentity identity, Domain.Entities.AppUser user) - { - ReplaceClaim(identity, AuthConstants.Claims.TenantId, user.TenantId.ToString()); - ReplaceClaim(identity, ClaimTypes.NameIdentifier, user.Id.ToString()); - } - - private static void ReplaceClaim(ClaimsIdentity identity, string claimType, string value) - { - foreach (Claim existing in identity.FindAll(claimType).ToArray()) - identity.RemoveClaim(existing); - - identity.AddClaim(new Claim(claimType, value)); - } - - private static bool IsServiceAccount(ClaimsPrincipal? principal) - { - string? username = principal?.FindFirstValue(AuthConstants.Claims.PreferredUsername); - return username != null - && username.StartsWith( - AuthConstants.Claims.ServiceAccountUsernamePrefix, - StringComparison.OrdinalIgnoreCase - ); - } - - private static void LogTokenValidated( - HttpContext httpContext, - ClaimsPrincipal? principal, - string scheme - ) - { - ILogger logger = httpContext - .RequestServices.GetRequiredService() - .CreateLogger(typeof(TenantClaimValidator)); - - if (principal?.Identity is not ClaimsIdentity identity) - { - logger.LogWarning("[{Scheme}] Token validated but no identity found", scheme); - return; - } - - string? name = identity.FindFirst(ClaimTypes.Name)?.Value; - string[] roles = identity.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); - string? tenantId = identity.FindFirst(AuthConstants.Claims.TenantId)?.Value; - - logger.LogInformation( - "[{Scheme}] Authenticated user={User}, tenant={TenantId}, roles=[{Roles}]", - scheme, - name, - tenantId, - string.Join(", ", roles) - ); - } -} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/UserProvisioningService.cs b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/UserProvisioningService.cs deleted file mode 100644 index ea4ed329..00000000 --- a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/UserProvisioningService.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Identity.Application.Security; -using Identity.Domain.Entities; -using Identity.Domain.Enums; -using Identity.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SharedKernel.Domain.Interfaces; - -namespace Identity.Infrastructure.Security.Tenant; - -/// -/// Provisions a new on first login when an accepted -/// exists for the authenticated email address. -/// Idempotent: returns the existing user immediately if one is already linked -/// to the given Keycloak subject ID. -/// -public sealed class UserProvisioningService : IUserProvisioningService -{ - private readonly IdentityDbContext _db; - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - - public UserProvisioningService( - IdentityDbContext db, - IUnitOfWork unitOfWork, - ILogger logger - ) - { - _db = db; - _unitOfWork = unitOfWork; - _logger = logger; - } - - public async Task ProvisionIfNeededAsync( - string keycloakUserId, - string email, - string username, - CancellationToken ct = default - ) - { - AppUser? existing = await _db - .Users.IgnoreQueryFilters() - .FirstOrDefaultAsync(u => u.KeycloakUserId == keycloakUserId, ct); - - if (existing is not null) - { - _logger.LogDebug( - "User provisioning skipped — AppUser already exists for KeycloakUserId={KeycloakUserId}", - keycloakUserId - ); - return existing; - } - - string normalizedEmail = AppUser.NormalizeEmail(email); - - TenantInvitation? invitation = await _db - .TenantInvitations.IgnoreQueryFilters() - .FirstOrDefaultAsync( - i => i.NormalizedEmail == normalizedEmail && i.Status == InvitationStatus.Accepted, - ct - ); - - if (invitation is null) - { - _logger.LogInformation( - "User provisioning skipped — no accepted invitation found for email={NormalizedEmail}", - normalizedEmail - ); - return null; - } - - AppUser user = new() - { - Username = username, - Email = email, - KeycloakUserId = keycloakUserId, - TenantId = invitation.TenantId, - IsActive = true, - Role = UserRole.User, - }; - - try - { - await _db.Users.AddAsync(user, ct); - await _unitOfWork.CommitAsync(ct); - _logger.LogInformation( - "Provisioned new AppUser={UserId} for KeycloakUserId={KeycloakUserId}, TenantId={TenantId}", - user.Id, - keycloakUserId, - invitation.TenantId - ); - return user; - } - catch (DbUpdateException ex) - { - _logger.LogWarning( - ex, - "DbUpdateException during provisioning for {KeycloakUserId}. Re-fetching.", - keycloakUserId - ); - - return await _db - .Users.IgnoreQueryFilters() - .FirstOrDefaultAsync(u => u.KeycloakUserId == keycloakUserId, ct) - ?? throw new InvalidOperationException( - $"Provisioning failed for KeycloakUserId={keycloakUserId} and no existing user was found.", - ex - ); - } - } -} diff --git a/src/Services/Notifications/Notifications.Api/Dockerfile b/src/Services/Notifications/Notifications.Api/Dockerfile deleted file mode 100644 index 541654e9..00000000 --- a/src/Services/Notifications/Notifications.Api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build -WORKDIR /src -COPY . . -RUN dotnet restore "src/Services/Notifications/Notifications.Api/Notifications.Api.csproj" -RUN dotnet publish "src/Services/Notifications/Notifications.Api/Notifications.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "Notifications.Api.dll"] diff --git a/src/Services/Notifications/Notifications.Api/Notifications.Api.csproj b/src/Services/Notifications/Notifications.Api/Notifications.Api.csproj deleted file mode 100644 index d3935621..00000000 --- a/src/Services/Notifications/Notifications.Api/Notifications.Api.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - diff --git a/src/Services/Notifications/Notifications.Api/Program.cs b/src/Services/Notifications/Notifications.Api/Program.cs deleted file mode 100644 index 16ad5c73..00000000 --- a/src/Services/Notifications/Notifications.Api/Program.cs +++ /dev/null @@ -1,127 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Notifications.Application.Common.Constants; -using Notifications.Application.Features.Emails.EventHandlers; -using Notifications.Application.Options; -using Notifications.Domain.Interfaces; -using Notifications.Infrastructure.Email; -using Notifications.Infrastructure.Persistence; -using Notifications.Infrastructure.Repositories; -using Polly; -using Polly.Retry; -using SharedKernel.Api.Extensions; -using SharedKernel.Messaging.Conventions; -using SharedKernel.Messaging.Topology; -using Wolverine; -using Wolverine.Http; -using Wolverine.RabbitMQ; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Host.UseSharedSerilog(); -builder.Services.AddSharedObservability( - builder.Configuration, - builder.Environment, - "notifications" -); - -// Database -string connectionString = builder.Configuration.GetRequiredConnectionString("DefaultConnection"); - -builder.Services.AddDbContext(options => - options.UseNpgsql(connectionString) -); - -// Email options -builder.Services.AddValidatedOptions(builder.Configuration, EmailOptions.SectionName); - -// Email services (queue, sender, renderer, store) -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddTransient(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); - -// TimeProvider for UTC timestamps -builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddSharedKeycloakJwtBearer(builder.Configuration, builder.Environment); -builder.Services.AddSharedAuthorization(); -builder.Services.AddSharedApiErrorHandling(); - -// Repository -builder.Services.AddScoped(); - -// Health checks -builder.Services.AddHealthChecks(); -builder.Services.AddSharedOpenApiDocumentation(); -builder.Services.AddWolverineHttp(); - -// Wolverine with RabbitMQ -builder.Host.UseWolverine(opts => -{ - opts.Discovery.IncludeAssembly(typeof(UserRegisteredNotificationHandler).Assembly); - - // Shared conventions - opts.ApplySharedConventions(); - opts.ApplySharedRetryPolicies(); - - // RabbitMQ transport - opts.UseSharedRabbitMq(builder.Configuration); - - // Listen to notification queues - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Notifications.UserRegistered, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.Identity); - } - ); - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Notifications.UserRoleChanged, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.Identity); - } - ); - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Notifications.InvitationCreated, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.Identity); - } - ); -}); - -// Resilience pipeline for SMTP retries -EmailOptions emailOptions = builder.Configuration.GetRequiredOptions( - EmailOptions.SectionName -); - -builder.Services.AddResiliencePipeline( - NotificationConstants.SmtpResiliencePipelineKey, - pipelineBuilder => - { - pipelineBuilder.AddRetry( - new RetryStrategyOptions - { - MaxRetryAttempts = emailOptions.MaxRetryAttempts, - BackoffType = DelayBackoffType.Exponential, - Delay = TimeSpan.FromSeconds(emailOptions.RetryBaseDelaySeconds), - UseJitter = true, - } - ); - } -); - -WebApplication app = builder.Build(); - -await app.MigrateDbAsync(); - -app.UseSharedExceptionHandlerAndAuthentication(); -app.UseSharedAuthorizationCachingAndInfrastructure(useOutputCaching: false); -app.MapWolverineEndpoints(); - -await app.RunAsync(); - -public partial class Program; diff --git a/src/Services/Notifications/Notifications.Api/Properties/launchSettings.json b/src/Services/Notifications/Notifications.Api/Properties/launchSettings.json deleted file mode 100644 index 837b2e91..00000000 --- a/src/Services/Notifications/Notifications.Api/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5233", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7057;http://localhost:5233", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Services/Notifications/Notifications.Api/appsettings.json b/src/Services/Notifications/Notifications.Api/appsettings.json deleted file mode 100644 index 4cdae3f3..00000000 --- a/src/Services/Notifications/Notifications.Api/appsettings.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "Host=postgres;Database=notifications_db;Username=postgres;Password=postgres" - }, - "Keycloak": { - "realm": "api-template", - "auth-server-url": "http://localhost:8180/", - "resource": "api-template" - }, - "RabbitMQ": { - "HostName": "rabbitmq" - }, - "Email": { - "SmtpHost": "mailpit", - "SmtpPort": 1025, - "UseSsl": false, - "SenderEmail": "noreply@api-template.local", - "SenderName": "API Template", - "BaseUrl": "http://localhost:3000", - "MaxRetryAttempts": 3, - "RetryBaseDelaySeconds": 2, - "InvitationTokenExpiryHours": 72 - }, - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "WriteTo": [ - { "Name": "Console" } - ] - }, - "Logging": { - "LogLevel": { - "Default": "Information" - } - } -} diff --git a/src/Services/Notifications/Notifications.Application/Common/Constants/NotificationConstants.cs b/src/Services/Notifications/Notifications.Application/Common/Constants/NotificationConstants.cs deleted file mode 100644 index 160348d1..00000000 --- a/src/Services/Notifications/Notifications.Application/Common/Constants/NotificationConstants.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Notifications.Application.Common.Constants; - -/// -/// Centralizes resilience pipeline keys and other shared identifiers used by the notification infrastructure. -/// -public static class NotificationConstants -{ - public const string SmtpResiliencePipelineKey = "smtp-send"; -} diff --git a/src/Services/Notifications/Notifications.Application/Features/Emails/Endpoints/GetFailedEmailsEndpoint.cs b/src/Services/Notifications/Notifications.Application/Features/Emails/Endpoints/GetFailedEmailsEndpoint.cs deleted file mode 100644 index 899daf00..00000000 --- a/src/Services/Notifications/Notifications.Application/Features/Emails/Endpoints/GetFailedEmailsEndpoint.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Notifications.Domain.Entities; -using Notifications.Domain.Interfaces; -using Wolverine.Http; - -namespace Notifications.Application.Features.Emails.Endpoints; - -/// -/// Wolverine HTTP endpoint that returns all failed email records for administrative review. -/// -public static class GetFailedEmailsEndpoint -{ - [WolverineGet("/api/v1/notifications/failed-emails")] - public static async Task> HandleAsync( - IFailedEmailRepository repository, - CancellationToken ct - ) - { - return await repository.GetAllAsync(ct); - } -} diff --git a/src/Services/Notifications/Notifications.Application/Features/Emails/Endpoints/RetryFailedEmailEndpoint.cs b/src/Services/Notifications/Notifications.Application/Features/Emails/Endpoints/RetryFailedEmailEndpoint.cs deleted file mode 100644 index f14a6cf7..00000000 --- a/src/Services/Notifications/Notifications.Application/Features/Emails/Endpoints/RetryFailedEmailEndpoint.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Notifications.Domain.Entities; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; -using Wolverine.Http; - -namespace Notifications.Application.Features.Emails.Endpoints; - -/// -/// Wolverine HTTP endpoint that re-enqueues a failed email for retry delivery. -/// -public static class RetryFailedEmailEndpoint -{ - [WolverinePost("/api/v1/notifications/failed-emails/{id}/retry")] - public static async Task HandleAsync( - Guid id, - IFailedEmailRepository repository, - IEmailQueue queue, - CancellationToken ct - ) - { - FailedEmail? email = await repository.GetByIdAsync(id, ct); - if (email is null) - { - return Results.NotFound($"Failed email {id} not found"); - } - - email.RetryCount++; - email.LastAttemptAtUtc = null; - email.ClaimedBy = null; - await repository.UpdateAsync(email, ct); - await repository.SaveChangesAsync(ct); - - await queue.EnqueueAsync( - new EmailMessage(email.To, email.Subject, email.HtmlBody, email.TemplateName), - ct - ); - - return Results.Ok(); - } -} diff --git a/src/Services/Notifications/Notifications.Application/Features/Emails/EventHandlers/TenantInvitationNotificationHandler.cs b/src/Services/Notifications/Notifications.Application/Features/Emails/EventHandlers/TenantInvitationNotificationHandler.cs deleted file mode 100644 index 33fe12cf..00000000 --- a/src/Services/Notifications/Notifications.Application/Features/Emails/EventHandlers/TenantInvitationNotificationHandler.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Microsoft.Extensions.Options; -using Notifications.Application.Options; -using Notifications.Domain.Constants; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; - -namespace Notifications.Application.Features.Emails.EventHandlers; - -/// -/// Wolverine message handler that consumes from RabbitMQ -/// and enqueues a tenant invitation email for delivery. -/// -public static class TenantInvitationNotificationHandler -{ - public static async Task HandleAsync( - TenantInvitationCreatedIntegrationEvent @event, - IEmailTemplateRenderer renderer, - IEmailQueue queue, - IOptions options, - CancellationToken ct - ) - { - string invitationUrl = $"{options.Value.BaseUrl}/invitations/accept?token={@event.Token}"; - - string html = await renderer.RenderAsync( - EmailTemplateNames.TenantInvitation, - new - { - @event.TenantName, - InvitationUrl = invitationUrl, - ExpiryHours = options.Value.InvitationTokenExpiryHours, - }, - ct - ); - - await queue.EnqueueAsync( - new EmailMessage( - @event.Email, - string.Format(EmailSubjects.TenantInvitation, @event.TenantName), - html, - EmailTemplateNames.TenantInvitation, - Retryable: true - ), - ct - ); - } -} diff --git a/src/Services/Notifications/Notifications.Application/Features/Emails/EventHandlers/UserRegisteredNotificationHandler.cs b/src/Services/Notifications/Notifications.Application/Features/Emails/EventHandlers/UserRegisteredNotificationHandler.cs deleted file mode 100644 index 3497526e..00000000 --- a/src/Services/Notifications/Notifications.Application/Features/Emails/EventHandlers/UserRegisteredNotificationHandler.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Microsoft.Extensions.Options; -using Notifications.Application.Options; -using Notifications.Domain.Constants; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; - -namespace Notifications.Application.Features.Emails.EventHandlers; - -/// -/// Wolverine message handler that consumes from RabbitMQ -/// and enqueues a welcome email for delivery. -/// -public static class UserRegisteredNotificationHandler -{ - public static async Task HandleAsync( - UserRegisteredIntegrationEvent @event, - IEmailTemplateRenderer renderer, - IEmailQueue queue, - IOptions options, - CancellationToken ct - ) - { - string html = await renderer.RenderAsync( - EmailTemplateNames.UserRegistration, - new - { - @event.Username, - @event.Email, - LoginUrl = $"{options.Value.BaseUrl}/login", - }, - ct - ); - - await queue.EnqueueAsync( - new EmailMessage( - @event.Email, - EmailSubjects.UserRegistration, - html, - EmailTemplateNames.UserRegistration, - Retryable: true - ), - ct - ); - } -} diff --git a/src/Services/Notifications/Notifications.Application/Features/Emails/EventHandlers/UserRoleChangedNotificationHandler.cs b/src/Services/Notifications/Notifications.Application/Features/Emails/EventHandlers/UserRoleChangedNotificationHandler.cs deleted file mode 100644 index 77b71168..00000000 --- a/src/Services/Notifications/Notifications.Application/Features/Emails/EventHandlers/UserRoleChangedNotificationHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Notifications.Domain.Constants; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; - -namespace Notifications.Application.Features.Emails.EventHandlers; - -/// -/// Wolverine message handler that consumes from RabbitMQ -/// and enqueues a role-change notification email for delivery. -/// -public static class UserRoleChangedNotificationHandler -{ - public static async Task HandleAsync( - UserRoleChangedIntegrationEvent @event, - IEmailTemplateRenderer renderer, - IEmailQueue queue, - CancellationToken ct - ) - { - string html = await renderer.RenderAsync( - EmailTemplateNames.UserRoleChanged, - new - { - @event.Username, - @event.OldRole, - @event.NewRole, - }, - ct - ); - - await queue.EnqueueAsync( - new EmailMessage( - @event.Email, - EmailSubjects.UserRoleChanged, - html, - EmailTemplateNames.UserRoleChanged, - Retryable: true - ), - ct - ); - } -} diff --git a/src/Services/Notifications/Notifications.Application/Notifications.Application.csproj b/src/Services/Notifications/Notifications.Application/Notifications.Application.csproj deleted file mode 100644 index c3074cfe..00000000 --- a/src/Services/Notifications/Notifications.Application/Notifications.Application.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - diff --git a/src/Services/Notifications/Notifications.Application/Options/EmailOptions.cs b/src/Services/Notifications/Notifications.Application/Options/EmailOptions.cs deleted file mode 100644 index 807c5659..00000000 --- a/src/Services/Notifications/Notifications.Application/Options/EmailOptions.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Notifications.Application.Options; - -/// -/// Configuration for the outbound SMTP email service, including connection settings, sender identity, -/// and retry behaviour. -/// -public sealed class EmailOptions -{ - public const string SectionName = "Email"; - - [Required] - public string SmtpHost { get; init; } = "localhost"; - - [Range(1, 65535)] - public int SmtpPort { get; init; } = 587; - public bool UseSsl { get; init; } = true; - - [Required] - [EmailAddress] - public string SenderEmail { get; init; } = string.Empty; - - [Required] - public string SenderName { get; init; } = string.Empty; - public string? Username { get; init; } - public string? Password { get; init; } - - [Range(1, 720)] - public int InvitationTokenExpiryHours { get; init; } = 72; - - [Required] - public string BaseUrl { get; init; } = string.Empty; - - [Range(1, 10)] - public int MaxRetryAttempts { get; init; } = 3; - - [Range(1, 300)] - public int RetryBaseDelaySeconds { get; init; } = 2; -} diff --git a/src/Services/Notifications/Notifications.Domain/Constants/EmailSubjects.cs b/src/Services/Notifications/Notifications.Domain/Constants/EmailSubjects.cs deleted file mode 100644 index d1feebcf..00000000 --- a/src/Services/Notifications/Notifications.Domain/Constants/EmailSubjects.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Notifications.Domain.Constants; - -/// -/// Central registry of email subject lines used by notification handlers. -/// Centralising these strings prevents magic-string duplication across handlers. -/// -public static class EmailSubjects -{ - public const string UserRegistration = "Welcome to the platform!"; - public const string TenantInvitation = "You've been invited to join {0}!"; - public const string UserRoleChanged = "Your role has been updated"; -} diff --git a/src/Services/Notifications/Notifications.Domain/Constants/EmailTemplateNames.cs b/src/Services/Notifications/Notifications.Domain/Constants/EmailTemplateNames.cs deleted file mode 100644 index 57a56c99..00000000 --- a/src/Services/Notifications/Notifications.Domain/Constants/EmailTemplateNames.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Notifications.Domain.Constants; - -/// -/// Central registry of email template identifiers used by . -/// Centralising these strings prevents magic-string duplication across notification handlers. -/// -public static class EmailTemplateNames -{ - public const string UserRegistration = "user-registration"; - public const string TenantInvitation = "tenant-invitation"; - public const string UserRoleChanged = "user-role-changed"; -} diff --git a/src/Services/Notifications/Notifications.Domain/Entities/FailedEmail.cs b/src/Services/Notifications/Notifications.Domain/Entities/FailedEmail.cs deleted file mode 100644 index dcb26b1f..00000000 --- a/src/Services/Notifications/Notifications.Domain/Entities/FailedEmail.cs +++ /dev/null @@ -1,27 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace Notifications.Domain.Entities; - -/// -/// Represents an email that could not be delivered and is queued for retry. -/// Supports pessimistic concurrency via claim fields to prevent duplicate processing across workers. -/// -public sealed class FailedEmail : IHasId -{ - /// Maximum character length stored for the field. - public const int LastErrorMaxLength = 2000; - - public Guid Id { get; set; } - public required string To { get; set; } - public required string Subject { get; set; } - public required string HtmlBody { get; set; } - public int RetryCount { get; set; } - public DateTime CreatedAtUtc { get; set; } - public DateTime? LastAttemptAtUtc { get; set; } - public string? LastError { get; set; } - public string? TemplateName { get; set; } - public bool IsDeadLettered { get; set; } - public string? ClaimedBy { get; set; } - public DateTime? ClaimedAtUtc { get; set; } - public DateTime? ClaimedUntilUtc { get; set; } -} diff --git a/src/Services/Notifications/Notifications.Domain/Interfaces/IEmailQueue.cs b/src/Services/Notifications/Notifications.Domain/Interfaces/IEmailQueue.cs deleted file mode 100644 index c424da20..00000000 --- a/src/Services/Notifications/Notifications.Domain/Interfaces/IEmailQueue.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Notifications.Domain.ValueObjects; - -namespace Notifications.Domain.Interfaces; - -/// -/// Write-side contract for enqueuing outbound email messages for asynchronous delivery. -/// -public interface IEmailQueue : IQueue; - -/// -/// Read-side contract for the email background service to consume queued items. -/// -public interface IEmailQueueReader : IQueueReader; diff --git a/src/Services/Notifications/Notifications.Domain/Interfaces/IEmailSender.cs b/src/Services/Notifications/Notifications.Domain/Interfaces/IEmailSender.cs deleted file mode 100644 index 219931c1..00000000 --- a/src/Services/Notifications/Notifications.Domain/Interfaces/IEmailSender.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Notifications.Domain.ValueObjects; - -namespace Notifications.Domain.Interfaces; - -/// -/// Application-layer abstraction for sending emails, decoupling the Application layer from -/// any specific mail provider (SMTP, SendGrid, AWS SES, etc.). -/// -public interface IEmailSender -{ - /// Transmits to its recipient via the configured mail provider. - Task SendAsync(EmailMessage message, CancellationToken ct = default); -} diff --git a/src/Services/Notifications/Notifications.Domain/Interfaces/IEmailTemplateRenderer.cs b/src/Services/Notifications/Notifications.Domain/Interfaces/IEmailTemplateRenderer.cs deleted file mode 100644 index c54d4599..00000000 --- a/src/Services/Notifications/Notifications.Domain/Interfaces/IEmailTemplateRenderer.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Notifications.Domain.Interfaces; - -/// -/// Application-layer abstraction for rendering HTML email bodies from named templates and a view model. -/// Decouples notification handlers from the templating engine (Razor, Scriban, Liquid, etc.). -/// -public interface IEmailTemplateRenderer -{ - /// - /// Renders the template identified by using the supplied - /// and returns the resulting HTML string. - /// - Task RenderAsync(string templateName, object model, CancellationToken ct = default); -} diff --git a/src/Services/Notifications/Notifications.Domain/Interfaces/IFailedEmailRepository.cs b/src/Services/Notifications/Notifications.Domain/Interfaces/IFailedEmailRepository.cs deleted file mode 100644 index 290d7e63..00000000 --- a/src/Services/Notifications/Notifications.Domain/Interfaces/IFailedEmailRepository.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Notifications.Domain.Entities; - -namespace Notifications.Domain.Interfaces; - -/// -/// Repository contract for records, providing pessimistic-claim operations -/// used by the email retry background service to prevent duplicate processing. -/// -public interface IFailedEmailRepository -{ - /// Persists a new failed-email record to the store. - Task AddAsync(FailedEmail failedEmail, CancellationToken ct = default); - - /// Returns all failed email records. - Task> GetAllAsync(CancellationToken ct = default); - - /// Returns a single failed email by its identifier, or null if not found. - Task GetByIdAsync(Guid id, CancellationToken ct = default); - - /// - /// Atomically claims a batch of unclaimed, retryable emails (those below ) - /// and returns them for processing. - /// - Task> ClaimRetryableBatchAsync( - int maxRetryAttempts, - int batchSize, - string claimedBy, - DateTime claimedAtUtc, - DateTime claimedUntilUtc, - CancellationToken ct = default - ); - - /// - /// Atomically claims a batch of emails whose claim lock has expired past , - /// allowing stale claims to be retried. - /// - Task> ClaimExpiredBatchAsync( - DateTime cutoff, - int batchSize, - string claimedBy, - DateTime claimedAtUtc, - DateTime claimedUntilUtc, - CancellationToken ct = default - ); - - /// Persists changes to an existing failed-email record (e.g. retry count increment or dead-letter flag). - Task UpdateAsync(FailedEmail failedEmail, CancellationToken ct = default); - - /// Permanently removes a successfully processed failed-email record from the store. - Task DeleteAsync(FailedEmail failedEmail, CancellationToken ct = default); - - /// Flushes all pending changes to the underlying store. - Task SaveChangesAsync(CancellationToken ct = default); -} diff --git a/src/Services/Notifications/Notifications.Domain/Interfaces/IFailedEmailStore.cs b/src/Services/Notifications/Notifications.Domain/Interfaces/IFailedEmailStore.cs deleted file mode 100644 index 894767b1..00000000 --- a/src/Services/Notifications/Notifications.Domain/Interfaces/IFailedEmailStore.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Notifications.Domain.ValueObjects; - -namespace Notifications.Domain.Interfaces; - -/// -/// Application-layer abstraction for persisting emails that could not be delivered, -/// enabling later inspection, manual retry, or dead-letter analysis. -/// -public interface IFailedEmailStore -{ - /// - /// Persists along with the description - /// so it can be reviewed or retried by the email retry background job. - /// - Task StoreFailedAsync(EmailMessage message, string error, CancellationToken ct = default); -} diff --git a/src/Services/Notifications/Notifications.Domain/Interfaces/IQueue.cs b/src/Services/Notifications/Notifications.Domain/Interfaces/IQueue.cs deleted file mode 100644 index 19b737ee..00000000 --- a/src/Services/Notifications/Notifications.Domain/Interfaces/IQueue.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Notifications.Domain.Interfaces; - -/// -/// Generic write-side abstraction for in-process queues used to decouple producers from -/// background consumers without taking a dependency on a specific transport (e.g. Channel, Redis). -/// -/// The type of item placed on the queue. -public interface IQueue : SharedKernel.Application.Queue.IQueue; diff --git a/src/Services/Notifications/Notifications.Domain/Interfaces/IQueueReader.cs b/src/Services/Notifications/Notifications.Domain/Interfaces/IQueueReader.cs deleted file mode 100644 index ec5afc45..00000000 --- a/src/Services/Notifications/Notifications.Domain/Interfaces/IQueueReader.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Notifications.Domain.Interfaces; - -/// -/// Generic read-side abstraction for in-process queues, allowing background consumers to drain -/// items without coupling to a specific transport implementation. -/// -/// The type of item read from the queue. -public interface IQueueReader : SharedKernel.Application.Queue.IQueueReader; diff --git a/src/Services/Notifications/Notifications.Domain/Notifications.Domain.csproj b/src/Services/Notifications/Notifications.Domain/Notifications.Domain.csproj deleted file mode 100644 index 76dfc0c7..00000000 --- a/src/Services/Notifications/Notifications.Domain/Notifications.Domain.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/Notifications/Notifications.Domain/ValueObjects/EmailMessage.cs b/src/Services/Notifications/Notifications.Domain/ValueObjects/EmailMessage.cs deleted file mode 100644 index d917d7fb..00000000 --- a/src/Services/Notifications/Notifications.Domain/ValueObjects/EmailMessage.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Notifications.Domain.ValueObjects; - -/// -/// Immutable value object representing a single outbound email queued for delivery. -/// Passed through and consumed by the email-sending background service. -/// -/// -/// Optional template name used for logging and dead-letter categorisation. -/// -/// -/// When true the email retry service will attempt redelivery on failure. -/// -public sealed record EmailMessage( - string To, - string Subject, - string HtmlBody, - string? TemplateName = null, - bool Retryable = false -); diff --git a/src/Services/Notifications/Notifications.Infrastructure/Email/ChannelEmailQueue.cs b/src/Services/Notifications/Notifications.Infrastructure/Email/ChannelEmailQueue.cs deleted file mode 100644 index 16151c2b..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Email/ChannelEmailQueue.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; -using SharedKernel.Infrastructure.Queue; - -namespace Notifications.Infrastructure.Email; - -/// -/// Bounded in-process email queue backed by a . -/// Implements both (producer) and (consumer) -/// so that callers and the sending background service remain decoupled. -/// -public sealed class ChannelEmailQueue - : BoundedChannelQueue, - IEmailQueue, - IEmailQueueReader -{ - private const int DefaultCapacity = 1000; - - public ChannelEmailQueue() - : base(DefaultCapacity) { } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Email/EmailSendingBackgroundService.cs b/src/Services/Notifications/Notifications.Infrastructure/Email/EmailSendingBackgroundService.cs deleted file mode 100644 index 2023c685..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Email/EmailSendingBackgroundService.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.Extensions.Logging; -using Notifications.Application.Common.Constants; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; -using Polly; -using Polly.Registry; -using SharedKernel.Infrastructure.Queue; - -namespace Notifications.Infrastructure.Email; - -/// -/// Hosted background service that drains , sending each -/// through the SMTP resilience pipeline and storing failures -/// via for later retry. -/// -public sealed class EmailSendingBackgroundService : QueueConsumerBackgroundService -{ - private readonly IEmailSender _sender; - private readonly ResiliencePipelineProvider _resiliencePipelineProvider; - private readonly IFailedEmailStore _failedEmailStore; - private readonly ILogger _logger; - - public EmailSendingBackgroundService( - IEmailQueueReader queue, - IEmailSender sender, - ResiliencePipelineProvider resiliencePipelineProvider, - IFailedEmailStore failedEmailStore, - ILogger logger - ) - : base(queue) - { - _sender = sender; - _resiliencePipelineProvider = resiliencePipelineProvider; - _failedEmailStore = failedEmailStore; - _logger = logger; - } - - /// Executes delivery of through the configured SMTP resilience pipeline. - protected override async Task ProcessItemAsync(EmailMessage message, CancellationToken ct) - { - ResiliencePipeline pipeline = _resiliencePipelineProvider.GetPipeline( - NotificationConstants.SmtpResiliencePipelineKey - ); - - await pipeline.ExecuteAsync( - async token => - { - await _sender.SendAsync(message, token); - }, - ct - ); - } - - /// Logs the final send failure and delegates to to persist the message for retry. - protected override async Task HandleErrorAsync( - EmailMessage message, - Exception ex, - CancellationToken ct - ) - { - _logger.LogError( - ex, - "Failed to send email to {Recipient} with subject '{Subject}' after all retry attempts.", - message.To, - message.Subject - ); - - await _failedEmailStore.StoreFailedAsync(message, ex.Message, ct); - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Email/FailedEmailErrorNormalizer.cs b/src/Services/Notifications/Notifications.Infrastructure/Email/FailedEmailErrorNormalizer.cs deleted file mode 100644 index 2ad92de7..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Email/FailedEmailErrorNormalizer.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Notifications.Domain.Entities; - -namespace Notifications.Infrastructure.Email; - -/// -/// Utility that truncates raw exception messages to the maximum length allowed by -/// before persisting them. -/// -internal static class FailedEmailErrorNormalizer -{ - /// Returns unchanged if it fits, or truncated to characters. - public static string? Normalize(string? error) - { - if (string.IsNullOrEmpty(error) || error.Length <= FailedEmail.LastErrorMaxLength) - { - return error; - } - - return error[..FailedEmail.LastErrorMaxLength]; - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Email/FailedEmailStore.cs b/src/Services/Notifications/Notifications.Infrastructure/Email/FailedEmailStore.cs deleted file mode 100644 index 1ff3c9b6..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Email/FailedEmailStore.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Notifications.Domain.Entities; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; - -namespace Notifications.Infrastructure.Email; - -/// -/// Infrastructure implementation of that persists a -/// record when delivery fails, provided the email is marked retryable. -/// Uses a new DI scope per call to avoid captive-dependency issues with scoped services. -/// -public sealed class FailedEmailStore : IFailedEmailStore -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - public FailedEmailStore(IServiceScopeFactory scopeFactory, ILogger logger) - { - _scopeFactory = scopeFactory; - _logger = logger; - } - - /// - /// Persists a new for if the message is - /// retryable; silently swallows storage errors to avoid masking the original send failure. - /// - public async Task StoreFailedAsync( - EmailMessage message, - string error, - CancellationToken ct = default - ) - { - if (!message.Retryable) - { - return; - } - - try - { - using IServiceScope scope = _scopeFactory.CreateScope(); - IFailedEmailRepository repository = - scope.ServiceProvider.GetRequiredService(); - TimeProvider timeProvider = scope.ServiceProvider.GetRequiredService(); - - FailedEmail failedEmail = new() - { - Id = Guid.NewGuid(), - To = message.To, - Subject = message.Subject, - HtmlBody = message.HtmlBody, - RetryCount = 0, - CreatedAtUtc = timeProvider.GetUtcNow().UtcDateTime, - LastError = FailedEmailErrorNormalizer.Normalize(error), - TemplateName = message.TemplateName, - ClaimedBy = null, - ClaimedAtUtc = null, - ClaimedUntilUtc = null, - }; - - await repository.AddAsync(failedEmail, ct); - await repository.SaveChangesAsync(ct); - - _logger.LogWarning( - "Stored failed email to {Recipient} with subject '{Subject}' for retry.", - message.To, - message.Subject - ); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Failed to store failed email to {Recipient} for retry.", - message.To - ); - } - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Email/FluidEmailTemplateRenderer.cs b/src/Services/Notifications/Notifications.Infrastructure/Email/FluidEmailTemplateRenderer.cs deleted file mode 100644 index f7981547..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Email/FluidEmailTemplateRenderer.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Collections.Concurrent; -using System.Reflection; -using Fluid; -using Notifications.Domain.Interfaces; - -namespace Notifications.Infrastructure.Email; - -/// -/// Renders Liquid email templates embedded as assembly resources using the Fluid library. -/// Parsed templates are cached in a to avoid -/// repeated parsing across requests. -/// -public sealed class FluidEmailTemplateRenderer : IEmailTemplateRenderer -{ - private static readonly FluidParser Parser = new(); - private static readonly Assembly ResourceAssembly = typeof(FluidEmailTemplateRenderer).Assembly; - private static readonly ConcurrentDictionary TemplateCache = new(); - - /// Retrieves (or parses and caches) the named template and renders it against . - public async Task RenderAsync( - string templateName, - object model, - CancellationToken ct = default - ) - { - IFluidTemplate template = await GetOrParseTemplateAsync(templateName); - TemplateContext context = new(model); - return await template.RenderAsync(context); - } - - private static async Task GetOrParseTemplateAsync(string templateName) - { - if (TemplateCache.TryGetValue(templateName, out IFluidTemplate? cached)) - return cached; - - string templateContent = await LoadTemplateAsync(templateName); - - if (!Parser.TryParse(templateContent, out IFluidTemplate? template, out string? error)) - throw new InvalidOperationException( - $"Failed to parse email template '{templateName}': {error}" - ); - - TemplateCache.TryAdd(templateName, template); - return template; - } - - private static async Task LoadTemplateAsync(string templateName) - { - string resourceName = - $"{ResourceAssembly.GetName().Name}.Email.Templates.{templateName}.liquid"; - - await using Stream stream = - ResourceAssembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException( - $"Email template '{templateName}' not found as embedded resource '{resourceName}'." - ); - - using StreamReader reader = new(stream); - return await reader.ReadToEndAsync(); - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Email/MailKitEmailSender.cs b/src/Services/Notifications/Notifications.Infrastructure/Email/MailKitEmailSender.cs deleted file mode 100644 index 980c9fc9..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Email/MailKitEmailSender.cs +++ /dev/null @@ -1,53 +0,0 @@ -using MailKit.Net.Smtp; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MimeKit; -using Notifications.Application.Options; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; - -namespace Notifications.Infrastructure.Email; - -/// -/// Infrastructure implementation of that delivers email over SMTP -/// using MailKit, with optional authentication and TLS controlled by . -/// -public sealed class MailKitEmailSender : IEmailSender -{ - private readonly EmailOptions _options; - private readonly ILogger _logger; - - public MailKitEmailSender(IOptions options, ILogger logger) - { - _options = options.Value; - _logger = logger; - } - - /// - /// Builds a MIME message, connects and optionally authenticates against the configured SMTP server, - /// sends the message, and disconnects cleanly before returning. - /// - public async Task SendAsync(EmailMessage message, CancellationToken ct = default) - { - var mimeMessage = new MimeMessage(); - mimeMessage.From.Add(new MailboxAddress(_options.SenderName, _options.SenderEmail)); - mimeMessage.To.Add(MailboxAddress.Parse(message.To)); - mimeMessage.Subject = message.Subject; - mimeMessage.Body = new TextPart("html") { Text = message.HtmlBody }; - - using var client = new SmtpClient(); - await client.ConnectAsync(_options.SmtpHost, _options.SmtpPort, _options.UseSsl, ct); - - if (!string.IsNullOrEmpty(_options.Username)) - await client.AuthenticateAsync(_options.Username, _options.Password, ct); - - await client.SendAsync(mimeMessage, ct); - await client.DisconnectAsync(quit: true, ct); - - _logger.LogInformation( - "Email sent to {Recipient} with subject '{Subject}'.", - message.To, - message.Subject - ); - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Email/Templates/tenant-invitation.liquid b/src/Services/Notifications/Notifications.Infrastructure/Email/Templates/tenant-invitation.liquid deleted file mode 100644 index 7919b675..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Email/Templates/tenant-invitation.liquid +++ /dev/null @@ -1,11 +0,0 @@ - - - - -

You've been invited!

-

You have been invited to join {{ TenantName }}.

-

Click the link below to accept the invitation:

-

Accept Invitation

-

This invitation expires in {{ ExpiryHours }} hours.

- - diff --git a/src/Services/Notifications/Notifications.Infrastructure/Email/Templates/user-registration.liquid b/src/Services/Notifications/Notifications.Infrastructure/Email/Templates/user-registration.liquid deleted file mode 100644 index f0f37e0e..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Email/Templates/user-registration.liquid +++ /dev/null @@ -1,9 +0,0 @@ - - - - -

Welcome, {{ Username }}!

-

Your account has been created with the email {{ Email }}.

-

You can log in at: {{ LoginUrl }}

- - diff --git a/src/Services/Notifications/Notifications.Infrastructure/Email/Templates/user-role-changed.liquid b/src/Services/Notifications/Notifications.Infrastructure/Email/Templates/user-role-changed.liquid deleted file mode 100644 index 21222fa4..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Email/Templates/user-role-changed.liquid +++ /dev/null @@ -1,10 +0,0 @@ - - - - -

Your Role Has Been Updated

-

Hello {{ Username }},

-

Your role has been changed from {{ OldRole }} to {{ NewRole }}.

-

If you have any questions, please contact your administrator.

- - diff --git a/src/Services/Notifications/Notifications.Infrastructure/Notifications.Infrastructure.csproj b/src/Services/Notifications/Notifications.Infrastructure/Notifications.Infrastructure.csproj deleted file mode 100644 index 41b4252f..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Notifications.Infrastructure.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/src/Services/Notifications/Notifications.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs b/src/Services/Notifications/Notifications.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs deleted file mode 100644 index 0212a0fa..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Notifications.Domain.Entities; - -namespace Notifications.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity, with composite indexes optimized for claim-based retry and expiration queries. -public sealed class FailedEmailConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(e => e.Id); - - builder.Property(e => e.To).IsRequired().HasMaxLength(320); - builder.Property(e => e.Subject).IsRequired().HasMaxLength(500); - builder.Property(e => e.HtmlBody).IsRequired(); - builder.Property(e => e.LastError).HasMaxLength(FailedEmail.LastErrorMaxLength); - builder.Property(e => e.TemplateName).HasMaxLength(100); - - builder - .Property(e => e.CreatedAtUtc) - .IsRequired() - .HasColumnType("timestamp with time zone"); - - builder.Property(e => e.LastAttemptAtUtc).HasColumnType("timestamp with time zone"); - builder.Property(e => e.ClaimedBy).HasMaxLength(200); - builder.Property(e => e.ClaimedAtUtc).HasColumnType("timestamp with time zone"); - builder.Property(e => e.ClaimedUntilUtc).HasColumnType("timestamp with time zone"); - - // Covers claim-based retry selection. - builder.HasIndex(e => new - { - e.IsDeadLettered, - e.RetryCount, - e.ClaimedUntilUtc, - e.LastAttemptAtUtc, - }); - - // Covers claim-based expiration/dead-letter selection. - builder.HasIndex(e => new - { - e.IsDeadLettered, - e.ClaimedUntilUtc, - e.CreatedAtUtc, - }); - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Persistence/Migrations/20260326232740_InitialCreate.Designer.cs b/src/Services/Notifications/Notifications.Infrastructure/Persistence/Migrations/20260326232740_InitialCreate.Designer.cs deleted file mode 100644 index 45ee17c9..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Persistence/Migrations/20260326232740_InitialCreate.Designer.cs +++ /dev/null @@ -1,89 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Notifications.Infrastructure.Persistence; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Notifications.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(NotificationsDbContext))] - [Migration("20260326232740_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Notifications.Domain.Entities.FailedEmail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("ClaimedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("ClaimedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ClaimedUntilUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("HtmlBody") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsDeadLettered") - .HasColumnType("boolean"); - - b.Property("LastAttemptAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("LastError") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("RetryCount") - .HasColumnType("integer"); - - b.Property("Subject") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("TemplateName") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("To") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeadLettered", "ClaimedUntilUtc", "CreatedAtUtc"); - - b.HasIndex("IsDeadLettered", "RetryCount", "ClaimedUntilUtc", "LastAttemptAtUtc"); - - b.ToTable("FailedEmails"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Persistence/Migrations/20260326232740_InitialCreate.cs b/src/Services/Notifications/Notifications.Infrastructure/Persistence/Migrations/20260326232740_InitialCreate.cs deleted file mode 100644 index 0553f2ba..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Persistence/Migrations/20260326232740_InitialCreate.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Notifications.Infrastructure.Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "FailedEmails", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - To = table.Column( - type: "character varying(320)", - maxLength: 320, - nullable: false - ), - Subject = table.Column( - type: "character varying(500)", - maxLength: 500, - nullable: false - ), - HtmlBody = table.Column(type: "text", nullable: false), - RetryCount = table.Column(type: "integer", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false - ), - LastAttemptAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - LastError = table.Column( - type: "character varying(2000)", - maxLength: 2000, - nullable: true - ), - TemplateName = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: true - ), - IsDeadLettered = table.Column(type: "boolean", nullable: false), - ClaimedBy = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: true - ), - ClaimedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - ClaimedUntilUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - }, - constraints: table => - { - table.PrimaryKey("PK_FailedEmails", x => x.Id); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_FailedEmails_IsDeadLettered_ClaimedUntilUtc_CreatedAtUtc", - table: "FailedEmails", - columns: new[] { "IsDeadLettered", "ClaimedUntilUtc", "CreatedAtUtc" } - ); - - migrationBuilder.CreateIndex( - name: "IX_FailedEmails_IsDeadLettered_RetryCount_ClaimedUntilUtc_Last~", - table: "FailedEmails", - columns: new[] - { - "IsDeadLettered", - "RetryCount", - "ClaimedUntilUtc", - "LastAttemptAtUtc", - } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "FailedEmails"); - } - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Persistence/Migrations/NotificationsDbContextModelSnapshot.cs b/src/Services/Notifications/Notifications.Infrastructure/Persistence/Migrations/NotificationsDbContextModelSnapshot.cs deleted file mode 100644 index 0b59ffcb..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Persistence/Migrations/NotificationsDbContextModelSnapshot.cs +++ /dev/null @@ -1,86 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Notifications.Infrastructure.Persistence; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Notifications.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(NotificationsDbContext))] - partial class NotificationsDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Notifications.Domain.Entities.FailedEmail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("ClaimedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("ClaimedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ClaimedUntilUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("HtmlBody") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsDeadLettered") - .HasColumnType("boolean"); - - b.Property("LastAttemptAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("LastError") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("RetryCount") - .HasColumnType("integer"); - - b.Property("Subject") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("TemplateName") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("To") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeadLettered", "ClaimedUntilUtc", "CreatedAtUtc"); - - b.HasIndex("IsDeadLettered", "RetryCount", "ClaimedUntilUtc", "LastAttemptAtUtc"); - - b.ToTable("FailedEmails"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContext.cs b/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContext.cs deleted file mode 100644 index be8605ea..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContext.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Notifications.Domain.Entities; - -namespace Notifications.Infrastructure.Persistence; - -/// -/// EF Core DbContext scoped to the Notifications microservice, managing only notification-related entities. -/// -public sealed class NotificationsDbContext : DbContext -{ - public DbSet FailedEmails => Set(); - - public NotificationsDbContext(DbContextOptions options) - : base(options) { } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfigurationsFromAssembly(typeof(NotificationsDbContext).Assembly); - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs b/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs deleted file mode 100644 index 3df44be6..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace Notifications.Infrastructure.Persistence; - -/// -/// Design-time factory used by EF Core tooling to create the DbContext -/// for migration scaffolding without requiring the full runtime DI container. -/// -public sealed class NotificationsDbContextDesignTimeFactory - : IDesignTimeDbContextFactory -{ - public NotificationsDbContext CreateDbContext(string[] args) - { - DbContextOptionsBuilder optionsBuilder = new(); - optionsBuilder.UseNpgsql( - "Host=localhost;Database=notifications_db;Username=postgres;Password=postgres" - ); - - return new NotificationsDbContext(optionsBuilder.Options); - } -} diff --git a/src/Services/Notifications/Notifications.Infrastructure/Repositories/FailedEmailRepository.cs b/src/Services/Notifications/Notifications.Infrastructure/Repositories/FailedEmailRepository.cs deleted file mode 100644 index 0a2d1702..00000000 --- a/src/Services/Notifications/Notifications.Infrastructure/Repositories/FailedEmailRepository.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Notifications.Domain.Entities; -using Notifications.Domain.Interfaces; -using Notifications.Infrastructure.Persistence; - -namespace Notifications.Infrastructure.Repositories; - -/// -/// EF Core repository for that provides CRUD operations -/// against the . -/// -public sealed class FailedEmailRepository : IFailedEmailRepository -{ - private readonly NotificationsDbContext _dbContext; - - public FailedEmailRepository(NotificationsDbContext dbContext) - { - _dbContext = dbContext; - } - - /// Stages the failed email for insertion without flushing to the database. - public Task AddAsync(FailedEmail failedEmail, CancellationToken ct = default) - { - _dbContext.FailedEmails.Add(failedEmail); - return Task.CompletedTask; - } - - /// Returns all failed email records ordered by creation date descending. - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await _dbContext.FailedEmails.OrderByDescending(e => e.CreatedAtUtc).ToListAsync(ct); - } - - /// Returns a single failed email by its identifier, or null if not found. - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await _dbContext.FailedEmails.FindAsync([id], ct); - } - - /// Claims a batch of retryable failed emails for processing. - public async Task> ClaimRetryableBatchAsync( - int maxRetryAttempts, - int batchSize, - string claimedBy, - DateTime claimedAtUtc, - DateTime claimedUntilUtc, - CancellationToken ct = default - ) - { - List emails = await _dbContext - .FailedEmails.Where(e => - !e.IsDeadLettered - && e.RetryCount < maxRetryAttempts - && (e.ClaimedUntilUtc == null || e.ClaimedUntilUtc < claimedAtUtc) - ) - .OrderBy(e => e.LastAttemptAtUtc) - .Take(batchSize) - .ToListAsync(ct); - - foreach (FailedEmail email in emails) - { - email.ClaimedBy = claimedBy; - email.ClaimedAtUtc = claimedAtUtc; - email.ClaimedUntilUtc = claimedUntilUtc; - } - - await _dbContext.SaveChangesAsync(ct); - return emails; - } - - /// Claims a batch of expired (stale claim) failed emails for reprocessing. - public async Task> ClaimExpiredBatchAsync( - DateTime cutoff, - int batchSize, - string claimedBy, - DateTime claimedAtUtc, - DateTime claimedUntilUtc, - CancellationToken ct = default - ) - { - List emails = await _dbContext - .FailedEmails.Where(e => - !e.IsDeadLettered && e.ClaimedUntilUtc != null && e.ClaimedUntilUtc < cutoff - ) - .OrderBy(e => e.CreatedAtUtc) - .Take(batchSize) - .ToListAsync(ct); - - foreach (FailedEmail email in emails) - { - email.ClaimedBy = claimedBy; - email.ClaimedAtUtc = claimedAtUtc; - email.ClaimedUntilUtc = claimedUntilUtc; - } - - await _dbContext.SaveChangesAsync(ct); - return emails; - } - - /// Stages an update for the failed email without flushing to the database. - public Task UpdateAsync(FailedEmail failedEmail, CancellationToken ct = default) - { - _dbContext.FailedEmails.Update(failedEmail); - return Task.CompletedTask; - } - - /// Stages a hard delete (physical removal) for the failed email without flushing to the database. - public Task DeleteAsync(FailedEmail failedEmail, CancellationToken ct = default) - { - _dbContext.FailedEmails.Remove(failedEmail); - return Task.CompletedTask; - } - - /// - public async Task SaveChangesAsync(CancellationToken ct = default) - { - await _dbContext.SaveChangesAsync(ct); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Controllers/V1/CategoriesController.cs b/src/Services/ProductCatalog/ProductCatalog.Api/Controllers/V1/CategoriesController.cs deleted file mode 100644 index 21416d1a..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Api/Controllers/V1/CategoriesController.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using ProductCatalog.Application.Features.Category.Commands; -using ProductCatalog.Application.Features.Category.DTOs; -using ProductCatalog.Application.Features.Category.Queries; -using SharedKernel.Api.Authorization; -using SharedKernel.Api.Controllers; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.DTOs; -using SharedKernel.Application.Security; -using SharedKernel.Domain.Common; -using Wolverine; - -namespace ProductCatalog.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that exposes CRUD endpoints for product categories, -/// including a stored-procedure-backed statistics query. -/// -public sealed class CategoriesController(IMessageBus bus) : ApiControllerBase -{ - [HttpGet] - [RequirePermission(Permission.Categories.Read)] - [OutputCache(PolicyName = CacheTags.Categories)] - public Task>> GetAll( - [FromQuery] CategoryFilter filter, - CancellationToken ct - ) => - InvokeToActionResultAsync>( - bus, - new GetCategoriesQuery(filter), - ct - ); - - [HttpGet("{id:guid}")] - [RequirePermission(Permission.Categories.Read)] - [OutputCache(PolicyName = CacheTags.Categories)] - public Task> GetById(Guid id, CancellationToken ct) => - InvokeToActionResultAsync(bus, new GetCategoryByIdQuery(id), ct); - - [HttpPost] - [RequirePermission(Permission.Categories.Create)] - public Task> Create( - CreateCategoriesRequest request, - CancellationToken ct - ) => InvokeToBatchResultAsync(bus, new CreateCategoriesCommand(request), ct); - - [HttpPut] - [RequirePermission(Permission.Categories.Update)] - public Task> Update( - UpdateCategoriesRequest request, - CancellationToken ct - ) => InvokeToBatchResultAsync(bus, new UpdateCategoriesCommand(request), ct); - - [HttpDelete] - [RequirePermission(Permission.Categories.Delete)] - public Task> Delete( - BatchDeleteRequest request, - CancellationToken ct - ) => InvokeToBatchResultAsync(bus, new DeleteCategoriesCommand(request), ct); - - [HttpGet("{id:guid}/stats")] - [RequirePermission(Permission.Categories.Read)] - [OutputCache(PolicyName = CacheTags.Categories)] - public Task> GetStats( - Guid id, - CancellationToken ct - ) => - InvokeToActionResultAsync( - bus, - new GetCategoryStatsQuery(id), - ct - ); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Controllers/V1/ProductDataController.cs b/src/Services/ProductCatalog/ProductCatalog.Api/Controllers/V1/ProductDataController.cs deleted file mode 100644 index dffc0428..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Api/Controllers/V1/ProductDataController.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using ProductCatalog.Application.Features.ProductData.Commands; -using ProductCatalog.Application.Features.ProductData.DTOs; -using ProductCatalog.Application.Features.ProductData.Queries; -using SharedKernel.Api.Authorization; -using SharedKernel.Api.Controllers; -using SharedKernel.Api.Extensions; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Security; -using Wolverine; - -namespace ProductCatalog.Api.Controllers.V1; - -[ApiVersion(1.0)] -[Route("api/v{version:apiVersion}/[controller]")] -/// -/// Presentation-layer controller that manages product supplementary data (images and videos) -/// stored in MongoDB. -/// -public sealed class ProductDataController(IMessageBus bus) : ApiControllerBase -{ - [HttpGet] - [RequirePermission(Permission.ProductData.Read)] - [OutputCache(PolicyName = CacheTags.ProductData)] - public Task>> GetAll( - [FromQuery] string? type, - CancellationToken ct - ) => - InvokeToActionResultAsync>( - bus, - new GetProductDataQuery(type), - ct - ); - - [HttpGet("{id:guid}")] - [RequirePermission(Permission.ProductData.Read)] - [OutputCache(PolicyName = CacheTags.ProductData)] - public Task> GetById(Guid id, CancellationToken ct) => - InvokeToActionResultAsync(bus, new GetProductDataByIdQuery(id), ct); - - [HttpPost("image")] - [RequirePermission(Permission.ProductData.Create)] - public Task> CreateImage( - CreateImageProductDataRequest request, - CancellationToken ct - ) => - InvokeToCreatedResultAsync( - bus, - new CreateImageProductDataCommand(request), - v => new { id = v.Id, version = this.GetApiVersion() }, - ct - ); - - [HttpPost("video")] - [RequirePermission(Permission.ProductData.Create)] - public Task> CreateVideo( - CreateVideoProductDataRequest request, - CancellationToken ct - ) => - InvokeToCreatedResultAsync( - bus, - new CreateVideoProductDataCommand(request), - v => new { id = v.Id, version = this.GetApiVersion() }, - ct - ); - - [HttpDelete("{id:guid}")] - [RequirePermission(Permission.ProductData.Delete)] - public Task Delete(Guid id, CancellationToken ct) => - InvokeToNoContentResultAsync(bus, new DeleteProductDataCommand(id), ct); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Controllers/V1/ProductsController.cs b/src/Services/ProductCatalog/ProductCatalog.Api/Controllers/V1/ProductsController.cs deleted file mode 100644 index 58328a06..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Api/Controllers/V1/ProductsController.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using ProductCatalog.Application.Features.Product.Commands; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Queries; -using SharedKernel.Api.Authorization; -using SharedKernel.Api.Controllers; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.DTOs; -using SharedKernel.Application.Security; -using Wolverine; - -namespace ProductCatalog.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that exposes full CRUD endpoints for the product catalog. -/// -public sealed class ProductsController(IMessageBus bus) : ApiControllerBase -{ - /// Returns a filtered, paginated product list including search facets. - [HttpGet] - [RequirePermission(Permission.Products.Read)] - [OutputCache(PolicyName = CacheTags.Products)] - public Task> GetAll( - [FromQuery] ProductFilter filter, - CancellationToken ct - ) => InvokeToActionResultAsync(bus, new GetProductsQuery(filter), ct); - - /// Returns a single product by its identifier, or 404 if not found. - [HttpGet("{id:guid}")] - [RequirePermission(Permission.Products.Read)] - [OutputCache(PolicyName = CacheTags.Products)] - public Task> GetById(Guid id, CancellationToken ct) => - InvokeToActionResultAsync(bus, new GetProductByIdQuery(id), ct); - - /// Creates multiple products in a single batch operation. - [HttpPost] - [RequirePermission(Permission.Products.Create)] - public Task> Create( - CreateProductsRequest request, - CancellationToken ct - ) => InvokeToBatchResultAsync(bus, new CreateProductsCommand(request), ct); - - /// Updates multiple products in a single batch operation. - [HttpPut] - [RequirePermission(Permission.Products.Update)] - public Task> Update( - UpdateProductsRequest request, - CancellationToken ct - ) => InvokeToBatchResultAsync(bus, new UpdateProductsCommand(request), ct); - - /// Soft-deletes multiple products in a single batch operation. - [HttpDelete] - [RequirePermission(Permission.Products.Delete)] - public Task> Delete( - BatchDeleteRequest request, - CancellationToken ct - ) => InvokeToBatchResultAsync(bus, new DeleteProductsCommand(request), ct); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Dockerfile b/src/Services/ProductCatalog/ProductCatalog.Api/Dockerfile deleted file mode 100644 index d357e1f1..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build -WORKDIR /src -COPY . . -RUN dotnet restore "src/Services/ProductCatalog/ProductCatalog.Api/ProductCatalog.Api.csproj" -RUN dotnet publish "src/Services/ProductCatalog/ProductCatalog.Api/ProductCatalog.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "ProductCatalog.Api.dll"] diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/ProductCatalog.Api.csproj b/src/Services/ProductCatalog/ProductCatalog.Api/ProductCatalog.Api.csproj deleted file mode 100644 index 7b869247..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Api/ProductCatalog.Api.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs b/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs deleted file mode 100644 index fc5aa171..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs +++ /dev/null @@ -1,145 +0,0 @@ -using Contracts.IntegrationEvents.Sagas; -using FluentValidation; -using Microsoft.EntityFrameworkCore; -using Polly; -using ProductCatalog.Application.Features.Product.Repositories; -using ProductCatalog.Application.Features.Product.Validation; -using ProductCatalog.Application.Sagas; -using ProductCatalog.Domain.Interfaces; -using ProductCatalog.Infrastructure.Persistence; -using ProductCatalog.Infrastructure.Repositories; -using ProductCatalog.Infrastructure.StoredProcedures; -using SharedKernel.Api.Extensions; -using SharedKernel.Api.OutputCaching; -using SharedKernel.Application.Security; -using SharedKernel.Messaging.Conventions; -using SharedKernel.Messaging.Topology; -using Wolverine; -using Wolverine.EntityFrameworkCore; -using Wolverine.FluentValidation; -using Wolverine.Postgresql; -using Wolverine.RabbitMQ; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Host.UseSharedSerilog(); -builder.Services.AddSharedObservability( - builder.Configuration, - builder.Environment, - "product-catalog" -); - -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetRequiredConnectionString("ProductCatalogDb")) -); - -builder.Services.AddScoped(sp => sp.GetRequiredService()); - -builder.Services.AddValidatedOptions( - builder.Configuration, - MongoDbSettings.SectionName -); -builder.Services.AddSingleton(); - -builder.Services.AddSharedInfrastructure(builder.Configuration); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); - -builder.Services.AddResiliencePipeline( - ProductCatalog.Application.Common.Resilience.ResiliencePipelineKeys.MongoProductDataDelete, - pipelineBuilder => - { - pipelineBuilder.AddRetry( - new Polly.Retry.RetryStrategyOptions - { - MaxRetryAttempts = 3, - Delay = TimeSpan.FromMilliseconds(200), - BackoffType = Polly.DelayBackoffType.Exponential, - } - ); - } -); - -builder.Services.AddSharedKeycloakJwtBearer(builder.Configuration, builder.Environment); -builder.Services.AddSharedAuthorization(enablePermissionPolicies: true); - -builder.Services.AddValidatorsFromAssemblyContaining(); - -builder.Services.AddControllers(); -builder.Services.AddSharedOpenApiDocumentation(); -builder.Services.AddSharedOutputCaching(builder.Configuration); - -builder.Services.AddHealthChecks(); - -builder.Host.UseWolverine(opts => -{ - opts.ApplySharedConventions(); - opts.ApplySharedRetryPolicies(); - - opts.UseFluentValidation(); - - opts.Discovery.IncludeAssembly(typeof(ProductDeletionSaga).Assembly); - opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); - - opts.PersistMessagesWithPostgresql( - builder.Configuration.GetRequiredConnectionString("ProductCatalogDb"), - "wolverine" - ); - opts.UseEntityFrameworkCoreTransactions(); - - opts.UseSharedRabbitMq(builder.Configuration); - - // Publish integration events to the product-catalog exchange - opts.PublishMessage() - .ToRabbitExchange( - RabbitMqTopology.Exchanges.ProductCatalog, - exchange => exchange.ExchangeType = Wolverine.RabbitMQ.ExchangeType.Fanout - ); - opts.PublishMessage() - .ToRabbitExchange( - RabbitMqTopology.Exchanges.ProductCatalog, - exchange => exchange.ExchangeType = Wolverine.RabbitMQ.ExchangeType.Fanout - ); - opts.PublishMessage() - .ToRabbitExchange( - RabbitMqTopology.Exchanges.ProductCatalog, - exchange => exchange.ExchangeType = Wolverine.RabbitMQ.ExchangeType.Fanout - ); - - // Listen for saga completion messages - opts.ListenToRabbitQueue(RabbitMqTopology.Queues.ProductCatalog.ReviewsCascadeCompleted); - opts.ListenToRabbitQueue(RabbitMqTopology.Queues.ProductCatalog.FilesCascadeCompleted); - opts.ListenToRabbitQueue(RabbitMqTopology.Queues.ProductCatalog.StartProductDeletionSaga); - - // Handle the TenantDeactivated event for product and category cascade - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.ProductCatalog.TenantDeactivated, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.Identity); - } - ); - - // Route completion messages back to Identity's queues - opts.PublishMessage() - .ToRabbitQueue(RabbitMqTopology.Queues.Identity.ProductsCascadeCompleted); - opts.PublishMessage() - .ToRabbitQueue(RabbitMqTopology.Queues.Identity.CategoriesCascadeCompleted); -}); - -WebApplication app = builder.Build(); - -await app.MigrateDbAsync(); - -app.UseSharedExceptionHandlerAndAuthentication(); -app.UseSharedAuthorizationCachingAndInfrastructure(useOutputCaching: true); -app.MapControllers(); - -await app.RunAsync(); - -public partial class Program; diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Properties/launchSettings.json b/src/Services/ProductCatalog/ProductCatalog.Api/Properties/launchSettings.json deleted file mode 100644 index 75dc8527..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Api/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5218", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7196;http://localhost:5218", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json b/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json deleted file mode 100644 index 7a697a41..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "WriteTo": [ - { "Name": "Console" } - ] - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "ProductCatalogDb": "Host=localhost;Port=5432;Database=productcatalog_db;Username=postgres;Password=postgres", - "RabbitMQ": "amqp://guest:guest@localhost:5672", - "Dragonfly": "localhost:6379" - }, - "MongoDB": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "productcatalog" - }, - "Keycloak": { - "realm": "api-template", - "auth-server-url": "http://localhost:8180/", - "resource": "api-template" - }, - "TransactionDefaults": { - "IsolationLevel": "ReadCommitted", - "TimeoutSeconds": 30, - "RetryEnabled": true, - "RetryCount": 3, - "RetryDelaySeconds": 5 - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Common/Errors/DomainErrors.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Common/Errors/DomainErrors.cs deleted file mode 100644 index 5ddff387..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Common/Errors/DomainErrors.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ErrorOr; -using SharedDomainErrors = SharedKernel.Application.Errors.DomainErrors; - -namespace ProductCatalog.Application.Common.Errors; - -/// -/// Factory methods producing instances for product catalog domain errors. -/// -public static class DomainErrors -{ - public static class Products - { - public static Error NotFound(Guid id) => - SharedDomainErrors.General.NotFound(ErrorCatalog.Products.NotFound, "Product", id); - } - - public static class Categories - { - public static Error NotFound(Guid id) => - SharedDomainErrors.General.NotFound(ErrorCatalog.Categories.NotFound, "Category", id); - } - - public static class ProductData - { - public static Error NotFound(Guid id) => - SharedDomainErrors.General.NotFound( - ErrorCatalog.ProductData.NotFound, - "ProductData", - id - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Common/Errors/ErrorCatalog.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Common/Errors/ErrorCatalog.cs deleted file mode 100644 index 96b26168..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Common/Errors/ErrorCatalog.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ProductCatalog.Application.Common.Errors; - -/// -/// Structured error codes for the Product Catalog service. -/// -public static class ErrorCatalog -{ - public static class Products - { - public const string NotFound = "PROD-0404"; - public const string NotFoundMessage = "Product with id '{0}' not found."; - } - - public static class Categories - { - public const string NotFound = "CAT-0404"; - public const string NotFoundMessage = "Category with id '{0}' not found."; - } - - public static class ProductData - { - public const string NotFound = "PD-0404"; - public const string NotFoundMessage = "ProductData with id(s) '{0}' not found."; - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Common/Resilience/ResiliencePipelineKeys.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Common/Resilience/ResiliencePipelineKeys.cs deleted file mode 100644 index 13c1ca53..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Common/Resilience/ResiliencePipelineKeys.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ProductCatalog.Application.Common.Resilience; - -/// -/// String constants that identify the named Polly resilience pipelines registered in the Product Catalog service. -/// -public static class ResiliencePipelineKeys -{ - public const string MongoProductDataDelete = "mongo-productdata-delete"; -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/EventHandlers/TenantDeactivatedEventHandler.cs b/src/Services/ProductCatalog/ProductCatalog.Application/EventHandlers/TenantDeactivatedEventHandler.cs deleted file mode 100644 index eea22081..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/EventHandlers/TenantDeactivatedEventHandler.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Contracts.IntegrationEvents.Sagas; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using ProductCatalog.Domain.Entities; -using SharedKernel.Infrastructure.Persistence.SoftDelete; -using Wolverine; - -namespace ProductCatalog.Application.EventHandlers; - -/// -/// Handles by cascade soft-deleting all -/// products and categories belonging to the tenant, then publishing -/// and -/// so the TenantDeactivationSaga can track progress. -/// -public sealed class TenantDeactivatedEventHandler -{ - public static async Task HandleAsync( - TenantDeactivatedIntegrationEvent @event, - DbContext dbContext, - IMessageBus bus, - TimeProvider timeProvider, - ILogger logger, - CancellationToken ct - ) - { - DateTime now = timeProvider.GetUtcNow().UtcDateTime; - - int deletedProducts = await dbContext - .Set() - .IgnoreQueryFilters() - .Where(p => p.TenantId == @event.TenantId && !p.IsDeleted) - .BulkSoftDeleteAsync(@event.ActorId, now, ct); - - int deletedCategories = await dbContext - .Set() - .IgnoreQueryFilters() - .Where(c => c.TenantId == @event.TenantId && !c.IsDeleted) - .BulkSoftDeleteAsync(@event.ActorId, now, ct); - - // ExecuteUpdateAsync bypasses the change tracker; clear stale cached entities - // so any subsequent reads in the same unit of work see the updated state. - dbContext.ChangeTracker.Clear(); - - logger.LogInformation( - "Tenant {TenantId} deactivated: soft-deleted {ProductCount} products, {CategoryCount} categories", - @event.TenantId, - deletedProducts, - deletedCategories - ); - - await bus.PublishAsync( - new ProductsCascadeCompleted(@event.CorrelationId, @event.TenantId, deletedProducts) - ); - await bus.PublishAsync( - new CategoriesCascadeCompleted(@event.CorrelationId, @event.TenantId, deletedCategories) - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/CategorySortFields.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/CategorySortFields.cs deleted file mode 100644 index 17653968..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/CategorySortFields.cs +++ /dev/null @@ -1,18 +0,0 @@ -using SharedKernel.Application.Sorting; -using CategoryEntity = ProductCatalog.Domain.Entities.Category; - -namespace ProductCatalog.Application.Features.Category; - -/// -/// Defines the allowed sort fields for category queries and maps them to entity expressions. -/// -public static class CategorySortFields -{ - public static readonly SortField Name = new("name"); - public static readonly SortField CreatedAt = new("createdAt"); - - public static readonly SortFieldMap Map = new SortFieldMap() - .Add(Name, c => c.Name) - .Add(CreatedAt, c => c.Audit.CreatedAtUtc) - .Default(c => c.Audit.CreatedAtUtc); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs deleted file mode 100644 index f9ecef60..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ErrorOr; -using FluentValidation; -using ProductCatalog.Application.Features.Category.DTOs; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.DTOs; -using SharedKernel.Domain.Interfaces; -using Wolverine; -using CategoryEntity = ProductCatalog.Domain.Entities.Category; - -namespace ProductCatalog.Application.Features.Category.Commands; - -/// Creates multiple categories in a single batch operation. -public sealed record CreateCategoriesCommand(CreateCategoriesRequest Request); - -/// Handles by validating all items and persisting in a single transaction. -public sealed class CreateCategoriesCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - CreateCategoriesCommand command, - ICategoryRepository repository, - IUnitOfWork unitOfWork, - IValidator itemValidator, - CancellationToken ct - ) - { - IReadOnlyList items = command.Request.Items; - BatchFailureContext context = new(items); - - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); - - if (context.HasFailures) - return (context.ToFailureResponse(), CacheInvalidationCascades.None); - - List entities = items - .Select(item => new CategoryEntity - { - Id = Guid.NewGuid(), - Name = item.Name, - Description = item.Description, - }) - .ToList(); - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.AddRangeAsync(entities, ct); - }, - ct - ); - - return ( - new BatchResponse([], items.Count, 0), - CacheInvalidationCascades.ForTag(CacheTags.Categories) - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/DeleteCategoriesCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/DeleteCategoriesCommand.cs deleted file mode 100644 index f3a09c95..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/DeleteCategoriesCommand.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using ErrorOr; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.Category.Specifications; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.DTOs; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace ProductCatalog.Application.Features.Category.Commands; - -/// Soft-deletes multiple categories in a single batch operation. -public sealed record DeleteCategoriesCommand(BatchDeleteRequest Request); - -/// Handles by loading all categories and deleting in a single transaction. -public sealed class DeleteCategoriesCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - DeleteCategoriesCommand command, - ICategoryRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - TimeProvider timeProvider, - CancellationToken ct - ) - { - IReadOnlyList ids = command.Request.Ids; - BatchFailureContext context = new(ids); - - // Load all target categories and mark missing ones as failed - List categories = await repository.ListAsync( - new CategoriesByIdsSpecification(ids.ToHashSet()), - ct - ); - - await context.ApplyRulesAsync( - ct, - new MarkMissingByIdBatchRule( - id => id, - categories.Select(category => category.Id).ToHashSet(), - ErrorCatalog.Categories.NotFoundMessage - ) - ); - - if (context.HasFailures) - return (context.ToFailureResponse(), CacheInvalidationCascades.None); - - Guid tenantId = categories[0].TenantId; - - // Remove categories in a single transaction - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.DeleteRangeAsync(categories, ct); - }, - ct - ); - - // Publish integration events for cascade in downstream services - DateTime occurredAtUtc = timeProvider.GetUtcNow().UtcDateTime; - foreach (Domain.Entities.Category category in categories) - { - await bus.PublishAsync( - new CategoryDeletedIntegrationEvent(category.Id, tenantId, occurredAtUtc) - ); - } - - return ( - new BatchResponse([], ids.Count, 0), - CacheInvalidationCascades.ForTag(CacheTags.Categories) - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs deleted file mode 100644 index 118c7bb5..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs +++ /dev/null @@ -1,82 +0,0 @@ -using ErrorOr; -using FluentValidation; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.Category.DTOs; -using ProductCatalog.Application.Features.Category.Specifications; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.DTOs; -using SharedKernel.Domain.Interfaces; -using Wolverine; -using CategoryEntity = ProductCatalog.Domain.Entities.Category; - -namespace ProductCatalog.Application.Features.Category.Commands; - -/// Updates multiple categories in a single batch operation. -public sealed record UpdateCategoriesCommand(UpdateCategoriesRequest Request); - -/// Handles by validating all items, loading categories in bulk, and updating in a single transaction. -public sealed class UpdateCategoriesCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - UpdateCategoriesCommand command, - ICategoryRepository repository, - IUnitOfWork unitOfWork, - IValidator itemValidator, - CancellationToken ct - ) - { - IReadOnlyList items = command.Request.Items; - BatchFailureContext context = new(items); - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); - - // Load all target categories and mark missing ones as failed - HashSet requestedIds = items - .Where((_, i) => !context.IsFailed(i)) - .Select(item => item.Id) - .ToHashSet(); - Dictionary categoryMap = ( - await repository.ListAsync(new CategoriesByIdsSpecification(requestedIds), ct) - ).ToDictionary(c => c.Id); - - await context.ApplyRulesAsync( - ct, - new MarkMissingByIdBatchRule( - item => item.Id, - categoryMap.Keys.ToHashSet(), - ErrorCatalog.Categories.NotFoundMessage - ) - ); - - if (context.HasFailures) - return (context.ToFailureResponse(), CacheInvalidationCascades.None); - - // Apply changes in a single transaction - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - for (int i = 0; i < items.Count; i++) - { - UpdateCategoryItem item = items[i]; - CategoryEntity category = categoryMap[item.Id]; - - category.Name = item.Name; - category.Description = item.Description; - - await repository.UpdateAsync(category, ct); - } - }, - ct - ); - - return ( - new BatchResponse([], items.Count, 0), - CacheInvalidationCascades.ForTag(CacheTags.Categories) - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CategoryFilter.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CategoryFilter.cs deleted file mode 100644 index 30cefb4f..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CategoryFilter.cs +++ /dev/null @@ -1,15 +0,0 @@ -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; - -namespace ProductCatalog.Application.Features.Category.DTOs; - -/// -/// Filter parameters for querying categories, supporting full-text search, sorting, and pagination. -/// -public sealed record CategoryFilter( - string? Query = null, - string? SortBy = null, - string? SortDirection = null, - int PageNumber = 1, - int PageSize = PaginationFilter.DefaultPageSize -) : PaginationFilter(PageNumber, PageSize), ISortableFilter; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CategoryResponse.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CategoryResponse.cs deleted file mode 100644 index 968e4523..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CategoryResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.Category.DTOs; - -/// -/// Read model returned by category queries, containing the public-facing representation of a category. -/// -public sealed record CategoryResponse( - Guid Id, - string Name, - string? Description, - DateTime CreatedAtUtc -) : IHasId; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CreateCategoriesRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CreateCategoriesRequest.cs deleted file mode 100644 index 9b91c7ff..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CreateCategoriesRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace ProductCatalog.Application.Features.Category.DTOs; - -/// -/// Carries a list of category items to be created in a single batch operation; accepts between 1 and 100 items. -/// -public sealed record CreateCategoriesRequest( - [MinLength(1, ErrorMessage = "At least one item is required.")] - [MaxLength(100, ErrorMessage = "Maximum 100 items per batch.")] - IReadOnlyList Items -); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CreateCategoryRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CreateCategoryRequest.cs deleted file mode 100644 index d6a2c044..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/CreateCategoryRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Category.DTOs; - -/// -/// Payload for creating a new category, carrying the name and optional description. -/// -public sealed record CreateCategoryRequest( - [NotEmpty(ErrorMessage = "Category name is required.")] - [MaxLength(200, ErrorMessage = "Category name must not exceed 200 characters.")] - string Name, - string? Description -); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs deleted file mode 100644 index f3e40052..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ProductCatalog.Application.Features.Category.DTOs; - -/// -/// Aggregated statistics for a single category, including product count, average price, and total review count. -/// -public sealed record ProductCategoryStatsResponse( - Guid CategoryId, - string CategoryName, - long ProductCount, - decimal AveragePrice, - long TotalReviews -); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs deleted file mode 100644 index a927a1ae..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.Category.DTOs; - -/// -/// Carries a list of category items to be updated in a single batch operation; accepts between 1 and 100 items. -/// -public sealed record UpdateCategoriesRequest( - [MinLength(1, ErrorMessage = "At least one item is required.")] - [MaxLength(100, ErrorMessage = "Maximum 100 items per batch.")] - IReadOnlyList Items -); - -/// -/// Represents a single category within a batch update request, including its ID and replacement data. -/// -public sealed record UpdateCategoryItem( - [NotEmpty(ErrorMessage = "Category ID is required.")] Guid Id, - [NotEmpty(ErrorMessage = "Category name is required.")] - [MaxLength(200, ErrorMessage = "Category name must not exceed 200 characters.")] - string Name, - string? Description -) : IHasId; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/UpdateCategoryRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/UpdateCategoryRequest.cs deleted file mode 100644 index 956c550d..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/DTOs/UpdateCategoryRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ProductCatalog.Application.Features.Category.DTOs; - -/// -/// Payload for updating an existing category's name and optional description. -/// -public sealed record UpdateCategoryRequest(string Name, string? Description); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Mappings/CategoryMappings.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Mappings/CategoryMappings.cs deleted file mode 100644 index 190bdd8b..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Mappings/CategoryMappings.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Linq.Expressions; -using ProductCatalog.Application.Features.Category.DTOs; -using CategoryEntity = ProductCatalog.Domain.Entities.Category; -using ProductCategoryStatsEntity = ProductCatalog.Domain.Entities.ProductCategoryStats; - -namespace ProductCatalog.Application.Features.Category.Mappings; - -/// -/// Provides mapping utilities between category domain entities and their response DTOs. -/// -public static class CategoryMappings -{ - /// - /// EF Core-compatible expression that projects a to a . - /// - public static readonly Expression> Projection = - category => new CategoryResponse( - category.Id, - category.Name, - category.Description, - category.Audit.CreatedAtUtc - ); - - private static readonly Func CompiledProjection = - Projection.Compile(); - - /// Maps a to a using the compiled projection. - public static CategoryResponse ToResponse(this CategoryEntity category) => - CompiledProjection(category); - - /// Maps a to a . - public static ProductCategoryStatsResponse ToResponse(this ProductCategoryStatsEntity stats) => - new( - stats.CategoryId, - stats.CategoryName, - stats.ProductCount, - stats.AveragePrice, - stats.TotalReviews - ); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Queries/GetCategoriesQuery.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Queries/GetCategoriesQuery.cs deleted file mode 100644 index 8a901181..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Queries/GetCategoriesQuery.cs +++ /dev/null @@ -1,28 +0,0 @@ -using ErrorOr; -using ProductCatalog.Application.Features.Category.DTOs; -using ProductCatalog.Application.Features.Category.Specifications; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Domain.Common; - -namespace ProductCatalog.Application.Features.Category.Queries; - -/// Returns a paginated, filtered, and sorted list of categories. -public sealed record GetCategoriesQuery(CategoryFilter Filter); - -/// Handles . -public sealed class GetCategoriesQueryHandler -{ - public static async Task>> HandleAsync( - GetCategoriesQuery request, - ICategoryRepository repository, - CancellationToken ct - ) - { - return await repository.GetPagedAsync( - new CategorySpecification(request.Filter), - request.Filter.PageNumber, - request.Filter.PageSize, - ct - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Queries/GetCategoryByIdQuery.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Queries/GetCategoryByIdQuery.cs deleted file mode 100644 index 962dffca..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Queries/GetCategoryByIdQuery.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ErrorOr; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.Category.DTOs; -using ProductCatalog.Application.Features.Category.Specifications; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.Category.Queries; - -/// Returns a single category by its unique identifier, or if not found. -public sealed record GetCategoryByIdQuery(Guid Id) : IHasId; - -/// Handles . -public sealed class GetCategoryByIdQueryHandler -{ - public static async Task> HandleAsync( - GetCategoryByIdQuery request, - ICategoryRepository repository, - CancellationToken ct - ) - { - CategoryResponse? result = await repository.FirstOrDefaultAsync( - new CategoryByIdSpecification(request.Id), - ct - ); - - if (result is null) - return DomainErrors.Categories.NotFound(request.Id); - - return result; - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Queries/GetCategoryStatsQuery.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Queries/GetCategoryStatsQuery.cs deleted file mode 100644 index ffe9d140..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Queries/GetCategoryStatsQuery.cs +++ /dev/null @@ -1,30 +0,0 @@ -using ErrorOr; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.Category.DTOs; -using ProductCatalog.Application.Features.Category.Mappings; -using ProductCatalog.Domain.Entities; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.Category.Queries; - -/// Returns aggregated statistics for a category by its identifier, or if not found. -public sealed record GetCategoryStatsQuery(Guid Id) : IHasId; - -/// Handles . -public sealed class GetCategoryStatsQueryHandler -{ - public static async Task> HandleAsync( - GetCategoryStatsQuery request, - ICategoryRepository repository, - CancellationToken ct - ) - { - ProductCategoryStats? stats = await repository.GetStatsByIdAsync(request.Id, ct); - - if (stats is null) - return DomainErrors.Categories.NotFound(request.Id); - - return stats.ToResponse(); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs deleted file mode 100644 index ffaa1b04..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Ardalis.Specification; -using CategoryEntity = ProductCatalog.Domain.Entities.Category; - -namespace ProductCatalog.Application.Features.Category.Specifications; - -/// -/// Ardalis specification that loads multiple categories by their IDs, used for batch update and delete operations. -/// -public sealed class CategoriesByIdsSpecification : Specification -{ - public CategoriesByIdsSpecification(IReadOnlyCollection ids) - { - Query.Where(category => ids.Contains(category.Id)); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategoryByIdSpecification.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategoryByIdSpecification.cs deleted file mode 100644 index d4dade27..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategoryByIdSpecification.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Ardalis.Specification; -using ProductCatalog.Application.Features.Category.DTOs; -using ProductCatalog.Application.Features.Category.Mappings; -using CategoryEntity = ProductCatalog.Domain.Entities.Category; - -namespace ProductCatalog.Application.Features.Category.Specifications; - -/// -/// Ardalis specification that fetches a single category by its identifier, projected directly to . -/// -public sealed class CategoryByIdSpecification : Specification -{ - public CategoryByIdSpecification(Guid id) - { - Query - .Where(category => category.Id == id) - .AsNoTracking() - .Select(CategoryMappings.Projection); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategoryFilterCriteria.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategoryFilterCriteria.cs deleted file mode 100644 index 774ca3ec..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategoryFilterCriteria.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Ardalis.Specification; -using Microsoft.EntityFrameworkCore; -using ProductCatalog.Application.Features.Category.DTOs; -using SharedKernel.Application.Search; -using CategoryEntity = ProductCatalog.Domain.Entities.Category; - -namespace ProductCatalog.Application.Features.Category.Specifications; - -/// -/// Extension methods that apply search criteria to an Ardalis specification builder. -/// Uses PostgreSQL full-text search (to_tsvector / websearch_to_tsquery) when a query term is present. -/// -internal static class CategoryFilterCriteria -{ - internal static void ApplyFilter( - this ISpecificationBuilder query, - CategoryFilter filter - ) - { - if (string.IsNullOrWhiteSpace(filter.Query)) - return; - - query.Where(category => - EF.Functions.ToTsVector( - SearchDefaults.TextSearchConfiguration, - category.Name + " " + (category.Description ?? string.Empty) - ) - .Matches( - EF.Functions.WebSearchToTsQuery( - SearchDefaults.TextSearchConfiguration, - filter.Query - ) - ) - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategorySpecification.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategorySpecification.cs deleted file mode 100644 index 9ed72a07..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Specifications/CategorySpecification.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Ardalis.Specification; -using ProductCatalog.Application.Features.Category.DTOs; -using ProductCatalog.Application.Features.Category.Mappings; -using CategoryEntity = ProductCatalog.Domain.Entities.Category; - -namespace ProductCatalog.Application.Features.Category.Specifications; - -/// -/// Ardalis specification for querying a filtered and sorted list of categories projected to . -/// -public sealed class CategorySpecification : Specification -{ - public CategorySpecification(CategoryFilter filter) - { - Query.ApplyFilter(filter); - Query.AsNoTracking(); - CategorySortFields.Map.ApplySort(Query, filter.SortBy, filter.SortDirection); - Query.Select(CategoryMappings.Projection); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Validation/CategoryFilterValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Validation/CategoryFilterValidator.cs deleted file mode 100644 index f25d41c3..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Validation/CategoryFilterValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentValidation; -using ProductCatalog.Application.Features.Category.DTOs; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Category.Validation; - -/// -/// FluentValidation validator for . -/// -public sealed class CategoryFilterValidator : AbstractValidator -{ - public CategoryFilterValidator() - { - Include(new PaginationFilterValidator()); - Include(new SortableFilterValidator(CategorySortFields.Map.AllowedNames)); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs deleted file mode 100644 index 662798b9..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using ProductCatalog.Application.Features.Category.DTOs; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Category.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class CreateCategoryRequestValidator - : DataAnnotationsValidator; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs deleted file mode 100644 index e7e67434..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using ProductCatalog.Application.Features.Category.DTOs; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Category.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class UpdateCategoryItemValidator : DataAnnotationsValidator; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs deleted file mode 100644 index bbcec8c2..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using ErrorOr; -using FluentValidation; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Repositories; -using ProductCatalog.Domain.Entities; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.DTOs; -using SharedKernel.Domain.Interfaces; -using Wolverine; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Commands; - -/// Creates multiple products in a single batch operation. -public sealed record CreateProductsCommand(CreateProductsRequest Request); - -/// Handles by validating all items, bulk-validating references, and persisting in a single transaction. -public sealed class CreateProductsCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - CreateProductsCommand command, - IProductRepository repository, - ICategoryRepository categoryRepository, - IProductDataRepository productDataRepository, - IUnitOfWork unitOfWork, - IMessageBus bus, - IValidator itemValidator, - TimeProvider timeProvider, - CancellationToken ct - ) - { - IReadOnlyList items = command.Request.Items; - BatchFailureContext context = new(items); - - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); - - // Reference checks skip only fluent-validation failures so both category and - // product-data issues can be reported for the same index (merged into one failure row). - context.AddFailures( - await ProductValidationHelper.CheckProductReferencesAsync( - items, - categoryRepository, - productDataRepository, - context.FailedIndices, - ct - ) - ); - - if (context.HasFailures) - return (context.ToFailureResponse(), CacheInvalidationCascades.None); - - // Build entities and persist in a single transaction - List entities = items - .Select(item => - { - Guid productId = Guid.NewGuid(); - return new ProductEntity - { - Id = productId, - Name = item.Name, - Description = item.Description, - Price = item.Price, - CategoryId = item.CategoryId, - ProductDataLinks = (item.ProductDataIds ?? []) - .Distinct() - .Select(pdId => ProductDataLink.Create(productId, pdId)) - .ToList(), - }; - }) - .ToList(); - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.AddRangeAsync(entities, ct); - }, - ct - ); - - // Publish integration events for each created product - DateTime occurredAtUtc = timeProvider.GetUtcNow().UtcDateTime; - foreach (ProductEntity entity in entities) - { - await bus.PublishAsync( - new ProductCreatedIntegrationEvent( - entity.Id, - entity.TenantId, - entity.Name, - occurredAtUtc - ) - ); - } - - return ( - new BatchResponse([], items.Count, 0), - CacheInvalidationCascades.ForTag(CacheTags.Products) - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/DeleteProductsCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/DeleteProductsCommand.cs deleted file mode 100644 index c9d4aca2..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/DeleteProductsCommand.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Contracts.IntegrationEvents.Sagas; -using ErrorOr; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.Product.Repositories; -using ProductCatalog.Application.Features.Product.Specifications; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using SharedKernel.Application.DTOs; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace ProductCatalog.Application.Features.Product.Commands; - -/// Soft-deletes multiple products and their associated data links in a single batch operation. -public sealed record DeleteProductsCommand(BatchDeleteRequest Request); - -/// Handles by loading all products, soft-deleting links and products in a single transaction. -public sealed class DeleteProductsCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - DeleteProductsCommand command, - IProductRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - IActorProvider actorProvider, - CancellationToken ct - ) - { - IReadOnlyList ids = command.Request.Ids; - BatchFailureContext context = new(ids); - - // Load all target products and mark missing ones as failed - List products = await repository.ListAsync( - new ProductsByIdsWithLinksSpecification(ids.ToHashSet()), - ct - ); - - await context.ApplyRulesAsync( - ct, - new MarkMissingByIdBatchRule( - id => id, - products.Select(product => product.Id).ToHashSet(), - ErrorCatalog.Products.NotFoundMessage - ) - ); - - if (context.HasFailures) - return (context.ToFailureResponse(), CacheInvalidationCascades.None); - - Guid tenantId = products[0].TenantId; - Guid correlationId = Guid.NewGuid(); - - // Soft-delete product-data links and remove products in a single transaction - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - foreach (Domain.Entities.Product product in products) - product.SoftDeleteProductDataLinks(); - - await repository.DeleteRangeAsync(products, ct); - await bus.PublishAsync( - new StartProductDeletionSaga( - correlationId, - ids, - tenantId, - actorProvider.ActorId - ) - ); - }, - ct - ); - - return ( - new BatchResponse([], ids.Count, 0), - CacheInvalidationCascades.ForTags(CacheTags.Products, CacheTags.ProductData) - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs deleted file mode 100644 index f354e462..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs +++ /dev/null @@ -1,105 +0,0 @@ -using ErrorOr; -using FluentValidation; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Repositories; -using ProductCatalog.Domain.Entities; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.DTOs; -using SharedKernel.Domain.Interfaces; -using Wolverine; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Commands; - -/// Updates multiple products in a single batch operation. -public sealed record UpdateProductsCommand(UpdateProductsRequest Request); - -/// Handles by validating all items, loading products in bulk, and updating in a single transaction. -public sealed class UpdateProductsCommandHandler -{ - /// - /// Wolverine compound-handler load step: validates and loads products, short-circuiting the - /// handler pipeline with a failure response when any validation rule fails. - /// - public static async Task<( - HandlerContinuation, - EntityLookup?, - OutgoingMessages - )> LoadAsync( - UpdateProductsCommand command, - IProductRepository repository, - ICategoryRepository categoryRepository, - IProductDataRepository productDataRepository, - IValidator itemValidator, - CancellationToken ct - ) - { - (BatchResponse? failure, Dictionary? productMap) = - await UpdateProductsValidator.ValidateAndLoadAsync( - command, - repository, - categoryRepository, - productDataRepository, - itemValidator, - ct - ); - - OutgoingMessages messages = new(); - - if (failure is not null) - { - messages.RespondToSender(failure); - return (HandlerContinuation.Stop, null, messages); - } - - return ( - HandlerContinuation.Continue, - new EntityLookup(productMap!), - messages - ); - } - - /// Applies changes and syncs product-data links in a single transaction. - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - UpdateProductsCommand command, - EntityLookup lookup, - IProductRepository repository, - IUnitOfWork unitOfWork, - CancellationToken ct - ) - { - IReadOnlyList items = command.Request.Items; - IReadOnlyDictionary productMap = lookup.Entities; - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - for (int i = 0; i < items.Count; i++) - { - UpdateProductItem item = items[i]; - ProductEntity product = productMap[item.Id]; - - product.UpdateDetails(item.Name, item.Description, item.Price, item.CategoryId); - - if (item.ProductDataIds is not null) - { - HashSet targetIds = item.ProductDataIds.ToHashSet(); - Dictionary existingById = - product.ProductDataLinks.ToDictionary(link => link.ProductDataId); - product.SyncProductDataLinks(targetIds, existingById); - } - - await repository.UpdateAsync(product, ct); - } - }, - ct - ); - - return ( - new BatchResponse([], items.Count, 0), - CacheInvalidationCascades.ForTag(CacheTags.Products) - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs deleted file mode 100644 index 9c89b8d1..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs +++ /dev/null @@ -1,77 +0,0 @@ -using FluentValidation; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Repositories; -using ProductCatalog.Application.Features.Product.Specifications; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Application.DTOs; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Commands; - -/// -/// Validates all items in an and loads target products. -/// Returns a failure when any rule fails, or null on the -/// happy path together with the loaded product map. -/// -internal static class UpdateProductsValidator -{ - internal static async Task<( - BatchResponse? Failure, - Dictionary? ProductMap - )> ValidateAndLoadAsync( - UpdateProductsCommand command, - IProductRepository repository, - ICategoryRepository categoryRepository, - IProductDataRepository productDataRepository, - IValidator itemValidator, - CancellationToken ct - ) - { - IReadOnlyList items = command.Request.Items; - BatchFailureContext context = new(items); - - // Validate each item (field-level rules — name, price, etc.) - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); - - // Load all target products and mark missing ones as failed - HashSet requestedIds = items - .Where((_, i) => !context.IsFailed(i)) - .Select(item => item.Id) - .ToHashSet(); - Dictionary productMap = ( - await repository.ListAsync(new ProductsByIdsWithLinksSpecification(requestedIds), ct) - ).ToDictionary(p => p.Id); - - await context.ApplyRulesAsync( - ct, - new MarkMissingByIdBatchRule( - item => item.Id, - productMap.Keys.ToHashSet(), - ErrorCatalog.Products.NotFoundMessage - ) - ); - - // Reference checks skip only earlier failures (validation + missing entity) so - // category and product-data issues on the same row are merged into one failure. - context.AddFailures( - await ProductValidationHelper.CheckProductReferencesAsync( - items, - categoryRepository, - productDataRepository, - context.FailedIndices, - ct - ) - ); - - if (context.HasFailures) - return (context.ToFailureResponse(), null); - - return (null, productMap); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/CreateProductRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/CreateProductRequest.cs deleted file mode 100644 index 0bdc0ed9..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/CreateProductRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Contracts; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Carries the data required to create a new product, including validation constraints enforced via data annotations. -/// -public sealed record CreateProductRequest( - [NotEmpty(ErrorMessage = "Product name is required.")] - [MaxLength(200, ErrorMessage = "Product name must not exceed 200 characters.")] - string Name, - string? Description, - [Range(0.01, double.MaxValue, ErrorMessage = "Price must be greater than zero.")] decimal Price, - Guid? CategoryId = null, - IReadOnlyCollection? ProductDataIds = null -) : IProductRequest; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/CreateProductsRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/CreateProductsRequest.cs deleted file mode 100644 index 8fdc052f..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/CreateProductsRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Carries a list of product items to be created in a single batch operation; accepts between 1 and 100 items. -/// -public sealed record CreateProductsRequest( - [MinLength(1, ErrorMessage = "At least one item is required.")] - [MaxLength(100, ErrorMessage = "Maximum 100 items per batch.")] - IReadOnlyList Items -); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs deleted file mode 100644 index 66f07156..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Represents a single category bucket in the product search facets, containing the category identity and the number of matching products. -/// -public sealed record ProductCategoryFacetValue(Guid? CategoryId, string CategoryName, int Count); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductFilter.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductFilter.cs deleted file mode 100644 index 881c22c8..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductFilter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; - -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Encapsulates all criteria available for querying and paging the product list, including text search, price range, date range, category filtering, and sorting. -/// -public sealed record ProductFilter( - string? Name = null, - string? Description = null, - decimal? MinPrice = null, - decimal? MaxPrice = null, - DateTime? CreatedFrom = null, - DateTime? CreatedTo = null, - string? SortBy = null, - string? SortDirection = null, - int PageNumber = 1, - int PageSize = PaginationFilter.DefaultPageSize, - string? Query = null, - IReadOnlyCollection? CategoryIds = null -) : PaginationFilter(PageNumber, PageSize), IDateRangeFilter, ISortableFilter; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs deleted file mode 100644 index 45258990..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Human-readable labels for the fixed price bucket ranges used in product search facets. -/// -public static class PriceBucketLabels -{ - public const string ZeroToFifty = "0 - 50"; - public const string FiftyToOneHundred = "50 - 100"; - public const string OneHundredToTwoHundredFifty = "100 - 250"; - public const string TwoHundredFiftyToFiveHundred = "250 - 500"; - public const string FiveHundredAndAbove = "500+"; -} - -/// -/// Represents a single price-range bucket in the product search facets, with a human-readable label and the count of matching products. -/// -public sealed record ProductPriceFacetBucketResponse( - string Label, - decimal MinPrice, - decimal? MaxPrice, - int Count -); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductResponse.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductResponse.cs deleted file mode 100644 index 2c0c9863..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Represents a product as returned by the Application layer to API consumers, projected from the domain entity. -/// -public sealed record ProductResponse( - Guid Id, - string Name, - string? Description, - decimal Price, - Guid? CategoryId, - DateTime CreatedAtUtc, - IReadOnlyCollection ProductDataIds -) : IHasId; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs deleted file mode 100644 index 548b59a3..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Aggregates all facet data returned alongside a product search result, enabling client-side filter refinement. -/// -public sealed record ProductSearchFacetsResponse( - IReadOnlyCollection Categories, - IReadOnlyCollection PriceBuckets -); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductsResponse.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductsResponse.cs deleted file mode 100644 index 2fd799aa..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/ProductsResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SharedKernel.Application.DTOs; -using SharedKernel.Domain.Common; - -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Combines a paged list of products with their associated search facets in a single response envelope. -/// -public sealed record ProductsResponse( - PagedResponse Page, - ProductSearchFacetsResponse Facets -) : IPagedItems, IHasFacets; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/UpdateProductRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/UpdateProductRequest.cs deleted file mode 100644 index af577962..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/UpdateProductRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Contracts; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Carries the replacement data for an existing product, subject to the same validation constraints as . -/// -public sealed record UpdateProductRequest( - [NotEmpty(ErrorMessage = "Product name is required.")] - [MaxLength(200, ErrorMessage = "Product name must not exceed 200 characters.")] - string Name, - string? Description, - [Range(0.01, double.MaxValue, ErrorMessage = "Price must be greater than zero.")] decimal Price, - Guid? CategoryId = null, - IReadOnlyCollection? ProductDataIds = null -) : IProductRequest; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/UpdateProductsRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/UpdateProductsRequest.cs deleted file mode 100644 index 59cd4615..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/DTOs/UpdateProductsRequest.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Contracts; -using SharedKernel.Application.Validation; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.Product.DTOs; - -/// -/// Carries a list of product items to be updated in a single batch operation; accepts between 1 and 100 items. -/// -public sealed record UpdateProductsRequest( - [MinLength(1, ErrorMessage = "At least one item is required.")] - [MaxLength(100, ErrorMessage = "Maximum 100 items per batch.")] - IReadOnlyList Items -); - -/// -/// Represents a single product within a batch update request, including its ID and replacement data. -/// -public sealed record UpdateProductItem( - [NotEmpty(ErrorMessage = "Product ID is required.")] Guid Id, - [NotEmpty(ErrorMessage = "Product name is required.")] - [MaxLength(200, ErrorMessage = "Product name must not exceed 200 characters.")] - string Name, - string? Description, - [Range(0.01, double.MaxValue, ErrorMessage = "Price must be greater than zero.")] decimal Price, - Guid? CategoryId = null, - IReadOnlyCollection? ProductDataIds = null -) : IProductRequest, IHasId; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Mappings/ProductMappings.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Mappings/ProductMappings.cs deleted file mode 100644 index 196f7f79..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Mappings/ProductMappings.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Linq.Expressions; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Mappings; - -/// -/// Provides EF Core-compatible projection expressions and in-memory mapping helpers for converting Product domain entities to DTOs. -/// -public static class ProductMappings -{ - /// - /// LINQ expression that projects a Product entity to a ; safe to pass directly into EF Core queries. - /// - public static readonly Expression> Projection = - p => new ProductResponse( - p.Id, - p.Name, - p.Description, - p.Price, - p.CategoryId, - p.Audit.CreatedAtUtc, - p.ProductDataLinks.Select(link => link.ProductDataId).ToArray() - ); - - private static readonly Func CompiledProjection = - Projection.Compile(); - - /// Maps a fully-loaded Product entity to a using the pre-compiled projection. - public static ProductResponse ToResponse(this ProductEntity product) => - CompiledProjection(product); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/ProductSortFields.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/ProductSortFields.cs deleted file mode 100644 index b7300423..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/ProductSortFields.cs +++ /dev/null @@ -1,20 +0,0 @@ -using SharedKernel.Application.Sorting; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product; - -/// -/// Defines the allowed sort fields for product queries and provides the used by specifications to apply ordering. -/// -public static class ProductSortFields -{ - public static readonly SortField Name = new("name"); - public static readonly SortField Price = new("price"); - public static readonly SortField CreatedAt = new("createdAt"); - - public static readonly SortFieldMap Map = new SortFieldMap() - .Add(Name, p => p.Name) - .Add(Price, p => (object)p.Price) - .Add(CreatedAt, p => p.Audit.CreatedAtUtc) - .Default(p => p.Audit.CreatedAtUtc); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/ProductValidationHelper.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/ProductValidationHelper.cs deleted file mode 100644 index fbad6031..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/ProductValidationHelper.cs +++ /dev/null @@ -1,166 +0,0 @@ -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.Category.Specifications; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.Product; - -/// Shared validation methods for product commands. -internal static class ProductValidationHelper -{ - /// - /// Checks all product references (category and product data) in a single call, merging - /// per-item failures from both checks. Items in are skipped. - /// - internal static async Task> CheckProductReferencesAsync( - IReadOnlyList items, - ICategoryRepository categoryRepository, - IProductDataRepository productDataRepository, - IReadOnlySet failedIndices, - CancellationToken ct - ) - where T : class, IProductRequest - { - // Category (EF Core / PostgreSQL) and product-data (MongoDB) checks use independent - // connections, so they can safely run in parallel. - Task> categoryTask = CheckCategoryReferencesAsync( - items, - item => item.CategoryId, - categoryRepository, - failedIndices, - ct - ); - Task> productDataTask = CheckProductDataReferencesAsync( - items, - item => item.ProductDataIds, - productDataRepository, - failedIndices, - ct - ); - await Task.WhenAll(categoryTask, productDataTask); - return BatchFailureMerge.MergeByIndex(categoryTask.Result, productDataTask.Result); - } - - /// - /// Checks that all referenced category IDs exist and returns per-item failures for items - /// that reference a missing category. Items in are skipped. - /// - internal static async Task> CheckCategoryReferencesAsync( - IReadOnlyList items, - Func categoryIdSelector, - ICategoryRepository categoryRepository, - IReadOnlySet failedIndices, - CancellationToken ct - ) - { - HashSet allCategoryIds = items - .Where(item => categoryIdSelector(item).HasValue) - .Select(item => categoryIdSelector(item)!.Value) - .ToHashSet(); - - if (allCategoryIds.Count == 0) - return []; - - List existing = await categoryRepository.ListAsync( - new CategoriesByIdsSpecification(allCategoryIds), - ct - ); - allCategoryIds.ExceptWith(existing.Select(c => c.Id)); - - if (allCategoryIds.Count == 0) - return []; - - List failures = new(); - - for (int i = 0; i < items.Count; i++) - { - if (failedIndices.Contains(i)) - continue; - - Guid? categoryId = categoryIdSelector(items[i]); - if (categoryId.HasValue && allCategoryIds.Contains(categoryId.Value)) - { - Guid? failureId = items[i] is IHasId hasId ? hasId.Id : null; - failures.Add( - new BatchResultItem( - i, - failureId, - [string.Format(ErrorCatalog.Categories.NotFoundMessage, categoryId)] - ) - ); - } - } - - return failures; - } - - /// - /// Checks that all referenced product-data IDs exist and returns per-item failures for items - /// that reference missing product data. Items in are skipped. - /// - internal static async Task> CheckProductDataReferencesAsync( - IReadOnlyList items, - Func?> productDataIdsSelector, - IProductDataRepository productDataRepository, - IReadOnlySet failedIndices, - CancellationToken ct - ) - { - Guid[] allProductDataIds = items - .Where(item => productDataIdsSelector(item) is { Count: > 0 }) - .SelectMany(item => productDataIdsSelector(item)!) - .Distinct() - .ToArray(); - - if (allProductDataIds.Length == 0) - return []; - - HashSet existingIds = ( - await productDataRepository.GetByIdsAsync(allProductDataIds, ct) - ) - .Select(pd => pd.Id) - .ToHashSet(); - - HashSet missingIds = allProductDataIds - .Where(id => !existingIds.Contains(id)) - .ToHashSet(); - - if (missingIds.Count == 0) - return []; - - List failures = new(); - - for (int i = 0; i < items.Count; i++) - { - if (failedIndices.Contains(i)) - continue; - - IReadOnlyCollection? pdIds = productDataIdsSelector(items[i]); - if (pdIds is not { Count: > 0 }) - continue; - - List missing = pdIds.Where(id => missingIds.Contains(id)).ToList(); - if (missing.Count > 0) - { - Guid? failureId = items[i] is IHasId hasId ? hasId.Id : null; - failures.Add( - new BatchResultItem( - i, - failureId, - [ - string.Format( - ErrorCatalog.ProductData.NotFoundMessage, - string.Join(", ", missing) - ), - ] - ) - ); - } - } - - return failures; - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Queries/GetProductByIdQuery.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Queries/GetProductByIdQuery.cs deleted file mode 100644 index 6d62ae3e..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Queries/GetProductByIdQuery.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ErrorOr; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Repositories; -using ProductCatalog.Application.Features.Product.Specifications; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.Product.Queries; - -/// Retrieves a single product by its unique identifier. -public sealed record GetProductByIdQuery(Guid Id) : IHasId; - -/// Handles by fetching from the product repository. -public sealed class GetProductByIdQueryHandler -{ - public static async Task> HandleAsync( - GetProductByIdQuery request, - IProductRepository repository, - CancellationToken ct - ) - { - ProductResponse? result = await repository.FirstOrDefaultAsync( - new ProductByIdSpecification(request.Id), - ct - ); - - if (result is null) - return DomainErrors.Products.NotFound(request.Id); - - return result; - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Queries/GetProductsQuery.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Queries/GetProductsQuery.cs deleted file mode 100644 index e4a938dc..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Queries/GetProductsQuery.cs +++ /dev/null @@ -1,31 +0,0 @@ -using ErrorOr; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Repositories; - -namespace ProductCatalog.Application.Features.Product.Queries; - -/// Retrieves a filtered, sorted, and paged list of products together with search facets. -public sealed record GetProductsQuery(ProductFilter Filter); - -/// Handles by fetching items, count, and facets from the repository. -public sealed class GetProductsQueryHandler -{ - public static async Task> HandleAsync( - GetProductsQuery request, - IProductRepository repository, - CancellationToken ct - ) - { - SharedKernel.Domain.Common.PagedResponse page = - await repository.GetPagedAsync(request.Filter, ct); - IReadOnlyList categoryFacets = - await repository.GetCategoryFacetsAsync(request.Filter, ct); - IReadOnlyList priceFacets = - await repository.GetPriceFacetsAsync(request.Filter, ct); - - return new ProductsResponse( - page, - new ProductSearchFacetsResponse(categoryFacets, priceFacets) - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Repositories/IProductRepository.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Repositories/IProductRepository.cs deleted file mode 100644 index 7becc9cf..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Repositories/IProductRepository.cs +++ /dev/null @@ -1,29 +0,0 @@ -using ProductCatalog.Application.Features.Product.DTOs; -using SharedKernel.Domain.Interfaces; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Repositories; - -/// -/// Domain-facing repository contract for products, extending the generic repository with product-specific filtered queries and facet aggregations. -/// -public interface IProductRepository : IRepository -{ - /// Returns a single-query paged result of products matching the given filter. - Task> GetPagedAsync( - ProductFilter filter, - CancellationToken ct = default - ); - - /// Returns category facet counts for the current filter, ignoring any active category-ID constraints so all categories remain selectable. - Task> GetCategoryFacetsAsync( - ProductFilter filter, - CancellationToken ct = default - ); - - /// Returns price-bucket facet counts for the current filter, ignoring any active price-range constraints so all buckets remain selectable. - Task> GetPriceFacetsAsync( - ProductFilter filter, - CancellationToken ct = default - ); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductByIdSpecification.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductByIdSpecification.cs deleted file mode 100644 index eb8ae44b..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductByIdSpecification.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Ardalis.Specification; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Mappings; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Specifications; - -/// -/// Ardalis specification that fetches a single product by its ID and projects it directly to a DTO. -/// -public sealed class ProductByIdSpecification : Specification -{ - public ProductByIdSpecification(Guid id) - { - Query.Where(product => product.Id == id).Select(ProductMappings.Projection); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs deleted file mode 100644 index 292286e9..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Ardalis.Specification; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Specifications; - -/// -/// Ardalis specification that loads a product by ID and eagerly includes its ProductDataLinks collection, used when link synchronisation or deletion is required. -/// -public sealed class ProductByIdWithLinksSpecification : Specification -{ - public ProductByIdWithLinksSpecification(Guid id) - { - Query.Where(product => product.Id == id).Include(product => product.ProductDataLinks); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs deleted file mode 100644 index 3ded1949..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Ardalis.Specification; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Specifications; - -/// -/// Ardalis specification used for the category facet query; applies all filter criteria except category-ID filtering so that counts reflect the full category distribution. -/// -public sealed class ProductCategoryFacetSpecification : Specification -{ - public ProductCategoryFacetSpecification(ProductFilter filter) - { - Query.ApplyFilter(filter, new ProductFilterCriteriaOptions(IgnoreCategoryIds: true)); - - Query.AsNoTracking(); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductFilterCriteria.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductFilterCriteria.cs deleted file mode 100644 index 0ed68eaf..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductFilterCriteria.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Ardalis.Specification; -using Microsoft.EntityFrameworkCore; -using ProductCatalog.Application.Features.Product.DTOs; -using SharedKernel.Application.Search; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Specifications; - -/// -/// Internal helper that extends with product-specific filter predicates, centralising all WHERE-clause logic for reuse across multiple specifications. -/// -internal static class ProductFilterCriteria -{ - /// - /// Applies the active predicates from to the specification builder, with optional overrides via to skip category-ID or price-range constraints when computing facets. - /// - internal static void ApplyFilter( - this ISpecificationBuilder query, - ProductFilter filter, - ProductFilterCriteriaOptions? options = null - ) - { - options ??= ProductFilterCriteriaOptions.Default; - - if (!string.IsNullOrWhiteSpace(filter.Name)) - query.Where(p => p.Name.Contains(filter.Name)); - - if (!string.IsNullOrWhiteSpace(filter.Description)) - query.Where(p => p.Description != null && p.Description.Contains(filter.Description)); - - if (!string.IsNullOrWhiteSpace(filter.Query)) - { - query.Where(p => - EF.Functions.ToTsVector( - SearchDefaults.TextSearchConfiguration, - p.Name + " " + (p.Description ?? string.Empty) - ) - .Matches( - EF.Functions.WebSearchToTsQuery( - SearchDefaults.TextSearchConfiguration, - filter.Query - ) - ) - ); - } - - if (!options.IgnorePriceRange && filter.MinPrice.HasValue) - query.Where(p => p.Price >= filter.MinPrice.Value); - - if (!options.IgnorePriceRange && filter.MaxPrice.HasValue) - query.Where(p => p.Price <= filter.MaxPrice.Value); - - if (filter.CreatedFrom.HasValue) - query.Where(p => p.Audit.CreatedAtUtc >= filter.CreatedFrom.Value); - - if (filter.CreatedTo.HasValue) - query.Where(p => p.Audit.CreatedAtUtc <= filter.CreatedTo.Value); - - if (!options.IgnoreCategoryIds && filter.CategoryIds is { Count: > 0 }) - query.Where(p => - p.CategoryId.HasValue && filter.CategoryIds.Contains(p.CategoryId.Value) - ); - } -} - -/// -/// Controls which filter predicates are suppressed when building specifications for facet queries. -/// -internal sealed record ProductFilterCriteriaOptions( - bool IgnoreCategoryIds = false, - bool IgnorePriceRange = false -) -{ - internal static ProductFilterCriteriaOptions Default => new(); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs deleted file mode 100644 index 9144b4da..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Ardalis.Specification; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Specifications; - -/// -/// Ardalis specification used for the price facet query; applies all filter criteria except the price range so that all price buckets remain visible regardless of the selected price filter. -/// -public sealed class ProductPriceFacetSpecification : Specification -{ - public ProductPriceFacetSpecification(ProductFilter filter) - { - Query.ApplyFilter(filter, new ProductFilterCriteriaOptions(IgnorePriceRange: true)); - - Query.AsNoTracking(); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductSpecification.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductSpecification.cs deleted file mode 100644 index a5d788d7..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductSpecification.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Ardalis.Specification; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Mappings; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Specifications; - -/// -/// Ardalis specification that applies the full product filter, sorting, and projection to produce a list. -/// -public sealed class ProductSpecification : Specification -{ - public ProductSpecification(ProductFilter filter) - { - Query.ApplyFilter(filter); - Query.AsNoTracking(); - - ProductSortFields.Map.ApplySort(Query, filter.SortBy, filter.SortDirection); - - Query.Select(ProductMappings.Projection); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs deleted file mode 100644 index 2215f2b3..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Ardalis.Specification; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Specifications; - -/// -/// Ardalis specification that loads multiple products by their IDs and eagerly includes -/// their ProductDataLinks collections, used for batch update and delete operations. -/// -public sealed class ProductsByIdsWithLinksSpecification : Specification -{ - public ProductsByIdsWithLinksSpecification(IReadOnlyCollection ids) - { - Query - .Where(product => ids.Contains(product.Id)) - .Include(product => product.ProductDataLinks); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/CreateProductRequestValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/CreateProductRequestValidator.cs deleted file mode 100644 index fee0c368..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/CreateProductRequestValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using ProductCatalog.Application.Features.Product.DTOs; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Product.Validation; - -/// -/// FluentValidation validator for , inheriting all rules from . -/// -public sealed class CreateProductRequestValidator - : ProductRequestValidatorBase; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/ProductFilterValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/ProductFilterValidator.cs deleted file mode 100644 index 20f59785..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/ProductFilterValidator.cs +++ /dev/null @@ -1,37 +0,0 @@ -using FluentValidation; -using ProductCatalog.Application.Features.Product.DTOs; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Product.Validation; - -/// -/// FluentValidation validator for ; composes pagination, date-range, sortable-field, and price-range rules including cross-field MinPrice/MaxPrice consistency. -/// -public sealed class ProductFilterValidator : AbstractValidator -{ - public ProductFilterValidator() - { - Include(new PaginationFilterValidator()); - Include(new DateRangeFilterValidator()); - Include(new SortableFilterValidator(ProductSortFields.Map.AllowedNames)); - - RuleFor(x => x.MinPrice) - .GreaterThanOrEqualTo(0) - .WithMessage("MinPrice must be greater than or equal to zero.") - .When(x => x.MinPrice.HasValue); - - RuleFor(x => x.MaxPrice) - .GreaterThanOrEqualTo(0) - .WithMessage("MaxPrice must be greater than or equal to zero.") - .When(x => x.MaxPrice.HasValue); - - RuleFor(x => x.MaxPrice) - .GreaterThanOrEqualTo(x => x.MinPrice!.Value) - .WithMessage("MaxPrice must be greater than or equal to MinPrice.") - .When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue); - - RuleForEach(x => x.CategoryIds) - .NotEqual(Guid.Empty) - .WithMessage("CategoryIds cannot contain an empty value."); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/UpdateProductItemValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/UpdateProductItemValidator.cs deleted file mode 100644 index 3da7819f..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/UpdateProductItemValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using ProductCatalog.Application.Features.Product.DTOs; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Product.Validation; - -/// -/// FluentValidation validator for , reusing the shared -/// product validation rules including the description-required-above-price-threshold rule. -/// -public sealed class UpdateProductItemValidator : ProductRequestValidatorBase; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/UpdateProductRequestValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/UpdateProductRequestValidator.cs deleted file mode 100644 index e60d85b6..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Validation/UpdateProductRequestValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using ProductCatalog.Application.Features.Product.DTOs; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.Product.Validation; - -/// -/// FluentValidation validator for , inheriting all rules from . -/// -public sealed class UpdateProductRequestValidator - : ProductRequestValidatorBase; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs deleted file mode 100644 index 3c8c0f81..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ErrorOr; -using ProductCatalog.Application.Features.ProductData.DTOs; -using ProductCatalog.Application.Features.ProductData.Mappings; -using ProductCatalog.Domain.Entities.ProductData; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using Wolverine; -using ProductDataEntity = ProductCatalog.Domain.Entities.ProductData.ProductData; - -namespace ProductCatalog.Application.Features.ProductData.Commands; - -public sealed record CreateImageProductDataCommand(CreateImageProductDataRequest Request); - -public sealed class CreateImageProductDataCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - CreateImageProductDataCommand command, - IProductDataRepository repository, - ITenantProvider tenantProvider, - TimeProvider timeProvider, - CancellationToken ct - ) - { - ImageProductData entity = new() - { - TenantId = tenantProvider.TenantId, - Title = command.Request.Title, - Description = command.Request.Description, - CreatedAt = timeProvider.GetUtcNow().UtcDateTime, - Width = command.Request.Width, - Height = command.Request.Height, - Format = command.Request.Format, - FileSizeBytes = command.Request.FileSizeBytes, - }; - - ProductDataEntity created = await repository.CreateAsync(entity, ct); - return (created.ToResponse(), CacheInvalidationCascades.ForTag(CacheTags.ProductData)); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs deleted file mode 100644 index ee105112..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ErrorOr; -using ProductCatalog.Application.Features.ProductData.DTOs; -using ProductCatalog.Application.Features.ProductData.Mappings; -using ProductCatalog.Domain.Entities.ProductData; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using Wolverine; -using ProductDataEntity = ProductCatalog.Domain.Entities.ProductData.ProductData; - -namespace ProductCatalog.Application.Features.ProductData.Commands; - -public sealed record CreateVideoProductDataCommand(CreateVideoProductDataRequest Request); - -public sealed class CreateVideoProductDataCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - CreateVideoProductDataCommand command, - IProductDataRepository repository, - ITenantProvider tenantProvider, - TimeProvider timeProvider, - CancellationToken ct - ) - { - VideoProductData entity = new() - { - TenantId = tenantProvider.TenantId, - Title = command.Request.Title, - Description = command.Request.Description, - CreatedAt = timeProvider.GetUtcNow().UtcDateTime, - DurationSeconds = command.Request.DurationSeconds, - Resolution = command.Request.Resolution, - Format = command.Request.Format, - FileSizeBytes = command.Request.FileSizeBytes, - }; - - ProductDataEntity created = await repository.CreateAsync(entity, ct); - return (created.ToResponse(), CacheInvalidationCascades.ForTag(CacheTags.ProductData)); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs deleted file mode 100644 index d398113c..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs +++ /dev/null @@ -1,81 +0,0 @@ -using ErrorOr; -using Microsoft.Extensions.Logging; -using Polly.Registry; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Common.Resilience; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace ProductCatalog.Application.Features.ProductData.Commands; - -public sealed record DeleteProductDataCommand(Guid Id) : IHasId; - -public sealed class DeleteProductDataCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - DeleteProductDataCommand command, - IProductDataRepository repository, - IProductDataLinkRepository productDataLinkRepository, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - IUnitOfWork unitOfWork, - TimeProvider timeProvider, - ResiliencePipelineProvider resiliencePipelineProvider, - ILogger logger, - CancellationToken ct - ) - { - Guid tenantId = tenantProvider.TenantId; - - Domain.Entities.ProductData.ProductData? data = await repository.GetByIdAsync( - command.Id, - ct - ); - - if (data is null || data.TenantId != tenantId) - return (DomainErrors.ProductData.NotFound(command.Id), CacheInvalidationCascades.None); - - DateTime deletedAtUtc = timeProvider.GetUtcNow().UtcDateTime; - Guid actorId = actorProvider.ActorId; - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await productDataLinkRepository.SoftDeleteActiveLinksForProductDataAsync( - command.Id, - ct - ); - }, - ct - ); - - Polly.ResiliencePipeline pipeline = resiliencePipelineProvider.GetPipeline( - ResiliencePipelineKeys.MongoProductDataDelete - ); - - try - { - await pipeline.ExecuteAsync( - async token => - await repository.SoftDeleteAsync(data.Id, actorId, deletedAtUtc, token), - ct - ); - } - catch (Exception ex) - { - logger.LogError( - ex, - "Failed to soft-delete ProductData document {ProductDataId} for tenant {TenantId}. Related ProductDataLinks may already be soft-deleted in PostgreSQL.", - data.Id, - tenantId - ); - throw; - } - - return (Result.Success, CacheInvalidationCascades.ForTag(CacheTags.ProductData)); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs deleted file mode 100644 index e014a449..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.ProductData.DTOs; - -/// -/// Payload for uploading image product data, including dimensions, format, and file size. -/// -public sealed record CreateImageProductDataRequest( - [NotEmpty(ErrorMessage = "Title is required.")] - [MaxLength(200, ErrorMessage = "Title must not exceed 200 characters.")] - string Title, - [MaxLength(1000, ErrorMessage = "Description must not exceed 1000 characters.")] - string? Description, - [Range(1, int.MaxValue, ErrorMessage = "Width must be greater than zero.")] int Width, - [Range(1, int.MaxValue, ErrorMessage = "Height must be greater than zero.")] int Height, - [NotEmpty(ErrorMessage = "Format is required.")] - [AllowedValues( - "jpg", - "png", - "gif", - "webp", - ErrorMessage = "Format must be one of: jpg, png, gif, webp." - )] - string Format, - [Range(1, long.MaxValue, ErrorMessage = "FileSizeBytes must be greater than zero.")] - long FileSizeBytes -); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs deleted file mode 100644 index fb931f78..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.ProductData.DTOs; - -/// -/// Payload for uploading video product data, including duration, resolution, format, and file size. -/// -public sealed record CreateVideoProductDataRequest( - [NotEmpty(ErrorMessage = "Title is required.")] - [MaxLength(200, ErrorMessage = "Title must not exceed 200 characters.")] - string Title, - [MaxLength(1000, ErrorMessage = "Description must not exceed 1000 characters.")] - string? Description, - [Range(1, int.MaxValue, ErrorMessage = "DurationSeconds must be greater than zero.")] - int DurationSeconds, - [NotEmpty(ErrorMessage = "Resolution is required.")] - [AllowedValues( - "720p", - "1080p", - "4K", - ErrorMessage = "Resolution must be one of: 720p, 1080p, 4K." - )] - string Resolution, - [NotEmpty(ErrorMessage = "Format is required.")] - [AllowedValues("mp4", "avi", "mkv", ErrorMessage = "Format must be one of: mp4, avi, mkv.")] - string Format, - [Range(1, long.MaxValue, ErrorMessage = "FileSizeBytes must be greater than zero.")] - long FileSizeBytes -); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/DTOs/ProductDataResponse.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/DTOs/ProductDataResponse.cs deleted file mode 100644 index 9ac662be..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/DTOs/ProductDataResponse.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Text.Json.Serialization; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.ProductData.DTOs; - -/// -/// Abstract base read model for product data, serialised as a polymorphic type using the type discriminator. -/// -[JsonDerivedType(typeof(ImageProductDataResponse), "image")] -[JsonDerivedType(typeof(VideoProductDataResponse), "video")] -public abstract record ProductDataResponse : IHasId -{ - public Guid Id { get; init; } - public string Type { get; init; } = string.Empty; - public string Title { get; init; } = string.Empty; - public string? Description { get; init; } - public DateTime CreatedAt { get; init; } - public string? Format { get; init; } - public long? FileSizeBytes { get; init; } -} - -/// -/// Read model for image product data, extending with pixel dimensions. -/// -public sealed record ImageProductDataResponse : ProductDataResponse -{ - public int Width { get; init; } - public int Height { get; init; } -} - -/// -/// Read model for video product data, extending with duration and resolution. -/// -public sealed record VideoProductDataResponse : ProductDataResponse -{ - public int DurationSeconds { get; init; } - public string? Resolution { get; init; } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Mappings/ProductDataMappings.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Mappings/ProductDataMappings.cs deleted file mode 100644 index bedd1603..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Mappings/ProductDataMappings.cs +++ /dev/null @@ -1,60 +0,0 @@ -using ProductCatalog.Application.Features.ProductData.DTOs; -using ImageProductDataEntity = ProductCatalog.Domain.Entities.ProductData.ImageProductData; -using ProductDataEntity = ProductCatalog.Domain.Entities.ProductData.ProductData; -using VideoProductDataEntity = ProductCatalog.Domain.Entities.ProductData.VideoProductData; - -namespace ProductCatalog.Application.Features.ProductData.Mappings; - -/// -/// Provides mapping utilities from product data domain entities to their polymorphic response DTOs. -/// -public static class ProductDataMappings -{ - /// - /// Maps a to the appropriate subtype. - /// - public static ProductDataResponse ToResponse(this ProductDataEntity data) => - data switch - { - ImageProductDataEntity image => image.ToImageResponse(), - VideoProductDataEntity video => video.ToVideoResponse(), - _ => throw new InvalidOperationException( - $"Unknown ProductData type: {data.GetType().Name}" - ), - }; - - private static T MapCommon(this ProductDataEntity data, T response, string type) - where T : ProductDataResponse => - response with - { - Id = data.Id, - Title = data.Title, - Description = data.Description, - CreatedAt = data.CreatedAt, - Type = type, - }; - - private static ImageProductDataResponse ToImageResponse(this ImageProductDataEntity image) => - image.MapCommon( - new ImageProductDataResponse - { - Width = image.Width, - Height = image.Height, - Format = image.Format, - FileSizeBytes = image.FileSizeBytes, - }, - "image" - ); - - private static VideoProductDataResponse ToVideoResponse(this VideoProductDataEntity video) => - video.MapCommon( - new VideoProductDataResponse - { - DurationSeconds = video.DurationSeconds, - Resolution = video.Resolution, - Format = video.Format, - FileSizeBytes = video.FileSizeBytes, - }, - "video" - ); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs deleted file mode 100644 index 697d483d..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs +++ /dev/null @@ -1,33 +0,0 @@ -using ErrorOr; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.ProductData.DTOs; -using ProductCatalog.Application.Features.ProductData.Mappings; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Context; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Application.Features.ProductData.Queries; - -public sealed record GetProductDataByIdQuery(Guid Id) : IHasId; - -public sealed class GetProductDataByIdQueryHandler -{ - public static async Task> HandleAsync( - GetProductDataByIdQuery request, - IProductDataRepository repository, - ITenantProvider tenantProvider, - CancellationToken ct - ) - { - Guid tenantId = tenantProvider.TenantId; - Domain.Entities.ProductData.ProductData? data = await repository.GetByIdAsync( - request.Id, - ct - ); - - if (data is null || data.TenantId != tenantId) - return DomainErrors.ProductData.NotFound(request.Id); - - return data.ToResponse(); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Queries/GetProductDataQuery.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Queries/GetProductDataQuery.cs deleted file mode 100644 index 8fcfd511..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Queries/GetProductDataQuery.cs +++ /dev/null @@ -1,24 +0,0 @@ -using ErrorOr; -using ProductCatalog.Application.Features.ProductData.DTOs; -using ProductCatalog.Application.Features.ProductData.Mappings; -using ProductCatalog.Domain.Interfaces; - -namespace ProductCatalog.Application.Features.ProductData.Queries; - -public sealed record GetProductDataQuery(string? Type); - -public sealed class GetProductDataQueryHandler -{ - public static async Task>> HandleAsync( - GetProductDataQuery request, - IProductDataRepository repository, - CancellationToken ct - ) - { - List items = await repository.GetAllAsync( - request.Type, - ct - ); - return items.Select(item => item.ToResponse()).ToList(); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs deleted file mode 100644 index e329a3cc..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using ProductCatalog.Application.Features.ProductData.DTOs; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.ProductData.Validation; - -/// -/// FluentValidation validator for , delegating to data-annotation-based validation rules. -/// -public sealed class CreateImageProductDataRequestValidator - : DataAnnotationsValidator; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs deleted file mode 100644 index 4476c968..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using ProductCatalog.Application.Features.ProductData.DTOs; -using SharedKernel.Application.Validation; - -namespace ProductCatalog.Application.Features.ProductData.Validation; - -/// -/// FluentValidation validator for , delegating to data-annotation-based validation rules. -/// -public sealed class CreateVideoProductDataRequestValidator - : DataAnnotationsValidator; diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/ProductCatalog.Application.csproj b/src/Services/ProductCatalog/ProductCatalog.Application/ProductCatalog.Application.csproj deleted file mode 100644 index 494059c8..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/ProductCatalog.Application.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Sagas/ProductDeletionSaga.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Sagas/ProductDeletionSaga.cs deleted file mode 100644 index f0b53d40..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Sagas/ProductDeletionSaga.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using Contracts.IntegrationEvents.Sagas; -using Microsoft.Extensions.Logging; -using Wolverine; - -namespace ProductCatalog.Application.Sagas; - -/// -/// Wolverine saga that coordinates the product deletion cascade across services. -/// Waits for both Reviews and File Storage to confirm cascade completion before finishing. -/// -public class ProductDeletionSaga : Saga -{ - public Guid Id { get; set; } - public IReadOnlyList ProductIds { get; set; } = []; - public Guid TenantId { get; set; } - public bool ReviewsCascaded { get; set; } - public bool FilesCascaded { get; set; } - - /// - /// Starts the saga and publishes the to trigger downstream cascades. - /// - public static ( - ProductDeletionSaga, - ProductDeletedIntegrationEvent, - ProductDeletionSagaTimeout - ) Start(StartProductDeletionSaga command, TimeProvider timeProvider) - { - ProductDeletionSaga saga = new() - { - Id = command.CorrelationId, - ProductIds = command.ProductIds, - TenantId = command.TenantId, - }; - - ProductDeletedIntegrationEvent integrationEvent = new( - command.CorrelationId, - command.ProductIds, - command.TenantId, - timeProvider.GetUtcNow().UtcDateTime - ); - - ProductDeletionSagaTimeout timeout = new(command.CorrelationId); - - return (saga, integrationEvent, timeout); - } - - /// - /// Handles confirmation that reviews have been cascade-deleted. - /// - public void Handle(ReviewsCascadeCompleted message) - { - ReviewsCascaded = true; - TryComplete(); - } - - /// - /// Handles confirmation that files have been orphaned. - /// - public void Handle(FilesCascadeCompleted message) - { - FilesCascaded = true; - TryComplete(); - } - - /// - /// Handles timeout when downstream services do not complete the cascade in time. - /// - public void Handle(ProductDeletionSagaTimeout timeout, ILogger logger) - { - if (ReviewsCascaded && FilesCascaded) - return; - - logger.LogWarning( - "ProductDeletionSaga timed out for {SagaId}. Pending confirmations: ReviewsCascaded={ReviewsCascaded}, FilesCascaded={FilesCascaded}, TenantId={TenantId}", - timeout.CorrelationId, - ReviewsCascaded, - FilesCascaded, - TenantId - ); - - MarkCompleted(); - } - - private void TryComplete() - { - if (ReviewsCascaded && FilesCascaded) - MarkCompleted(); - } - - public static void NotFound(ReviewsCascadeCompleted msg, ILogger logger) => - logger.LogWarning( - "Received {MessageType} for unknown saga {SagaId}", - nameof(ReviewsCascadeCompleted), - msg.CorrelationId - ); - - public static void NotFound(FilesCascadeCompleted msg, ILogger logger) => - logger.LogWarning( - "Received {MessageType} for unknown saga {SagaId}", - nameof(FilesCascadeCompleted), - msg.CorrelationId - ); - - public static void NotFound( - ProductDeletionSagaTimeout msg, - ILogger logger - ) => - logger.LogInformation( - "Received {MessageType} for already-completed or missing saga {SagaId}", - nameof(ProductDeletionSagaTimeout), - msg.CorrelationId - ); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/Category.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/Category.cs deleted file mode 100644 index ebded5e2..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/Category.cs +++ /dev/null @@ -1,32 +0,0 @@ -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Domain.Entities; - -/// -/// Domain entity representing a product category within a tenant. -/// Acts as an aggregate root that groups related entities. -/// -public sealed class Category : IAuditableTenantEntity, IHasId -{ - public Guid Id { get; set; } - - public required string Name - { - get => field; - set => - field = string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Category name cannot be empty.", nameof(Name)) - : value.Trim(); - } - - public string? Description { get; set; } - - public ICollection Products { get; set; } = []; - - public Guid TenantId { get; set; } - public AuditInfo Audit { get; set; } = new(); - public bool IsDeleted { get; set; } - public DateTime? DeletedAtUtc { get; set; } - public Guid? DeletedBy { get; set; } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/Product.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/Product.cs deleted file mode 100644 index 7eccc046..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/Product.cs +++ /dev/null @@ -1,98 +0,0 @@ -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Domain.Entities; - -/// -/// Core domain entity representing a product in the catalog. -/// This is the aggregate root - all business rules around products start here. -/// -public sealed class Product : IAuditableTenantEntity, IHasId -{ - /// Unique identifier generated when the product is created. - public Guid Id { get; set; } - - /// Display name of the product. Required, max 200 characters (enforced by EF config + FluentValidation). - public required string Name - { - get => field; - set => - field = string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Product name cannot be empty.", nameof(Name)) - : value.Trim(); - } - - /// Optional longer description of the product. - public string? Description { get; set; } - - /// Price with 18,2 decimal precision (enforced by EF config). - public decimal Price - { - get => field; - set => - field = - value >= 0 - ? value - : throw new ArgumentOutOfRangeException( - nameof(Price), - "Price must be greater than or equal to zero." - ); - } - - public Guid? CategoryId { get; set; } - - public Category? Category { get; set; } - - public ICollection ProductDataLinks { get; set; } = []; - - public Guid TenantId { get; set; } - public AuditInfo Audit { get; set; } = new(); - public bool IsDeleted { get; set; } - public DateTime? DeletedAtUtc { get; set; } - public Guid? DeletedBy { get; set; } - - /// - /// Atomically replaces all mutable product fields in a single call, enforcing property-level invariants. - /// - public void UpdateDetails(string name, string? description, decimal price, Guid? categoryId) - { - Name = name; - Description = description; - Price = price; - CategoryId = categoryId; - } - - /// - /// Reconciles the product's collection against the desired set of . - /// Removes links not in the target set and creates new links as needed. - /// - public void SyncProductDataLinks( - HashSet targetIds, - Dictionary existingById - ) - { - foreach ( - ProductDataLink link in ProductDataLinks - .Where(link => !targetIds.Contains(link.ProductDataId)) - .ToArray() - ) - ProductDataLinks.Remove(link); - - foreach (Guid productDataId in targetIds) - { - if (!existingById.ContainsKey(productDataId)) - { - ProductDataLinks.Add(ProductDataLink.Create(Id, productDataId)); - } - } - } - - /// - /// Removes all current product data links from the in-memory collection, preparing them for soft-delete by the persistence layer. - /// - public void SoftDeleteProductDataLinks() - { - foreach (ProductDataLink link in ProductDataLinks.ToArray()) - ProductDataLinks.Remove(link); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductCategoryStats.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductCategoryStats.cs deleted file mode 100644 index f500672c..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductCategoryStats.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace ProductCatalog.Domain.Entities; - -/// -/// Keyless entity — no backing database table. -/// Used exclusively as a result type for the get_product_category_stats stored procedure. -/// EF Core maps each column from the SQL result set to these properties. -/// -public sealed class ProductCategoryStats -{ - public Guid CategoryId { get; set; } - public string CategoryName { get; set; } = string.Empty; - public long ProductCount { get; set; } - public decimal AveragePrice { get; set; } - public long TotalReviews { get; set; } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductData/ImageProductData.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductData/ImageProductData.cs deleted file mode 100644 index 69a86ea8..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductData/ImageProductData.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MongoDB.Bson.Serialization.Attributes; - -namespace ProductCatalog.Domain.Entities.ProductData; - -/// -/// MongoDB document subtype that represents image media linked to a product, storing image-specific metadata such as dimensions and format. -/// -[BsonDiscriminator("image")] -public sealed class ImageProductData : ProductData -{ - public int Width { get; set; } - - public int Height { get; set; } - - public string Format { get; set; } = string.Empty; - - public long FileSizeBytes { get; set; } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductData/ProductData.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductData/ProductData.cs deleted file mode 100644 index f80fa203..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductData/ProductData.cs +++ /dev/null @@ -1,30 +0,0 @@ -using MongoDB.Bson.Serialization.Attributes; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Domain.Entities.ProductData; - -/// -/// Abstract base document stored in MongoDB that describes rich media associated with products. -/// Serves as the discriminator root for the and subtypes. -/// -[BsonDiscriminator(RootClass = true)] -[BsonKnownTypes(typeof(ImageProductData), typeof(VideoProductData))] -public abstract class ProductData : IHasId -{ - [BsonId] - public Guid Id { get; set; } = Guid.NewGuid(); - - public Guid TenantId { get; set; } - - public string Title { get; set; } = string.Empty; - - public string? Description { get; set; } - - public DateTime CreatedAt { get; set; } - - public bool IsDeleted { get; set; } - - public DateTime? DeletedAtUtc { get; set; } - - public Guid? DeletedBy { get; set; } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductData/VideoProductData.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductData/VideoProductData.cs deleted file mode 100644 index d3dd5a91..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductData/VideoProductData.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MongoDB.Bson.Serialization.Attributes; - -namespace ProductCatalog.Domain.Entities.ProductData; - -/// -/// MongoDB document subtype that represents video media linked to a product, storing video-specific metadata such as duration and resolution. -/// -[BsonDiscriminator("video")] -public sealed class VideoProductData : ProductData -{ - public int DurationSeconds { get; set; } - - public string Resolution { get; set; } = string.Empty; - - public string Format { get; set; } = string.Empty; - - public long FileSizeBytes { get; set; } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductDataLink.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductDataLink.cs deleted file mode 100644 index 229fa6ba..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Entities/ProductDataLink.cs +++ /dev/null @@ -1,43 +0,0 @@ -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace ProductCatalog.Domain.Entities; - -/// -/// Join entity that associates a with a document stored in MongoDB. -/// Supports soft-delete so that links can be restored without data loss. -/// -public sealed class ProductDataLink : IAuditableTenantEntity -{ - public Guid ProductId { get; set; } - - public Guid ProductDataId { get; set; } - - public Guid TenantId { get; set; } - - public AuditInfo Audit { get; set; } = new(); - - public bool IsDeleted { get; set; } - - public DateTime? DeletedAtUtc { get; set; } - - public Guid? DeletedBy { get; set; } - - public Product Product { get; set; } = null!; - - /// - /// Factory method that creates a new for the given product and product-data pair. - /// - public static ProductDataLink Create(Guid productId, Guid productDataId) => - new() { ProductId = productId, ProductDataId = productDataId }; - - /// - /// Clears all soft-delete fields, effectively un-deleting this link. - /// - public void Restore() - { - IsDeleted = false; - DeletedAtUtc = null; - DeletedBy = null; - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/ICategoryRepository.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/ICategoryRepository.cs deleted file mode 100644 index 5d8ed11e..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/ICategoryRepository.cs +++ /dev/null @@ -1,17 +0,0 @@ -using ProductCatalog.Domain.Entities; -using SharedKernel.Domain.Interfaces; - -namespace ProductCatalog.Domain.Interfaces; - -/// -/// Repository contract for entities, extending the generic repository with category-specific queries. -/// -public interface ICategoryRepository : IRepository -{ - /// - /// Calls the get_product_category_stats(p_category_id) PostgreSQL stored procedure - /// and returns aggregated statistics for the given category. - /// Returns null when no category with the specified ID exists. - /// - Task GetStatsByIdAsync(Guid categoryId, CancellationToken ct = default); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IProductDataLinkRepository.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IProductDataLinkRepository.cs deleted file mode 100644 index 5ee7a2ee..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IProductDataLinkRepository.cs +++ /dev/null @@ -1,43 +0,0 @@ -using ProductCatalog.Domain.Entities; - -namespace ProductCatalog.Domain.Interfaces; - -/// -/// Repository contract for managing join records between relational products and MongoDB product-data documents. -/// -public interface IProductDataLinkRepository -{ - /// - /// Returns all links for the specified product, optionally including soft-deleted records. - /// - Task> ListByProductIdAsync( - Guid productId, - bool includeDeleted = false, - CancellationToken ct = default - ); - - /// - /// Returns links for the specified product IDs in a single query, optionally including soft-deleted records. - /// - Task>> ListByProductIdsAsync( - IReadOnlyCollection productIds, - bool includeDeleted = false, - CancellationToken ct = default - ); - - /// - /// Returns true if at least one non-deleted link references the given product-data document. - /// - Task HasActiveLinksForProductDataAsync( - Guid productDataId, - CancellationToken ct = default - ); - - /// - /// Soft-deletes all active links that reference the given product-data document. - /// - Task SoftDeleteActiveLinksForProductDataAsync( - Guid productDataId, - CancellationToken ct = default - ); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IProductDataRepository.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IProductDataRepository.cs deleted file mode 100644 index 45dc4b2e..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IProductDataRepository.cs +++ /dev/null @@ -1,42 +0,0 @@ -using ProductCatalog.Domain.Entities.ProductData; - -namespace ProductCatalog.Domain.Interfaces; - -/// -/// Repository contract for documents stored in MongoDB. -/// Provides CRUD and soft-delete operations scoped to the current tenant. -/// -public interface IProductDataRepository -{ - /// Returns the product-data document with the given ID, or null if not found or soft-deleted. - Task GetByIdAsync(Guid id, CancellationToken ct = default); - - /// Returns all non-deleted product-data documents whose IDs are in the provided collection. - Task> GetByIdsAsync(IEnumerable ids, CancellationToken ct = default); - - /// - /// Returns all non-deleted product-data documents, optionally filtered by discriminator (e.g. "image" or "video"). - /// - Task> GetAllAsync(string? type = null, CancellationToken ct = default); - - /// Inserts a new product-data document and returns the persisted instance. - Task CreateAsync(ProductData productData, CancellationToken ct = default); - - /// Soft-deletes the product-data document with the given ID, recording the actor and timestamp. - Task SoftDeleteAsync( - Guid id, - Guid actorId, - DateTime deletedAtUtc, - CancellationToken ct = default - ); - - /// - /// Soft-deletes all product-data documents belonging to the specified tenant and returns the count of affected documents. - /// - Task SoftDeleteByTenantAsync( - Guid tenantId, - Guid actorId, - DateTime deletedAtUtc, - CancellationToken ct = default - ); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IStoredProcedure.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IStoredProcedure.cs deleted file mode 100644 index 4f549b46..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IStoredProcedure.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ProductCatalog.Domain.Interfaces; - -/// -/// Represents a single stored procedure call. -/// Each stored procedure is its own sealed record that owns: -/// - the SQL template (the function name) -/// - the parameter values (as constructor properties) -/// - the result type (via the generic parameter) -/// -/// -/// The keyless entity type that EF Core will materialise from the procedure result set. -/// Must be registered with HasNoKey() in the DbContext. -/// -public interface IStoredProcedure - where TResult : class -{ - /// - /// Returns an interpolated SQL string with all parameter values embedded. - /// EF Core automatically converts each interpolated value into a named - /// SQL parameter (@p0, @p1, ...), preventing SQL injection. - /// - FormattableString ToSql(); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IStoredProcedureExecutor.cs b/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IStoredProcedureExecutor.cs deleted file mode 100644 index 3a2f12cf..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/Interfaces/IStoredProcedureExecutor.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ProductCatalog.Domain.Interfaces; - -/// -/// Executes stored procedures and maps the result set to strongly-typed objects. -/// -public interface IStoredProcedureExecutor -{ - /// - /// Executes a procedure and returns the first matching row, or null - /// when the result set is empty. - /// - Task QueryFirstAsync( - IStoredProcedure procedure, - CancellationToken ct = default - ) - where TResult : class; - - /// - /// Executes a procedure and returns all rows as a read-only list. - /// - Task> QueryManyAsync( - IStoredProcedure procedure, - CancellationToken ct = default - ) - where TResult : class; - - /// - /// Executes a procedure that performs a write operation (INSERT / UPDATE / DELETE) - /// and returns the number of affected rows. - /// - Task ExecuteAsync(FormattableString sql, CancellationToken ct = default); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Domain/ProductCatalog.Domain.csproj b/src/Services/ProductCatalog/ProductCatalog.Domain/ProductCatalog.Domain.csproj deleted file mode 100644 index 87935df6..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Domain/ProductCatalog.Domain.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs deleted file mode 100644 index 4af339be..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductCatalog.Domain.Entities; -using SharedKernel.Infrastructure.Persistence.Configurations; - -namespace ProductCatalog.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity, including a full-text search GIN index. -public sealed class CategoryConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(c => c.Id); - builder.ConfigureTenantAuditable(); - - builder.Property(c => c.Name).IsRequired().HasMaxLength(100); - - builder.Property(c => c.Description).HasMaxLength(500); - - builder.HasIndex(c => new { c.TenantId, c.Name }).IsUnique(); - builder - .HasIndex(c => new { c.Name, c.Description }) - .HasMethod("GIN") - .IsTsVectorExpressionIndex("english"); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs deleted file mode 100644 index d184e8db..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductCatalog.Domain.Entities; - -namespace ProductCatalog.Infrastructure.Persistence.Configurations; - -/// -/// Registers as a keyless entity. -/// -public sealed class ProductCategoryStatsConfiguration - : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasNoKey(); - builder.ToTable("ProductCategoryStats", t => t.ExcludeFromMigrations()); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductConfiguration.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductConfiguration.cs deleted file mode 100644 index 39a60294..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductConfiguration.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductCatalog.Domain.Entities; -using SharedKernel.Infrastructure.Persistence.Configurations; - -namespace ProductCatalog.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity, including price precision and a full-text search GIN index. -public sealed class ProductConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(p => p.Id); - builder.ConfigureTenantAuditable(); - - builder.Property(p => p.Name).IsRequired().HasMaxLength(200); - - builder.Property(p => p.Description).HasMaxLength(1000); - - builder.Property(p => p.Price).HasPrecision(18, 2); - - builder - .HasOne(p => p.Category) - .WithMany(c => c.Products) - .HasForeignKey(p => p.CategoryId) - .OnDelete(DeleteBehavior.SetNull); - - builder.HasIndex(p => new { p.TenantId, p.Name }); - builder - .HasIndex(p => new { p.Name, p.Description }) - .HasMethod("GIN") - .IsTsVectorExpressionIndex("english"); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs deleted file mode 100644 index f420b77c..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductCatalog.Domain.Entities; -using SharedKernel.Infrastructure.Persistence.Configurations; - -namespace ProductCatalog.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the join entity with a composite primary key. -public sealed class ProductDataLinkConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(x => new { x.ProductId, x.ProductDataId }); - builder.ConfigureTenantAuditable(); - - builder.HasIndex(x => new - { - x.TenantId, - x.ProductDataId, - x.IsDeleted, - }); - - builder - .HasOne(x => x.Product) - .WithMany(p => p.ProductDataLinks) - .HasForeignKey(x => x.ProductId) - .OnDelete(DeleteBehavior.Restrict); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductDeletionSagaConfiguration.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductDeletionSagaConfiguration.cs deleted file mode 100644 index 5d3c7189..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Configurations/ProductDeletionSagaConfiguration.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductCatalog.Application.Sagas; - -namespace ProductCatalog.Infrastructure.Persistence.Configurations; - -/// -/// EF Core mapping for ProductDeletionSaga persisted state. -/// Sagas are cross-service process managers and are not tenant-filtered. -/// -public sealed class ProductDeletionSagaConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("ProductDeletionSagas", "sagas"); - - builder.HasKey(s => s.Id); - - builder.Property(s => s.Id).IsRequired(); - builder.Property(s => s.TenantId).IsRequired(); - builder.Property(s => s.ReviewsCascaded).IsRequired(); - builder.Property(s => s.FilesCascaded).IsRequired(); - - // IReadOnlyList is only useful at start and not needed for completion correlation/state. - builder.Ignore(s => s.ProductIds); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260326232813_InitialCreate.Designer.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260326232813_InitialCreate.Designer.cs deleted file mode 100644 index 282d9e59..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260326232813_InitialCreate.Designer.cs +++ /dev/null @@ -1,371 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using ProductCatalog.Infrastructure.Persistence; - -#nullable disable - -namespace ProductCatalog.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(ProductCatalogDbContext))] - [Migration("20260326232813_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("Name", "Description") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Name") - .IsUnique(); - - b.ToTable("Categories", t => - { - t.HasCheckConstraint("CK_Categories_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - b.HasIndex("Name", "Description") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Name"); - - b.ToTable("Products", t => - { - t.HasCheckConstraint("CK_Products_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductCategoryStats", b => - { - b.Property("AveragePrice") - .HasColumnType("numeric"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CategoryName") - .IsRequired() - .HasColumnType("text"); - - b.Property("ProductCount") - .HasColumnType("bigint"); - - b.Property("TotalReviews") - .HasColumnType("bigint"); - - b.ToTable("ProductCategoryStats", null, t => - { - t.ExcludeFromMigrations(); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductDataLink", b => - { - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("ProductDataId") - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("ProductId", "ProductDataId"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "ProductDataId", "IsDeleted"); - - b.ToTable("ProductDataLinks", t => - { - t.HasCheckConstraint("CK_ProductDataLinks_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("CategoryId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.HasOne("ProductCatalog.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductDataLink", b => - { - b.HasOne("ProductCatalog.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductDataLinkProductId") - .HasColumnType("uuid"); - - b1.Property("ProductDataLinkProductDataId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductDataLinkProductId", "ProductDataLinkProductDataId"); - - b1.ToTable("ProductDataLinks"); - - b1.WithOwner() - .HasForeignKey("ProductDataLinkProductId", "ProductDataLinkProductDataId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260326232813_InitialCreate.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260326232813_InitialCreate.cs deleted file mode 100644 index 038c3ed1..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260326232813_InitialCreate.cs +++ /dev/null @@ -1,293 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ProductCatalog.Infrastructure.Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Categories", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - Description = table.Column( - type: "character varying(500)", - maxLength: 500, - nullable: true - ), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_Categories", x => x.Id); - table.CheckConstraint( - "CK_Categories_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - } - ); - - migrationBuilder.CreateTable( - name: "Products", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: false - ), - Description = table.Column( - type: "character varying(1000)", - maxLength: 1000, - nullable: true - ), - Price = table.Column( - type: "numeric(18,2)", - precision: 18, - scale: 2, - nullable: false - ), - CategoryId = table.Column(type: "uuid", nullable: true), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_Products", x => x.Id); - table.CheckConstraint( - "CK_Products_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - table.ForeignKey( - name: "FK_Products_Categories_CategoryId", - column: x => x.CategoryId, - principalTable: "Categories", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull - ); - } - ); - - migrationBuilder.CreateTable( - name: "ProductDataLinks", - columns: table => new - { - ProductId = table.Column(type: "uuid", nullable: false), - ProductDataId = table.Column(type: "uuid", nullable: false), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey( - "PK_ProductDataLinks", - x => new { x.ProductId, x.ProductDataId } - ); - table.CheckConstraint( - "CK_ProductDataLinks_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - table.ForeignKey( - name: "FK_ProductDataLinks_Products_ProductId", - column: x => x.ProductId, - principalTable: "Products", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); - } - ); - - migrationBuilder - .CreateIndex( - name: "IX_Categories_Name_Description", - table: "Categories", - columns: new[] { "Name", "Description" } - ) - .Annotation("Npgsql:IndexMethod", "GIN") - .Annotation("Npgsql:TsVectorConfig", "english"); - - migrationBuilder.CreateIndex( - name: "IX_Categories_TenantId", - table: "Categories", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_Categories_TenantId_IsDeleted", - table: "Categories", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_Categories_TenantId_Name", - table: "Categories", - columns: new[] { "TenantId", "Name" }, - unique: true - ); - - migrationBuilder.CreateIndex( - name: "IX_ProductDataLinks_TenantId", - table: "ProductDataLinks", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_ProductDataLinks_TenantId_IsDeleted", - table: "ProductDataLinks", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_ProductDataLinks_TenantId_ProductDataId_IsDeleted", - table: "ProductDataLinks", - columns: new[] { "TenantId", "ProductDataId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_Products_CategoryId", - table: "Products", - column: "CategoryId" - ); - - migrationBuilder - .CreateIndex( - name: "IX_Products_Name_Description", - table: "Products", - columns: new[] { "Name", "Description" } - ) - .Annotation("Npgsql:IndexMethod", "GIN") - .Annotation("Npgsql:TsVectorConfig", "english"); - - migrationBuilder.CreateIndex( - name: "IX_Products_TenantId", - table: "Products", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_Products_TenantId_IsDeleted", - table: "Products", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_Products_TenantId_Name", - table: "Products", - columns: new[] { "TenantId", "Name" } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "ProductDataLinks"); - - migrationBuilder.DropTable(name: "Products"); - - migrationBuilder.DropTable(name: "Categories"); - } - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329104542_AddProductDeletionSaga.Designer.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329104542_AddProductDeletionSaga.Designer.cs deleted file mode 100644 index 1f0bb344..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329104542_AddProductDeletionSaga.Designer.cs +++ /dev/null @@ -1,393 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using ProductCatalog.Infrastructure.Persistence; - -#nullable disable - -namespace ProductCatalog.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(ProductCatalogDbContext))] - [Migration("20260329104542_AddProductDeletionSaga")] - partial class AddProductDeletionSaga - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ProductCatalog.Application.Sagas.ProductDeletionSaga", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("FilesCascaded") - .HasColumnType("boolean"); - - b.Property("ReviewsCascaded") - .HasColumnType("boolean"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Version") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("ProductDeletionSagas", (string)null); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("Name", "Description") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Name") - .IsUnique(); - - b.ToTable("Categories", t => - { - t.HasCheckConstraint("CK_Categories_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - b.HasIndex("Name", "Description") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Name"); - - b.ToTable("Products", t => - { - t.HasCheckConstraint("CK_Products_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductCategoryStats", b => - { - b.Property("AveragePrice") - .HasColumnType("numeric"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CategoryName") - .IsRequired() - .HasColumnType("text"); - - b.Property("ProductCount") - .HasColumnType("bigint"); - - b.Property("TotalReviews") - .HasColumnType("bigint"); - - b.ToTable("ProductCategoryStats", null, t => - { - t.ExcludeFromMigrations(); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductDataLink", b => - { - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("ProductDataId") - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("ProductId", "ProductDataId"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "ProductDataId", "IsDeleted"); - - b.ToTable("ProductDataLinks", t => - { - t.HasCheckConstraint("CK_ProductDataLinks_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("CategoryId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.HasOne("ProductCatalog.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductDataLink", b => - { - b.HasOne("ProductCatalog.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductDataLinkProductId") - .HasColumnType("uuid"); - - b1.Property("ProductDataLinkProductDataId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductDataLinkProductId", "ProductDataLinkProductDataId"); - - b1.ToTable("ProductDataLinks"); - - b1.WithOwner() - .HasForeignKey("ProductDataLinkProductId", "ProductDataLinkProductDataId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329104542_AddProductDeletionSaga.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329104542_AddProductDeletionSaga.cs deleted file mode 100644 index e109b84e..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329104542_AddProductDeletionSaga.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ProductCatalog.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddProductDeletionSaga : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ProductDeletionSagas", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - TenantId = table.Column(type: "uuid", nullable: false), - ReviewsCascaded = table.Column(type: "boolean", nullable: false), - FilesCascaded = table.Column(type: "boolean", nullable: false), - Version = table.Column(type: "integer", nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_ProductDeletionSagas", x => x.Id); - } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "ProductDeletionSagas"); - } - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329142515_MoveProductDeletionSagaToSagasSchema.Designer.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329142515_MoveProductDeletionSagaToSagasSchema.Designer.cs deleted file mode 100644 index baed4943..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329142515_MoveProductDeletionSagaToSagasSchema.Designer.cs +++ /dev/null @@ -1,393 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using ProductCatalog.Infrastructure.Persistence; - -#nullable disable - -namespace ProductCatalog.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(ProductCatalogDbContext))] - [Migration("20260329142515_MoveProductDeletionSagaToSagasSchema")] - partial class MoveProductDeletionSagaToSagasSchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ProductCatalog.Application.Sagas.ProductDeletionSaga", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("FilesCascaded") - .HasColumnType("boolean"); - - b.Property("ReviewsCascaded") - .HasColumnType("boolean"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Version") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("ProductDeletionSagas", "sagas"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("Name", "Description") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Name") - .IsUnique(); - - b.ToTable("Categories", t => - { - t.HasCheckConstraint("CK_Categories_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - b.HasIndex("Name", "Description") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Name"); - - b.ToTable("Products", t => - { - t.HasCheckConstraint("CK_Products_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductCategoryStats", b => - { - b.Property("AveragePrice") - .HasColumnType("numeric"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CategoryName") - .IsRequired() - .HasColumnType("text"); - - b.Property("ProductCount") - .HasColumnType("bigint"); - - b.Property("TotalReviews") - .HasColumnType("bigint"); - - b.ToTable("ProductCategoryStats", null, t => - { - t.ExcludeFromMigrations(); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductDataLink", b => - { - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("ProductDataId") - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("ProductId", "ProductDataId"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "ProductDataId", "IsDeleted"); - - b.ToTable("ProductDataLinks", t => - { - t.HasCheckConstraint("CK_ProductDataLinks_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("CategoryId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.HasOne("ProductCatalog.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductDataLink", b => - { - b.HasOne("ProductCatalog.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductDataLinkProductId") - .HasColumnType("uuid"); - - b1.Property("ProductDataLinkProductDataId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductDataLinkProductId", "ProductDataLinkProductDataId"); - - b1.ToTable("ProductDataLinks"); - - b1.WithOwner() - .HasForeignKey("ProductDataLinkProductId", "ProductDataLinkProductDataId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329142515_MoveProductDeletionSagaToSagasSchema.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329142515_MoveProductDeletionSagaToSagasSchema.cs deleted file mode 100644 index c562173e..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/20260329142515_MoveProductDeletionSagaToSagasSchema.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ProductCatalog.Infrastructure.Persistence.Migrations -{ - /// - public partial class MoveProductDeletionSagaToSagasSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema(name: "sagas"); - - migrationBuilder.RenameTable( - name: "ProductDeletionSagas", - newName: "ProductDeletionSagas", - newSchema: "sagas" - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameTable( - name: "ProductDeletionSagas", - schema: "sagas", - newName: "ProductDeletionSagas" - ); - } - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/ProductCatalogDbContextModelSnapshot.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/ProductCatalogDbContextModelSnapshot.cs deleted file mode 100644 index 999fb051..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/Migrations/ProductCatalogDbContextModelSnapshot.cs +++ /dev/null @@ -1,390 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using ProductCatalog.Infrastructure.Persistence; - -#nullable disable - -namespace ProductCatalog.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(ProductCatalogDbContext))] - partial class ProductCatalogDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ProductCatalog.Application.Sagas.ProductDeletionSaga", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("FilesCascaded") - .HasColumnType("boolean"); - - b.Property("ReviewsCascaded") - .HasColumnType("boolean"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Version") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("ProductDeletionSagas", "sagas"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("Name", "Description") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Name") - .IsUnique(); - - b.ToTable("Categories", t => - { - t.HasCheckConstraint("CK_Categories_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - b.HasIndex("Name", "Description") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Name"); - - b.ToTable("Products", t => - { - t.HasCheckConstraint("CK_Products_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductCategoryStats", b => - { - b.Property("AveragePrice") - .HasColumnType("numeric"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CategoryName") - .IsRequired() - .HasColumnType("text"); - - b.Property("ProductCount") - .HasColumnType("bigint"); - - b.Property("TotalReviews") - .HasColumnType("bigint"); - - b.ToTable("ProductCategoryStats", null, t => - { - t.ExcludeFromMigrations(); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductDataLink", b => - { - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("ProductDataId") - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("ProductId", "ProductDataId"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "ProductDataId", "IsDeleted"); - - b.ToTable("ProductDataLinks", t => - { - t.HasCheckConstraint("CK_ProductDataLinks_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("CategoryId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.HasOne("ProductCatalog.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.ProductDataLink", b => - { - b.HasOne("ProductCatalog.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductDataLinkProductId") - .HasColumnType("uuid"); - - b1.Property("ProductDataLinkProductDataId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductDataLinkProductId", "ProductDataLinkProductDataId"); - - b1.ToTable("ProductDataLinks"); - - b1.WithOwner() - .HasForeignKey("ProductDataLinkProductId", "ProductDataLinkProductDataId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("ProductCatalog.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs deleted file mode 100644 index cecb2d35..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.Extensions.Options; -using MongoDB.Bson; -using MongoDB.Driver; -using MongoDB.Driver.Core.Extensions.DiagnosticSources; -using ProductCatalog.Domain.Entities.ProductData; - -namespace ProductCatalog.Infrastructure.Persistence; - -/// -/// Thin wrapper around the MongoDB driver that configures the client with diagnostic -/// activity tracing and exposes typed collection accessors for domain document types. -/// -public sealed class MongoDbContext -{ - private readonly IMongoDatabase _database; - - public MongoDbContext(IOptions settings) - { - MongoClientSettings clientSettings = MongoClientSettings.FromConnectionString( - settings.Value.ConnectionString - ); - clientSettings.ServerSelectionTimeout = TimeSpan.FromSeconds(5); - clientSettings.ClusterConfigurator = cb => - cb.Subscribe(new DiagnosticsActivityEventSubscriber()); - MongoClient client = new(clientSettings); - _database = client.GetDatabase(settings.Value.DatabaseName); - } - - public IMongoCollection ProductData => - _database.GetCollection("product_data"); - - /// Sends a ping command to verify that the MongoDB server is reachable. - public Task PingAsync(CancellationToken cancellationToken = default) => - _database.RunCommandAsync( - new BsonDocument("ping", 1), - cancellationToken: cancellationToken - ); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbSettings.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbSettings.cs deleted file mode 100644 index 89e02a1f..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbSettings.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace ProductCatalog.Infrastructure.Persistence; - -/// -/// Strongly-typed settings for the MongoDB connection, bound from the application configuration. -/// -public sealed class MongoDbSettings -{ - public const string SectionName = "MongoDB"; - - [Required] - public string ConnectionString { get; init; } = string.Empty; - - [Required] - public string DatabaseName { get; init; } = string.Empty; -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs deleted file mode 100644 index 0b3b4ba6..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using ProductCatalog.Application.Sagas; -using ProductCatalog.Domain.Entities; -using SharedKernel.Application.Context; -using SharedKernel.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Persistence.Auditing; -using SharedKernel.Infrastructure.Persistence.SoftDelete; - -namespace ProductCatalog.Infrastructure.Persistence; - -/// -/// EF Core context for the Product Catalog microservice. -/// Enforces multi-tenancy, audit stamping, soft delete, and optimistic concurrency. -/// -public sealed class ProductCatalogDbContext : TenantAuditableDbContext -{ - public ProductCatalogDbContext( - DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor - ) - : base( - options, - tenantProvider, - actorProvider, - timeProvider, - softDeleteCascadeRules, - entityStateManager, - softDeleteProcessor - ) { } - - public DbSet Products => Set(); - public DbSet ProductDataLinks => Set(); - public DbSet Categories => Set(); - public DbSet ProductCategoryStats => Set(); - public DbSet ProductDeletionSagas => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(ProductCatalogDbContext).Assembly); - - // Global query filters for multi-tenancy and soft-delete - modelBuilder - .Entity() - .HasQueryFilter(e => (!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted); - modelBuilder - .Entity() - .HasQueryFilter(e => (!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted); - modelBuilder - .Entity() - .HasQueryFilter(e => (!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs deleted file mode 100644 index f291b874..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace ProductCatalog.Infrastructure.Persistence; - -/// -/// Design-time factory used by EF Core tooling to create the DbContext -/// for migration scaffolding without requiring the full runtime DI container. -/// -public sealed class ProductCatalogDbContextDesignTimeFactory - : IDesignTimeDbContextFactory -{ - public ProductCatalogDbContext CreateDbContext(string[] args) - { - DbContextOptionsBuilder optionsBuilder = new(); - optionsBuilder.UseNpgsql( - "Host=localhost;Database=productcatalog_db;Username=postgres;Password=postgres" - ); - - return new ProductCatalogDbContext( - optionsBuilder.Options, - tenantProvider: null!, - actorProvider: null!, - timeProvider: TimeProvider.System, - softDeleteCascadeRules: [], - entityStateManager: null!, - softDeleteProcessor: null! - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/ProductCatalog.Infrastructure.csproj b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/ProductCatalog.Infrastructure.csproj deleted file mode 100644 index 45e5fbef..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/ProductCatalog.Infrastructure.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/CategoryRepository.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/CategoryRepository.cs deleted file mode 100644 index 5d655074..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/CategoryRepository.cs +++ /dev/null @@ -1,43 +0,0 @@ -using ProductCatalog.Domain.Entities; -using ProductCatalog.Domain.Interfaces; -using ProductCatalog.Infrastructure.Persistence; -using ProductCatalog.Infrastructure.StoredProcedures; -using SharedKernel.Application.Context; -using SharedKernel.Infrastructure.Repositories; - -namespace ProductCatalog.Infrastructure.Repositories; - -/// -/// EF Core repository for that extends the base repository with -/// stored-procedure-based stats retrieval. -/// -public sealed class CategoryRepository : RepositoryBase, ICategoryRepository -{ - private readonly IStoredProcedureExecutor _spExecutor; - private readonly ITenantProvider _tenantProvider; - - public CategoryRepository( - ProductCatalogDbContext dbContext, - IStoredProcedureExecutor spExecutor, - ITenantProvider tenantProvider - ) - : base(dbContext) - { - _spExecutor = spExecutor; - _tenantProvider = tenantProvider; - } - - /// - /// Retrieves aggregate product statistics for the given category via a stored procedure. - /// - public Task GetStatsByIdAsync( - Guid categoryId, - CancellationToken ct = default - ) - { - return _spExecutor.QueryFirstAsync( - new GetProductCategoryStatsProcedure(categoryId, _tenantProvider.TenantId), - ct - ); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/ProductDataLinkRepository.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/ProductDataLinkRepository.cs deleted file mode 100644 index 73298185..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/ProductDataLinkRepository.cs +++ /dev/null @@ -1,91 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using ProductCatalog.Domain.Entities; -using ProductCatalog.Domain.Interfaces; -using ProductCatalog.Infrastructure.Persistence; -using SharedKernel.Application.Context; - -namespace ProductCatalog.Infrastructure.Repositories; - -/// -/// EF Core repository for join entities, providing queries -/// that selectively bypass global filters when deleted links must be included. -/// -public sealed class ProductDataLinkRepository : IProductDataLinkRepository -{ - private readonly ProductCatalogDbContext _dbContext; - private readonly ITenantProvider _tenantProvider; - - public ProductDataLinkRepository( - ProductCatalogDbContext dbContext, - ITenantProvider tenantProvider - ) - { - _dbContext = dbContext; - _tenantProvider = tenantProvider; - } - - public async Task> ListByProductIdAsync( - Guid productId, - bool includeDeleted = false, - CancellationToken ct = default - ) - { - IQueryable query = includeDeleted - ? _dbContext - .ProductDataLinks.IgnoreQueryFilters() - .Where(link => - link.TenantId == _tenantProvider.TenantId && link.ProductId == productId - ) - : _dbContext.ProductDataLinks.Where(link => link.ProductId == productId); - - return await query.ToListAsync(ct); - } - - public async Task< - IReadOnlyDictionary> - > ListByProductIdsAsync( - IReadOnlyCollection productIds, - bool includeDeleted = false, - CancellationToken ct = default - ) - { - if (productIds.Count == 0) - return new Dictionary>(); - - IQueryable query = includeDeleted - ? _dbContext - .ProductDataLinks.IgnoreQueryFilters() - .Where(link => - link.TenantId == _tenantProvider.TenantId && productIds.Contains(link.ProductId) - ) - : _dbContext.ProductDataLinks.Where(link => productIds.Contains(link.ProductId)); - - List links = await query.ToListAsync(ct); - return links - .GroupBy(link => link.ProductId) - .ToDictionary( - group => group.Key, - group => (IReadOnlyList)group.ToList() - ); - } - - public Task HasActiveLinksForProductDataAsync( - Guid productDataId, - CancellationToken ct = default - ) => _dbContext.ProductDataLinks.AnyAsync(link => link.ProductDataId == productDataId, ct); - - public async Task SoftDeleteActiveLinksForProductDataAsync( - Guid productDataId, - CancellationToken ct = default - ) - { - List links = await _dbContext - .ProductDataLinks.Where(link => link.ProductDataId == productDataId) - .ToListAsync(ct); - - if (links.Count == 0) - return; - - _dbContext.ProductDataLinks.RemoveRange(links); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/ProductDataRepository.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/ProductDataRepository.cs deleted file mode 100644 index a3accb15..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/ProductDataRepository.cs +++ /dev/null @@ -1,121 +0,0 @@ -using MongoDB.Driver; -using ProductCatalog.Domain.Entities.ProductData; -using ProductCatalog.Domain.Interfaces; -using ProductCatalog.Infrastructure.Persistence; -using SharedKernel.Application.Context; - -namespace ProductCatalog.Infrastructure.Repositories; - -/// -/// MongoDB repository for documents, applying tenant and soft-delete -/// isolation at the query level since MongoDB has no EF Core global filter equivalent. -/// -public sealed class ProductDataRepository : IProductDataRepository -{ - private readonly IMongoCollection _collection; - private readonly ITenantProvider _tenantProvider; - - public ProductDataRepository(MongoDbContext context, ITenantProvider tenantProvider) - { - _collection = context.ProductData; - _tenantProvider = tenantProvider; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) => - await _collection - .Find(x => x.Id == id && x.TenantId == _tenantProvider.TenantId && !x.IsDeleted) - .FirstOrDefaultAsync(ct); - - public async Task> GetByIdsAsync( - IEnumerable ids, - CancellationToken ct = default - ) - { - Guid[] idArray = ids.Distinct().ToArray(); - - if (idArray.Length == 0) - return []; - - return await _collection - .Find( - Builders.Filter.And( - Builders.Filter.In(x => x.Id, idArray), - Builders.Filter.Eq(x => x.TenantId, _tenantProvider.TenantId), - Builders.Filter.Eq(x => x.IsDeleted, false) - ) - ) - .ToListAsync(ct); - } - - public async Task> GetAllAsync( - string? type = null, - CancellationToken ct = default - ) - { - FilterDefinition filter = type is null - ? Builders.Filter.And( - Builders.Filter.Eq(x => x.TenantId, _tenantProvider.TenantId), - Builders.Filter.Eq(x => x.IsDeleted, false) - ) - : Builders.Filter.And( - Builders.Filter.Eq(x => x.TenantId, _tenantProvider.TenantId), - Builders.Filter.Eq("_t", type), - Builders.Filter.Eq(x => x.IsDeleted, false) - ); - - return await _collection.Find(filter).ToListAsync(ct); - } - - public async Task CreateAsync( - ProductData productData, - CancellationToken ct = default - ) - { - await _collection.InsertOneAsync(productData, cancellationToken: ct); - return productData; - } - - public async Task SoftDeleteAsync( - Guid id, - Guid actorId, - DateTime deletedAtUtc, - CancellationToken ct = default - ) - { - UpdateDefinition update = Builders - .Update.Set(x => x.IsDeleted, true) - .Set(x => x.DeletedAtUtc, deletedAtUtc) - .Set(x => x.DeletedBy, actorId); - - await _collection.UpdateOneAsync( - x => x.Id == id && x.TenantId == _tenantProvider.TenantId && !x.IsDeleted, - update, - cancellationToken: ct - ); - } - - public async Task SoftDeleteByTenantAsync( - Guid tenantId, - Guid actorId, - DateTime deletedAtUtc, - CancellationToken ct = default - ) - { - FilterDefinition filter = Builders.Filter.And( - Builders.Filter.Eq(x => x.TenantId, tenantId), - Builders.Filter.Eq(x => x.IsDeleted, false) - ); - - UpdateDefinition update = Builders - .Update.Set(x => x.IsDeleted, true) - .Set(x => x.DeletedAtUtc, deletedAtUtc) - .Set(x => x.DeletedBy, actorId); - - UpdateResult result = await _collection.UpdateManyAsync( - filter, - update, - cancellationToken: ct - ); - return result.ModifiedCount; - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/ProductRepository.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/ProductRepository.cs deleted file mode 100644 index ecee3b38..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Repositories/ProductRepository.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Repositories; -using ProductCatalog.Application.Features.Product.Specifications; -using ProductCatalog.Domain.Entities; -using ProductCatalog.Infrastructure.Persistence; -using SharedKernel.Domain.Common; -using SharedKernel.Infrastructure.Repositories; - -namespace ProductCatalog.Infrastructure.Repositories; - -/// -/// EF Core repository for with specification-based listing, -/// count, category facet, and price bucket facet queries. -/// -public class ProductRepository : RepositoryBase, IProductRepository -{ - private static readonly IReadOnlyList DefaultPriceBuckets = - [ - new(PriceBucketLabels.ZeroToFifty, 0m, 50m, 0), - new(PriceBucketLabels.FiftyToOneHundred, 50m, 100m, 0), - new(PriceBucketLabels.OneHundredToTwoHundredFifty, 100m, 250m, 0), - new(PriceBucketLabels.TwoHundredFiftyToFiveHundred, 250m, 500m, 0), - new(PriceBucketLabels.FiveHundredAndAbove, 500m, null, 0), - ]; - - private ProductCatalogDbContext ProductCatalogDb => (ProductCatalogDbContext)DbContext; - - public ProductRepository(ProductCatalogDbContext dbContext) - : base(dbContext) { } - - /// Returns a single-query paged result of products matching the given filter. - public async Task> GetPagedAsync( - ProductFilter filter, - CancellationToken ct = default - ) - { - return await GetPagedAsync( - new ProductSpecification(filter), - filter.PageNumber, - filter.PageSize, - ct - ); - } - - /// Returns category facet counts for products matching the filter, ordered by descending count then category name. - public async Task> GetCategoryFacetsAsync( - ProductFilter filter, - CancellationToken ct = default - ) - { - ProductCategoryFacetSpecification specification = new(filter); - IQueryable query = - Ardalis.Specification.EntityFrameworkCore.SpecificationEvaluator.Default.GetQuery( - ProductCatalogDb.Products.AsQueryable(), - specification - ); - - return await query - .GroupBy(product => new - { - product.CategoryId, - CategoryName = product.Category != null ? product.Category.Name : "Uncategorized", - }) - .Select(group => new - { - group.Key.CategoryId, - group.Key.CategoryName, - Count = group.Count(), - }) - .OrderByDescending(group => group.Count) - .ThenBy(group => group.CategoryName) - .Select(group => new ProductCategoryFacetValue( - group.CategoryId, - group.CategoryName, - group.Count - )) - .ToArrayAsync(ct); - } - - /// Returns fixed price bucket facet counts computed in a single server-side aggregate query. - public async Task> GetPriceFacetsAsync( - ProductFilter filter, - CancellationToken ct = default - ) - { - ProductPriceFacetSpecification specification = new(filter); - IQueryable query = - Ardalis.Specification.EntityFrameworkCore.SpecificationEvaluator.Default.GetQuery( - ProductCatalogDb.Products.AsQueryable(), - specification - ); - - PriceFacetCounts? counts = await query - .GroupBy(_ => 1) - .Select(group => new PriceFacetCounts( - group.Count(product => product.Price >= 0m && product.Price < 50m), - group.Count(product => product.Price >= 50m && product.Price < 100m), - group.Count(product => product.Price >= 100m && product.Price < 250m), - group.Count(product => product.Price >= 250m && product.Price < 500m), - group.Count(product => product.Price >= 500m) - )) - .SingleOrDefaultAsync(ct); - - return DefaultPriceBuckets - .Select(bucket => - bucket with - { - Count = bucket.Label switch - { - PriceBucketLabels.ZeroToFifty => counts?.ZeroToFifty ?? 0, - PriceBucketLabels.FiftyToOneHundred => counts?.FiftyToOneHundred ?? 0, - PriceBucketLabels.OneHundredToTwoHundredFifty => - counts?.OneHundredToTwoHundredFifty ?? 0, - PriceBucketLabels.TwoHundredFiftyToFiveHundred => - counts?.TwoHundredFiftyToFiveHundred ?? 0, - PriceBucketLabels.FiveHundredAndAbove => counts?.FiveHundredAndAbove ?? 0, - _ => 0, - }, - } - ) - .ToArray(); - } - - private sealed record PriceFacetCounts( - int ZeroToFifty, - int FiftyToOneHundred, - int OneHundredToTwoHundredFifty, - int TwoHundredFiftyToFiveHundred, - int FiveHundredAndAbove - ); -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs deleted file mode 100644 index fee41835..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs +++ /dev/null @@ -1,14 +0,0 @@ -using ProductCatalog.Domain.Entities; -using ProductCatalog.Domain.Interfaces; - -namespace ProductCatalog.Infrastructure.StoredProcedures; - -/// -/// Calls the get_product_category_stats(p_category_id, p_tenant_id) PostgreSQL function. -/// -public sealed record GetProductCategoryStatsProcedure(Guid CategoryId, Guid TenantId) - : IStoredProcedure -{ - public FormattableString ToSql() => - $"SELECT * FROM get_product_category_stats({CategoryId}, {TenantId})"; -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs deleted file mode 100644 index d2e26924..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using ProductCatalog.Domain.Interfaces; -using ProductCatalog.Infrastructure.Persistence; - -namespace ProductCatalog.Infrastructure.StoredProcedures; - -/// -/// EF Core implementation of . -/// -public sealed class StoredProcedureExecutor : IStoredProcedureExecutor -{ - private readonly ProductCatalogDbContext _dbContext; - - public StoredProcedureExecutor(ProductCatalogDbContext dbContext) - { - _dbContext = dbContext; - } - - public Task QueryFirstAsync( - IStoredProcedure procedure, - CancellationToken ct = default - ) - where TResult : class => - _dbContext.Set().FromSql(procedure.ToSql()).FirstOrDefaultAsync(ct); - - public async Task> QueryManyAsync( - IStoredProcedure procedure, - CancellationToken ct = default - ) - where TResult : class => - await _dbContext.Set().FromSql(procedure.ToSql()).ToListAsync(ct); - - public Task ExecuteAsync(FormattableString sql, CancellationToken ct = default) => - _dbContext.Database.ExecuteSqlAsync(sql, ct); -} diff --git a/src/Services/Reviews/Reviews.Api/Controllers/V1/ProductReviewsController.cs b/src/Services/Reviews/Reviews.Api/Controllers/V1/ProductReviewsController.cs deleted file mode 100644 index 383ec06f..00000000 --- a/src/Services/Reviews/Reviews.Api/Controllers/V1/ProductReviewsController.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using Reviews.Application.Common.Responses; -using Reviews.Application.Features.CreateReview; -using Reviews.Application.Features.DeleteReview; -using Reviews.Application.Features.GetReviewById; -using Reviews.Application.Features.GetReviews; -using Reviews.Application.Features.GetReviewsByProductId; -using SharedKernel.Api.Authorization; -using SharedKernel.Api.Controllers; -using SharedKernel.Api.Extensions; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Security; -using SharedKernel.Domain.Common; -using Wolverine; - -namespace Reviews.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that exposes CRUD endpoints for product reviews, -/// with output-cache support and a dedicated by-product lookup endpoint. -/// -public sealed class ProductReviewsController(IMessageBus bus) : ApiControllerBase -{ - [HttpGet] - [RequirePermission(Permission.ProductReviews.Read)] - [OutputCache(PolicyName = CacheTags.Reviews)] - public Task>> GetAll( - [FromQuery] ProductReviewFilter filter, - CancellationToken ct - ) => - InvokeToActionResultAsync>( - bus, - new GetProductReviewsQuery(filter), - ct - ); - - [HttpGet("{id:guid}")] - [RequirePermission(Permission.ProductReviews.Read)] - [OutputCache(PolicyName = CacheTags.Reviews)] - public Task> GetById(Guid id, CancellationToken ct) => - InvokeToActionResultAsync( - bus, - new GetProductReviewByIdQuery(id), - ct - ); - - [HttpGet("by-product/{productId:guid}")] - [RequirePermission(Permission.ProductReviews.Read)] - [OutputCache(PolicyName = CacheTags.Reviews)] - public Task>> GetByProductId( - Guid productId, - CancellationToken ct - ) => - InvokeToActionResultAsync>( - bus, - new GetProductReviewsByProductIdQuery(productId), - ct - ); - - [HttpPost] - [RequirePermission(Permission.ProductReviews.Create)] - public Task> Create( - CreateProductReviewRequest request, - CancellationToken ct - ) => - InvokeToCreatedResultAsync( - bus, - new CreateProductReviewCommand(request), - v => new { id = v.Id, version = this.GetApiVersion() }, - ct - ); - - [HttpDelete("{id:guid}")] - [RequirePermission(Permission.ProductReviews.Delete)] - public Task Delete(Guid id, CancellationToken ct) => - InvokeToNoContentResultAsync(bus, new DeleteProductReviewCommand(id), ct); -} diff --git a/src/Services/Reviews/Reviews.Api/Dockerfile b/src/Services/Reviews/Reviews.Api/Dockerfile deleted file mode 100644 index 466d3972..00000000 --- a/src/Services/Reviews/Reviews.Api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build -WORKDIR /src -COPY . . -RUN dotnet restore "src/Services/Reviews/Reviews.Api/Reviews.Api.csproj" -RUN dotnet publish "src/Services/Reviews/Reviews.Api/Reviews.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "Reviews.Api.dll"] diff --git a/src/Services/Reviews/Reviews.Api/Program.cs b/src/Services/Reviews/Reviews.Api/Program.cs deleted file mode 100644 index 25d71398..00000000 --- a/src/Services/Reviews/Reviews.Api/Program.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Contracts.IntegrationEvents.Reviews; -using Contracts.IntegrationEvents.Sagas; -using FluentValidation; -using Microsoft.EntityFrameworkCore; -using Reviews.Application.Features.ProductEvents; -using Reviews.Domain.Interfaces; -using Reviews.Infrastructure.Persistence; -using Reviews.Infrastructure.Repositories; -using SharedKernel.Api.Extensions; -using SharedKernel.Api.OutputCaching; -using SharedKernel.Application.Security; -using SharedKernel.Messaging.Conventions; -using SharedKernel.Messaging.Topology; -using Wolverine; -using Wolverine.EntityFrameworkCore; -using Wolverine.FluentValidation; -using Wolverine.Postgresql; -using Wolverine.RabbitMQ; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Host.UseSharedSerilog(); -builder.Services.AddSharedObservability(builder.Configuration, builder.Environment, "reviews"); - -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetRequiredConnectionString("ReviewsDb")) -); - -// Register DbContext as the base type for event handlers that receive DbContext -builder.Services.AddScoped(sp => sp.GetRequiredService()); - -builder.Services.AddSharedInfrastructure(builder.Configuration); - -builder.Services.AddScoped(); -builder.Services.AddSingleton(); - -builder.Services.AddSharedKeycloakJwtBearer(builder.Configuration, builder.Environment); -builder.Services.AddSharedAuthorization(enablePermissionPolicies: true); - -builder.Services.AddValidatorsFromAssemblyContaining(); - -builder.Services.AddControllers(); -builder.Services.AddSharedOpenApiDocumentation(); -builder.Services.AddSharedOutputCaching(builder.Configuration); - -builder.Services.AddHealthChecks(); - -builder.Host.UseWolverine(opts => -{ - opts.ApplySharedConventions(); - opts.ApplySharedRetryPolicies(); - - opts.UseFluentValidation(); - - opts.Discovery.IncludeAssembly(typeof(ProductCreatedEventHandler).Assembly); - opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); - - opts.PersistMessagesWithPostgresql( - builder.Configuration.GetRequiredConnectionString("ReviewsDb"), - "wolverine" - ); - opts.UseEntityFrameworkCoreTransactions(); - - opts.UseSharedRabbitMq(builder.Configuration); - - opts.PublishMessage() - .ToRabbitExchange( - RabbitMqTopology.Exchanges.Reviews, - exchange => - { - exchange.ExchangeType = Wolverine.RabbitMQ.ExchangeType.Fanout; - exchange.IsDurable = true; - } - ); - - // Listen to integration event queues - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Reviews.ProductCreated, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.ProductCatalog); - } - ); - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Reviews.ProductDeleted, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.ProductCatalog); - } - ); - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Reviews.TenantDeactivated, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.Identity); - } - ); - - // Route product deletion cascade completion back to ProductCatalog saga queue. - opts.PublishMessage() - .ToRabbitQueue(RabbitMqTopology.Queues.ProductCatalog.ReviewsCascadeCompleted); -}); - -WebApplication app = builder.Build(); - -await app.MigrateDbAsync(); - -app.UseSharedExceptionHandlerAndAuthentication(); -app.UseSharedAuthorizationCachingAndInfrastructure(useOutputCaching: true); -app.MapControllers(); - -await app.RunAsync(); - -public partial class Program; diff --git a/src/Services/Reviews/Reviews.Api/Properties/launchSettings.json b/src/Services/Reviews/Reviews.Api/Properties/launchSettings.json deleted file mode 100644 index cb49f454..00000000 --- a/src/Services/Reviews/Reviews.Api/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5066", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7035;http://localhost:5066", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Services/Reviews/Reviews.Api/Reviews.Api.csproj b/src/Services/Reviews/Reviews.Api/Reviews.Api.csproj deleted file mode 100644 index de338f7a..00000000 --- a/src/Services/Reviews/Reviews.Api/Reviews.Api.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - - - diff --git a/src/Services/Reviews/Reviews.Api/appsettings.json b/src/Services/Reviews/Reviews.Api/appsettings.json deleted file mode 100644 index 8ddd73ce..00000000 --- a/src/Services/Reviews/Reviews.Api/appsettings.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "WriteTo": [ - { "Name": "Console" } - ] - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "ReviewsDb": "Host=localhost;Port=5432;Database=reviews_db;Username=postgres;Password=postgres", - "RabbitMQ": "amqp://guest:guest@localhost:5672", - "Dragonfly": "localhost:6379" - }, - "Keycloak": { - "realm": "api-template", - "auth-server-url": "http://localhost:8180/", - "resource": "api-template" - }, - "TransactionDefaults": { - "IsolationLevel": "ReadCommitted", - "TimeoutSeconds": 30, - "RetryEnabled": true, - "RetryCount": 3, - "RetryDelaySeconds": 5 - } -} diff --git a/src/Services/Reviews/Reviews.Application/Common/Errors/DomainErrors.cs b/src/Services/Reviews/Reviews.Application/Common/Errors/DomainErrors.cs deleted file mode 100644 index 5780a8ee..00000000 --- a/src/Services/Reviews/Reviews.Application/Common/Errors/DomainErrors.cs +++ /dev/null @@ -1,33 +0,0 @@ -using ErrorOr; -using SharedDomainErrors = SharedKernel.Application.Errors.DomainErrors; -using SharedErrorCatalog = SharedKernel.Application.Errors.ErrorCatalog; - -namespace Reviews.Application.Common.Errors; - -/// -/// Factory methods producing instances for Reviews-specific error codes. -/// -public static class DomainErrors -{ - public static class Auth - { - public static Error ForbiddenOwnReviewsOnly() => - Error.Forbidden( - code: SharedErrorCatalog.Auth.Forbidden, - description: ErrorCatalog.Auth.ForbiddenOwnReviewsOnly - ); - } - - public static class Reviews - { - public static Error NotFound(Guid id) => - SharedDomainErrors.General.NotFound(ErrorCatalog.Reviews.ReviewNotFound, "Review", id); - - public static Error ProductNotFoundForReview(Guid productId) => - SharedDomainErrors.General.NotFound( - ErrorCatalog.Reviews.ProductNotFoundForReview, - "Product", - productId - ); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Common/Errors/ErrorCatalog.cs b/src/Services/Reviews/Reviews.Application/Common/Errors/ErrorCatalog.cs deleted file mode 100644 index cc52184f..00000000 --- a/src/Services/Reviews/Reviews.Application/Common/Errors/ErrorCatalog.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Reviews.Application.Common.Errors; - -/// -/// Structured error codes for the Reviews microservice. -/// -public static class ErrorCatalog -{ - /// Error descriptions for authentication and authorisation failures. - public static class Auth - { - public const string ForbiddenOwnReviewsOnly = "You can only delete your own reviews."; - } - - /// Error codes specific to the Reviews domain. - public static class Reviews - { - public const string ProductNotFoundForReview = "REV-2101"; - public const string ReviewNotFound = "REV-0404"; - } -} diff --git a/src/Services/Reviews/Reviews.Application/Common/Mappings/ProductReviewMappings.cs b/src/Services/Reviews/Reviews.Application/Common/Mappings/ProductReviewMappings.cs deleted file mode 100644 index 39ae6165..00000000 --- a/src/Services/Reviews/Reviews.Application/Common/Mappings/ProductReviewMappings.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Linq.Expressions; -using Reviews.Application.Common.Responses; -using ProductReviewEntity = Reviews.Domain.Entities.ProductReview; - -namespace Reviews.Application.Common.Mappings; - -/// -/// Provides mapping utilities between product review domain entities and their response DTOs. -/// The compiled projection is shared across specifications and in-memory conversions. -/// -public static class ProductReviewMappings -{ - /// - /// EF Core-compatible expression that projects a to a . - /// Shared with specifications to ensure a consistent shape from both DB queries and entity-to-DTO conversions. - /// - public static readonly Expression> Projection = - r => new ProductReviewResponse( - r.Id, - r.ProductId, - r.UserId, - r.Comment, - r.Rating, - r.Audit.CreatedAtUtc - ); - - private static readonly Func CompiledProjection = - Projection.Compile(); - - /// Maps a to a using the compiled projection. - public static ProductReviewResponse ToResponse(this ProductReviewEntity review) => - CompiledProjection(review); -} diff --git a/src/Services/Reviews/Reviews.Application/Common/Responses/ProductReviewResponse.cs b/src/Services/Reviews/Reviews.Application/Common/Responses/ProductReviewResponse.cs deleted file mode 100644 index 3a10fe07..00000000 --- a/src/Services/Reviews/Reviews.Application/Common/Responses/ProductReviewResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace Reviews.Application.Common.Responses; - -/// -/// Read model returned by product review queries, representing a single review submitted by a user for a product. -/// -public sealed record ProductReviewResponse( - Guid Id, - Guid ProductId, - Guid UserId, - string? Comment, - int Rating, - DateTime CreatedAtUtc -) : IHasId; diff --git a/src/Services/Reviews/Reviews.Application/Common/Sorting/ProductReviewSortFields.cs b/src/Services/Reviews/Reviews.Application/Common/Sorting/ProductReviewSortFields.cs deleted file mode 100644 index 5b9459e2..00000000 --- a/src/Services/Reviews/Reviews.Application/Common/Sorting/ProductReviewSortFields.cs +++ /dev/null @@ -1,26 +0,0 @@ -using SharedKernel.Application.Sorting; -using ProductReviewEntity = Reviews.Domain.Entities.ProductReview; - -namespace Reviews.Application.Common.Sorting; - -/// -/// Defines the allowed sort fields for product review queries and maps them to entity expressions. -/// -public static class ProductReviewSortFields -{ - /// Sort by review rating. - public static readonly SortField Rating = new("rating"); - - /// Sort by review creation timestamp. - public static readonly SortField CreatedAt = new("createdAt"); - - /// - /// The sort field map used to resolve and apply sorting to product review specifications. - /// Defaults to sorting by when no sort field is specified. - /// - public static readonly SortFieldMap Map = - new SortFieldMap() - .Add(Rating, r => (object)r.Rating) - .Add(CreatedAt, r => r.Audit.CreatedAtUtc) - .Default(r => r.Audit.CreatedAtUtc); -} diff --git a/src/Services/Reviews/Reviews.Application/Features/CreateReview/CreateProductReviewCommand.cs b/src/Services/Reviews/Reviews.Application/Features/CreateReview/CreateProductReviewCommand.cs deleted file mode 100644 index 1f748acd..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/CreateReview/CreateProductReviewCommand.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Contracts.IntegrationEvents.Reviews; -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Reviews.Application.Common.Errors; -using Reviews.Application.Common.Mappings; -using Reviews.Application.Common.Responses; -using Reviews.Domain.Entities; -using Reviews.Domain.Interfaces; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using SharedKernel.Application.Validation; -using SharedKernel.Domain.Interfaces; -using Wolverine; -using ProductReviewEntity = Reviews.Domain.Entities.ProductReview; - -namespace Reviews.Application.Features.CreateReview; - -/// Payload for submitting a new product review, including the target product, an optional comment, and a 1-5 star rating. -public sealed record CreateProductReviewRequest( - [NotEmpty(ErrorMessage = "ProductId is required.")] Guid ProductId, - string? Comment, - [Range(1, 5, ErrorMessage = "Rating must be between 1 and 5.")] int Rating -); - -/// Creates a new product review for the authenticated user and returns the persisted representation. -public sealed record CreateProductReviewCommand(CreateProductReviewRequest Request); - -/// Handles . -public sealed class CreateProductReviewCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - CreateProductReviewCommand command, - IProductReviewRepository reviewRepository, - DbContext dbContext, - IUnitOfWork unitOfWork, - IActorProvider actorProvider, - IMessageBus bus, - TimeProvider timeProvider, - CancellationToken ct - ) - { - Guid userId = actorProvider.ActorId; - - bool productExists = await dbContext - .Set() - .AnyAsync(p => p.ProductId == command.Request.ProductId && p.IsActive, ct); - - if (!productExists) - return ( - DomainErrors.Reviews.ProductNotFoundForReview(command.Request.ProductId), - CacheInvalidationCascades.None - ); - - ProductReviewEntity review = await unitOfWork.ExecuteInTransactionAsync( - async () => - { - ProductReviewEntity entity = new() - { - Id = Guid.NewGuid(), - ProductId = command.Request.ProductId, - UserId = userId, - Comment = command.Request.Comment, - Rating = command.Request.Rating, - }; - - await reviewRepository.AddAsync(entity, ct); - return entity; - }, - ct - ); - - await bus.PublishAsync( - new ReviewCreatedIntegrationEvent( - review.Id, - review.ProductId, - review.UserId, - review.TenantId, - review.Rating, - timeProvider.GetUtcNow().UtcDateTime - ) - ); - - return (review.ToResponse(), CacheInvalidationCascades.ForTag(CacheTags.Reviews)); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/CreateReview/CreateProductReviewRequestValidator.cs b/src/Services/Reviews/Reviews.Application/Features/CreateReview/CreateProductReviewRequestValidator.cs deleted file mode 100644 index 570dd6b4..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/CreateReview/CreateProductReviewRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SharedKernel.Application.Validation; - -namespace Reviews.Application.Features.CreateReview; - -/// -/// FluentValidation validator for , delegating to data-annotation-based validation rules. -/// -public sealed class CreateProductReviewRequestValidator - : DataAnnotationsValidator; diff --git a/src/Services/Reviews/Reviews.Application/Features/DeleteReview/DeleteProductReviewCommand.cs b/src/Services/Reviews/Reviews.Application/Features/DeleteReview/DeleteProductReviewCommand.cs deleted file mode 100644 index dd0426ca..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/DeleteReview/DeleteProductReviewCommand.cs +++ /dev/null @@ -1,50 +0,0 @@ -using ErrorOr; -using Reviews.Application.Common.Errors; -using Reviews.Domain.Interfaces; -using SharedKernel.Application.Common.Events; -using SharedKernel.Application.Context; -using SharedKernel.Application.Extensions; -using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Domain.Interfaces; -using Wolverine; - -namespace Reviews.Application.Features.DeleteReview; - -/// Deletes the product review with the given identifier; only the review's author may delete it. -public sealed record DeleteProductReviewCommand(Guid Id) : IHasId; - -/// Handles . -public sealed class DeleteProductReviewCommandHandler -{ - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - DeleteProductReviewCommand command, - IProductReviewRepository reviewRepository, - IUnitOfWork unitOfWork, - IActorProvider actorProvider, - CancellationToken ct - ) - { - Guid userId = actorProvider.ActorId; - ErrorOr reviewResult = await reviewRepository.GetByIdOrError( - command.Id, - DomainErrors.Reviews.NotFound(command.Id), - ct - ); - if (reviewResult.IsError) - return (reviewResult.Errors, CacheInvalidationCascades.None); - Domain.Entities.ProductReview review = reviewResult.Value; - - if (review.UserId != userId) - return (DomainErrors.Auth.ForbiddenOwnReviewsOnly(), CacheInvalidationCascades.None); - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await reviewRepository.DeleteAsync(review, ct); - }, - ct - ); - - return (Result.Success, CacheInvalidationCascades.ForTag(CacheTags.Reviews)); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/GetReviewById/GetProductReviewByIdQuery.cs b/src/Services/Reviews/Reviews.Application/Features/GetReviewById/GetProductReviewByIdQuery.cs deleted file mode 100644 index a706bf78..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/GetReviewById/GetProductReviewByIdQuery.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ErrorOr; -using Reviews.Application.Common.Errors; -using Reviews.Application.Common.Mappings; -using Reviews.Application.Common.Responses; -using Reviews.Domain.Interfaces; -using SharedKernel.Domain.Entities.Contracts; - -namespace Reviews.Application.Features.GetReviewById; - -/// Returns a single product review by its unique identifier, or not found if missing. -public sealed record GetProductReviewByIdQuery(Guid Id) : IHasId; - -/// Handles . -public sealed class GetProductReviewByIdQueryHandler -{ - public static async Task> HandleAsync( - GetProductReviewByIdQuery request, - IProductReviewRepository reviewRepository, - CancellationToken ct - ) - { - Domain.Entities.ProductReview? item = await reviewRepository.GetByIdAsync(request.Id, ct); - return item is null ? DomainErrors.Reviews.NotFound(request.Id) : item.ToResponse(); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/GetReviews/GetProductReviewsQuery.cs b/src/Services/Reviews/Reviews.Application/Features/GetReviews/GetProductReviewsQuery.cs deleted file mode 100644 index b7addd4a..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/GetReviews/GetProductReviewsQuery.cs +++ /dev/null @@ -1,27 +0,0 @@ -using ErrorOr; -using Reviews.Application.Common.Responses; -using Reviews.Domain.Interfaces; -using SharedKernel.Domain.Common; - -namespace Reviews.Application.Features.GetReviews; - -/// Returns a paginated, filtered, and sorted list of product reviews. -public sealed record GetProductReviewsQuery(ProductReviewFilter Filter); - -/// Handles . -public sealed class GetProductReviewsQueryHandler -{ - public static async Task>> HandleAsync( - GetProductReviewsQuery request, - IProductReviewRepository reviewRepository, - CancellationToken ct - ) - { - return await reviewRepository.GetPagedAsync( - new ProductReviewSpecification(request.Filter), - request.Filter.PageNumber, - request.Filter.PageSize, - ct - ); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/GetReviews/ProductReviewFilter.cs b/src/Services/Reviews/Reviews.Application/Features/GetReviews/ProductReviewFilter.cs deleted file mode 100644 index e4203123..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/GetReviews/ProductReviewFilter.cs +++ /dev/null @@ -1,52 +0,0 @@ -using FluentValidation; -using Reviews.Application.Common.Sorting; -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; -using SharedKernel.Application.Validation; - -namespace Reviews.Application.Features.GetReviews; - -/// -/// Filter parameters for querying product reviews, supporting filtering by product, user, rating range, date range, sorting, and pagination. -/// -public sealed record ProductReviewFilter( - Guid? ProductId = null, - Guid? UserId = null, - int? MinRating = null, - int? MaxRating = null, - DateTime? CreatedFrom = null, - DateTime? CreatedTo = null, - string? SortBy = null, - string? SortDirection = null, - int PageNumber = 1, - int PageSize = PaginationFilter.DefaultPageSize -) : PaginationFilter(PageNumber, PageSize), IDateRangeFilter, ISortableFilter; - -/// -/// FluentValidation validator for . -/// Composes pagination, date-range, sortable, and rating-range validation rules. -/// -public sealed class ProductReviewFilterValidator : AbstractValidator -{ - public ProductReviewFilterValidator() - { - Include(new PaginationFilterValidator()); - Include(new DateRangeFilterValidator()); - Include(new SortableFilterValidator(ProductReviewSortFields.Map.AllowedNames)); - - RuleFor(x => x.MinRating) - .InclusiveBetween(1, 5) - .WithMessage("MinRating must be between 1 and 5.") - .When(x => x.MinRating.HasValue); - - RuleFor(x => x.MaxRating) - .InclusiveBetween(1, 5) - .WithMessage("MaxRating must be between 1 and 5.") - .When(x => x.MaxRating.HasValue); - - RuleFor(x => x.MaxRating) - .GreaterThanOrEqualTo(x => x.MinRating!.Value) - .WithMessage("MaxRating must be greater than or equal to MinRating.") - .When(x => x.MinRating.HasValue && x.MaxRating.HasValue); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/GetReviews/ProductReviewSpecification.cs b/src/Services/Reviews/Reviews.Application/Features/GetReviews/ProductReviewSpecification.cs deleted file mode 100644 index 2b156419..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/GetReviews/ProductReviewSpecification.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Ardalis.Specification; -using Reviews.Application.Common.Mappings; -using Reviews.Application.Common.Responses; -using Reviews.Application.Common.Sorting; -using ProductReviewEntity = Reviews.Domain.Entities.ProductReview; - -namespace Reviews.Application.Features.GetReviews; - -/// -/// Ardalis specification for querying a filtered and sorted list of product reviews -/// projected to . -/// -public sealed class ProductReviewSpecification : Specification -{ - /// Initialises the specification by applying filter, sort, and projection from . - public ProductReviewSpecification(ProductReviewFilter filter) - { - Query.ApplyFilter(filter); - ProductReviewSortFields.Map.ApplySort(Query, filter.SortBy, filter.SortDirection); - Query.Select(ProductReviewMappings.Projection); - } -} - -/// -/// Extension methods that apply criteria to an Ardalis specification builder. -/// Each filter field is applied conditionally, only when a value is present. -/// -internal static class ProductReviewFilterCriteria -{ - internal static void ApplyFilter( - this ISpecificationBuilder query, - ProductReviewFilter filter - ) - { - if (filter.ProductId.HasValue) - query.Where(r => r.ProductId == filter.ProductId.Value); - - if (filter.UserId.HasValue) - query.Where(r => r.UserId == filter.UserId.Value); - - if (filter.MinRating.HasValue) - query.Where(r => r.Rating >= filter.MinRating.Value); - - if (filter.MaxRating.HasValue) - query.Where(r => r.Rating <= filter.MaxRating.Value); - - if (filter.CreatedFrom.HasValue) - query.Where(r => r.Audit.CreatedAtUtc >= filter.CreatedFrom.Value); - - if (filter.CreatedTo.HasValue) - query.Where(r => r.Audit.CreatedAtUtc <= filter.CreatedTo.Value); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/GetReviewsByProductId/GetProductReviewsByProductIdQuery.cs b/src/Services/Reviews/Reviews.Application/Features/GetReviewsByProductId/GetProductReviewsByProductIdQuery.cs deleted file mode 100644 index 1ba56901..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/GetReviewsByProductId/GetProductReviewsByProductIdQuery.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ErrorOr; -using Reviews.Application.Common.Responses; -using Reviews.Application.Features.GetReviewsByProductIds; -using Reviews.Domain.Interfaces; - -namespace Reviews.Application.Features.GetReviewsByProductId; - -/// Returns all reviews for a specific product, ordered by creation date descending. -public sealed record GetProductReviewsByProductIdQuery(Guid ProductId); - -/// Handles . -public sealed class GetProductReviewsByProductIdQueryHandler -{ - public static async Task>> HandleAsync( - GetProductReviewsByProductIdQuery request, - IProductReviewRepository reviewRepository, - CancellationToken ct - ) - { - return await reviewRepository.ListAsync( - new GetReviewsByProductIdsSpec([request.ProductId]), - ct - ); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/GetReviewsByProductIds/GetReviewsByProductIdsHandler.cs b/src/Services/Reviews/Reviews.Application/Features/GetReviewsByProductIds/GetReviewsByProductIdsHandler.cs deleted file mode 100644 index 65662d01..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/GetReviewsByProductIds/GetReviewsByProductIdsHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -using ErrorOr; -using Reviews.Application.Common.Responses; -using Reviews.Domain.Interfaces; - -namespace Reviews.Application.Features.GetReviewsByProductIds; - -/// Returns reviews grouped by product id for a batch of product identifiers. -public sealed record GetReviewsByProductIdsQuery(IReadOnlyCollection ProductIds); - -/// -/// Wolverine message handler for . -/// Invoked internally via bus.InvokeAsync (not exposed as an HTTP endpoint). -/// -public sealed class GetReviewsByProductIdsHandler -{ - public static async Task< - ErrorOr> - > HandleAsync( - GetReviewsByProductIdsQuery request, - IProductReviewRepository reviewRepository, - CancellationToken ct - ) - { - if (request.ProductIds.Count == 0) - return ErrorOrFactory.From>( - new Dictionary() - ); - - List reviews = await reviewRepository.ListAsync( - new GetReviewsByProductIdsSpec(request.ProductIds), - ct - ); - - Dictionary grouped = reviews - .GroupBy(review => review.ProductId) - .ToDictionary(g => g.Key, g => g.ToArray()); - return ErrorOrFactory.From>(grouped); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/GetReviewsByProductIds/GetReviewsByProductIdsSpec.cs b/src/Services/Reviews/Reviews.Application/Features/GetReviewsByProductIds/GetReviewsByProductIdsSpec.cs deleted file mode 100644 index 55818cbc..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/GetReviewsByProductIds/GetReviewsByProductIdsSpec.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Ardalis.Specification; -using Reviews.Application.Common.Mappings; -using Reviews.Application.Common.Responses; -using ProductReviewEntity = Reviews.Domain.Entities.ProductReview; - -namespace Reviews.Application.Features.GetReviewsByProductIds; - -/// -/// Ardalis specification that retrieves reviews for a collection of product ids in a single query, -/// ordered by creation date descending and projected to . -/// -public sealed class GetReviewsByProductIdsSpec : Specification -{ - /// Initialises the specification for the given set of . - public GetReviewsByProductIdsSpec(IReadOnlyCollection productIds) - { - Query - .Where(r => productIds.Contains(r.ProductId)) - .OrderByDescending(r => r.Audit.CreatedAtUtc) - .Select(ProductReviewMappings.Projection); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/ProductEvents/ProductCreatedEventHandler.cs b/src/Services/Reviews/Reviews.Application/Features/ProductEvents/ProductCreatedEventHandler.cs deleted file mode 100644 index ddc5a908..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/ProductEvents/ProductCreatedEventHandler.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Reviews.Domain.Entities; - -namespace Reviews.Application.Features.ProductEvents; - -/// -/// Handles by creating or reactivating -/// a local for product existence checks. -/// -public sealed class ProductCreatedEventHandler -{ - public static async Task HandleAsync( - ProductCreatedIntegrationEvent @event, - DbContext dbContext, - ILogger logger, - CancellationToken ct - ) - { - ProductProjection? existing = await dbContext - .Set() - .FindAsync([@event.ProductId], ct); - - if (existing is not null) - { - existing.Name = @event.Name; - existing.IsActive = true; - existing.TenantId = @event.TenantId; - logger.LogInformation( - "Reactivated ProductProjection for product {ProductId}", - @event.ProductId - ); - } - else - { - ProductProjection projection = new() - { - ProductId = @event.ProductId, - TenantId = @event.TenantId, - Name = @event.Name, - IsActive = true, - }; - dbContext.Set().Add(projection); - logger.LogInformation( - "Created ProductProjection for product {ProductId}", - @event.ProductId - ); - } - - await dbContext.SaveChangesAsync(ct); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/ProductEvents/ProductDeletedEventHandler.cs b/src/Services/Reviews/Reviews.Application/Features/ProductEvents/ProductDeletedEventHandler.cs deleted file mode 100644 index 02c6543e..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/ProductEvents/ProductDeletedEventHandler.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using Contracts.IntegrationEvents.Sagas; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SharedKernel.Infrastructure.Persistence.SoftDelete; -using Wolverine; -using ProductProjection = Reviews.Domain.Entities.ProductProjection; -using ProductReviewEntity = Reviews.Domain.Entities.ProductReview; - -namespace Reviews.Application.Features.ProductEvents; - -/// -/// Handles by marking product projections inactive -/// and cascade soft-deleting all associated reviews. -/// -public sealed class ProductDeletedEventHandler -{ - public static async Task HandleAsync( - ProductDeletedIntegrationEvent @event, - DbContext dbContext, - IMessageBus bus, - TimeProvider timeProvider, - ILogger logger, - CancellationToken ct - ) - { - await dbContext - .Set() - .Where(p => @event.ProductIds.Contains(p.ProductId)) - .ExecuteUpdateAsync(setters => setters.SetProperty(p => p.IsActive, false), ct); - - DateTime now = timeProvider.GetUtcNow().UtcDateTime; - int deletedCount = await dbContext - .Set() - .Where(r => @event.ProductIds.Contains(r.ProductId) && !r.IsDeleted) - .BulkSoftDeleteAsync(actorId: null, now, ct); - - logger.LogInformation( - "Cascade soft-deleted {Count} reviews for products {ProductIds}", - deletedCount, - @event.ProductIds - ); - - await bus.PublishAsync(new ReviewsCascadeCompleted(@event.CorrelationId, deletedCount)); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Features/TenantDeactivation/TenantDeactivatedEventHandler.cs b/src/Services/Reviews/Reviews.Application/Features/TenantDeactivation/TenantDeactivatedEventHandler.cs deleted file mode 100644 index fe2f77cd..00000000 --- a/src/Services/Reviews/Reviews.Application/Features/TenantDeactivation/TenantDeactivatedEventHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SharedKernel.Infrastructure.Persistence.SoftDelete; -using ProductProjection = Reviews.Domain.Entities.ProductProjection; -using ProductReviewEntity = Reviews.Domain.Entities.ProductReview; - -namespace Reviews.Application.Features.TenantDeactivation; - -/// -/// Handles by cascade soft-deleting -/// all reviews and marking all product projections inactive for the given tenant. -/// -public sealed class TenantDeactivatedEventHandler -{ - public static async Task HandleAsync( - TenantDeactivatedIntegrationEvent @event, - DbContext dbContext, - TimeProvider timeProvider, - ILogger logger, - CancellationToken ct - ) - { - DateTime now = timeProvider.GetUtcNow().UtcDateTime; - - int deletedReviews = await dbContext - .Set() - .IgnoreQueryFilters() - .Where(r => r.TenantId == @event.TenantId && !r.IsDeleted) - .BulkSoftDeleteAsync(@event.ActorId, now, ct); - - int deactivatedProducts = await dbContext - .Set() - .Where(p => p.TenantId == @event.TenantId && p.IsActive) - .ExecuteUpdateAsync(setters => setters.SetProperty(p => p.IsActive, false), ct); - - logger.LogInformation( - "Tenant {TenantId} deactivated: soft-deleted {ReviewCount} reviews, deactivated {ProductCount} product projections", - @event.TenantId, - deletedReviews, - deactivatedProducts - ); - } -} diff --git a/src/Services/Reviews/Reviews.Application/Reviews.Application.csproj b/src/Services/Reviews/Reviews.Application/Reviews.Application.csproj deleted file mode 100644 index f7a8dd63..00000000 --- a/src/Services/Reviews/Reviews.Application/Reviews.Application.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - net10.0 - enable - enable - - - - - - - diff --git a/src/Services/Reviews/Reviews.Domain/Entities/ProductProjection.cs b/src/Services/Reviews/Reviews.Domain/Entities/ProductProjection.cs deleted file mode 100644 index 75054c3f..00000000 --- a/src/Services/Reviews/Reviews.Domain/Entities/ProductProjection.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Reviews.Domain.Entities; - -public sealed class ProductProjection -{ - public Guid ProductId { get; set; } - public Guid TenantId { get; set; } - public string Name { get; set; } = string.Empty; - public bool IsActive { get; set; } = true; -} diff --git a/src/Services/Reviews/Reviews.Domain/Entities/ProductReview.cs b/src/Services/Reviews/Reviews.Domain/Entities/ProductReview.cs deleted file mode 100644 index b94aca1d..00000000 --- a/src/Services/Reviews/Reviews.Domain/Entities/ProductReview.cs +++ /dev/null @@ -1,34 +0,0 @@ -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace Reviews.Domain.Entities; - -/// -/// Domain entity representing a user's review of a product, including a 1-5 star rating and an optional comment. -/// -public sealed class ProductReview : IAuditableTenantEntity, IHasId -{ - public Guid Id { get; set; } - public Guid ProductId { get; set; } - public Guid UserId { get; set; } - public string? Comment { get; set; } - - /// Integer score from 1 (worst) to 5 (best); throws on assignment outside that range. - public int Rating - { - get => field; - set => - field = value is >= 1 and <= 5 - ? value - : throw new ArgumentOutOfRangeException( - nameof(Rating), - "Rating must be between 1 and 5." - ); - } - - public Guid TenantId { get; set; } - public AuditInfo Audit { get; set; } = new(); - public bool IsDeleted { get; set; } - public DateTime? DeletedAtUtc { get; set; } - public Guid? DeletedBy { get; set; } -} diff --git a/src/Services/Reviews/Reviews.Domain/Interfaces/IProductReviewRepository.cs b/src/Services/Reviews/Reviews.Domain/Interfaces/IProductReviewRepository.cs deleted file mode 100644 index fc1917ef..00000000 --- a/src/Services/Reviews/Reviews.Domain/Interfaces/IProductReviewRepository.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Reviews.Domain.Entities; -using SharedKernel.Domain.Interfaces; - -namespace Reviews.Domain.Interfaces; - -/// -/// Repository contract for entities, inheriting all generic CRUD operations from . -/// -public interface IProductReviewRepository : IRepository { } diff --git a/src/Services/Reviews/Reviews.Domain/Reviews.Domain.csproj b/src/Services/Reviews/Reviews.Domain/Reviews.Domain.csproj deleted file mode 100644 index e5effa8b..00000000 --- a/src/Services/Reviews/Reviews.Domain/Reviews.Domain.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Configurations/ProductProjectionConfiguration.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/Configurations/ProductProjectionConfiguration.cs deleted file mode 100644 index 86a6491b..00000000 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Configurations/ProductProjectionConfiguration.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Reviews.Domain.Entities; - -namespace Reviews.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the read model. -public sealed class ProductProjectionConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(p => p.ProductId); - - builder.Property(p => p.TenantId).IsRequired(); - - builder.Property(p => p.Name).HasMaxLength(500).IsRequired(); - - builder.Property(p => p.IsActive).IsRequired().HasDefaultValue(true); - - builder.HasIndex(p => p.TenantId); - } -} diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs deleted file mode 100644 index 6dff54c9..00000000 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Reviews.Domain.Entities; -using SharedKernel.Infrastructure.Persistence.Configurations; - -namespace Reviews.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity. -public sealed class ProductReviewConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(r => r.Id); - builder.ConfigureTenantAuditable(); - - builder.Property(r => r.Comment).HasMaxLength(2000); - - builder.Property(r => r.Rating).IsRequired(); - - builder.HasIndex(r => new { r.TenantId, r.ProductId }); - } -} diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Migrations/20260326232822_InitialCreate.Designer.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/Migrations/20260326232822_InitialCreate.Designer.cs deleted file mode 100644 index f5ce907e..00000000 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Migrations/20260326232822_InitialCreate.Designer.cs +++ /dev/null @@ -1,152 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Reviews.Infrastructure.Persistence; - -#nullable disable - -namespace Reviews.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(ReviewsDbContext))] - [Migration("20260326232822_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Reviews.Domain.Entities.ProductProjection", b => - { - b.Property("ProductId") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("Name") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("ProductId"); - - b.HasIndex("TenantId"); - - b.ToTable("ProductProjections"); - }); - - modelBuilder.Entity("Reviews.Domain.Entities.ProductReview", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Comment") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "ProductId"); - - b.ToTable("ProductReviews", t => - { - t.HasCheckConstraint("CK_ProductReviews_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Reviews.Domain.Entities.ProductReview", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductReviewId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductReviewId"); - - b1.ToTable("ProductReviews"); - - b1.WithOwner() - .HasForeignKey("ProductReviewId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Migrations/20260326232822_InitialCreate.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/Migrations/20260326232822_InitialCreate.cs deleted file mode 100644 index c01c4397..00000000 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Migrations/20260326232822_InitialCreate.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Reviews.Infrastructure.Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ProductProjections", - columns: table => new - { - ProductId = table.Column(type: "uuid", nullable: false), - TenantId = table.Column(type: "uuid", nullable: false), - Name = table.Column( - type: "character varying(500)", - maxLength: 500, - nullable: false - ), - IsActive = table.Column( - type: "boolean", - nullable: false, - defaultValue: true - ), - }, - constraints: table => - { - table.PrimaryKey("PK_ProductProjections", x => x.ProductId); - } - ); - - migrationBuilder.CreateTable( - name: "ProductReviews", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - ProductId = table.Column(type: "uuid", nullable: false), - UserId = table.Column(type: "uuid", nullable: false), - Comment = table.Column( - type: "character varying(2000)", - maxLength: 2000, - nullable: true - ), - Rating = table.Column(type: "integer", nullable: false), - TenantId = table.Column(type: "uuid", nullable: false), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_ProductReviews", x => x.Id); - table.CheckConstraint( - "CK_ProductReviews_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_ProductProjections_TenantId", - table: "ProductProjections", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_ProductReviews_TenantId", - table: "ProductReviews", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_ProductReviews_TenantId_IsDeleted", - table: "ProductReviews", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_ProductReviews_TenantId_ProductId", - table: "ProductReviews", - columns: new[] { "TenantId", "ProductId" } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "ProductProjections"); - - migrationBuilder.DropTable(name: "ProductReviews"); - } - } -} diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs deleted file mode 100644 index fe97d476..00000000 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs +++ /dev/null @@ -1,149 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Reviews.Infrastructure.Persistence; - -#nullable disable - -namespace Reviews.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(ReviewsDbContext))] - partial class ReviewsDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Reviews.Domain.Entities.ProductProjection", b => - { - b.Property("ProductId") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("Name") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("ProductId"); - - b.HasIndex("TenantId"); - - b.ToTable("ProductProjections"); - }); - - modelBuilder.Entity("Reviews.Domain.Entities.ProductReview", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Comment") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "ProductId"); - - b.ToTable("ProductReviews", t => - { - t.HasCheckConstraint("CK_ProductReviews_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Reviews.Domain.Entities.ProductReview", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("ProductReviewId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductReviewId"); - - b1.ToTable("ProductReviews"); - - b1.WithOwner() - .HasForeignKey("ProductReviewId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs deleted file mode 100644 index 1933ab7a..00000000 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Reviews.Domain.Entities; -using SharedKernel.Application.Context; -using SharedKernel.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Persistence.Auditing; -using SharedKernel.Infrastructure.Persistence.SoftDelete; - -namespace Reviews.Infrastructure.Persistence; - -/// -/// EF Core context for the Reviews microservice. -/// Enforces multi-tenancy, audit stamping, soft delete, and optimistic concurrency. -/// -public sealed class ReviewsDbContext : TenantAuditableDbContext -{ - public ReviewsDbContext( - DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor - ) - : base( - options, - tenantProvider, - actorProvider, - timeProvider, - softDeleteCascadeRules, - entityStateManager, - softDeleteProcessor - ) { } - - public DbSet ProductReviews => Set(); - public DbSet ProductProjections => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(ReviewsDbContext).Assembly); - - // Global query filters for multi-tenancy and soft-delete - modelBuilder - .Entity() - .HasQueryFilter(e => (!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted); - } -} diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs deleted file mode 100644 index f0967f3e..00000000 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace Reviews.Infrastructure.Persistence; - -/// -/// Design-time factory used by EF Core tooling to create the DbContext -/// for migration scaffolding without requiring the full runtime DI container. -/// -public sealed class ReviewsDbContextDesignTimeFactory - : IDesignTimeDbContextFactory -{ - public ReviewsDbContext CreateDbContext(string[] args) - { - DbContextOptionsBuilder optionsBuilder = new(); - optionsBuilder.UseNpgsql( - "Host=localhost;Database=reviews_db;Username=postgres;Password=postgres" - ); - - return new ReviewsDbContext( - optionsBuilder.Options, - tenantProvider: null!, - actorProvider: null!, - timeProvider: TimeProvider.System, - softDeleteCascadeRules: [], - entityStateManager: null!, - softDeleteProcessor: null! - ); - } -} diff --git a/src/Services/Reviews/Reviews.Infrastructure/Repositories/ProductReviewRepository.cs b/src/Services/Reviews/Reviews.Infrastructure/Repositories/ProductReviewRepository.cs deleted file mode 100644 index f570dbbd..00000000 --- a/src/Services/Reviews/Reviews.Infrastructure/Repositories/ProductReviewRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Reviews.Domain.Entities; -using Reviews.Domain.Interfaces; -using Reviews.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Repositories; - -namespace Reviews.Infrastructure.Repositories; - -/// EF Core repository for , inheriting all standard CRUD and specification query support from . -public sealed class ProductReviewRepository - : RepositoryBase, - IProductReviewRepository -{ - public ProductReviewRepository(ReviewsDbContext dbContext) - : base(dbContext) { } -} diff --git a/src/Services/Reviews/Reviews.Infrastructure/Reviews.Infrastructure.csproj b/src/Services/Reviews/Reviews.Infrastructure/Reviews.Infrastructure.csproj deleted file mode 100644 index ddacd317..00000000 --- a/src/Services/Reviews/Reviews.Infrastructure/Reviews.Infrastructure.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - - - - - - - diff --git a/src/Services/Webhooks/Webhooks.Api/Dockerfile b/src/Services/Webhooks/Webhooks.Api/Dockerfile deleted file mode 100644 index 5bff8e1f..00000000 --- a/src/Services/Webhooks/Webhooks.Api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build -WORKDIR /src -COPY . . -RUN dotnet restore "src/Services/Webhooks/Webhooks.Api/Webhooks.Api.csproj" -RUN dotnet publish "src/Services/Webhooks/Webhooks.Api/Webhooks.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "Webhooks.Api.dll"] diff --git a/src/Services/Webhooks/Webhooks.Api/Program.cs b/src/Services/Webhooks/Webhooks.Api/Program.cs deleted file mode 100644 index b8447e7f..00000000 --- a/src/Services/Webhooks/Webhooks.Api/Program.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Http.Resilience; -using Polly; -using SharedKernel.Api.Extensions; -using SharedKernel.Api.Security; -using SharedKernel.Application.Context; -using SharedKernel.Messaging.Conventions; -using SharedKernel.Messaging.Topology; -using Webhooks.Application.Common.Constants; -using Webhooks.Application.Common.Contracts; -using Webhooks.Application.Features.Delivery.EventHandlers; -using Webhooks.Domain.Interfaces; -using Webhooks.Infrastructure.Delivery; -using Webhooks.Infrastructure.Hmac; -using Webhooks.Infrastructure.Persistence; -using Webhooks.Infrastructure.Repositories; -using Wolverine; -using Wolverine.Http; -using Wolverine.RabbitMQ; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Host.UseSharedSerilog(); -builder.Services.AddSharedObservability(builder.Configuration, builder.Environment, "webhooks"); - -// Database -string connectionString = builder.Configuration.GetRequiredConnectionString("DefaultConnection"); - -builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); - -// TimeProvider for HMAC timestamp generation -builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddHttpContextAccessor(); -builder.Services.AddScoped(); -builder.Services.AddSharedKeycloakJwtBearer(builder.Configuration, builder.Environment); -builder.Services.AddSharedAuthorization(); -builder.Services.AddSharedApiErrorHandling(); - -// HMAC signing -builder.Services.AddSingleton(); - -// Webhook delivery -builder.Services.AddScoped(); - -// Repositories -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// Outgoing HTTP client with retry resilience -builder - .Services.AddHttpClient(WebhookConstants.OutgoingHttpClientName) - .AddResilienceHandler( - "outgoing-webhook-retry", - pipeline => - { - pipeline.AddRetry( - new HttpRetryStrategyOptions - { - MaxRetryAttempts = 3, - BackoffType = DelayBackoffType.Exponential, - Delay = TimeSpan.FromSeconds(2), - UseJitter = true, - } - ); - } - ); - -// Health checks -builder.Services.AddHealthChecks(); -builder.Services.AddSharedOpenApiDocumentation(); -builder.Services.AddWolverineHttp(); - -// Wolverine with RabbitMQ -builder.Host.UseWolverine(opts => -{ - opts.Discovery.IncludeAssembly(typeof(ProductCreatedWebhookHandler).Assembly); - - // Shared conventions - opts.ApplySharedConventions(); - opts.ApplySharedRetryPolicies(); - - // RabbitMQ transport - opts.UseSharedRabbitMq(builder.Configuration); - - // Listen to webhook delivery queues - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Webhooks.ProductCreated, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.ProductCatalog); - } - ); - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Webhooks.ProductDeleted, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.ProductCatalog); - } - ); - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Webhooks.ReviewCreated, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.Reviews); - } - ); - opts.ListenToRabbitQueue( - RabbitMqTopology.Queues.Webhooks.CategoryDeleted, - queue => - { - queue.BindExchange(RabbitMqTopology.Exchanges.ProductCatalog); - } - ); -}); - -WebApplication app = builder.Build(); - -await app.MigrateDbAsync(); - -app.UseSharedExceptionHandlerAndAuthentication(); -app.UseSharedAuthorizationCachingAndInfrastructure(useOutputCaching: false); -app.MapWolverineEndpoints(); - -await app.RunAsync(); - -public partial class Program; diff --git a/src/Services/Webhooks/Webhooks.Api/Properties/launchSettings.json b/src/Services/Webhooks/Webhooks.Api/Properties/launchSettings.json deleted file mode 100644 index e559c821..00000000 --- a/src/Services/Webhooks/Webhooks.Api/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5026", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7263;http://localhost:5026", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Services/Webhooks/Webhooks.Api/Webhooks.Api.csproj b/src/Services/Webhooks/Webhooks.Api/Webhooks.Api.csproj deleted file mode 100644 index cfe29cfb..00000000 --- a/src/Services/Webhooks/Webhooks.Api/Webhooks.Api.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - diff --git a/src/Services/Webhooks/Webhooks.Api/appsettings.json b/src/Services/Webhooks/Webhooks.Api/appsettings.json deleted file mode 100644 index 77284402..00000000 --- a/src/Services/Webhooks/Webhooks.Api/appsettings.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "Host=postgres;Database=webhooks_db;Username=postgres;Password=postgres" - }, - "Keycloak": { - "realm": "api-template", - "auth-server-url": "http://localhost:8180/", - "resource": "api-template" - }, - "RabbitMQ": { - "HostName": "rabbitmq" - }, - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "WriteTo": [ - { "Name": "Console" } - ] - }, - "Logging": { - "LogLevel": { - "Default": "Information" - } - } -} diff --git a/src/Services/Webhooks/Webhooks.Application/Common/Constants/WebhookConstants.cs b/src/Services/Webhooks/Webhooks.Application/Common/Constants/WebhookConstants.cs deleted file mode 100644 index fc5c2e49..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Common/Constants/WebhookConstants.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Webhooks.Application.Common.Constants; - -/// -/// Centralizes header names and HTTP client identifiers used by the webhook delivery infrastructure. -/// -public static class WebhookConstants -{ - public const string SignatureHeader = "X-Webhook-Signature"; - public const string TimestampHeader = "X-Webhook-Timestamp"; - public const string OutgoingHttpClientName = "OutgoingWebhook"; -} diff --git a/src/Services/Webhooks/Webhooks.Application/Common/Constants/WebhookEventTypes.cs b/src/Services/Webhooks/Webhooks.Application/Common/Constants/WebhookEventTypes.cs deleted file mode 100644 index cdda52a6..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Common/Constants/WebhookEventTypes.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Webhooks.Application.Common.Constants; - -/// -/// Named constants for the webhook event types that this service can deliver. -/// -public static class WebhookEventTypes -{ - public const string ProductCreated = "product.created"; - public const string ProductDeleted = "product.deleted"; - public const string ReviewCreated = "review.created"; - public const string CategoryDeleted = "category.deleted"; -} diff --git a/src/Services/Webhooks/Webhooks.Application/Common/Contracts/IWebhookDeliveryService.cs b/src/Services/Webhooks/Webhooks.Application/Common/Contracts/IWebhookDeliveryService.cs deleted file mode 100644 index e43fed78..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Common/Contracts/IWebhookDeliveryService.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Webhooks.Application.Common.Contracts; - -/// -/// Application-layer abstraction for delivering webhook payloads to all active subscribers -/// that are registered for a given event type. -/// -public interface IWebhookDeliveryService -{ - /// - /// Signs and delivers the to all active subscribers - /// that are registered for the specified . - /// - Task DeliverAsync( - string eventType, - string serializedPayload, - Guid tenantId, - CancellationToken ct = default - ); -} diff --git a/src/Services/Webhooks/Webhooks.Application/Common/Contracts/IWebhookPayloadSigner.cs b/src/Services/Webhooks/Webhooks.Application/Common/Contracts/IWebhookPayloadSigner.cs deleted file mode 100644 index a9e265b1..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Common/Contracts/IWebhookPayloadSigner.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Webhooks.Application.Common.Contracts; - -/// -/// Application-layer abstraction for signing outgoing webhook payloads so that receivers can -/// verify authenticity. Implementations provide the HMAC or similar signing algorithm. -/// -public interface IWebhookPayloadSigner -{ - /// - /// Computes a signature and timestamp for the given string - /// using the specified . - /// - WebhookSignatureResult Sign(string payload, string secret); -} - -/// -/// Value object containing the computed HMAC signature and the timestamp used as the signing input, -/// both of which are included as HTTP headers on outgoing webhook deliveries. -/// -public sealed record WebhookSignatureResult(string Signature, string Timestamp); diff --git a/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/CategoryDeletedWebhookHandler.cs b/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/CategoryDeletedWebhookHandler.cs deleted file mode 100644 index e7280f37..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/CategoryDeletedWebhookHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using Contracts.IntegrationEvents.ProductCatalog; -using Microsoft.Extensions.Logging; -using Webhooks.Application.Common.Constants; -using Webhooks.Application.Common.Contracts; - -namespace Webhooks.Application.Features.Delivery.EventHandlers; - -/// -/// Wolverine message handler that consumes from RabbitMQ -/// and delivers webhook notifications to all subscribers registered for the "category.deleted" event type. -/// -public static class CategoryDeletedWebhookHandler -{ - public static async Task HandleAsync( - CategoryDeletedIntegrationEvent @event, - IWebhookDeliveryService deliveryService, - ILogger logger, - CancellationToken ct - ) - { - logger.LogInformation( - "Delivering webhook for category deleted: {CategoryId}", - @event.CategoryId - ); - - string payload = JsonSerializer.Serialize(@event); - await deliveryService.DeliverAsync( - WebhookEventTypes.CategoryDeleted, - payload, - @event.TenantId, - ct - ); - } -} diff --git a/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/ProductCreatedWebhookHandler.cs b/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/ProductCreatedWebhookHandler.cs deleted file mode 100644 index 672b2d67..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/ProductCreatedWebhookHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using Contracts.IntegrationEvents.ProductCatalog; -using Microsoft.Extensions.Logging; -using Webhooks.Application.Common.Constants; -using Webhooks.Application.Common.Contracts; - -namespace Webhooks.Application.Features.Delivery.EventHandlers; - -/// -/// Wolverine message handler that consumes from RabbitMQ -/// and delivers webhook notifications to all subscribers registered for the "product.created" event type. -/// -public static class ProductCreatedWebhookHandler -{ - public static async Task HandleAsync( - ProductCreatedIntegrationEvent @event, - IWebhookDeliveryService deliveryService, - ILogger logger, - CancellationToken ct - ) - { - logger.LogInformation( - "Delivering webhook for product created: {ProductId}", - @event.ProductId - ); - - string payload = JsonSerializer.Serialize(@event); - await deliveryService.DeliverAsync( - WebhookEventTypes.ProductCreated, - payload, - @event.TenantId, - ct - ); - } -} diff --git a/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/ProductDeletedWebhookHandler.cs b/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/ProductDeletedWebhookHandler.cs deleted file mode 100644 index 83ecb286..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/ProductDeletedWebhookHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using Contracts.IntegrationEvents.ProductCatalog; -using Microsoft.Extensions.Logging; -using Webhooks.Application.Common.Constants; -using Webhooks.Application.Common.Contracts; - -namespace Webhooks.Application.Features.Delivery.EventHandlers; - -/// -/// Wolverine message handler that consumes from RabbitMQ -/// and delivers webhook notifications to all subscribers registered for the "product.deleted" event type. -/// -public static class ProductDeletedWebhookHandler -{ - public static async Task HandleAsync( - ProductDeletedIntegrationEvent @event, - IWebhookDeliveryService deliveryService, - ILogger logger, - CancellationToken ct - ) - { - logger.LogInformation( - "Delivering webhook for product deleted: {ProductIds}", - string.Join(", ", @event.ProductIds) - ); - - string payload = JsonSerializer.Serialize(@event); - await deliveryService.DeliverAsync( - WebhookEventTypes.ProductDeleted, - payload, - @event.TenantId, - ct - ); - } -} diff --git a/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/ReviewCreatedWebhookHandler.cs b/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/ReviewCreatedWebhookHandler.cs deleted file mode 100644 index 68ede80e..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Features/Delivery/EventHandlers/ReviewCreatedWebhookHandler.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json; -using Contracts.IntegrationEvents.Reviews; -using Microsoft.Extensions.Logging; -using Webhooks.Application.Common.Constants; -using Webhooks.Application.Common.Contracts; - -namespace Webhooks.Application.Features.Delivery.EventHandlers; - -/// -/// Wolverine message handler that consumes from RabbitMQ -/// and delivers webhook notifications to all subscribers registered for the "review.created" event type. -/// -public static class ReviewCreatedWebhookHandler -{ - public static async Task HandleAsync( - ReviewCreatedIntegrationEvent @event, - IWebhookDeliveryService deliveryService, - ILogger logger, - CancellationToken ct - ) - { - logger.LogInformation( - "Delivering webhook for review created: {ReviewId} on product {ProductId}", - @event.ReviewId, - @event.ProductId - ); - - string payload = JsonSerializer.Serialize(@event); - await deliveryService.DeliverAsync( - WebhookEventTypes.ReviewCreated, - payload, - @event.TenantId, - ct - ); - } -} diff --git a/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/DTOs/WebhookSubscriptionDTOs.cs b/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/DTOs/WebhookSubscriptionDTOs.cs deleted file mode 100644 index 204b7f1a..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/DTOs/WebhookSubscriptionDTOs.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Webhooks.Application.Features.Subscriptions.DTOs; - -/// -/// Request payload for creating a new webhook subscription. -/// -public sealed record CreateWebhookSubscriptionRequest( - string Url, - string Secret, - IReadOnlyList EventTypes -); - -/// -/// Response payload representing a webhook subscription. -/// -public sealed record WebhookSubscriptionResponse( - Guid Id, - string Url, - bool IsActive, - IReadOnlyList EventTypes, - DateTime CreatedAtUtc -); diff --git a/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/Endpoints/CreateWebhookSubscriptionEndpoint.cs b/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/Endpoints/CreateWebhookSubscriptionEndpoint.cs deleted file mode 100644 index 8f883d1d..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/Endpoints/CreateWebhookSubscriptionEndpoint.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Microsoft.AspNetCore.Http; -using SharedKernel.Application.Context; -using Webhooks.Application.Features.Subscriptions.DTOs; -using Webhooks.Domain.Entities; -using Webhooks.Domain.Interfaces; -using Wolverine.Http; - -namespace Webhooks.Application.Features.Subscriptions.Endpoints; - -/// -/// Wolverine HTTP endpoint that creates a new webhook subscription. -/// -public static class CreateWebhookSubscriptionEndpoint -{ - [WolverinePost("/api/v1/webhooks/subscriptions")] - public static async Task HandleAsync( - CreateWebhookSubscriptionRequest request, - IWebhookSubscriptionRepository repository, - ITenantProvider tenantProvider, - CancellationToken ct - ) - { - List errors = Validate(request); - if (errors.Count > 0) - return Results.ValidationProblem( - new Dictionary { ["request"] = errors.ToArray() } - ); - - WebhookSubscription subscription = new() - { - Id = Guid.NewGuid(), - TenantId = tenantProvider.TenantId, - Url = request.Url, - Secret = request.Secret, - IsActive = true, - EventTypes = request - .EventTypes.Select(et => new WebhookSubscriptionEventType - { - Id = Guid.NewGuid(), - EventType = et, - }) - .ToList(), - }; - - await repository.AddAsync(subscription, ct); - await repository.SaveChangesAsync(ct); - - WebhookSubscriptionResponse response = new( - subscription.Id, - subscription.Url, - subscription.IsActive, - request.EventTypes.ToList(), - subscription.Audit.CreatedAtUtc - ); - - return Results.Created($"/api/v1/webhooks/subscriptions/{subscription.Id}", response); - } - - private static List Validate(CreateWebhookSubscriptionRequest request) - { - List errors = []; - - if ( - !Uri.TryCreate(request.Url, UriKind.Absolute, out Uri? uri) - || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) - ) - { - errors.Add("Url must be a valid absolute HTTP or HTTPS URL."); - } - - if ( - string.IsNullOrWhiteSpace(request.Secret) - || request.Secret.Length < WebhookSubscription.SecretMinLength - ) - { - errors.Add( - $"Secret must be at least {WebhookSubscription.SecretMinLength} characters long." - ); - } - - if (request.EventTypes is null || request.EventTypes.Count == 0) - { - errors.Add("EventTypes must contain at least one event type."); - } - - return errors; - } -} diff --git a/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/Endpoints/DeleteWebhookSubscriptionEndpoint.cs b/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/Endpoints/DeleteWebhookSubscriptionEndpoint.cs deleted file mode 100644 index 440b94b4..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/Endpoints/DeleteWebhookSubscriptionEndpoint.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Webhooks.Domain.Entities; -using Webhooks.Domain.Interfaces; -using Wolverine.Http; - -namespace Webhooks.Application.Features.Subscriptions.Endpoints; - -/// -/// Wolverine HTTP endpoint that deletes a webhook subscription by its identifier. -/// -public static class DeleteWebhookSubscriptionEndpoint -{ - [WolverineDelete("/api/v1/webhooks/subscriptions/{id}")] - public static async Task HandleAsync( - Guid id, - IWebhookSubscriptionRepository repository, - CancellationToken ct - ) - { - WebhookSubscription? subscription = await repository.GetByIdAsync(id, ct); - if (subscription is null) - return Results.NotFound(); - - await repository.DeleteAsync(subscription, ct); - await repository.SaveChangesAsync(ct); - - return Results.NoContent(); - } -} diff --git a/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/Endpoints/GetWebhookSubscriptionsEndpoint.cs b/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/Endpoints/GetWebhookSubscriptionsEndpoint.cs deleted file mode 100644 index 2a4569e0..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Features/Subscriptions/Endpoints/GetWebhookSubscriptionsEndpoint.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Webhooks.Application.Features.Subscriptions.DTOs; -using Webhooks.Domain.Entities; -using Webhooks.Domain.Interfaces; -using Wolverine.Http; - -namespace Webhooks.Application.Features.Subscriptions.Endpoints; - -/// -/// Wolverine HTTP endpoint that returns all webhook subscriptions. -/// -public static class GetWebhookSubscriptionsEndpoint -{ - [WolverineGet("/api/v1/webhooks/subscriptions")] - public static async Task> HandleAsync( - IWebhookSubscriptionRepository repository, - CancellationToken ct - ) - { - IReadOnlyList subscriptions = await repository.GetAllAsync(ct); - - return subscriptions - .Select(s => new WebhookSubscriptionResponse( - s.Id, - s.Url, - s.IsActive, - s.EventTypes.Select(et => et.EventType).ToList(), - s.Audit.CreatedAtUtc - )) - .ToList(); - } -} diff --git a/src/Services/Webhooks/Webhooks.Application/Webhooks.Application.csproj b/src/Services/Webhooks/Webhooks.Application/Webhooks.Application.csproj deleted file mode 100644 index 12b6b5dd..00000000 --- a/src/Services/Webhooks/Webhooks.Application/Webhooks.Application.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - diff --git a/src/Services/Webhooks/Webhooks.Domain/Entities/WebhookDeliveryLog.cs b/src/Services/Webhooks/Webhooks.Domain/Entities/WebhookDeliveryLog.cs deleted file mode 100644 index b56feb69..00000000 --- a/src/Services/Webhooks/Webhooks.Domain/Entities/WebhookDeliveryLog.cs +++ /dev/null @@ -1,32 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace Webhooks.Domain.Entities; - -/// -/// Immutable audit record of a single webhook delivery attempt, capturing the HTTP status code -/// and any error message for debugging and retry decisions. -/// -public sealed class WebhookDeliveryLog : IHasId -{ - public const int ErrorMaxLength = 2000; - - public Guid Id { get; set; } - public Guid WebhookSubscriptionId { get; set; } - - /// The event type that triggered this delivery (e.g. "product.created"). - public required string EventType { get; set; } - - /// The serialized JSON payload that was delivered. - public required string Payload { get; set; } - - /// HTTP status code returned by the subscriber's endpoint, or null if the request failed before a response. - public int? HttpStatusCode { get; set; } - - /// Whether the delivery was successful (2xx status code). - public bool Success { get; set; } - - /// Error message if the delivery failed. - public string? Error { get; set; } - - public DateTime AttemptedAtUtc { get; set; } -} diff --git a/src/Services/Webhooks/Webhooks.Domain/Entities/WebhookSubscription.cs b/src/Services/Webhooks/Webhooks.Domain/Entities/WebhookSubscription.cs deleted file mode 100644 index 9746f98f..00000000 --- a/src/Services/Webhooks/Webhooks.Domain/Entities/WebhookSubscription.cs +++ /dev/null @@ -1,39 +0,0 @@ -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace Webhooks.Domain.Entities; - -/// -/// Represents a tenant-scoped webhook subscription that defines a callback URL, -/// a shared HMAC secret for signing deliveries, and the set of event types the subscriber is interested in. -/// -public sealed class WebhookSubscription : IAuditableTenantEntity, IHasId -{ - public const int UrlMaxLength = 2048; - public const int SecretMinLength = 16; - public const int SecretMaxLength = 256; - public const int EventTypeMaxLength = 100; - - public Guid Id { get; set; } - public Guid TenantId { get; set; } - - /// The HTTPS callback URL that webhook payloads are delivered to. - public required string Url { get; set; } - - /// The shared HMAC-SHA256 secret used to sign outgoing deliveries to this subscriber. - public required string Secret { get; set; } - - /// Whether this subscription is actively receiving deliveries. - public bool IsActive { get; set; } = true; - - /// Navigation property for the event types this subscription is interested in. - public ICollection EventTypes { get; set; } = []; - - // IAuditableEntity - public AuditInfo Audit { get; set; } = new(); - - // ISoftDeletable - public bool IsDeleted { get; set; } - public DateTime? DeletedAtUtc { get; set; } - public Guid? DeletedBy { get; set; } -} diff --git a/src/Services/Webhooks/Webhooks.Domain/Entities/WebhookSubscriptionEventType.cs b/src/Services/Webhooks/Webhooks.Domain/Entities/WebhookSubscriptionEventType.cs deleted file mode 100644 index 35fcedf0..00000000 --- a/src/Services/Webhooks/Webhooks.Domain/Entities/WebhookSubscriptionEventType.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Webhooks.Domain.Entities; - -/// -/// Value-type child record representing a single event type that a listens to. -/// Stored as a separate table to support efficient filtering by event type. -/// -public sealed class WebhookSubscriptionEventType -{ - public Guid Id { get; set; } - public Guid WebhookSubscriptionId { get; set; } - - /// The event type string (e.g. "product.created", "review.created"). - public required string EventType { get; set; } -} diff --git a/src/Services/Webhooks/Webhooks.Domain/Interfaces/IWebhookDeliveryLogRepository.cs b/src/Services/Webhooks/Webhooks.Domain/Interfaces/IWebhookDeliveryLogRepository.cs deleted file mode 100644 index de998dd0..00000000 --- a/src/Services/Webhooks/Webhooks.Domain/Interfaces/IWebhookDeliveryLogRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Webhooks.Domain.Entities; - -namespace Webhooks.Domain.Interfaces; - -/// -/// Repository abstraction for persisting records. -/// -public interface IWebhookDeliveryLogRepository -{ - Task AddAsync(WebhookDeliveryLog log, CancellationToken ct = default); - Task SaveChangesAsync(CancellationToken ct = default); -} diff --git a/src/Services/Webhooks/Webhooks.Domain/Interfaces/IWebhookSubscriptionRepository.cs b/src/Services/Webhooks/Webhooks.Domain/Interfaces/IWebhookSubscriptionRepository.cs deleted file mode 100644 index 3b822e07..00000000 --- a/src/Services/Webhooks/Webhooks.Domain/Interfaces/IWebhookSubscriptionRepository.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Webhooks.Domain.Entities; - -namespace Webhooks.Domain.Interfaces; - -/// -/// Repository abstraction for persistence operations. -/// -public interface IWebhookSubscriptionRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetActiveByEventTypeAsync( - string eventType, - Guid tenantId, - CancellationToken ct = default - ); - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(WebhookSubscription subscription, CancellationToken ct = default); - Task UpdateAsync(WebhookSubscription subscription, CancellationToken ct = default); - Task DeleteAsync(WebhookSubscription subscription, CancellationToken ct = default); - Task SaveChangesAsync(CancellationToken ct = default); -} diff --git a/src/Services/Webhooks/Webhooks.Domain/Webhooks.Domain.csproj b/src/Services/Webhooks/Webhooks.Domain/Webhooks.Domain.csproj deleted file mode 100644 index e5effa8b..00000000 --- a/src/Services/Webhooks/Webhooks.Domain/Webhooks.Domain.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Delivery/WebhookDeliveryService.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Delivery/WebhookDeliveryService.cs deleted file mode 100644 index fcc17b6d..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Delivery/WebhookDeliveryService.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Net.Http.Headers; -using System.Text; -using Microsoft.Extensions.Logging; -using Webhooks.Application.Common.Constants; -using Webhooks.Application.Common.Contracts; -using Webhooks.Domain.Entities; -using Webhooks.Domain.Interfaces; - -namespace Webhooks.Infrastructure.Delivery; - -/// -/// Delivers webhook payloads to all active subscribers registered for a given event type, -/// signing each payload with the subscriber's own HMAC secret and logging delivery results. -/// -public sealed class WebhookDeliveryService : IWebhookDeliveryService -{ - private readonly IWebhookSubscriptionRepository _subscriptionRepository; - private readonly IWebhookDeliveryLogRepository _deliveryLogRepository; - private readonly IWebhookPayloadSigner _signer; - private readonly IHttpClientFactory _httpClientFactory; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - - public WebhookDeliveryService( - IWebhookSubscriptionRepository subscriptionRepository, - IWebhookDeliveryLogRepository deliveryLogRepository, - IWebhookPayloadSigner signer, - IHttpClientFactory httpClientFactory, - TimeProvider timeProvider, - ILogger logger - ) - { - _subscriptionRepository = subscriptionRepository; - _deliveryLogRepository = deliveryLogRepository; - _signer = signer; - _httpClientFactory = httpClientFactory; - _timeProvider = timeProvider; - _logger = logger; - } - - /// - public async Task DeliverAsync( - string eventType, - string serializedPayload, - Guid tenantId, - CancellationToken ct = default - ) - { - IReadOnlyList subscriptions = - await _subscriptionRepository.GetActiveByEventTypeAsync(eventType, tenantId, ct); - - foreach (WebhookSubscription subscription in subscriptions) - { - await DeliverToSubscriberAsync(subscription, eventType, serializedPayload, ct); - } - } - - private async Task DeliverToSubscriberAsync( - WebhookSubscription subscription, - string eventType, - string serializedPayload, - CancellationToken ct - ) - { - WebhookDeliveryLog log = new() - { - Id = Guid.NewGuid(), - WebhookSubscriptionId = subscription.Id, - EventType = eventType, - Payload = serializedPayload, - AttemptedAtUtc = _timeProvider.GetUtcNow().UtcDateTime, - }; - - try - { - WebhookSignatureResult signatureResult = _signer.Sign( - serializedPayload, - subscription.Secret - ); - - using HttpClient client = _httpClientFactory.CreateClient( - WebhookConstants.OutgoingHttpClientName - ); - using HttpRequestMessage request = new(HttpMethod.Post, subscription.Url) - { - Content = new StringContent( - serializedPayload, - Encoding.UTF8, - new MediaTypeHeaderValue("application/json") - ), - }; - - request.Headers.Add(WebhookConstants.SignatureHeader, signatureResult.Signature); - request.Headers.Add(WebhookConstants.TimestampHeader, signatureResult.Timestamp); - - using HttpResponseMessage response = await client.SendAsync(request, ct); - - log.HttpStatusCode = (int)response.StatusCode; - log.Success = response.IsSuccessStatusCode; - - if (!response.IsSuccessStatusCode) - { - log.Error = $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; - _logger.LogWarning( - "Webhook delivery to {Url} returned {StatusCode}", - subscription.Url, - response.StatusCode - ); - } - else - { - _logger.LogInformation("Webhook delivered to {Url}", subscription.Url); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - log.Success = false; - log.Error = - ex.Message.Length > WebhookDeliveryLog.ErrorMaxLength - ? ex.Message[..WebhookDeliveryLog.ErrorMaxLength] - : ex.Message; - - _logger.LogError(ex, "Failed to deliver webhook to {Url}", subscription.Url); - } - - await _deliveryLogRepository.AddAsync(log, ct); - await _deliveryLogRepository.SaveChangesAsync(ct); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Hmac/HmacHelper.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Hmac/HmacHelper.cs deleted file mode 100644 index 47c78820..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Hmac/HmacHelper.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace Webhooks.Infrastructure.Hmac; - -/// -/// Internal helper that computes the HMAC-SHA256 signature over a timestamp-prefixed payload, -/// shared by both the signer and validator to ensure consistent signing format. -/// -internal static class HmacHelper -{ - /// - /// Computes HMAC-SHA256 over the string {timestamp}.{payload} using the given key bytes. - /// - public static byte[] ComputeHash(byte[] keyBytes, string timestamp, string payload) - { - string signedContent = $"{timestamp}.{payload}"; - byte[] contentBytes = Encoding.UTF8.GetBytes(signedContent); - return HMACSHA256.HashData(keyBytes, contentBytes); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Hmac/HmacWebhookPayloadSigner.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Hmac/HmacWebhookPayloadSigner.cs deleted file mode 100644 index 313a75a5..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Hmac/HmacWebhookPayloadSigner.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Text; -using Webhooks.Application.Common.Contracts; - -namespace Webhooks.Infrastructure.Hmac; - -/// -/// Signs outgoing webhook payloads using HMAC-SHA256 with a per-subscription secret, producing -/// a signature and UTC Unix timestamp for inclusion in request headers. -/// -public sealed class HmacWebhookPayloadSigner : IWebhookPayloadSigner -{ - private readonly TimeProvider _timeProvider; - - public HmacWebhookPayloadSigner(TimeProvider timeProvider) - { - _timeProvider = timeProvider; - } - - /// Computes the HMAC-SHA256 signature over the current timestamp and payload, returning both values as a . - public WebhookSignatureResult Sign(string payload, string secret) - { - byte[] keyBytes = Encoding.UTF8.GetBytes(secret); - string timestamp = _timeProvider.GetUtcNow().ToUnixTimeSeconds().ToString(); - byte[] hashBytes = HmacHelper.ComputeHash(keyBytes, timestamp, payload); - string signature = Convert.ToHexStringLower(hashBytes); - - return new WebhookSignatureResult(signature, timestamp); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Configurations/WebhookDeliveryLogConfiguration.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Configurations/WebhookDeliveryLogConfiguration.cs deleted file mode 100644 index ceefbcab..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Configurations/WebhookDeliveryLogConfiguration.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Webhooks.Domain.Entities; - -namespace Webhooks.Infrastructure.Persistence.Configurations; - -/// -/// EF Core configuration for the entity. -/// -public sealed class WebhookDeliveryLogConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(e => e.Id); - - builder - .Property(e => e.EventType) - .IsRequired() - .HasMaxLength(WebhookSubscription.EventTypeMaxLength); - - builder.Property(e => e.Payload).IsRequired(); - - builder.Property(e => e.Error).HasMaxLength(WebhookDeliveryLog.ErrorMaxLength); - - builder - .Property(e => e.AttemptedAtUtc) - .IsRequired() - .HasColumnType("timestamp with time zone"); - - builder.HasIndex(e => e.WebhookSubscriptionId); - builder.HasIndex(e => e.AttemptedAtUtc); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Configurations/WebhookSubscriptionConfiguration.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Configurations/WebhookSubscriptionConfiguration.cs deleted file mode 100644 index c27c5fdb..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Configurations/WebhookSubscriptionConfiguration.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SharedKernel.Infrastructure.Persistence.Configurations; -using Webhooks.Domain.Entities; - -namespace Webhooks.Infrastructure.Persistence.Configurations; - -/// -/// EF Core configuration for the entity, applying standard -/// tenant-auditable conventions and relationship mappings. -/// -public sealed class WebhookSubscriptionConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(e => e.Id); - - builder.Property(e => e.Url).IsRequired().HasMaxLength(WebhookSubscription.UrlMaxLength); - - builder - .Property(e => e.Secret) - .IsRequired() - .HasMaxLength(WebhookSubscription.SecretMaxLength); - - builder.Property(e => e.IsActive).IsRequired().HasDefaultValue(true); - - builder - .HasMany(e => e.EventTypes) - .WithOne() - .HasForeignKey(et => et.WebhookSubscriptionId) - .OnDelete(DeleteBehavior.Cascade); - - builder.ConfigureTenantAuditable(); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Configurations/WebhookSubscriptionEventTypeConfiguration.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Configurations/WebhookSubscriptionEventTypeConfiguration.cs deleted file mode 100644 index e71d3c1c..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Configurations/WebhookSubscriptionEventTypeConfiguration.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Webhooks.Domain.Entities; - -namespace Webhooks.Infrastructure.Persistence.Configurations; - -/// -/// EF Core configuration for the entity. -/// -public sealed class WebhookSubscriptionEventTypeConfiguration - : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(e => e.Id); - - builder - .Property(e => e.EventType) - .IsRequired() - .HasMaxLength(WebhookSubscription.EventTypeMaxLength); - - builder.HasIndex(e => e.EventType); - builder.HasIndex(e => new { e.WebhookSubscriptionId, e.EventType }).IsUnique(); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Migrations/20260326232726_InitialCreate.Designer.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Migrations/20260326232726_InitialCreate.Designer.cs deleted file mode 100644 index 76cbaed5..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Migrations/20260326232726_InitialCreate.Designer.cs +++ /dev/null @@ -1,204 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Webhooks.Infrastructure.Persistence; - -#nullable disable - -namespace Webhooks.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(WebhooksDbContext))] - [Migration("20260326232726_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookDeliveryLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AttemptedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("Error") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("HttpStatusCode") - .HasColumnType("integer"); - - b.Property("Payload") - .IsRequired() - .HasColumnType("text"); - - b.Property("Success") - .HasColumnType("boolean"); - - b.Property("WebhookSubscriptionId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("AttemptedAtUtc"); - - b.HasIndex("WebhookSubscriptionId"); - - b.ToTable("WebhookDeliveryLogs"); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscription", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Secret") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Url") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("WebhookSubscriptions", t => - { - t.HasCheckConstraint("CK_WebhookSubscriptions_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscriptionEventType", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("WebhookSubscriptionId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("EventType"); - - b.HasIndex("WebhookSubscriptionId", "EventType") - .IsUnique(); - - b.ToTable("WebhookSubscriptionEventTypes"); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscription", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("WebhookSubscriptionId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("WebhookSubscriptionId"); - - b1.ToTable("WebhookSubscriptions"); - - b1.WithOwner() - .HasForeignKey("WebhookSubscriptionId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscriptionEventType", b => - { - b.HasOne("Webhooks.Domain.Entities.WebhookSubscription", null) - .WithMany("EventTypes") - .HasForeignKey("WebhookSubscriptionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscription", b => - { - b.Navigation("EventTypes"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Migrations/20260326232726_InitialCreate.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Migrations/20260326232726_InitialCreate.cs deleted file mode 100644 index b5356ff6..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Migrations/20260326232726_InitialCreate.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Webhooks.Infrastructure.Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "WebhookDeliveryLogs", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - WebhookSubscriptionId = table.Column(type: "uuid", nullable: false), - EventType = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - Payload = table.Column(type: "text", nullable: false), - HttpStatusCode = table.Column(type: "integer", nullable: true), - Success = table.Column(type: "boolean", nullable: false), - Error = table.Column( - type: "character varying(2000)", - maxLength: 2000, - nullable: true - ), - AttemptedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false - ), - }, - constraints: table => - { - table.PrimaryKey("PK_WebhookDeliveryLogs", x => x.Id); - } - ); - - migrationBuilder.CreateTable( - name: "WebhookSubscriptions", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - TenantId = table.Column(type: "uuid", nullable: false), - Url = table.Column( - type: "character varying(2048)", - maxLength: 2048, - nullable: false - ), - Secret = table.Column( - type: "character varying(256)", - maxLength: 256, - nullable: false - ), - IsActive = table.Column( - type: "boolean", - nullable: false, - defaultValue: true - ), - CreatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - CreatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - UpdatedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()" - ), - UpdatedBy = table.Column( - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000") - ), - IsDeleted = table.Column( - type: "boolean", - nullable: false, - defaultValue: false - ), - DeletedAtUtc = table.Column( - type: "timestamp with time zone", - nullable: true - ), - DeletedBy = table.Column(type: "uuid", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_WebhookSubscriptions", x => x.Id); - table.CheckConstraint( - "CK_WebhookSubscriptions_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - } - ); - - migrationBuilder.CreateTable( - name: "WebhookSubscriptionEventTypes", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - WebhookSubscriptionId = table.Column(type: "uuid", nullable: false), - EventType = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: false - ), - }, - constraints: table => - { - table.PrimaryKey("PK_WebhookSubscriptionEventTypes", x => x.Id); - table.ForeignKey( - name: "FK_WebhookSubscriptionEventTypes_WebhookSubscriptions_WebhookS~", - column: x => x.WebhookSubscriptionId, - principalTable: "WebhookSubscriptions", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_WebhookDeliveryLogs_AttemptedAtUtc", - table: "WebhookDeliveryLogs", - column: "AttemptedAtUtc" - ); - - migrationBuilder.CreateIndex( - name: "IX_WebhookDeliveryLogs_WebhookSubscriptionId", - table: "WebhookDeliveryLogs", - column: "WebhookSubscriptionId" - ); - - migrationBuilder.CreateIndex( - name: "IX_WebhookSubscriptionEventTypes_EventType", - table: "WebhookSubscriptionEventTypes", - column: "EventType" - ); - - migrationBuilder.CreateIndex( - name: "IX_WebhookSubscriptionEventTypes_WebhookSubscriptionId_EventTy~", - table: "WebhookSubscriptionEventTypes", - columns: new[] { "WebhookSubscriptionId", "EventType" }, - unique: true - ); - - migrationBuilder.CreateIndex( - name: "IX_WebhookSubscriptions_TenantId", - table: "WebhookSubscriptions", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_WebhookSubscriptions_TenantId_IsDeleted", - table: "WebhookSubscriptions", - columns: new[] { "TenantId", "IsDeleted" } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "WebhookDeliveryLogs"); - - migrationBuilder.DropTable(name: "WebhookSubscriptionEventTypes"); - - migrationBuilder.DropTable(name: "WebhookSubscriptions"); - } - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Migrations/WebhooksDbContextModelSnapshot.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Migrations/WebhooksDbContextModelSnapshot.cs deleted file mode 100644 index b010b32c..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/Migrations/WebhooksDbContextModelSnapshot.cs +++ /dev/null @@ -1,201 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Webhooks.Infrastructure.Persistence; - -#nullable disable - -namespace Webhooks.Infrastructure.Persistence.Migrations -{ - [DbContext(typeof(WebhooksDbContext))] - partial class WebhooksDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookDeliveryLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AttemptedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("Error") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("HttpStatusCode") - .HasColumnType("integer"); - - b.Property("Payload") - .IsRequired() - .HasColumnType("text"); - - b.Property("Success") - .HasColumnType("boolean"); - - b.Property("WebhookSubscriptionId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("AttemptedAtUtc"); - - b.HasIndex("WebhookSubscriptionId"); - - b.ToTable("WebhookDeliveryLogs"); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscription", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Secret") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Url") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("WebhookSubscriptions", t => - { - t.HasCheckConstraint("CK_WebhookSubscriptions_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscriptionEventType", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("WebhookSubscriptionId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("EventType"); - - b.HasIndex("WebhookSubscriptionId", "EventType") - .IsUnique(); - - b.ToTable("WebhookSubscriptionEventTypes"); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscription", b => - { - b.OwnsOne("SharedKernel.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("WebhookSubscriptionId") - .HasColumnType("uuid"); - - b1.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("CreatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")) - .HasColumnName("UpdatedBy"); - - b1.HasKey("WebhookSubscriptionId"); - - b1.ToTable("WebhookSubscriptions"); - - b1.WithOwner() - .HasForeignKey("WebhookSubscriptionId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscriptionEventType", b => - { - b.HasOne("Webhooks.Domain.Entities.WebhookSubscription", null) - .WithMany("EventTypes") - .HasForeignKey("WebhookSubscriptionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Webhooks.Domain.Entities.WebhookSubscription", b => - { - b.Navigation("EventTypes"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContext.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContext.cs deleted file mode 100644 index 252a0d7c..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContext.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Webhooks.Domain.Entities; - -namespace Webhooks.Infrastructure.Persistence; - -/// -/// EF Core DbContext scoped to the Webhooks microservice, managing webhook subscription -/// and delivery log entities. -/// -public sealed class WebhooksDbContext : DbContext -{ - public DbSet WebhookSubscriptions => Set(); - public DbSet WebhookSubscriptionEventTypes => - Set(); - public DbSet WebhookDeliveryLogs => Set(); - - public WebhooksDbContext(DbContextOptions options) - : base(options) { } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfigurationsFromAssembly(typeof(WebhooksDbContext).Assembly); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs deleted file mode 100644 index 78b874dd..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace Webhooks.Infrastructure.Persistence; - -/// -/// Design-time factory used by EF Core tooling to create the DbContext -/// for migration scaffolding without requiring the full runtime DI container. -/// -public sealed class WebhooksDbContextDesignTimeFactory - : IDesignTimeDbContextFactory -{ - public WebhooksDbContext CreateDbContext(string[] args) - { - DbContextOptionsBuilder optionsBuilder = new(); - optionsBuilder.UseNpgsql( - "Host=localhost;Database=webhooks_db;Username=postgres;Password=postgres" - ); - - return new WebhooksDbContext(optionsBuilder.Options); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Repositories/WebhookDeliveryLogRepository.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Repositories/WebhookDeliveryLogRepository.cs deleted file mode 100644 index 91060f4d..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Repositories/WebhookDeliveryLogRepository.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Webhooks.Domain.Entities; -using Webhooks.Domain.Interfaces; -using Webhooks.Infrastructure.Persistence; - -namespace Webhooks.Infrastructure.Repositories; - -/// -/// EF Core repository for that provides persistence -/// against the . -/// -public sealed class WebhookDeliveryLogRepository : IWebhookDeliveryLogRepository -{ - private readonly WebhooksDbContext _dbContext; - - public WebhookDeliveryLogRepository(WebhooksDbContext dbContext) - { - _dbContext = dbContext; - } - - /// - public async Task AddAsync(WebhookDeliveryLog log, CancellationToken ct = default) - { - await _dbContext.WebhookDeliveryLogs.AddAsync(log, ct); - } - - /// - public async Task SaveChangesAsync(CancellationToken ct = default) - { - await _dbContext.SaveChangesAsync(ct); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Repositories/WebhookSubscriptionRepository.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Repositories/WebhookSubscriptionRepository.cs deleted file mode 100644 index 8f380acf..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Repositories/WebhookSubscriptionRepository.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Context; -using Webhooks.Domain.Entities; -using Webhooks.Domain.Interfaces; -using Webhooks.Infrastructure.Persistence; - -namespace Webhooks.Infrastructure.Repositories; - -/// -/// EF Core repository for that provides CRUD operations -/// against the . -/// -public sealed class WebhookSubscriptionRepository : IWebhookSubscriptionRepository -{ - private readonly WebhooksDbContext _dbContext; - private readonly ITenantProvider _tenantProvider; - - public WebhookSubscriptionRepository( - WebhooksDbContext dbContext, - ITenantProvider tenantProvider - ) - { - _dbContext = dbContext; - _tenantProvider = tenantProvider; - } - - /// - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await _dbContext - .WebhookSubscriptions.Include(s => s.EventTypes) - .FirstOrDefaultAsync( - s => s.Id == id && s.TenantId == _tenantProvider.TenantId && !s.IsDeleted, - ct - ); - } - - /// - public async Task> GetActiveByEventTypeAsync( - string eventType, - Guid tenantId, - CancellationToken ct = default - ) - { - return await _dbContext - .WebhookSubscriptions.Where(s => s.IsActive && !s.IsDeleted && s.TenantId == tenantId) - .Where(s => s.EventTypes.Any(et => et.EventType == eventType)) - .ToListAsync(ct); - } - - /// - public async Task> GetAllAsync( - CancellationToken ct = default - ) - { - return await _dbContext - .WebhookSubscriptions.Include(s => s.EventTypes) - .Where(s => !s.IsDeleted && s.TenantId == _tenantProvider.TenantId) - .OrderByDescending(s => s.Audit.CreatedAtUtc) - .ToListAsync(ct); - } - - /// - public async Task AddAsync(WebhookSubscription subscription, CancellationToken ct = default) - { - await _dbContext.WebhookSubscriptions.AddAsync(subscription, ct); - } - - /// - public Task UpdateAsync(WebhookSubscription subscription, CancellationToken ct = default) - { - _dbContext.WebhookSubscriptions.Update(subscription); - return Task.CompletedTask; - } - - /// - public Task DeleteAsync(WebhookSubscription subscription, CancellationToken ct = default) - { - _dbContext.WebhookSubscriptions.Remove(subscription); - return Task.CompletedTask; - } - - /// - public async Task SaveChangesAsync(CancellationToken ct = default) - { - await _dbContext.SaveChangesAsync(ct); - } -} diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Webhooks.Infrastructure.csproj b/src/Services/Webhooks/Webhooks.Infrastructure/Webhooks.Infrastructure.csproj deleted file mode 100644 index f228ab2b..00000000 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Webhooks.Infrastructure.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/src/SharedKernel/SharedKernel.Api/Authorization/PermissionAuthorizationHandler.cs b/src/SharedKernel/SharedKernel.Api/Authorization/PermissionAuthorizationHandler.cs deleted file mode 100644 index 2fc27af0..00000000 --- a/src/SharedKernel/SharedKernel.Api/Authorization/PermissionAuthorizationHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Authorization; -using SharedKernel.Application.Security; - -namespace SharedKernel.Api.Authorization; - -/// -/// ASP.NET Core authorization handler that evaluates a -/// by checking the current user's role claims against the application's role-permission map. -/// -public sealed class PermissionAuthorizationHandler : AuthorizationHandler -{ - private readonly IRolePermissionMap _rolePermissionMap; - - public PermissionAuthorizationHandler(IRolePermissionMap rolePermissionMap) - { - _rolePermissionMap = rolePermissionMap; - } - - /// - /// Succeeds the requirement when at least one of the user's role claims grants the required permission. - /// - protected override Task HandleRequirementAsync( - AuthorizationHandlerContext context, - PermissionRequirement requirement - ) - { - IEnumerable roleClaims = context.User.FindAll(ClaimTypes.Role); - - foreach (Claim roleClaim in roleClaims) - { - if (_rolePermissionMap.HasPermission(roleClaim.Value, requirement.Permission)) - { - context.Succeed(requirement); - break; - } - } - - return Task.CompletedTask; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Authorization/PermissionAuthorizationOptions.cs b/src/SharedKernel/SharedKernel.Api/Authorization/PermissionAuthorizationOptions.cs deleted file mode 100644 index 10da35b8..00000000 --- a/src/SharedKernel/SharedKernel.Api/Authorization/PermissionAuthorizationOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SharedKernel.Api.Authorization; - -public sealed class PermissionAuthorizationOptions -{ - public IReadOnlyList AuthenticationSchemes { get; set; } = []; -} diff --git a/src/SharedKernel/SharedKernel.Api/Authorization/PermissionPolicyProvider.cs b/src/SharedKernel/SharedKernel.Api/Authorization/PermissionPolicyProvider.cs deleted file mode 100644 index 87b95a28..00000000 --- a/src/SharedKernel/SharedKernel.Api/Authorization/PermissionPolicyProvider.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.Options; -using SharedKernel.Application.Security; - -namespace SharedKernel.Api.Authorization; - -public sealed class PermissionPolicyProvider : IAuthorizationPolicyProvider -{ - private readonly DefaultAuthorizationPolicyProvider _fallback; - private readonly IReadOnlyList _authenticationSchemes; - private readonly ConcurrentDictionary _cache = new(); - - public PermissionPolicyProvider( - IOptions authorizationOptions, - IOptions permissionOptions - ) - { - _fallback = new DefaultAuthorizationPolicyProvider(authorizationOptions); - _authenticationSchemes = permissionOptions.Value.AuthenticationSchemes; - } - - public Task GetPolicyAsync(string policyName) - { - if (!Permission.All.Contains(policyName)) - return _fallback.GetPolicyAsync(policyName); - - AuthorizationPolicy policy = _cache.GetOrAdd( - policyName, - name => - { - AuthorizationPolicyBuilder builder = - _authenticationSchemes.Count > 0 - ? new AuthorizationPolicyBuilder(_authenticationSchemes.ToArray()) - : new AuthorizationPolicyBuilder(); - - return builder - .RequireAuthenticatedUser() - .AddRequirements(new PermissionRequirement(name)) - .Build(); - } - ); - - return Task.FromResult(policy); - } - - public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync(); - - public Task GetFallbackPolicyAsync() => - _fallback.GetFallbackPolicyAsync(); -} diff --git a/src/SharedKernel/SharedKernel.Api/Authorization/PermissionRequirement.cs b/src/SharedKernel/SharedKernel.Api/Authorization/PermissionRequirement.cs deleted file mode 100644 index 6a7036e9..00000000 --- a/src/SharedKernel/SharedKernel.Api/Authorization/PermissionRequirement.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace SharedKernel.Api.Authorization; - -/// -/// Authorization requirement that represents a named permission that a user must hold. -/// Evaluated by . -/// -public sealed record PermissionRequirement(string Permission) : IAuthorizationRequirement; diff --git a/src/SharedKernel/SharedKernel.Api/Authorization/RequirePermissionAttribute.cs b/src/SharedKernel/SharedKernel.Api/Authorization/RequirePermissionAttribute.cs deleted file mode 100644 index 001f83a6..00000000 --- a/src/SharedKernel/SharedKernel.Api/Authorization/RequirePermissionAttribute.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace SharedKernel.Api.Authorization; - -/// -/// Marks a controller or action as requiring a specific named permission. -/// The permission name is used as an ASP.NET Core authorization policy name, -/// which is evaluated by the policy-based authorization infrastructure. -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] -public sealed class RequirePermissionAttribute : AuthorizeAttribute -{ - /// - /// Initializes the attribute with the given permission name, applied as the authorization policy. - /// - public RequirePermissionAttribute(string permission) - : base(policy: permission) { } -} diff --git a/src/SharedKernel/SharedKernel.Api/Controllers/ApiControllerBase.cs b/src/SharedKernel/SharedKernel.Api/Controllers/ApiControllerBase.cs deleted file mode 100644 index 57b55336..00000000 --- a/src/SharedKernel/SharedKernel.Api/Controllers/ApiControllerBase.cs +++ /dev/null @@ -1,94 +0,0 @@ -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using SharedKernel.Api.ErrorOrMapping; -using SharedKernel.Application.DTOs; -using Wolverine; - -namespace SharedKernel.Api.Controllers; - -/// -/// Base controller for all API controllers, providing shared route conventions -/// and common response helpers. -/// -[ApiController] -[Route("api/v{version:apiVersion}/[controller]")] -public abstract class ApiControllerBase : ControllerBase -{ - internal ActionResult OkOrUnprocessable(BatchResponse response) => - response.FailureCount > 0 ? UnprocessableEntity(response) : Ok(response); - - /// Invokes a Wolverine message and maps to an HTTP result. - /// - /// Uses a single type argument so callers are not forced to specify - /// both response and message types (C# does not partially infer method type parameters). - /// - protected async Task> InvokeToActionResultAsync( - IMessageBus bus, - object message, - CancellationToken cancellationToken - ) - { - ErrorOr result = await bus.InvokeAsync>( - message, - cancellationToken - ); - return result.ToActionResult(this); - } - - /// Invokes a batch command/query and maps the result via . - protected async Task> InvokeToBatchResultAsync( - IMessageBus bus, - TMessage message, - CancellationToken cancellationToken - ) - { - ErrorOr result = await bus.InvokeAsync>( - message!, - cancellationToken - ); - return result.ToBatchResult(this); - } - - /// Invokes a command that returns and maps to 204 No Content. - protected async Task InvokeToNoContentResultAsync( - IMessageBus bus, - TMessage message, - CancellationToken cancellationToken - ) - { - ErrorOr result = await bus.InvokeAsync>( - message!, - cancellationToken - ); - return result.ToNoContentResult(this); - } - - /// Invokes a command that returns and maps to 200 OK with no body. - protected async Task InvokeToOkResultAsync( - IMessageBus bus, - TMessage message, - CancellationToken cancellationToken - ) - { - ErrorOr result = await bus.InvokeAsync>( - message!, - cancellationToken - ); - return result.ToOkResult(this); - } - - /// Invokes a command and maps success to 201 Created at GetById. - protected async Task> InvokeToCreatedResultAsync( - IMessageBus bus, - object message, - Func routeValuesFactory, - CancellationToken cancellationToken - ) - { - ErrorOr result = await bus.InvokeAsync>( - message, - cancellationToken - ); - return result.ToCreatedResult(this, routeValuesFactory); - } -} diff --git a/src/SharedKernel/SharedKernel.Api/ErrorOrMapping/ErrorOrExtensions.cs b/src/SharedKernel/SharedKernel.Api/ErrorOrMapping/ErrorOrExtensions.cs deleted file mode 100644 index a1c5205c..00000000 --- a/src/SharedKernel/SharedKernel.Api/ErrorOrMapping/ErrorOrExtensions.cs +++ /dev/null @@ -1,159 +0,0 @@ -using ErrorOr; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using SharedKernel.Api.Controllers; -using SharedKernel.Application.DTOs; - -namespace SharedKernel.Api.ErrorOrMapping; - -/// -/// Extension methods that convert results to -/// responses, producing the same RFC 7807 ProblemDetails format as . -/// -public static class ErrorOrExtensions -{ - /// Maps a successful result to 200 OK, or errors to ProblemDetails. - public static ActionResult ToActionResult( - this ErrorOr result, - ControllerBase controller - ) - { - if (!result.IsError) - return controller.Ok(result.Value); - - return ToProblemResult(result.Errors, controller); - } - - /// Maps a successful result to 201 Created, or errors to ProblemDetails. - public static ActionResult ToCreatedResult( - this ErrorOr result, - ApiControllerBase controller, - Func routeValuesFactory - ) - { - if (!result.IsError) - return controller.CreatedAtAction( - "GetById", - routeValuesFactory(result.Value), - result.Value - ); - - return ToProblemResult(result.Errors, controller); - } - - /// Maps a successful void result to 204 NoContent, or errors to ProblemDetails. - public static IActionResult ToNoContentResult( - this ErrorOr result, - ControllerBase controller - ) - { - if (!result.IsError) - return controller.NoContent(); - - return ToProblemDetails(result.Errors, controller); - } - - /// - /// Maps a successful batch result through , - /// or request-level errors to ProblemDetails. - /// - public static ActionResult ToBatchResult( - this ErrorOr result, - ApiControllerBase controller - ) - { - if (!result.IsError) - return controller.OkOrUnprocessable(result.Value); - - return ToProblemResult(result.Errors, controller); - } - - /// Maps a successful void result to 200 OK, or errors to ProblemDetails. - public static IActionResult ToOkResult(this ErrorOr result, ControllerBase controller) - { - if (!result.IsError) - return controller.Ok(); - - return ToProblemDetails(result.Errors, controller); - } - - /// - /// Returns ProblemDetails for the error case of any result. - /// Use when the success case is handled separately by the caller. - /// - public static IActionResult ToErrorResult(this ErrorOr result, ControllerBase controller) - { - return ToProblemDetails(result.Errors, controller); - } - - private static ActionResult ToProblemResult( - List errors, - ControllerBase controller - ) => ToProblemDetails(errors, controller); - - private static ObjectResult ToProblemDetails( - List errors, - ControllerBase controller - ) - { - ProblemDetails problemDetails = BuildProblemDetails(errors, controller.HttpContext); - return new ObjectResult(problemDetails) { StatusCode = problemDetails.Status }; - } - - internal static ProblemDetails BuildProblemDetails( - List errors, - HttpContext httpContext - ) - { - ErrorOr.Error firstError = errors[0]; - int statusCode = MapToStatusCode(firstError.Type); - string title = MapToTitle(firstError.Type); - string errorCode = firstError.Code; - string detail = firstError.Description; - - if (errors.Count > 1 && firstError.Type == ErrorType.Validation) - detail = string.Join(" ", errors.Select(e => e.Description)); - - ProblemDetails problemDetails = new ProblemDetails - { - Status = statusCode, - Title = title, - Detail = detail, - Instance = httpContext.Request.Path, - Type = BuildTypeUri(errorCode), - }; - - problemDetails.Extensions["errorCode"] = errorCode; - problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier; - - if (firstError.Metadata is { Count: > 0 }) - problemDetails.Extensions["metadata"] = firstError.Metadata; - - return problemDetails; - } - - private static int MapToStatusCode(ErrorType errorType) => - errorType switch - { - ErrorType.Validation => StatusCodes.Status400BadRequest, - ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, - ErrorType.Forbidden => StatusCodes.Status403Forbidden, - ErrorType.NotFound => StatusCodes.Status404NotFound, - ErrorType.Conflict => StatusCodes.Status409Conflict, - _ => StatusCodes.Status500InternalServerError, - }; - - private static string MapToTitle(ErrorType errorType) => - errorType switch - { - ErrorType.Validation => "Bad Request", - ErrorType.Unauthorized => "Unauthorized", - ErrorType.Forbidden => "Forbidden", - ErrorType.NotFound => "Not Found", - ErrorType.Conflict => "Conflict", - _ => "Internal Server Error", - }; - - private static string BuildTypeUri(string errorCode) => - $"https://api-template.local/errors/{errorCode}"; -} diff --git a/src/SharedKernel/SharedKernel.Api/ErrorOrMapping/ErrorOrHttpExtensions.cs b/src/SharedKernel/SharedKernel.Api/ErrorOrMapping/ErrorOrHttpExtensions.cs deleted file mode 100644 index 6765a77d..00000000 --- a/src/SharedKernel/SharedKernel.Api/ErrorOrMapping/ErrorOrHttpExtensions.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ErrorOr; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace SharedKernel.Api.ErrorOrMapping; - -/// -/// Maps to minimal / Wolverine HTTP using the same ProblemDetails shape as MVC helpers. -/// -public static class ErrorOrHttpExtensions -{ - /// Maps a list of entries to an RFC 7807 JSON response. - public static IResult ToProblemDetailsIResult( - this List errors, - HttpContext httpContext - ) - { - ProblemDetails problemDetails = ErrorOrExtensions.BuildProblemDetails(errors, httpContext); - return Results.Json( - problemDetails, - statusCode: problemDetails.Status ?? StatusCodes.Status500InternalServerError, - contentType: "application/problem+json" - ); - } - - public static IResult ToIResult( - this ErrorOr result, - HttpContext httpContext, - Func onSuccess - ) - { - if (!result.IsError) - return onSuccess(result.Value); - - return result.Errors.ToProblemDetailsIResult(httpContext); - } -} diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs deleted file mode 100644 index 4aa4681a..00000000 --- a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs +++ /dev/null @@ -1,210 +0,0 @@ -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SharedKernel.Application.Errors; -using SharedKernel.Domain.Exceptions; - -namespace SharedKernel.Api.ExceptionHandling; - -/// -/// Central REST exception translator for the HTTP pipeline. -/// -/// -/// Converts domain/application exceptions to RFC7807 -/// responses with stable status codes and error codes. -/// -public sealed class ApiExceptionHandler : IExceptionHandler -{ - private const int ClientClosedRequestStatusCode = 499; - private readonly ILogger _logger; - private readonly IProblemDetailsService _problemDetailsService; - - public ApiExceptionHandler( - ILogger logger, - IProblemDetailsService problemDetailsService - ) - { - _logger = logger; - _problemDetailsService = problemDetailsService; - } - - /// - /// Maps an exception to HTTP status + payload metadata, logs it with severity by status code, - /// and writes an RFC7807 response through . - /// - public async ValueTask TryHandleAsync( - HttpContext context, - Exception exception, - CancellationToken cancellationToken - ) - { - if (IsClientAbortedRequest(context, exception, cancellationToken)) - { - if (!context.Response.HasStarted) - context.Response.StatusCode = ClientClosedRequestStatusCode; - - return true; - } - - ( - int statusCode, - string title, - string detail, - string errorCode, - IReadOnlyDictionary? metadata - ) = Resolve(exception); - - ProblemDetails problemDetails = new ProblemDetails - { - Status = statusCode, - Title = title, - Detail = detail, - Instance = context.Request.Path, - Type = BuildTypeUri(errorCode), - }; - - problemDetails.Extensions["errorCode"] = errorCode; - if (metadata is not null && metadata.Count > 0) - problemDetails.Extensions["metadata"] = metadata; - - if (statusCode >= StatusCodes.Status500InternalServerError) - { - _logger.LogError( - exception, - "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}", - statusCode, - errorCode, - context.TraceIdentifier - ); - } - else - { - _logger.LogWarning( - exception, - "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}", - statusCode, - errorCode, - context.TraceIdentifier - ); - } - - context.Response.StatusCode = statusCode; - bool wasWritten = await _problemDetailsService.TryWriteAsync( - new ProblemDetailsContext - { - HttpContext = context, - Exception = exception, - ProblemDetails = problemDetails, - } - ); - - return wasWritten; - } - - private static bool IsClientAbortedRequest( - HttpContext context, - Exception exception, - CancellationToken cancellationToken - ) => - exception is OperationCanceledException - && ( - context.RequestAborted.IsCancellationRequested - || cancellationToken.IsCancellationRequested - ); - - private static ( - int StatusCode, - string Title, - string Detail, - string ErrorCode, - IReadOnlyDictionary? Metadata - ) Resolve(Exception exception) - { - if (exception is AppException appException) - { - (int statusCode, string title, string defaultErrorCode) = MapToHttp(appException); - string errorCode = ResolveErrorCode(appException, defaultErrorCode); - - return (statusCode, title, appException.Message, errorCode, appException.Metadata); - } - - if (exception is DbUpdateConcurrencyException) - { - return ( - StatusCodes.Status409Conflict, - "Conflict", - "The resource was modified by another request. Please retrieve the latest version and retry.", - ErrorCatalog.General.ConcurrencyConflict, - null - ); - } - - return ( - StatusCodes.Status500InternalServerError, - "Internal Server Error", - "An unexpected error occurred.", - ErrorCatalog.General.Unknown, - null - ); - } - - private static (int StatusCode, string Title, string ErrorCode) MapToHttp( - AppException appException - ) => - appException switch - { - ValidationException => ( - StatusCodes.Status400BadRequest, - "Bad Request", - ErrorCatalog.General.ValidationFailed - ), - UnauthorizedException => ( - StatusCodes.Status401Unauthorized, - "Unauthorized", - ErrorCatalog.General.Unknown - ), - ForbiddenException => ( - StatusCodes.Status403Forbidden, - "Forbidden", - ErrorCatalog.Auth.Forbidden - ), - NotFoundException => ( - StatusCodes.Status404NotFound, - "Not Found", - ErrorCatalog.General.NotFound - ), - ConflictException => ( - StatusCodes.Status409Conflict, - "Conflict", - ErrorCatalog.General.Conflict - ), - _ => ( - StatusCodes.Status500InternalServerError, - "Internal Server Error", - ErrorCatalog.General.Unknown - ), - }; - - private static string ResolveErrorCode(AppException appException, string defaultErrorCode) - { - if (!string.IsNullOrWhiteSpace(appException.ErrorCode)) - return appException.ErrorCode!; - - if ( - appException.Metadata is not null - && appException.Metadata.TryGetValue("errorCode", out object? metadataErrorCode) - && metadataErrorCode is string value - && !string.IsNullOrWhiteSpace(value) - ) - { - return value; - } - - return defaultErrorCode; - } - - private static string BuildTypeUri(string errorCode) => - $"https://api-template.local/errors/{errorCode}"; -} diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiProblemDetailsOptions.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiProblemDetailsOptions.cs deleted file mode 100644 index ec5ad18e..00000000 --- a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiProblemDetailsOptions.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.AspNetCore.Http; -using SharedKernel.Application.Errors; - -namespace SharedKernel.Api.ExceptionHandling; - -/// -/// Provides a static configuration helper that customizes the global -/// for the API presentation layer. -/// -public static class ApiProblemDetailsOptions -{ - /// - /// Configures global enrichment for API responses. - /// - /// - /// Adds a request trace identifier, guarantees an errorCode fallback, and - /// ensures a stable type URI shape for error documentation. - /// - public static void Configure(ProblemDetailsOptions options) - { - options.CustomizeProblemDetails = context => - { - IDictionary extensions = context.ProblemDetails.Extensions; - extensions["traceId"] = context.HttpContext.TraceIdentifier; - - // Preserve errorCode set by upstream handlers; only fall back when not provided. - string errorCode = - extensions.TryGetValue("errorCode", out object? existingErrorCode) - && existingErrorCode is string existing - ? existing - : ErrorCatalog.General.Unknown; - - extensions["errorCode"] = errorCode; - context.ProblemDetails.Type ??= $"https://api-template.local/errors/{errorCode}"; - }; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ApiErrorHandlingExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ApiErrorHandlingExtensions.cs deleted file mode 100644 index 6a3e80fd..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ApiErrorHandlingExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using SharedKernel.Api.ExceptionHandling; - -namespace SharedKernel.Api.Extensions; - -/// -/// Registers RFC 7807 ProblemDetails and the shared exception handler. -/// Call this in every API host — regardless of whether it uses EF Core. -/// -public static class ApiErrorHandlingExtensions -{ - /// - /// Registers RFC 7807 ProblemDetails and the shared . - /// - public static IServiceCollection AddSharedApiErrorHandling(this IServiceCollection services) - { - services.AddProblemDetails(ApiProblemDetailsOptions.Configure); - services.AddExceptionHandler(); - return services; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/AuthorizationExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/AuthorizationExtensions.cs deleted file mode 100644 index 0a7fa20a..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/AuthorizationExtensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.DependencyInjection; -using SharedKernel.Api.Authorization; - -namespace SharedKernel.Api.Extensions; - -public static class AuthorizationExtensions -{ - public static IServiceCollection AddSharedAuthorization( - this IServiceCollection services, - IEnumerable? authenticationSchemes = null, - bool enablePermissionPolicies = false - ) - { - string[] schemes = - authenticationSchemes?.Where(scheme => !string.IsNullOrWhiteSpace(scheme)).ToArray() - ?? [JwtBearerDefaults.AuthenticationScheme]; - - services.Configure(options => - { - options.AuthenticationSchemes = schemes; - }); - - AuthorizationPolicyBuilder fallbackPolicyBuilder = - schemes.Length > 0 - ? new AuthorizationPolicyBuilder(schemes) - : new AuthorizationPolicyBuilder(); - - services - .AddAuthorizationBuilder() - .SetFallbackPolicy(fallbackPolicyBuilder.RequireAuthenticatedUser().Build()); - - if (enablePermissionPolicies) - { - services.AddSingleton(); - services.AddSingleton(); - } - - return services; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ConfigurationExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ConfigurationExtensions.cs deleted file mode 100644 index 0fc3f4d7..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ConfigurationExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace SharedKernel.Api.Extensions; - -public static class ConfigurationExtensions -{ - public static string GetRequiredConnectionString( - this IConfiguration configuration, - string connectionStringName - ) => - configuration.GetConnectionString(connectionStringName) - ?? throw new InvalidOperationException( - $"Connection string '{connectionStringName}' is not configured." - ); - - public static TOptions GetRequiredOptions( - this IConfiguration configuration, - string sectionName - ) - where TOptions : class => - configuration.GetRequiredSection(sectionName).Get() - ?? throw new InvalidOperationException( - $"Configuration section '{sectionName}' is invalid or missing required values." - ); -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ControllerExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ControllerExtensions.cs deleted file mode 100644 index 8cc40c84..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ControllerExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace SharedKernel.Api.Extensions; - -/// -/// Presentation-layer helper extensions for providing -/// convenient access to API versioning metadata. -/// -public static class ControllerExtensions -{ - /// - /// Returns the API version string (e.g. "1") from the current request context, - /// used when building Location headers for Created/Accepted responses. - /// - public static string GetApiVersion(this ControllerBase controller) => - controller.HttpContext.GetRequestedApiVersion()!.ToString(); -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs deleted file mode 100644 index 393d0490..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -namespace SharedKernel.Api.Extensions; - -public static class HostExtensions -{ - public static async Task MigrateDbAsync(this WebApplication app) - where TDbContext : DbContext - { - using AsyncServiceScope scope = app.Services.CreateAsyncScope(); - TDbContext dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.MigrateAsync(); - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs deleted file mode 100644 index 9e7fc35e..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.IdentityModel.Tokens; -using SharedKernel.Application.Security; - -namespace SharedKernel.Api.Extensions; - -public static class KeycloakAuthExtensions -{ - public static AuthenticationBuilder AddSharedKeycloakJwtBearer( - this IServiceCollection services, - IConfiguration configuration, - IHostEnvironment environment, - bool requireTenantClaim = true, - Action? configureOptions = null - ) - { - AuthenticationBuilder authBuilder = services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - IConfigurationSection keycloak = configuration.GetRequiredSection("Keycloak"); - string authServerUrl = GetRequiredValue(keycloak, "auth-server-url"); - string realm = GetRequiredValue(keycloak, "realm"); - string resource = GetRequiredValue(keycloak, "resource"); - - options.Authority = BuildAuthority(authServerUrl, realm); - options.Audience = resource; - options.RequireHttpsMetadata = !environment.IsDevelopment(); - options.TokenValidationParameters = BuildTokenValidationParameters( - environment.IsDevelopment() - ); - - configureOptions?.Invoke(options); - WrapTokenValidated(options, requireTenantClaim); - }); - - return authBuilder; - } - - public static string BuildAuthority(string authServerUrl, string realm) => - $"{authServerUrl.TrimEnd('/')}/realms/{realm}"; - - private static TokenValidationParameters BuildTokenValidationParameters(bool isDevelopment) => - new() - { - LogTokenId = isDevelopment, - LogValidationExceptions = isDevelopment, - RequireExpirationTime = true, - RequireSignedTokens = true, - RequireAudience = true, - SaveSigninToken = false, - TryAllIssuerSigningKeys = true, - ValidateActor = false, - ValidateIssuer = true, - ValidateAudience = true, - ValidateIssuerSigningKey = true, - ValidateLifetime = true, - ValidateTokenReplay = false, - ClockSkew = TimeSpan.FromMinutes(5), - }; - - private static void WrapTokenValidated(JwtBearerOptions options, bool requireTenantClaim) - { - JwtBearerEvents existingEvents = options.Events ?? new JwtBearerEvents(); - Func? existingHandler = existingEvents.OnTokenValidated; - - options.Events = new JwtBearerEvents - { - OnTokenValidated = async context => - { - KeycloakClaimsPrincipalMapper.MapClaims(context.Principal); - - if (existingHandler is not null) - await existingHandler(context); - - if (requireTenantClaim && !HasValidTenantClaim(context.Principal)) - context.Fail($"Missing required {SharedAuthConstants.Claims.TenantId} claim."); - }, - OnAuthenticationFailed = existingEvents.OnAuthenticationFailed, - OnChallenge = existingEvents.OnChallenge, - OnForbidden = existingEvents.OnForbidden, - OnMessageReceived = existingEvents.OnMessageReceived, - }; - } - - private static bool HasValidTenantClaim(ClaimsPrincipal? principal) => - principal?.HasClaim(c => - c.Type == SharedAuthConstants.Claims.TenantId - && Guid.TryParse(c.Value, out Guid tenantId) - && tenantId != Guid.Empty - ) == true - || IsServiceAccount(principal); - - private static bool IsServiceAccount(ClaimsPrincipal? principal) - { - string? username = principal?.FindFirstValue( - SharedAuthConstants.KeycloakClaims.PreferredUsername - ); - return username != null - && username.StartsWith( - SharedAuthConstants.KeycloakClaims.ServiceAccountUsernamePrefix, - StringComparison.OrdinalIgnoreCase - ); - } - - private static string GetRequiredValue(IConfigurationSection section, string key) => - !string.IsNullOrWhiteSpace(section[key]) - ? section[key]! - : throw new InvalidOperationException( - $"Configuration key '{section.Path}:{key}' is required." - ); -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs deleted file mode 100644 index 423ab013..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Npgsql; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; - -namespace SharedKernel.Api.Extensions; - -public static class ObservabilityExtensions -{ - public static IServiceCollection AddSharedObservability( - this IServiceCollection services, - IConfiguration configuration, - IHostEnvironment environment, - string serviceName - ) - { - string otlpEndpoint = configuration["Observability:Otlp:Endpoint"] ?? "http://alloy:4317"; - - services - .AddOpenTelemetry() - .ConfigureResource(resource => - resource.AddService(serviceName: serviceName, serviceVersion: "1.0.0") - ) - .WithTracing(tracing => - { - tracing - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddNpgsql() - .AddSource("Wolverine"); - - if (environment.IsDevelopment()) - tracing.AddConsoleExporter(); - - tracing.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); - }) - .WithMetrics(metrics => - { - metrics - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddProcessInstrumentation() - .AddMeter("Wolverine"); - - metrics.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); - }); - - return services; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/OpenApiExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/OpenApiExtensions.cs deleted file mode 100644 index 8fd7056b..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/OpenApiExtensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using SharedKernel.Api.OpenApi; -using SharedKernel.Application.Security; - -namespace SharedKernel.Api.Extensions; - -public static class OpenApiExtensions -{ - /// - /// Registers the shared OpenAPI pipeline (OAuth2 scheme + standardized error responses). - /// - public static IServiceCollection AddSharedOpenApiDocumentation(this IServiceCollection services) - { - services.AddOpenApi( - SharedAuthConstants.OpenApi.DefaultDocumentName, - options => - { - options.AddDocumentTransformer(); - options.AddDocumentTransformer(); - options.AddOperationTransformer(); - } - ); - - return services; - } - - /// - /// Maps the default OpenAPI JSON endpoint in development. - /// - public static WebApplication MapSharedOpenApiEndpoint(this WebApplication app) - { - if (!app.Environment.IsDevelopment()) - { - return app; - } - - app.MapOpenApi().AllowAnonymous(); - return app; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/OptionsRegistrationExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/OptionsRegistrationExtensions.cs deleted file mode 100644 index 7abee892..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/OptionsRegistrationExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace SharedKernel.Api.Extensions; - -public static class OptionsRegistrationExtensions -{ - public static OptionsBuilder AddValidatedOptions( - this IServiceCollection services, - IConfiguration configuration, - string sectionName - ) - where TOptions : class - { - IConfigurationSection section = configuration.GetRequiredSection(sectionName); - - return services - .AddOptions() - .Bind(section) - .ValidateDataAnnotations() - .ValidateOnStart(); - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs deleted file mode 100644 index e26316dc..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.OutputCaching; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using SharedKernel.Api.OutputCaching; -using SharedKernel.Application.Common.Events; - -namespace SharedKernel.Api.Extensions; - -public static class OutputCachingExtensions -{ - public static IServiceCollection AddSharedOutputCaching( - this IServiceCollection services, - IConfiguration configuration - ) - { - string? dragonflyConnectionString = configuration.GetConnectionString("Dragonfly"); - - if (!string.IsNullOrWhiteSpace(dragonflyConnectionString)) - { - services.AddStackExchangeRedisOutputCache(options => - { - options.Configuration = dragonflyConnectionString; - options.InstanceName = RedisInstanceNames.OutputCache; - }); - } - else - { - services.AddOutputCache(); - } - - services.AddScoped(); - - services - .AddOptions() - .Bind(configuration.GetSection(CachingOptions.SectionName)) - .ValidateDataAnnotations() - .ValidateOnStart(); - - services.AddSingleton>(sp => - { - CachingOptions cachingOptions = sp.GetRequiredService>().Value; - - return new ConfigureOptions(options => - { - options.AddBasePolicy(builder => builder.NoCache()); - - ReadOnlySpan<(string Name, int ExpirationSeconds)> policies = - [ - (CacheTags.Products, cachingOptions.ProductsExpirationSeconds), - (CacheTags.Categories, cachingOptions.CategoriesExpirationSeconds), - (CacheTags.Reviews, cachingOptions.ReviewsExpirationSeconds), - (CacheTags.ProductData, cachingOptions.ProductDataExpirationSeconds), - (CacheTags.Tenants, cachingOptions.TenantsExpirationSeconds), - ( - CacheTags.TenantInvitations, - cachingOptions.TenantInvitationsExpirationSeconds - ), - (CacheTags.Users, cachingOptions.UsersExpirationSeconds), - (CacheTags.Files, cachingOptions.FilesExpirationSeconds), - ]; - - foreach (var (name, expirationSeconds) in policies) - { - options.AddPolicy( - name, - new TenantAwareOutputCachePolicy( - name, - TimeSpan.FromSeconds(expirationSeconds) - ) - ); - } - }); - }); - - return services; - } - - public static WebApplication UseSharedOutputCaching(this WebApplication app) - { - app.UseOutputCache(); - return app; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs deleted file mode 100644 index 12f7f670..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Serilog; - -namespace SharedKernel.Api.Extensions; - -public static class SerilogExtensions -{ - public static IHostBuilder UseSharedSerilog(this IHostBuilder hostBuilder) - { - return hostBuilder.UseSerilog( - (context, services, loggerConfiguration) => - { - loggerConfiguration - .ReadFrom.Configuration(context.Configuration) - .ReadFrom.Services(services) - .Enrich.FromLogContext() - .Enrich.WithProperty("Application", context.HostingEnvironment.ApplicationName); - } - ); - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs b/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs deleted file mode 100644 index 8ea4abff..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Asp.Versioning; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SharedKernel.Api.Security; -using SharedKernel.Application.Context; -using SharedKernel.Application.Options; -using SharedKernel.Domain.Interfaces; -using SharedKernel.Infrastructure.Persistence.Auditing; -using SharedKernel.Infrastructure.Persistence.SoftDelete; -using SharedKernel.Infrastructure.Persistence.UnitOfWork; - -namespace SharedKernel.Api.Extensions; - -/// -/// Centralized registration of shared infrastructure services used by tenant-aware microservices -/// that rely on EF Core, UnitOfWork, multi-tenancy, and auditing. -/// -public static class SharedServiceRegistration -{ - /// - /// Registers the shared infrastructure services: UnitOfWork, transaction provider, - /// auditable entity state manager, soft-delete processor, HTTP context accessor, - /// tenant/actor providers, TimeProvider, and API versioning. - /// - /// The concrete EF Core DbContext for the calling service. - public static IServiceCollection AddSharedInfrastructure( - this IServiceCollection services, - IConfiguration configuration - ) - where TDbContext : DbContext - { - // Transaction & Unit of Work - services.AddValidatedOptions( - configuration, - TransactionDefaultsOptions.SectionName - ); - services.AddScoped(); - services.AddScoped(sp => new UnitOfWork( - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService() - )); - - // Auditing & Soft Delete - services.AddScoped(); - services.AddScoped(); - - // Context providers - services.AddHttpContextAccessor(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(TimeProvider.System); - - // Exception handling & ProblemDetails (RFC 7807) - services.AddSharedApiErrorHandling(); - - // API versioning - services - .AddApiVersioning(options => - { - options.DefaultApiVersion = new ApiVersion(1, 0); - options.AssumeDefaultVersionWhenUnspecified = true; - options.ReportApiVersions = true; - }) - .AddApiExplorer(options => - { - options.GroupNameFormat = "'v'VVV"; - options.SubstituteApiVersionInUrl = true; - }); - - return services; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs deleted file mode 100644 index c3a0c7d9..00000000 --- a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace SharedKernel.Api.Extensions; - -/// -/// Shared HTTP pipeline helpers for microservice API hosts. -/// -public static class WebApplicationPipelineExtensions -{ - /// - /// Registers the outermost exception handler and authentication middleware. - /// Call this first, then add any service-specific middleware (e.g. CSRF), - /// then call . - /// - public static WebApplication UseSharedExceptionHandlerAndAuthentication(this WebApplication app) - { - app.UseExceptionHandler(); - app.UseAuthentication(); - return app; - } - - /// - /// Registers authorization, optional output cache, OpenAPI endpoint, and health checks. - /// Call this after and any service-specific middleware. - /// Map your endpoints (MapControllers, MapWolverineEndpoints, …) after this call. - /// - public static WebApplication UseSharedAuthorizationCachingAndInfrastructure( - this WebApplication app, - bool useOutputCaching - ) - { - app.UseAuthorization(); - - if (useOutputCaching) - app.UseSharedOutputCaching(); - - app.MapSharedOpenApiEndpoint(); - app.MapHealthChecks("/health").AllowAnonymous(); - - return app; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Filters/Idempotency/IdempotencyActionFilter.cs b/src/SharedKernel/SharedKernel.Api/Filters/Idempotency/IdempotencyActionFilter.cs deleted file mode 100644 index f80ddaac..00000000 --- a/src/SharedKernel/SharedKernel.Api/Filters/Idempotency/IdempotencyActionFilter.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Net.Mime; -using System.Text.Json; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using SharedKernel.Application.Contracts; - -namespace SharedKernel.Api.Filters.Idempotency; - -/// -/// Action filter that enforces idempotency for endpoints decorated with . -/// On the first call the response is stored in ; subsequent calls with -/// the same Idempotency-Key header replay the cached response without re-executing the action. -/// -public sealed class IdempotencyActionFilter : IAsyncActionFilter -{ - private readonly IIdempotencyStore _store; - - public IdempotencyActionFilter(IIdempotencyStore store) - { - _store = store; - } - - /// - /// Intercepts the action execution to check for a cached idempotent result or to store - /// a new one, ensuring at-most-once semantics for the decorated endpoint. - /// - public async Task OnActionExecutionAsync( - ActionExecutingContext context, - ActionExecutionDelegate next - ) - { - IdempotentAttribute? attribute = context - .ActionDescriptor.EndpointMetadata.OfType() - .FirstOrDefault(); - - if (attribute is null) - { - await next(); - return; - } - - if ( - !context.HttpContext.Request.Headers.TryGetValue( - IdempotencyConstants.HeaderName, - out Microsoft.Extensions.Primitives.StringValues keyValues - ) || string.IsNullOrWhiteSpace(keyValues) - ) - { - context.Result = new BadRequestObjectResult( - "Idempotency-Key header is required for this endpoint." - ); - return; - } - - string key = keyValues.ToString(); - if (key.Length > IdempotencyConstants.MaxKeyLength) - { - context.Result = new BadRequestObjectResult( - $"Idempotency key must not exceed {IdempotencyConstants.MaxKeyLength} characters." - ); - return; - } - - TimeSpan resultTtl = TimeSpan.FromHours(attribute.TtlHours); - TimeSpan lockTimeout = TimeSpan.FromSeconds(attribute.LockTimeoutSeconds); - CancellationToken ct = context.HttpContext.RequestAborted; - - IdempotencyCacheEntry? existing = await _store.TryGetAsync(key, ct); - if (existing is not null) - { - if (existing.LocationHeader is not null) - context.HttpContext.Response.Headers.Location = existing.LocationHeader; - - context.Result = new ContentResult - { - StatusCode = existing.StatusCode, - Content = existing.ResponseBody, - ContentType = existing.ResponseContentType, - }; - return; - } - - if (!await _store.TryAcquireAsync(key, lockTimeout, ct)) - { - context.Result = new ConflictObjectResult( - "A request with this idempotency key is already being processed." - ); - return; - } - - ActionExecutedContext executedContext; - try - { - executedContext = await next(); - } - catch - { - await _store.ReleaseAsync(key, ct); - throw; - } - - if ( - executedContext.Result is ObjectResult objectResult - && objectResult.StatusCode is >= 200 and < 300 - ) - { - string? responseBody = objectResult.Value is not null - ? JsonSerializer.Serialize(objectResult.Value, JsonSerializerOptions.Web) - : null; - - string? locationHeader = executedContext.Result switch - { - CreatedResult cr => cr.Location, - _ => null, - }; - - IdempotencyCacheEntry entry = new IdempotencyCacheEntry( - objectResult.StatusCode ?? 200, - responseBody, - MediaTypeNames.Application.Json, - locationHeader - ); - - await _store.SetAsync(key, entry, resultTtl, ct); - } - - await _store.ReleaseAsync(key, ct); - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Filters/Idempotency/IdempotencyConstants.cs b/src/SharedKernel/SharedKernel.Api/Filters/Idempotency/IdempotencyConstants.cs deleted file mode 100644 index 7dd20b8e..00000000 --- a/src/SharedKernel/SharedKernel.Api/Filters/Idempotency/IdempotencyConstants.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SharedKernel.Api.Filters.Idempotency; - -/// -/// Shared constants for the idempotency feature: header name, key constraints, and default timeouts. -/// -public static class IdempotencyConstants -{ - public const string HeaderName = "Idempotency-Key"; - public const int DefaultTtlHours = 24; - public const int LockTimeoutSeconds = 30; - public const int MaxKeyLength = 100; -} diff --git a/src/SharedKernel/SharedKernel.Api/Filters/Idempotency/IdempotentAttribute.cs b/src/SharedKernel/SharedKernel.Api/Filters/Idempotency/IdempotentAttribute.cs deleted file mode 100644 index f940a2d3..00000000 --- a/src/SharedKernel/SharedKernel.Api/Filters/Idempotency/IdempotentAttribute.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SharedKernel.Api.Filters.Idempotency; - -/// -/// Marks an action method as idempotent, enabling the -/// to store and replay responses using the Idempotency-Key request header. -/// -[AttributeUsage(AttributeTargets.Method)] -public sealed class IdempotentAttribute : Attribute -{ - public int TtlHours { get; set; } = IdempotencyConstants.DefaultTtlHours; - public int LockTimeoutSeconds { get; set; } = IdempotencyConstants.LockTimeoutSeconds; -} diff --git a/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs b/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs deleted file mode 100644 index 89af7ba8..00000000 --- a/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs +++ /dev/null @@ -1,67 +0,0 @@ -using FluentValidation; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace SharedKernel.Api.Filters.Validation; - -/// -/// Global action filter that automatically validates all action arguments using FluentValidation. -/// Runs before every controller action. If a registered exists for an -/// argument type, it is resolved from DI and executed. On failure, returns HTTP 400 with a -/// body -- the controller method is never invoked. -/// Arguments without a registered validator are silently skipped. -/// -public sealed class FluentValidationActionFilter : IAsyncActionFilter -{ - private readonly IServiceProvider _serviceProvider; - - public FluentValidationActionFilter(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - } - - /// - /// Iterates over all action arguments, resolves a matching from DI - /// for each, and short-circuits with HTTP 400 if any validation fails. - /// - public async Task OnActionExecutionAsync( - ActionExecutingContext context, - ActionExecutionDelegate next - ) - { - foreach (object? argument in context.ActionArguments.Values) - { - if (argument is null) - continue; - - Type argumentType = argument.GetType(); - Type validatorType = typeof(IValidator<>).MakeGenericType(argumentType); - IValidator? validator = _serviceProvider.GetService(validatorType) as IValidator; - - if (validator is null) - continue; - - ValidationContext validationContext = new ValidationContext(argument); - FluentValidation.Results.ValidationResult result = await validator.ValidateAsync( - validationContext, - context.HttpContext.RequestAborted - ); - - if (result.IsValid) - continue; - - foreach (FluentValidation.Results.ValidationFailure error in result.Errors) - context.ModelState.AddModelError(error.PropertyName, error.ErrorMessage); - } - - if (!context.ModelState.IsValid) - { - context.Result = new BadRequestObjectResult( - new ValidationProblemDetails(context.ModelState) - ); - return; - } - - await next(); - } -} diff --git a/src/SharedKernel/SharedKernel.Api/OpenApi/AuthorizationResponsesOperationTransformer.cs b/src/SharedKernel/SharedKernel.Api/OpenApi/AuthorizationResponsesOperationTransformer.cs deleted file mode 100644 index 055993e1..00000000 --- a/src/SharedKernel/SharedKernel.Api/OpenApi/AuthorizationResponsesOperationTransformer.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi; - -namespace SharedKernel.Api.OpenApi; - -/// -/// Adds 401/403 responses for endpoints that require authorization. -/// -public sealed class AuthorizationResponsesOperationTransformer : IOpenApiOperationTransformer -{ - public Task TransformAsync( - OpenApiOperation operation, - OpenApiOperationTransformerContext context, - CancellationToken cancellationToken - ) - { - var endpointMetadata = context.Description.ActionDescriptor.EndpointMetadata; - bool hasAllowAnonymous = endpointMetadata.OfType().Any(); - bool hasAuthorize = endpointMetadata.OfType().Any(); - - if (hasAuthorize && !hasAllowAnonymous) - { - OpenApiErrorResponseHelper.AddErrorResponse( - operation, - StatusCodes.Status401Unauthorized - ); - OpenApiErrorResponseHelper.AddErrorResponse(operation, StatusCodes.Status403Forbidden); - } - - return Task.CompletedTask; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs b/src/SharedKernel/SharedKernel.Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs deleted file mode 100644 index c2e3d779..00000000 --- a/src/SharedKernel/SharedKernel.Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.Extensions.Configuration; -using Microsoft.OpenApi; -using SharedKernel.Api.Extensions; -using SharedKernel.Application.Security; - -namespace SharedKernel.Api.OpenApi; - -/// -/// Adds a Keycloak OAuth2 authorization code security scheme to OpenAPI documents. -/// -public sealed class BearerSecuritySchemeDocumentTransformer : IOpenApiDocumentTransformer -{ - private readonly IAuthenticationSchemeProvider _schemeProvider; - private readonly IConfiguration _configuration; - - public BearerSecuritySchemeDocumentTransformer( - IAuthenticationSchemeProvider schemeProvider, - IConfiguration configuration - ) - { - _schemeProvider = schemeProvider; - _configuration = configuration; - } - - public async Task TransformAsync( - OpenApiDocument document, - OpenApiDocumentTransformerContext context, - CancellationToken cancellationToken - ) - { - IEnumerable schemes = await _schemeProvider.GetAllSchemesAsync(); - if (!schemes.Any(s => s.Name == JwtBearerDefaults.AuthenticationScheme)) - { - return; - } - - IConfigurationSection keycloak = _configuration.GetSection("Keycloak"); - string authServerUrl = keycloak["auth-server-url"] ?? string.Empty; - string realm = keycloak["realm"] ?? string.Empty; - if (string.IsNullOrWhiteSpace(authServerUrl) || string.IsNullOrWhiteSpace(realm)) - { - return; - } - - string authority = KeycloakAuthExtensions.BuildAuthority(authServerUrl, realm); - var securityScheme = new OpenApiSecurityScheme - { - Type = SecuritySchemeType.OAuth2, - Description = "Keycloak OAuth2 Authorization Code flow", - Flows = new OpenApiOAuthFlows - { - AuthorizationCode = new OpenApiOAuthFlow - { - AuthorizationUrl = new Uri( - $"{authority}/{SharedAuthConstants.OpenIdConnect.AuthorizationEndpointPath}" - ), - TokenUrl = new Uri( - $"{authority}/{SharedAuthConstants.OpenIdConnect.TokenEndpointPath}" - ), - Scopes = new Dictionary - { - [SharedAuthConstants.Scopes.OpenId] = "OpenID Connect", - [SharedAuthConstants.Scopes.Profile] = "User profile", - [SharedAuthConstants.Scopes.Email] = "Email address", - }, - }, - }, - }; - - var components = document.Components ??= new OpenApiComponents(); - components.SecuritySchemes ??= new Dictionary(); - components.SecuritySchemes[SharedAuthConstants.OpenApi.OAuth2Scheme] = securityScheme; - - var requirement = new OpenApiSecurityRequirement(); - requirement[ - new OpenApiSecuritySchemeReference( - SharedAuthConstants.OpenApi.OAuth2Scheme, - document, - null - ) - ] = [SharedAuthConstants.Scopes.OpenId]; - - document.Security ??= []; - document.Security.Add(requirement); - } -} diff --git a/src/SharedKernel/SharedKernel.Api/OpenApi/OpenApiErrorResponseHelper.cs b/src/SharedKernel/SharedKernel.Api/OpenApi/OpenApiErrorResponseHelper.cs deleted file mode 100644 index da7bcae4..00000000 --- a/src/SharedKernel/SharedKernel.Api/OpenApi/OpenApiErrorResponseHelper.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.OpenApi; - -namespace SharedKernel.Api.OpenApi; - -/// -/// Adds RFC 7807 application/problem+json response metadata to OpenAPI operations. -/// -internal static class OpenApiErrorResponseHelper -{ - internal static void AddErrorResponse( - OpenApiOperation operation, - int statusCode, - IOpenApiSchema? schema = null, - string? description = null - ) - { - string statusCodeKey = statusCode.ToString(); - operation.Responses ??= new OpenApiResponses(); - if (operation.Responses.ContainsKey(statusCodeKey)) - { - return; - } - - string resolvedDescription = string.IsNullOrWhiteSpace(description) - ? ReasonPhrases.GetReasonPhrase(statusCode) - : description; - - operation.Responses[statusCodeKey] = new OpenApiResponse - { - Description = resolvedDescription, - Content = new Dictionary - { - ["application/problem+json"] = new OpenApiMediaType { Schema = schema }, - }, - }; - } -} diff --git a/src/SharedKernel/SharedKernel.Api/OpenApi/ProblemDetailsOpenApiTransformer.cs b/src/SharedKernel/SharedKernel.Api/OpenApi/ProblemDetailsOpenApiTransformer.cs deleted file mode 100644 index eb070704..00000000 --- a/src/SharedKernel/SharedKernel.Api/OpenApi/ProblemDetailsOpenApiTransformer.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi; - -namespace SharedKernel.Api.OpenApi; - -/// -/// Attaches a shared ProblemDetails schema and standard error responses to operations. -/// -public sealed class ProblemDetailsOpenApiTransformer : IOpenApiDocumentTransformer -{ - public Task TransformAsync( - OpenApiDocument document, - OpenApiDocumentTransformerContext context, - CancellationToken cancellationToken - ) - { - document.Components ??= new OpenApiComponents(); - document.Components.Schemas ??= new Dictionary(); - - IOpenApiSchema problemDetailsSchema = BuildProblemDetailsSchema(); - document.Components.Schemas["ApiProblemDetails"] = problemDetailsSchema; - - foreach (OpenApiPathItem path in document.Paths.Values) - { - if (path.Operations is null) - { - continue; - } - - foreach (OpenApiOperation operation in path.Operations.Values) - { - int[] errorStatusCodes = - [ - StatusCodes.Status400BadRequest, - StatusCodes.Status401Unauthorized, - StatusCodes.Status403Forbidden, - StatusCodes.Status404NotFound, - StatusCodes.Status409Conflict, - StatusCodes.Status500InternalServerError, - ]; - - foreach (int statusCode in errorStatusCodes) - { - OpenApiErrorResponseHelper.AddErrorResponse( - operation, - statusCode, - problemDetailsSchema - ); - } - } - } - - return Task.CompletedTask; - } - - private static IOpenApiSchema BuildProblemDetailsSchema() => - new OpenApiSchema - { - Type = JsonSchemaType.Object, - Description = "RFC 7807 ProblemDetails payload used by API error responses.", - Properties = new Dictionary - { - ["type"] = new OpenApiSchema { Type = JsonSchemaType.String }, - ["title"] = new OpenApiSchema { Type = JsonSchemaType.String }, - ["status"] = new OpenApiSchema { Type = JsonSchemaType.Integer, Format = "int32" }, - ["detail"] = new OpenApiSchema { Type = JsonSchemaType.String }, - ["instance"] = new OpenApiSchema { Type = JsonSchemaType.String }, - ["traceId"] = new OpenApiSchema { Type = JsonSchemaType.String }, - ["errorCode"] = new OpenApiSchema { Type = JsonSchemaType.String }, - ["metadata"] = new OpenApiSchema - { - Type = JsonSchemaType.Object | JsonSchemaType.Null, - AdditionalProperties = new OpenApiSchema - { - Type = - JsonSchemaType.String - | JsonSchemaType.Integer - | JsonSchemaType.Number - | JsonSchemaType.Boolean - | JsonSchemaType.Null - | JsonSchemaType.Object - | JsonSchemaType.Array, - }, - }, - }, - Required = new HashSet { "type", "title", "status", "traceId", "errorCode" }, - }; -} diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/CacheInvalidationHandler.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/CacheInvalidationHandler.cs deleted file mode 100644 index 3872e2fb..00000000 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/CacheInvalidationHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SharedKernel.Application.Common.Events; - -namespace SharedKernel.Api.OutputCaching; - -public static class CacheInvalidationHandler -{ - public static Task HandleAsync( - CacheInvalidationNotification @event, - IOutputCacheInvalidationService outputCacheInvalidationService, - CancellationToken ct - ) => outputCacheInvalidationService.EvictAsync(@event.CacheTag, ct); -} diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/CachingOptions.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/CachingOptions.cs deleted file mode 100644 index 7d7eeb58..00000000 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/CachingOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace SharedKernel.Api.OutputCaching; - -public sealed class CachingOptions -{ - public const string SectionName = "Caching"; - - [Range(1, int.MaxValue)] - public int ProductsExpirationSeconds { get; set; } = 30; - - [Range(1, int.MaxValue)] - public int CategoriesExpirationSeconds { get; set; } = 60; - - [Range(1, int.MaxValue)] - public int ReviewsExpirationSeconds { get; set; } = 30; - - [Range(1, int.MaxValue)] - public int ProductDataExpirationSeconds { get; set; } = 30; - - [Range(1, int.MaxValue)] - public int TenantsExpirationSeconds { get; set; } = 60; - - [Range(1, int.MaxValue)] - public int TenantInvitationsExpirationSeconds { get; set; } = 30; - - [Range(1, int.MaxValue)] - public int UsersExpirationSeconds { get; set; } = 30; - - [Range(1, int.MaxValue)] - public int FilesExpirationSeconds { get; set; } = 60; -} diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/IOutputCacheInvalidationService.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/IOutputCacheInvalidationService.cs deleted file mode 100644 index cb7981ba..00000000 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/IOutputCacheInvalidationService.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SharedKernel.Api.OutputCaching; - -public interface IOutputCacheInvalidationService -{ - Task EvictAsync(string tag, CancellationToken cancellationToken = default); - Task EvictAsync(IEnumerable tags, CancellationToken cancellationToken = default); -} diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs deleted file mode 100644 index 6f1e390c..00000000 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.AspNetCore.OutputCaching; -using Microsoft.Extensions.Logging; - -namespace SharedKernel.Api.OutputCaching; - -public sealed class OutputCacheInvalidationService : IOutputCacheInvalidationService -{ - private readonly IOutputCacheStore _store; - private readonly ILogger _logger; - - public OutputCacheInvalidationService( - IOutputCacheStore store, - ILogger logger - ) - { - _store = store; - _logger = logger; - } - - public Task EvictAsync(string tag, CancellationToken cancellationToken = default) => - EvictAsync([tag], cancellationToken); - - public async Task EvictAsync( - IEnumerable tags, - CancellationToken cancellationToken = default - ) - { - foreach (string tag in tags.Distinct(StringComparer.Ordinal)) - { - try - { - await _store.EvictByTagAsync(tag, cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to evict output cache tag {Tag}.", tag); - } - } - } -} diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/RedisInstanceNames.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/RedisInstanceNames.cs deleted file mode 100644 index 7f237155..00000000 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/RedisInstanceNames.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SharedKernel.Api.OutputCaching; - -internal static class RedisInstanceNames -{ - public const string OutputCache = "ApiTemplate:OutputCache:"; -} diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs deleted file mode 100644 index f45eadd5..00000000 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OutputCaching; -using SharedKernel.Application.Security; - -namespace SharedKernel.Api.OutputCaching; - -/// -/// Allows caching for authenticated GET/HEAD requests and isolates cache entries by tenant. -/// -public sealed class TenantAwareOutputCachePolicy : IOutputCachePolicy -{ - private readonly string? _tag; - private readonly TimeSpan? _expiration; - - public TenantAwareOutputCachePolicy() - : this(null, null) { } - - public TenantAwareOutputCachePolicy(string? tag, TimeSpan? expiration) - { - _tag = tag; - _expiration = expiration; - } - - public ValueTask CacheRequestAsync( - OutputCacheContext context, - CancellationToken cancellationToken - ) - { - if ( - !HttpMethods.IsGet(context.HttpContext.Request.Method) - && !HttpMethods.IsHead(context.HttpContext.Request.Method) - ) - { - return ValueTask.CompletedTask; - } - - context.EnableOutputCaching = true; - context.AllowCacheLookup = true; - context.AllowCacheStorage = true; - - string tenantId = - context.HttpContext.User.FindFirstValue(SharedAuthConstants.Claims.TenantId) - ?? string.Empty; - context.CacheVaryByRules.VaryByValues[SharedAuthConstants.Claims.TenantId] = tenantId; - if (!string.IsNullOrWhiteSpace(_tag)) - { - context.Tags.Add(_tag); - } - - if (_expiration.HasValue) - { - context.ResponseExpirationTimeSpan = _expiration; - } - - return ValueTask.CompletedTask; - } - - public ValueTask ServeFromCacheAsync( - OutputCacheContext context, - CancellationToken cancellationToken - ) => ValueTask.CompletedTask; - - public ValueTask ServeResponseAsync( - OutputCacheContext context, - CancellationToken cancellationToken - ) => ValueTask.CompletedTask; -} diff --git a/src/SharedKernel/SharedKernel.Api/Security/HttpActorProvider.cs b/src/SharedKernel/SharedKernel.Api/Security/HttpActorProvider.cs deleted file mode 100644 index c07d1f67..00000000 --- a/src/SharedKernel/SharedKernel.Api/Security/HttpActorProvider.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Http; -using SharedKernel.Application.Context; -using SharedKernel.Application.Security; - -namespace SharedKernel.Api.Security; - -/// -/// Resolves actor identity for auditing from the current HTTP principal. -/// -public sealed class HttpActorProvider : IActorProvider -{ - private readonly IHttpContextAccessor _httpContextAccessor; - - public HttpActorProvider(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - public Guid ActorId - { - get - { - ClaimsPrincipal? user = _httpContextAccessor.HttpContext?.User; - string? raw = - user?.FindFirstValue(ClaimTypes.NameIdentifier) - ?? user?.FindFirstValue(SharedAuthConstants.Claims.Subject) - ?? user?.FindFirstValue(ClaimTypes.Name); - - return Guid.TryParse(raw, out Guid id) ? id : Guid.Empty; - } - } -} diff --git a/src/SharedKernel/SharedKernel.Api/Security/HttpTenantProvider.cs b/src/SharedKernel/SharedKernel.Api/Security/HttpTenantProvider.cs deleted file mode 100644 index 9484eb95..00000000 --- a/src/SharedKernel/SharedKernel.Api/Security/HttpTenantProvider.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Http; -using SharedKernel.Application.Context; -using SharedKernel.Application.Security; - -namespace SharedKernel.Api.Security; - -/// -/// Resolves tenant identity from the current authenticated HTTP principal. -/// -public sealed class HttpTenantProvider : ITenantProvider -{ - private readonly IHttpContextAccessor _httpContextAccessor; - - public HttpTenantProvider(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - public Guid TenantId - { - get - { - string? claimValue = _httpContextAccessor.HttpContext?.User.FindFirstValue( - SharedAuthConstants.Claims.TenantId - ); - return Guid.TryParse(claimValue, out Guid tenantId) ? tenantId : Guid.Empty; - } - } - - public bool HasTenant => TenantId != Guid.Empty; -} diff --git a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj deleted file mode 100644 index ff538327..00000000 --- a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/SharedKernel/SharedKernel.Application/Batch/BatchFailureContext.cs b/src/SharedKernel/SharedKernel.Application/Batch/BatchFailureContext.cs deleted file mode 100644 index 7f030848..00000000 --- a/src/SharedKernel/SharedKernel.Application/Batch/BatchFailureContext.cs +++ /dev/null @@ -1,45 +0,0 @@ -using SharedKernel.Application.DTOs; - -namespace SharedKernel.Application.Batch; - -/// -/// Holds batch items and collects per-item failures across validation rules. -/// -public sealed class BatchFailureContext -{ - private readonly List _failures = []; - private readonly HashSet _failedIndices = []; - - public BatchFailureContext(IReadOnlyList items) => Items = items; - - public IReadOnlyList Items { get; } - public bool HasFailures => _failures.Count > 0; - public IReadOnlySet FailedIndices => _failedIndices; - - public void AddFailure(int index, Guid? id, IReadOnlyList errors) - { - _failures.Add(new BatchResultItem(index, id, errors)); - _failedIndices.Add(index); - } - - public void AddFailure(int index, Guid? id, string error) => AddFailure(index, id, [error]); - - public void AddFailures(IEnumerable failures) - { - foreach (BatchResultItem failure in failures) - { - _failures.Add(failure); - _failedIndices.Add(failure.Index); - } - } - - public bool IsFailed(int index) => _failedIndices.Contains(index); - - public async Task ApplyRulesAsync(CancellationToken ct, params IBatchRule[] rules) - { - for (int i = 0; i < rules.Length; i++) - await rules[i].ApplyAsync(this, ct); - } - - public BatchResponse ToFailureResponse() => new(_failures, 0, _failures.Count); -} diff --git a/src/SharedKernel/SharedKernel.Application/Batch/BatchFailureMerge.cs b/src/SharedKernel/SharedKernel.Application/Batch/BatchFailureMerge.cs deleted file mode 100644 index a004850c..00000000 --- a/src/SharedKernel/SharedKernel.Application/Batch/BatchFailureMerge.cs +++ /dev/null @@ -1,44 +0,0 @@ -using SharedKernel.Application.DTOs; - -namespace SharedKernel.Application.Batch; - -/// -/// Merges per-item batch failures that share the same index (e.g. missing category and missing product data). -/// -public static class BatchFailureMerge -{ - public static List MergeByIndex( - IEnumerable first, - IEnumerable second - ) - { - Dictionary> errorsByIndex = new(); - Dictionary idByIndex = new(); - - void Accumulate(BatchResultItem item) - { - if (!errorsByIndex.TryGetValue(item.Index, out List? list)) - { - list = []; - errorsByIndex[item.Index] = list; - } - - list.AddRange(item.Errors); - - if (!idByIndex.TryGetValue(item.Index, out Guid? existingId)) - idByIndex[item.Index] = item.Id; - else if (existingId is null && item.Id is not null) - idByIndex[item.Index] = item.Id; - } - - foreach (BatchResultItem x in first) - Accumulate(x); - foreach (BatchResultItem x in second) - Accumulate(x); - - return errorsByIndex - .OrderBy(kv => kv.Key) - .Select(kv => new BatchResultItem(kv.Key, idByIndex[kv.Key], kv.Value)) - .ToList(); - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Batch/EntityLookup.cs b/src/SharedKernel/SharedKernel.Application/Batch/EntityLookup.cs deleted file mode 100644 index b21756f1..00000000 --- a/src/SharedKernel/SharedKernel.Application/Batch/EntityLookup.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SharedKernel.Application.Batch; - -/// -/// Wraps a dictionary of loaded entities for passing between Wolverine compound-handler -/// LoadAsync and HandleAsync steps with unambiguous type matching. -/// -public sealed record EntityLookup(IReadOnlyDictionary Entities); diff --git a/src/SharedKernel/SharedKernel.Application/Batch/IBatchRule.cs b/src/SharedKernel/SharedKernel.Application/Batch/IBatchRule.cs deleted file mode 100644 index 6f8726c2..00000000 --- a/src/SharedKernel/SharedKernel.Application/Batch/IBatchRule.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SharedKernel.Application.Batch; - -public interface IBatchRule -{ - Task ApplyAsync(BatchFailureContext context, CancellationToken ct); -} diff --git a/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs b/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs deleted file mode 100644 index 0063c700..00000000 --- a/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs +++ /dev/null @@ -1,31 +0,0 @@ -using FluentValidation; -using SharedKernel.Domain.Entities.Contracts; - -namespace SharedKernel.Application.Batch.Rules; - -public sealed class FluentValidationBatchRule(IValidator validator) - : IBatchRule -{ - private readonly IValidator _validator = validator; - - public async Task ApplyAsync(BatchFailureContext context, CancellationToken ct) - { - for (int i = 0; i < context.Items.Count; i++) - { - if (context.IsFailed(i)) - continue; - - FluentValidation.Results.ValidationResult validationResult = - await _validator.ValidateAsync(context.Items[i], ct); - if (!validationResult.IsValid) - { - Guid? id = context.Items[i] is IHasId hasId ? hasId.Id : null; - context.AddFailure( - i, - id, - validationResult.Errors.Select(error => error.ErrorMessage).ToList() - ); - } - } - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Batch/Rules/MarkMissingByIdBatchRule.cs b/src/SharedKernel/SharedKernel.Application/Batch/Rules/MarkMissingByIdBatchRule.cs deleted file mode 100644 index 4d565348..00000000 --- a/src/SharedKernel/SharedKernel.Application/Batch/Rules/MarkMissingByIdBatchRule.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace SharedKernel.Application.Batch.Rules; - -public sealed class MarkMissingByIdBatchRule( - Func idSelector, - IReadOnlySet foundIds, - string notFoundMessageTemplate -) : IBatchRule -{ - public Task ApplyAsync(BatchFailureContext context, CancellationToken ct) - { - for (int i = 0; i < context.Items.Count; i++) - { - if (context.IsFailed(i)) - continue; - - Guid id = idSelector(context.Items[i]); - if (!foundIds.Contains(id)) - context.AddFailure(i, id, string.Format(notFoundMessageTemplate, id)); - } - - return Task.CompletedTask; - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Common/Events/CacheInvalidationCascades.cs b/src/SharedKernel/SharedKernel.Application/Common/Events/CacheInvalidationCascades.cs deleted file mode 100644 index f1f42e49..00000000 --- a/src/SharedKernel/SharedKernel.Application/Common/Events/CacheInvalidationCascades.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Wolverine; - -namespace SharedKernel.Application.Common.Events; - -/// -/// Builds Wolverine cascading for output-cache eviction -/// (). -/// -public static class CacheInvalidationCascades -{ - private static readonly OutgoingMessages EmptyMessages = new(); - - /// No additional cascaded messages. - public static OutgoingMessages None => EmptyMessages; - - public static OutgoingMessages ForTag(string cacheTag) => - new OutgoingMessages { new CacheInvalidationNotification(cacheTag) }; - - public static OutgoingMessages ForTags(params string[] cacheTags) - { - OutgoingMessages messages = new(); - foreach (string tag in cacheTags) - messages.Add(new CacheInvalidationNotification(tag)); - - return messages; - } - - public static OutgoingMessages ForTags(IEnumerable cacheTags) - { - OutgoingMessages messages = new(); - foreach (string tag in cacheTags.Distinct(StringComparer.Ordinal)) - messages.Add(new CacheInvalidationNotification(tag)); - - return messages; - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Common/Events/CacheInvalidationNotification.cs b/src/SharedKernel/SharedKernel.Application/Common/Events/CacheInvalidationNotification.cs deleted file mode 100644 index c20a7a77..00000000 --- a/src/SharedKernel/SharedKernel.Application/Common/Events/CacheInvalidationNotification.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SharedKernel.Application.Common.Events; - -/// -/// Message used to evict one named output-cache tag. -/// -public sealed record CacheInvalidationNotification(string CacheTag); diff --git a/src/SharedKernel/SharedKernel.Application/Common/Events/CacheTags.cs b/src/SharedKernel/SharedKernel.Application/Common/Events/CacheTags.cs deleted file mode 100644 index 3ac233e6..00000000 --- a/src/SharedKernel/SharedKernel.Application/Common/Events/CacheTags.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SharedKernel.Application.Common.Events; - -/// -/// Shared output-cache policy and tag names. -/// -public static class CacheTags -{ - public const string Products = "Products"; - public const string Categories = "Categories"; - public const string Reviews = "Reviews"; - public const string ProductData = "ProductData"; - public const string Tenants = "Tenants"; - public const string TenantInvitations = "TenantInvitations"; - public const string Users = "Users"; - public const string Files = "Files"; -} diff --git a/src/SharedKernel/SharedKernel.Application/Context/IActorProvider.cs b/src/SharedKernel/SharedKernel.Application/Context/IActorProvider.cs deleted file mode 100644 index 0f7424c8..00000000 --- a/src/SharedKernel/SharedKernel.Application/Context/IActorProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SharedKernel.Application.Context; - -/// -/// Provides the identity of the currently authenticated user (actor) executing a request. -/// Consumed by Application-layer handlers and domain services that need the actor for auditing or authorization. -/// -public interface IActorProvider -{ - /// Gets the unique identifier of the acting user. - Guid ActorId { get; } -} diff --git a/src/SharedKernel/SharedKernel.Application/Context/ITenantProvider.cs b/src/SharedKernel/SharedKernel.Application/Context/ITenantProvider.cs deleted file mode 100644 index f4e36555..00000000 --- a/src/SharedKernel/SharedKernel.Application/Context/ITenantProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace SharedKernel.Application.Context; - -/// -/// Provides the tenant context for the current request, enabling multi-tenant data isolation -/// at the Application layer without coupling handlers to HTTP or infrastructure concerns. -/// -public interface ITenantProvider -{ - /// Gets the unique identifier of the current tenant. - Guid TenantId { get; } - - /// - /// Returns true when the current request is scoped to a tenant; - /// false for system-level or anonymous requests. - /// - bool HasTenant { get; } -} diff --git a/src/SharedKernel/SharedKernel.Application/Contracts/IDateRangeFilter.cs b/src/SharedKernel/SharedKernel.Application/Contracts/IDateRangeFilter.cs deleted file mode 100644 index bd0bfe4e..00000000 --- a/src/SharedKernel/SharedKernel.Application/Contracts/IDateRangeFilter.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SharedKernel.Application.Contracts; - -/// -/// Marks a query/filter request as supporting optional creation-date range filtering. -/// Query handlers use this interface to apply a consistent date predicate without duplicating logic. -/// -public interface IDateRangeFilter -{ - /// Inclusive lower bound of the creation-date filter; null means no lower bound. - DateTime? CreatedFrom { get; } - - /// Inclusive upper bound of the creation-date filter; null means no upper bound. - DateTime? CreatedTo { get; } -} diff --git a/src/SharedKernel/SharedKernel.Application/Contracts/IIdempotencyStore.cs b/src/SharedKernel/SharedKernel.Application/Contracts/IIdempotencyStore.cs deleted file mode 100644 index a87471de..00000000 --- a/src/SharedKernel/SharedKernel.Application/Contracts/IIdempotencyStore.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace SharedKernel.Application.Contracts; - -/// -/// Application-layer abstraction for the idempotency store used to short-circuit duplicate -/// requests and replay cached responses without re-executing business logic. -/// -public interface IIdempotencyStore -{ - /// - /// Retrieves a previously cached response entry for , - /// or returns null if no entry exists. - /// - Task TryGetAsync(string key, CancellationToken ct = default); - - /// - /// Atomically checks if the key exists and acquires a lock if not. - /// Returns true if the lock was acquired (key was not present), false otherwise. - /// - Task TryAcquireAsync(string key, TimeSpan ttl, CancellationToken ct = default); - - /// - /// Stores under with the given , - /// replacing the in-flight lock entry so subsequent duplicates receive the cached response. - /// - Task SetAsync( - string key, - IdempotencyCacheEntry entry, - TimeSpan ttl, - CancellationToken ct = default - ); - - /// - /// Releases the lock for the given key so a retry with the same key can proceed. - /// Only releases if the lock is still owned (not yet replaced by a cached result). - /// - Task ReleaseAsync(string key, CancellationToken ct = default); -} - -/// -/// Cached HTTP response snapshot stored by the idempotency middleware for replay on duplicate requests. -/// -public sealed record IdempotencyCacheEntry( - int StatusCode, - string? ResponseBody, - string? ResponseContentType, - string? LocationHeader = null -); diff --git a/src/SharedKernel/SharedKernel.Application/Contracts/IProductRequest.cs b/src/SharedKernel/SharedKernel.Application/Contracts/IProductRequest.cs deleted file mode 100644 index 25ec6596..00000000 --- a/src/SharedKernel/SharedKernel.Application/Contracts/IProductRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SharedKernel.Application.Contracts; - -/// -/// Shared contract for create and update product command requests, enabling reuse of -/// FluentValidation rules across both operations without duplicating property declarations. -/// -public interface IProductRequest -{ - string Name { get; } - string? Description { get; } - decimal Price { get; } - Guid? CategoryId { get; } - IReadOnlyCollection? ProductDataIds { get; } -} diff --git a/src/SharedKernel/SharedKernel.Application/Contracts/ISortableFilter.cs b/src/SharedKernel/SharedKernel.Application/Contracts/ISortableFilter.cs deleted file mode 100644 index 49f050ce..00000000 --- a/src/SharedKernel/SharedKernel.Application/Contracts/ISortableFilter.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SharedKernel.Application.Contracts; - -/// -/// Marks a query/filter request as supporting optional sorting parameters. -/// Query handlers use this interface to apply a consistent ordering strategy without duplicating logic. -/// -public interface ISortableFilter -{ - /// Name of the field to sort by; null applies default ordering. - string? SortBy { get; } - - /// Sort direction, typically "asc" or "desc"; null applies default direction. - string? SortDirection { get; } -} diff --git a/src/SharedKernel/SharedKernel.Application/DTOs/BatchDeleteRequest.cs b/src/SharedKernel/SharedKernel.Application/DTOs/BatchDeleteRequest.cs deleted file mode 100644 index 521a03a0..00000000 --- a/src/SharedKernel/SharedKernel.Application/DTOs/BatchDeleteRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace SharedKernel.Application.DTOs; - -/// -/// Carries a list of entity identifiers to be deleted in a single batch operation; accepts between 1 and 100 IDs. -/// -public sealed record BatchDeleteRequest( - [MinLength(1, ErrorMessage = "At least one ID is required.")] - [MaxLength(100, ErrorMessage = "Maximum 100 IDs per batch.")] - IReadOnlyList Ids -); diff --git a/src/SharedKernel/SharedKernel.Application/DTOs/BatchResponse.cs b/src/SharedKernel/SharedKernel.Application/DTOs/BatchResponse.cs deleted file mode 100644 index 9be8d310..00000000 --- a/src/SharedKernel/SharedKernel.Application/DTOs/BatchResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SharedKernel.Application.DTOs; - -/// -/// Summarises the outcome of a batch operation, including per-item failure details and aggregate counts. -/// -public sealed record BatchResponse( - IReadOnlyList Failures, - int SuccessCount, - int FailureCount -); - -/// -/// Represents a failed item within a batch operation, including its zero-based index, -/// the affected entity ID (when known), and validation/existence errors. -/// -public sealed record BatchResultItem(int Index, Guid? Id, IReadOnlyList Errors); diff --git a/src/SharedKernel/SharedKernel.Application/DTOs/FileDownloadInfo.cs b/src/SharedKernel/SharedKernel.Application/DTOs/FileDownloadInfo.cs deleted file mode 100644 index 1ac5eba5..00000000 --- a/src/SharedKernel/SharedKernel.Application/DTOs/FileDownloadInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace SharedKernel.Application.DTOs; - -/// Stored-file metadata; the API opens the stream after a successful Wolverine query. -public sealed record FileDownloadInfo(string StoragePath, string ContentType, string FileName); diff --git a/src/SharedKernel/SharedKernel.Application/DTOs/IHasFacets.cs b/src/SharedKernel/SharedKernel.Application/DTOs/IHasFacets.cs deleted file mode 100644 index 0d4a8b62..00000000 --- a/src/SharedKernel/SharedKernel.Application/DTOs/IHasFacets.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SharedKernel.Application.DTOs; - -/// -/// Marks a query response as carrying faceted aggregation data alongside the primary result set, -/// enabling clients to render filter counts or category breakdowns without an extra round-trip. -/// -/// The type that holds the facet aggregations specific to the query. -public interface IHasFacets -{ - TFacets Facets { get; } -} diff --git a/src/SharedKernel/SharedKernel.Application/DTOs/IPagedItems.cs b/src/SharedKernel/SharedKernel.Application/DTOs/IPagedItems.cs deleted file mode 100644 index f087e177..00000000 --- a/src/SharedKernel/SharedKernel.Application/DTOs/IPagedItems.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SharedKernel.Domain.Common; - -namespace SharedKernel.Application.DTOs; - -/// -/// Marks a query response as wrapping a , providing a consistent -/// shape for all paginated query results across the Application layer. -/// -/// The type of items in the page. -public interface IPagedItems -{ - PagedResponse Page { get; } -} diff --git a/src/SharedKernel/SharedKernel.Application/DTOs/PaginationFilter.cs b/src/SharedKernel/SharedKernel.Application/DTOs/PaginationFilter.cs deleted file mode 100644 index da5c2a80..00000000 --- a/src/SharedKernel/SharedKernel.Application/DTOs/PaginationFilter.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace SharedKernel.Application.DTOs; - -/// -/// Reusable pagination input carried by list query requests. -/// Data-annotation constraints enforce valid ranges so FluentValidation and model binding both reject bad input. -/// -public record PaginationFilter( - [Range(1, int.MaxValue, ErrorMessage = "PageNumber must be greater than or equal to 1.")] - int PageNumber = 1, - [Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")] int PageSize = 20 -) -{ - /// Default page size applied when none is specified by the caller. - public const int DefaultPageSize = 20; - - /// Maximum allowed page size to prevent unbounded queries. - public const int MaxPageSize = 100; -} diff --git a/src/SharedKernel/SharedKernel.Application/Errors/DomainErrors.cs b/src/SharedKernel/SharedKernel.Application/Errors/DomainErrors.cs deleted file mode 100644 index 360db3f0..00000000 --- a/src/SharedKernel/SharedKernel.Application/Errors/DomainErrors.cs +++ /dev/null @@ -1,28 +0,0 @@ -using ErrorOr; - -namespace SharedKernel.Application.Errors; - -/// -/// Factory methods producing instances for cross-cutting error codes. -/// Service-specific error factories belong in their respective Application layers. -/// -public static class DomainErrors -{ - public static class General - { - public static Error NotFound(string entityName, Guid id) => - Error.NotFound( - code: ErrorCatalog.General.NotFound, - description: $"{entityName} with id '{id}' not found." - ); - - public static Error NotFound(string code, string entityName, Guid id) => - Error.NotFound(code: code, description: $"{entityName} with id '{id}' not found."); - - public static Error NotFound(string code, string entityName, string identifier) => - Error.NotFound(code: code, description: $"{entityName} '{identifier}' not found."); - - public static Error Conflict(string code, string description) => - Error.Conflict(code: code, description: description); - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Errors/ErrorCatalog.cs b/src/SharedKernel/SharedKernel.Application/Errors/ErrorCatalog.cs deleted file mode 100644 index 4e0f376d..00000000 --- a/src/SharedKernel/SharedKernel.Application/Errors/ErrorCatalog.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace SharedKernel.Application.Errors; - -/// -/// Central catalog of structured error codes shared across all services. -/// Service-specific error codes belong in their respective Application layers. -/// -public static class ErrorCatalog -{ - /// Cross-cutting error codes not tied to a specific domain concept. - public static class General - { - public const string Unknown = "GEN-0001"; - public const string ValidationFailed = "GEN-0400"; - public const string PageOutOfRange = "GEN-0400-PAGE"; - public const string NotFound = "GEN-0404"; - public const string Conflict = "GEN-0409"; - public const string ConcurrencyConflict = "GEN-0409-CONCURRENCY"; - } - - /// Error codes for authentication and authorisation failures. - public static class Auth - { - public const string Forbidden = "AUTH-0403"; - public const string CsrfHeaderMissing = "AUTH-0403-CSRF"; - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Extensions/RepositoryExtensions.cs b/src/SharedKernel/SharedKernel.Application/Extensions/RepositoryExtensions.cs deleted file mode 100644 index 4ac64cc9..00000000 --- a/src/SharedKernel/SharedKernel.Application/Extensions/RepositoryExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Ardalis.Specification; -using ErrorOr; - -namespace SharedKernel.Application.Extensions; - -/// -/// Extension methods for Ardalis repository base providing ErrorOr-based lookups. -/// -public static class RepositoryExtensions -{ - /// - /// Returns the entity by wrapped in , - /// or the supplied when the entity does not exist. - /// - public static async Task> GetByIdOrError( - this IRepositoryBase repository, - Guid id, - Error notFoundError, - CancellationToken ct = default - ) - where T : class - { - T? entity = await repository.GetByIdAsync(id, ct); - return entity is null ? notFoundError : entity; - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs b/src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs deleted file mode 100644 index 2eb99a0c..00000000 --- a/src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs +++ /dev/null @@ -1,53 +0,0 @@ -using ErrorOr; -using FluentValidation; -using SharedKernel.Application.Errors; -using Wolverine; - -namespace SharedKernel.Application.Middleware; - -/// -/// Wolverine handler middleware that validates incoming messages using FluentValidation -/// and short-circuits with errors instead of throwing exceptions. -/// Applied only to handlers whose return type is ErrorOr<T>. -/// -public static class ErrorOrValidationMiddleware -{ - /// - /// Runs FluentValidation before the handler executes. If validation fails, - /// returns with validation errors - /// so the handler is never invoked. - /// - public static async Task<(HandlerContinuation, ErrorOr)> BeforeAsync< - TMessage, - TResponse - >(TMessage message, IValidator? validator = null, CancellationToken ct = default) - { - if (validator is null) - return (HandlerContinuation.Continue, default!); - - FluentValidation.Results.ValidationResult validationResult = await validator.ValidateAsync( - message, - ct - ); - - if (validationResult.IsValid) - return (HandlerContinuation.Continue, default!); - - List errors = validationResult - .Errors.Select(e => - { - Dictionary metadata = new() { ["propertyName"] = e.PropertyName }; - if (e.AttemptedValue is not null) - metadata["attemptedValue"] = e.AttemptedValue; - - return Error.Validation( - code: ErrorCatalog.General.ValidationFailed, - description: e.ErrorMessage, - metadata: metadata - ); - }) - .ToList(); - - return (HandlerContinuation.Stop, errors); - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Options/TransactionDefaultsOptions.cs b/src/SharedKernel/SharedKernel.Application/Options/TransactionDefaultsOptions.cs deleted file mode 100644 index 5635cda6..00000000 --- a/src/SharedKernel/SharedKernel.Application/Options/TransactionDefaultsOptions.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Data; -using SharedKernel.Domain.Options; - -namespace SharedKernel.Application.Options; - -/// -/// Application-level defaults for database transaction settings that can be overridden per call site. -/// Consumed by infrastructure components to build consistent instances. -/// -public sealed class TransactionDefaultsOptions -{ - public const string SectionName = "TransactionDefaults"; - - [Range(0, int.MaxValue)] - public IsolationLevel IsolationLevel { get; set; } = IsolationLevel.ReadCommitted; - - [Range(0, int.MaxValue)] - public int TimeoutSeconds { get; set; } = 30; - public bool RetryEnabled { get; set; } = true; - - [Range(0, int.MaxValue)] - public int RetryCount { get; set; } = 3; - - [Range(0, int.MaxValue)] - public int RetryDelaySeconds { get; set; } = 5; - - /// - /// Resolves the effective by combining the configured defaults - /// in this instance with the specified . - /// - /// - /// Optional per-call overrides. Any null or unset properties on - /// will fall back to the corresponding default value defined on this . - /// - /// - /// A new instance containing the resolved transaction settings. - /// - public TransactionOptions Resolve(TransactionOptions? overrides) - { - TransactionOptions resolved = new() - { - IsolationLevel = overrides?.IsolationLevel ?? IsolationLevel, - TimeoutSeconds = overrides?.TimeoutSeconds ?? TimeoutSeconds, - RetryEnabled = overrides?.RetryEnabled ?? RetryEnabled, - RetryCount = overrides?.RetryCount ?? RetryCount, - RetryDelaySeconds = overrides?.RetryDelaySeconds ?? RetryDelaySeconds, - }; - - ValidateNonNegative(resolved.TimeoutSeconds, nameof(TransactionOptions.TimeoutSeconds)); - ValidateNonNegative(resolved.RetryCount, nameof(TransactionOptions.RetryCount)); - ValidateNonNegative( - resolved.RetryDelaySeconds, - nameof(TransactionOptions.RetryDelaySeconds) - ); - - return resolved; - } - - /// - /// Throws when the given integer value is negative, - /// enforcing that transaction numeric settings are always non-negative. - /// - private static void ValidateNonNegative(int? value, string parameterName) - { - if (value < 0) - { - throw new ArgumentOutOfRangeException( - parameterName, - value, - $"{parameterName} cannot be negative." - ); - } - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Queue/IQueue.cs b/src/SharedKernel/SharedKernel.Application/Queue/IQueue.cs deleted file mode 100644 index 12b9a6e1..00000000 --- a/src/SharedKernel/SharedKernel.Application/Queue/IQueue.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace SharedKernel.Application.Queue; - -/// -/// Generic write-side abstraction for in-process queues used to decouple producers from -/// background consumers without taking a dependency on a specific transport (e.g. Channel, Redis). -/// -/// The type of item placed on the queue. -public interface IQueue -{ - /// - /// Adds to the queue, waiting asynchronously if the queue is full. - /// - ValueTask EnqueueAsync(T item, CancellationToken ct = default); -} - -/// -/// Generic read-side abstraction for in-process queues, allowing background consumers to drain -/// items without coupling to a specific transport implementation. -/// -/// The type of item read from the queue. -public interface IQueueReader -{ - /// - /// Returns an async stream that yields items as they become available, completing only when - /// is cancelled or the underlying channel is closed. - /// - IAsyncEnumerable ReadAllAsync(CancellationToken ct = default); -} diff --git a/src/SharedKernel/SharedKernel.Application/Search/SearchDefaults.cs b/src/SharedKernel/SharedKernel.Application/Search/SearchDefaults.cs deleted file mode 100644 index 98228fd9..00000000 --- a/src/SharedKernel/SharedKernel.Application/Search/SearchDefaults.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SharedKernel.Application.Search; - -/// -/// Shared defaults for full-text search across filter specifications. -/// -public static class SearchDefaults -{ - /// - /// PostgreSQL text search configuration used by all full-text search queries. - /// - public const string TextSearchConfiguration = "english"; -} diff --git a/src/SharedKernel/SharedKernel.Application/Security/DefaultRolePermissionMap.cs b/src/SharedKernel/SharedKernel.Application/Security/DefaultRolePermissionMap.cs deleted file mode 100644 index 575a04da..00000000 --- a/src/SharedKernel/SharedKernel.Application/Security/DefaultRolePermissionMap.cs +++ /dev/null @@ -1,64 +0,0 @@ -namespace SharedKernel.Application.Security; - -public sealed class DefaultRolePermissionMap : IRolePermissionMap -{ - private static readonly IReadOnlySet Empty = new HashSet( - StringComparer.Ordinal - ); - - private static readonly IReadOnlyDictionary> Map = BuildMap(); - - public IReadOnlySet GetPermissions(string role) => - Map.TryGetValue(role, out IReadOnlySet? permissions) ? permissions : Empty; - - public bool HasPermission(string role, string permission) => - GetPermissions(role).Contains(permission); - - private static Dictionary> BuildMap() - { - HashSet tenantAdminPermissions = new(StringComparer.Ordinal) - { - Permission.Products.Read, - Permission.Products.Create, - Permission.Products.Update, - Permission.Products.Delete, - Permission.Categories.Read, - Permission.Categories.Create, - Permission.Categories.Update, - Permission.Categories.Delete, - Permission.ProductReviews.Read, - Permission.ProductReviews.Create, - Permission.ProductReviews.Delete, - Permission.ProductData.Read, - Permission.ProductData.Create, - Permission.ProductData.Delete, - Permission.Users.Read, - Permission.Users.Create, - Permission.Users.Update, - Permission.Users.Delete, - Permission.Tenants.Read, - Permission.Invitations.Read, - Permission.Invitations.Create, - Permission.Invitations.Revoke, - Permission.Files.Upload, - Permission.Files.Download, - }; - - HashSet userPermissions = new(StringComparer.Ordinal) - { - Permission.Products.Read, - Permission.Categories.Read, - Permission.ProductReviews.Read, - Permission.ProductReviews.Create, - Permission.ProductData.Read, - Permission.Files.Download, - }; - - return new Dictionary>(StringComparer.Ordinal) - { - [SharedAuthConstants.Roles.PlatformAdmin] = Permission.All, - [SharedAuthConstants.Roles.TenantAdmin] = tenantAdminPermissions, - [SharedAuthConstants.Roles.User] = userPermissions, - }; - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Security/IRolePermissionMap.cs b/src/SharedKernel/SharedKernel.Application/Security/IRolePermissionMap.cs deleted file mode 100644 index bc432975..00000000 --- a/src/SharedKernel/SharedKernel.Application/Security/IRolePermissionMap.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace SharedKernel.Application.Security; - -/// -/// Defines the contract for querying which permissions are granted to a given role. -/// Decouples authorization policy evaluation from the concrete permission mapping strategy. -/// -public interface IRolePermissionMap -{ - /// Returns the complete set of permission strings granted to . - IReadOnlySet GetPermissions(string role); - - /// - /// Returns when has been granted - /// the specified . - /// - bool HasPermission(string role, string permission); -} diff --git a/src/SharedKernel/SharedKernel.Application/Security/KeycloakClaimsPrincipalMapper.cs b/src/SharedKernel/SharedKernel.Application/Security/KeycloakClaimsPrincipalMapper.cs deleted file mode 100644 index 678f9c59..00000000 --- a/src/SharedKernel/SharedKernel.Application/Security/KeycloakClaimsPrincipalMapper.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Security.Claims; -using System.Text.Json; - -namespace SharedKernel.Application.Security; - -public static class KeycloakClaimsPrincipalMapper -{ - public static void MapClaims(ClaimsPrincipal? principal) - { - if (principal?.Identity is not ClaimsIdentity identity) - return; - - MapUsername(identity); - MapRealmRoles(identity); - } - - private static void MapUsername(ClaimsIdentity identity) - { - if (identity.FindFirst(ClaimTypes.Name) is not null) - return; - - string? preferredUsername = identity - .FindFirst(SharedAuthConstants.KeycloakClaims.PreferredUsername) - ?.Value; - - if (!string.IsNullOrWhiteSpace(preferredUsername)) - identity.AddClaim(new Claim(ClaimTypes.Name, preferredUsername)); - } - - private static void MapRealmRoles(ClaimsIdentity identity) - { - Claim? realmAccess = identity.FindFirst(SharedAuthConstants.KeycloakClaims.RealmAccess); - if (realmAccess is null) - return; - - using JsonDocument document = JsonDocument.Parse(realmAccess.Value); - if ( - !document.RootElement.TryGetProperty( - SharedAuthConstants.KeycloakClaims.Roles, - out JsonElement roles - ) - ) - { - return; - } - - HashSet existingRoles = identity - .FindAll(ClaimTypes.Role) - .Select(claim => claim.Value) - .ToHashSet(StringComparer.Ordinal); - - foreach (JsonElement role in roles.EnumerateArray()) - { - string? value = role.GetString(); - if (!string.IsNullOrWhiteSpace(value) && existingRoles.Add(value)) - identity.AddClaim(new Claim(ClaimTypes.Role, value)); - } - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Security/Permission.cs b/src/SharedKernel/SharedKernel.Application/Security/Permission.cs deleted file mode 100644 index 5b583f03..00000000 --- a/src/SharedKernel/SharedKernel.Application/Security/Permission.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Reflection; - -namespace SharedKernel.Application.Security; - -/// -/// Centralised registry of all fine-grained permission string constants used throughout the application. -/// Nested classes group permissions by domain resource; enumerates every declared permission via reflection. -/// -public static class Permission -{ - /// Permissions governing product resource access. - public static class Products - { - public const string Read = "Products.Read"; - public const string Create = "Products.Create"; - public const string Update = "Products.Update"; - public const string Delete = "Products.Delete"; - } - - /// Permissions governing category resource access. - public static class Categories - { - public const string Read = "Categories.Read"; - public const string Create = "Categories.Create"; - public const string Update = "Categories.Update"; - public const string Delete = "Categories.Delete"; - } - - /// Permissions governing product review resource access. - public static class ProductReviews - { - public const string Read = "ProductReviews.Read"; - public const string Create = "ProductReviews.Create"; - public const string Delete = "ProductReviews.Delete"; - } - - /// Permissions governing supplementary product data resource access. - public static class ProductData - { - public const string Read = "ProductData.Read"; - public const string Create = "ProductData.Create"; - public const string Delete = "ProductData.Delete"; - } - - /// Permissions governing user account resource access. - public static class Users - { - public const string Read = "Users.Read"; - public const string Create = "Users.Create"; - public const string Update = "Users.Update"; - public const string Delete = "Users.Delete"; - } - - /// Permissions governing tenant resource access. - public static class Tenants - { - public const string Read = "Tenants.Read"; - public const string Create = "Tenants.Create"; - public const string Delete = "Tenants.Delete"; - } - - /// Permissions governing tenant invitation resource access. - public static class Invitations - { - public const string Read = "Invitations.Read"; - public const string Create = "Invitations.Create"; - public const string Revoke = "Invitations.Revoke"; - } - - /// Permissions governing example/showcase endpoint access. - public static class Examples - { - public const string Read = "Examples.Read"; - public const string Create = "Examples.Create"; - public const string Update = "Examples.Update"; - public const string Execute = "Examples.Execute"; - public const string Upload = "Examples.Upload"; - public const string Download = "Examples.Download"; - } - - /// Permissions governing file storage resource access. - public static class Files - { - public const string Upload = "Files.Upload"; - public const string Download = "Files.Download"; - } - - private static readonly Lazy> LazyAll = new(() => - { - HashSet permissions = new(StringComparer.Ordinal); - foreach ( - Type nestedType in typeof(Permission).GetNestedTypes( - BindingFlags.Public | BindingFlags.Static - ) - ) - { - foreach ( - FieldInfo field in nestedType.GetFields( - BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy - ) - ) - { - if ( - field.IsLiteral - && field.FieldType == typeof(string) - && field.GetRawConstantValue() is string value - ) - { - permissions.Add(value); - } - } - } - return permissions; - }); - - /// - /// Returns a lazily-initialised, read-only set containing every permission constant declared - /// across all nested resource classes, discovered via reflection. - /// - public static IReadOnlySet All => LazyAll.Value; -} diff --git a/src/SharedKernel/SharedKernel.Application/Security/SharedAuthConstants.cs b/src/SharedKernel/SharedKernel.Application/Security/SharedAuthConstants.cs deleted file mode 100644 index f4a16bec..00000000 --- a/src/SharedKernel/SharedKernel.Application/Security/SharedAuthConstants.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace SharedKernel.Application.Security; - -/// -/// Shared authentication claim constants used across all microservices -/// for extracting identity information from JWT tokens. -/// -public static class SharedAuthConstants -{ - /// Relative path segments for Keycloak OpenID Connect endpoints. - public static class OpenIdConnect - { - public const string AuthorizationEndpointPath = "protocol/openid-connect/auth"; - public const string TokenEndpointPath = "protocol/openid-connect/token"; - } - - /// OpenAPI/Scalar constants shared by service hosts and gateway. - public static class OpenApi - { - public const string OAuth2Scheme = "OAuth2"; - public const string ScalarClientId = "api-template-scalar"; - public const string DefaultDocumentName = "v1"; - } - - /// Default OIDC scopes requested by interactive API tooling. - public static class Scopes - { - public const string OpenId = "openid"; - public const string Profile = "profile"; - public const string Email = "email"; - public static readonly string[] Default = [OpenId, Profile, Email]; - } - - /// JWT claim names shared across all services. - public static class Claims - { - public const string Subject = "sub"; - public const string TenantId = "tenant_id"; - } - - public static class KeycloakClaims - { - public const string PreferredUsername = "preferred_username"; - public const string RealmAccess = "realm_access"; - public const string Roles = "roles"; - public const string ServiceAccountUsernamePrefix = "service-account-"; - } - - public static class Roles - { - public const string User = "User"; - public const string PlatformAdmin = "PlatformAdmin"; - public const string TenantAdmin = "TenantAdmin"; - } -} diff --git a/src/SharedKernel/SharedKernel.Application/SharedKernel.Application.csproj b/src/SharedKernel/SharedKernel.Application/SharedKernel.Application.csproj deleted file mode 100644 index 8ba406df..00000000 --- a/src/SharedKernel/SharedKernel.Application/SharedKernel.Application.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - - - diff --git a/src/SharedKernel/SharedKernel.Application/Sorting/SortField.cs b/src/SharedKernel/SharedKernel.Application/Sorting/SortField.cs deleted file mode 100644 index 1c2ee7a2..00000000 --- a/src/SharedKernel/SharedKernel.Application/Sorting/SortField.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace SharedKernel.Application.Sorting; - -/// -/// Represents a named, case-insensitive sort field that can be compared against a raw string value -/// supplied by an API caller. -/// -public sealed record SortField(string Value) -{ - /// - /// Returns when (after trimming) matches - /// this field's using a case-insensitive ordinal comparison. - /// - public bool Matches(string? input) => - string.Equals(Value, input?.Trim(), StringComparison.OrdinalIgnoreCase); -} diff --git a/src/SharedKernel/SharedKernel.Application/Sorting/SortFieldMap.cs b/src/SharedKernel/SharedKernel.Application/Sorting/SortFieldMap.cs deleted file mode 100644 index 1ec0cbcf..00000000 --- a/src/SharedKernel/SharedKernel.Application/Sorting/SortFieldMap.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Linq.Expressions; -using Ardalis.Specification; - -namespace SharedKernel.Application.Sorting; - -/// -/// Fluent builder that maps named values to strongly-typed key-selector expressions -/// and applies the resulting OrderBy / OrderByDescending clause to an Ardalis Specification query. -/// -public sealed class SortFieldMap - where TEntity : class -{ - private readonly record struct Entry( - SortField Field, - Expression> KeySelector - ); - - private readonly List _entries = []; - private Expression>? _default; - - /// Returns the collection of registered sort field names that callers are permitted to use. - public IReadOnlyCollection AllowedNames => - _entries.Select(e => e.Field.Value).ToArray(); - - /// Registers a named sort field paired with its key-selector expression and returns for chaining. - public SortFieldMap Add( - SortField field, - Expression> keySelector - ) - { - _entries.Add(new(field, keySelector)); - return this; - } - - /// Sets the fallback key-selector applied when no recognised sort field is supplied by the caller. - public SortFieldMap Default(Expression> keySelector) - { - _default = keySelector; - return this; - } - - /// - /// Resolves the appropriate key selector from and appends an - /// OrderBy or OrderByDescending clause to . - /// Defaults to descending order; uses the fallback key selector when - /// is unrecognised or . - /// - public void ApplySort( - ISpecificationBuilder query, - string? sortBy, - string? sortDirection - ) - { - bool desc = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase); - Expression>? key = - _entries.FirstOrDefault(e => e.Field.Matches(sortBy)).KeySelector ?? _default; - - if (key is null) - return; - - if (desc) - query.OrderByDescending(key); - else - query.OrderBy(key); - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Validation/DataAnnotationsValidator.cs b/src/SharedKernel/SharedKernel.Application/Validation/DataAnnotationsValidator.cs deleted file mode 100644 index 4ed4f782..00000000 --- a/src/SharedKernel/SharedKernel.Application/Validation/DataAnnotationsValidator.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Reflection; -using FluentValidation; - -namespace SharedKernel.Application.Validation; - -/// -/// Base FluentValidation validator that bridges Data Annotations attributes into the FluentValidation -/// pipeline. Validates both property-level and constructor-parameter-level attributes, making it suitable -/// for records whose validation attributes are declared on primary constructor parameters. -/// -public abstract class DataAnnotationsValidator : AbstractValidator - where T : class -{ - protected DataAnnotationsValidator() - { - RuleFor(x => x) - .Custom( - static (model, context) => - { - List results = new(); - Validator.TryValidateObject( - model, - new ValidationContext(model), - results, - validateAllProperties: true - ); - - // For records, also validate constructor parameter attributes that may not be on properties. - ValidateConstructorParameterAttributes(model, results); - - foreach (ValidationResult result in results) - context.AddFailure( - result.MemberNames.FirstOrDefault() ?? string.Empty, - result.ErrorMessage! - ); - } - ); - } - - /// - /// Inspects the first public constructor of and runs any - /// instances found on its parameters, appending - /// failures to . Skips parameters whose member names already have failures. - /// - private static void ValidateConstructorParameterAttributes( - T model, - List results - ) - { - Type type = model.GetType(); - ConstructorInfo? constructor = type.GetConstructors().FirstOrDefault(); - if (constructor is null) - return; - - HashSet existingMembers = new(results.SelectMany(r => r.MemberNames)); - - foreach (ParameterInfo parameter in constructor.GetParameters()) - { - if (existingMembers.Contains(parameter.Name ?? string.Empty)) - continue; - - IEnumerable validationAttributes = - parameter.GetCustomAttributes(); - PropertyInfo? property = type.GetProperty( - parameter.Name!, - BindingFlags.Public | BindingFlags.Instance - ); - if (property is null) - continue; - - object? value = property.GetValue(model); - ValidationContext validationContext = new(model) { MemberName = parameter.Name }; - - foreach (ValidationAttribute attribute in validationAttributes) - { - ValidationResult? result = attribute.GetValidationResult(value, validationContext); - if (result != ValidationResult.Success && result is not null) - results.Add(result); - } - } - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Validation/DateRangeFilterValidator.cs b/src/SharedKernel/SharedKernel.Application/Validation/DateRangeFilterValidator.cs deleted file mode 100644 index e44a8c6c..00000000 --- a/src/SharedKernel/SharedKernel.Application/Validation/DateRangeFilterValidator.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FluentValidation; -using SharedKernel.Application.Contracts; - -namespace SharedKernel.Application.Validation; - -/// -/// FluentValidation validator that enforces date-range coherence for any filter implementing -/// : CreatedTo must be greater than or equal to CreatedFrom -/// when both values are provided. -/// -public sealed class DateRangeFilterValidator : AbstractValidator - where T : IDateRangeFilter -{ - public DateRangeFilterValidator() - { - RuleFor(x => x.CreatedTo) - .GreaterThanOrEqualTo(x => x.CreatedFrom!.Value) - .WithMessage("CreatedTo must be greater than or equal to CreatedFrom.") - .When(x => x.CreatedFrom.HasValue && x.CreatedTo.HasValue); - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Validation/FluentValidationExtensions.cs b/src/SharedKernel/SharedKernel.Application/Validation/FluentValidationExtensions.cs deleted file mode 100644 index e935aadb..00000000 --- a/src/SharedKernel/SharedKernel.Application/Validation/FluentValidationExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using FluentValidation; -using SharedKernel.Application.Errors; - -namespace SharedKernel.Application.Validation; - -/// -/// Extension methods that integrate FluentValidation with the application's error-handling conventions. -/// -public static class FluentValidationExtensions -{ - /// - /// Validates and throws a domain - /// when validation fails, - /// aggregating all error messages into a single semicolon-delimited string. - /// - public static async Task ValidateAndThrowAppAsync( - this IValidator validator, - T instance, - CancellationToken ct = default, - string? errorCode = null - ) - { - FluentValidation.Results.ValidationResult result = await validator.ValidateAsync( - instance, - ct - ); - if (!result.IsValid) - throw new SharedKernel.Domain.Exceptions.ValidationException( - string.Join("; ", result.Errors.Select(e => e.ErrorMessage)), - errorCode ?? ErrorCatalog.General.ValidationFailed - ); - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Validation/NotEmptyAttribute.cs b/src/SharedKernel/SharedKernel.Application/Validation/NotEmptyAttribute.cs deleted file mode 100644 index f54f88c6..00000000 --- a/src/SharedKernel/SharedKernel.Application/Validation/NotEmptyAttribute.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace SharedKernel.Application.Validation; - -/// -/// Data annotation attribute that rejects , whitespace strings, and -/// values. Applicable to properties and constructor parameters. -/// -[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] -public sealed class NotEmptyAttribute : ValidationAttribute -{ - public NotEmptyAttribute() - : base("'{0}' is required and must not be empty, whitespace, or Guid.Empty.") { } - - protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) - { - bool isEmpty = - value is null - || (value is string str && string.IsNullOrWhiteSpace(str)) - || (value is Guid guid && guid == Guid.Empty); - - if (isEmpty) - return new ValidationResult( - FormatErrorMessage(validationContext.DisplayName), - [validationContext.MemberName!] - ); - - return ValidationResult.Success; - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Validation/PaginationFilterValidator.cs b/src/SharedKernel/SharedKernel.Application/Validation/PaginationFilterValidator.cs deleted file mode 100644 index 5f0f38f8..00000000 --- a/src/SharedKernel/SharedKernel.Application/Validation/PaginationFilterValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SharedKernel.Application.DTOs; - -namespace SharedKernel.Application.Validation; - -/// -/// Validates instances by running all Data Annotation attributes -/// declared on the record's properties and constructor parameters. -/// -public sealed class PaginationFilterValidator : DataAnnotationsValidator; diff --git a/src/SharedKernel/SharedKernel.Application/Validation/ProductRequestValidatorBase.cs b/src/SharedKernel/SharedKernel.Application/Validation/ProductRequestValidatorBase.cs deleted file mode 100644 index 7ae2c2b4..00000000 --- a/src/SharedKernel/SharedKernel.Application/Validation/ProductRequestValidatorBase.cs +++ /dev/null @@ -1,38 +0,0 @@ -using FluentValidation; -using SharedKernel.Application.Contracts; - -namespace SharedKernel.Application.Validation; - -/// -/// Shared FluentValidation extension methods and constants for product-related validation rules. -/// -public static class ProductValidationRules -{ - public const decimal DescriptionRequiredPriceThreshold = 1000; - public const string DescriptionRequiredMessage = - "Description is required for products priced above 1000."; - - /// - /// Adds a rule that makes the string property non-empty when the product price exceeds . - /// - public static IRuleBuilderOptions RequiredAbovePriceThreshold( - this IRuleBuilder ruleBuilder, - Func priceAccessor - ) => - ruleBuilder - .NotEmpty() - .WithMessage(DescriptionRequiredMessage) - .When(x => priceAccessor(x) > DescriptionRequiredPriceThreshold); -} - -/// -/// Abstract base validator for create/update product requests; inherits data-annotation validation and adds the shared description-required-above-price-threshold rule. -/// -public abstract class ProductRequestValidatorBase : DataAnnotationsValidator - where T : class, IProductRequest -{ - protected ProductRequestValidatorBase() - { - RuleFor(x => x.Description).RequiredAbovePriceThreshold(x => x.Price); - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Validation/SortableFilterValidator.cs b/src/SharedKernel/SharedKernel.Application/Validation/SortableFilterValidator.cs deleted file mode 100644 index 2ff9a749..00000000 --- a/src/SharedKernel/SharedKernel.Application/Validation/SortableFilterValidator.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FluentValidation; -using SharedKernel.Application.Contracts; - -namespace SharedKernel.Application.Validation; - -/// -/// FluentValidation validator that ensures SortBy is one of a known set of allowed field names -/// and that SortDirection is either asc or desc (case-insensitive). -/// -public sealed class SortableFilterValidator : AbstractValidator - where T : ISortableFilter -{ - public SortableFilterValidator(IReadOnlyCollection allowedSortFields) - { - RuleFor(x => x.SortBy) - .Must(s => - s is null - || allowedSortFields.Any(f => f.Equals(s, StringComparison.OrdinalIgnoreCase)) - ) - .WithMessage($"SortBy must be one of: {string.Join(", ", allowedSortFields)}."); - - RuleFor(x => x.SortDirection) - .Must(s => - s is null - || s.Equals("asc", StringComparison.OrdinalIgnoreCase) - || s.Equals("desc", StringComparison.OrdinalIgnoreCase) - ) - .WithMessage("SortDirection must be one of: asc, desc."); - } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Common/PagedResponse.cs b/src/SharedKernel/SharedKernel.Domain/Common/PagedResponse.cs deleted file mode 100644 index 92df104d..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Common/PagedResponse.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace SharedKernel.Domain.Common; - -/// -/// Generic paged result envelope returned by list queries throughout the Application layer. -/// Carries the current page of items together with metadata needed for client-side pagination controls. -/// -/// The type of items in the page. -public record PagedResponse(IEnumerable Items, int TotalCount, int PageNumber, int PageSize) -{ - /// Total number of pages derived from and . - public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); - - /// Returns true when a previous page exists. - public bool HasPreviousPage => PageNumber > 1; - - /// Returns true when a subsequent page exists. - public bool HasNextPage => PageNumber < TotalPages; -} diff --git a/src/SharedKernel/SharedKernel.Domain/Entities/AuditDefaults.cs b/src/SharedKernel/SharedKernel.Domain/Entities/AuditDefaults.cs deleted file mode 100644 index d41b8b12..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Entities/AuditDefaults.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SharedKernel.Domain.Entities; - -/// -/// Provides well-known sentinel values used to populate when no real actor is available. -/// -public static class AuditDefaults -{ - /// - /// The actor ID assigned to audit fields when an operation is performed by the system rather than a human user. - /// - public static readonly Guid SystemActorId = Guid.Empty; -} diff --git a/src/SharedKernel/SharedKernel.Domain/Entities/AuditInfo.cs b/src/SharedKernel/SharedKernel.Domain/Entities/AuditInfo.cs deleted file mode 100644 index d3a8d4c5..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Entities/AuditInfo.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace SharedKernel.Domain.Entities; - -/// -/// Value object that records who created and last modified an entity, and when. -/// Embedded as an owned type on all implementations. -/// -public sealed class AuditInfo -{ - public DateTime CreatedAtUtc { get; set; } - public Guid CreatedBy { get; set; } = AuditDefaults.SystemActorId; - public DateTime UpdatedAtUtc { get; set; } - public Guid UpdatedBy { get; set; } = AuditDefaults.SystemActorId; -} diff --git a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/IAuditableEntity.cs b/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/IAuditableEntity.cs deleted file mode 100644 index 513793e5..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/IAuditableEntity.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SharedKernel.Domain.Entities.Contracts; - -/// -/// Marks a domain entity as auditable, requiring it to expose an owned object -/// that records creation and last-modification metadata. -/// -public interface IAuditableEntity -{ - AuditInfo Audit { get; set; } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/IAuditableTenantEntity.cs b/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/IAuditableTenantEntity.cs deleted file mode 100644 index 16cdb2a3..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/IAuditableTenantEntity.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SharedKernel.Domain.Entities.Contracts; - -/// -/// Composite entity contract that combines tenant isolation, audit tracking, and soft-delete capability. -/// All first-class tenant-scoped domain entities implement this interface. -/// -public interface IAuditableTenantEntity : ITenantEntity, IAuditableEntity, ISoftDeletable { } diff --git a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/IHasId.cs b/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/IHasId.cs deleted file mode 100644 index 9185a202..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/IHasId.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SharedKernel.Domain.Entities.Contracts; - -/// -/// Marks a type that carries a unique identity. -/// -public interface IHasId -{ - Guid Id { get; } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/ISoftDeletable.cs b/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/ISoftDeletable.cs deleted file mode 100644 index c962631d..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/ISoftDeletable.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SharedKernel.Domain.Entities.Contracts; - -/// -/// Marks a domain entity as soft-deletable, meaning it is logically removed by setting -/// rather than being physically purged from the database. -/// -public interface ISoftDeletable -{ - bool IsDeleted { get; set; } - DateTime? DeletedAtUtc { get; set; } - Guid? DeletedBy { get; set; } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/ITenantEntity.cs b/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/ITenantEntity.cs deleted file mode 100644 index 5ab7fc28..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Entities/Contracts/ITenantEntity.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SharedKernel.Domain.Entities.Contracts; - -/// -/// Marks a domain entity as belonging to a specific tenant, enabling query-level tenant isolation -/// via global EF Core query filters. -/// -public interface ITenantEntity -{ - Guid TenantId { get; set; } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Exceptions/AppException.cs b/src/SharedKernel/SharedKernel.Domain/Exceptions/AppException.cs deleted file mode 100644 index b6047b80..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Exceptions/AppException.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace SharedKernel.Domain.Exceptions; - -/// -/// Base class for all domain exceptions in this application. -/// Concrete subtypes map to specific HTTP status codes in the global exception handler. -/// -public abstract class AppException : Exception -{ - /// Optional machine-readable error code that callers can use for programmatic error handling. - public string? ErrorCode { get; } - - /// Optional key-value bag of contextual data that can be included in the error response. - public IReadOnlyDictionary? Metadata { get; } - - protected AppException( - string message, - string? errorCode = null, - IReadOnlyDictionary? metadata = null - ) - : base(message) - { - ErrorCode = errorCode; - Metadata = metadata; - } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Exceptions/ConflictException.cs b/src/SharedKernel/SharedKernel.Domain/Exceptions/ConflictException.cs deleted file mode 100644 index ee20ce38..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Exceptions/ConflictException.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SharedKernel.Domain.Exceptions; - -/// -/// Thrown when a requested operation cannot proceed because it conflicts with the current state of an existing resource (HTTP 409). -/// -public sealed class ConflictException : AppException -{ - public ConflictException( - string message, - string? errorCode = null, - IReadOnlyDictionary? metadata = null - ) - : base(message, errorCode, metadata) { } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Exceptions/ForbiddenException.cs b/src/SharedKernel/SharedKernel.Domain/Exceptions/ForbiddenException.cs deleted file mode 100644 index 29ca8ac2..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Exceptions/ForbiddenException.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SharedKernel.Domain.Exceptions; - -/// -/// Thrown when an authenticated user attempts to access a resource or perform an action they are not authorized for (HTTP 403). -/// -public sealed class ForbiddenException : AppException -{ - public ForbiddenException(string message, string? errorCode = null) - : base(message, errorCode) { } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Exceptions/NotFoundException.cs b/src/SharedKernel/SharedKernel.Domain/Exceptions/NotFoundException.cs deleted file mode 100644 index 68399dc3..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Exceptions/NotFoundException.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SharedKernel.Domain.Exceptions; - -/// -/// Thrown when a requested entity cannot be found by the given identifier (HTTP 404). -/// The message is automatically formatted as "{entityName} with id '{id}' not found.". -/// -public sealed class NotFoundException : AppException -{ - public NotFoundException( - string entityName, - object id, - string? errorCode = null, - IReadOnlyDictionary? metadata = null - ) - : base($"{entityName} with id '{id}' not found.", errorCode, metadata) { } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Exceptions/UnauthorizedException.cs b/src/SharedKernel/SharedKernel.Domain/Exceptions/UnauthorizedException.cs deleted file mode 100644 index d34f4c25..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Exceptions/UnauthorizedException.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SharedKernel.Domain.Exceptions; - -/// -/// Thrown when a request lacks valid authentication credentials required to access a resource (HTTP 401). -/// -public sealed class UnauthorizedException : AppException -{ - public UnauthorizedException(string message, string? errorCode = null) - : base(message, errorCode) { } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Exceptions/ValidationException.cs b/src/SharedKernel/SharedKernel.Domain/Exceptions/ValidationException.cs deleted file mode 100644 index 333951f6..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Exceptions/ValidationException.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SharedKernel.Domain.Exceptions; - -/// -/// Thrown when input data fails domain or application validation rules (HTTP 422). -/// -public sealed class ValidationException : AppException -{ - public ValidationException( - string message, - string? errorCode = null, - IReadOnlyDictionary? metadata = null - ) - : base(message, errorCode, metadata) { } -} diff --git a/src/SharedKernel/SharedKernel.Domain/Interfaces/IRepository.cs b/src/SharedKernel/SharedKernel.Domain/Interfaces/IRepository.cs deleted file mode 100644 index c322efdc..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Interfaces/IRepository.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Ardalis.Specification; -using SharedKernel.Domain.Common; - -namespace SharedKernel.Domain.Interfaces; - -/// -/// Generic repository abstraction that extends Ardalis with an additional -/// delete-by-ID overload, providing a consistent data-access contract for all relational domain entities. -/// -public interface IRepository : IRepositoryBase - where T : class -{ - // Inherited from IRepositoryBase (Ardalis): - // GetByIdAsync(TId id, ct) - // ListAsync(ISpecification, ct) → List - // ListAsync(ISpecification, ct) → List ← HTTP path - // FirstOrDefaultAsync, CountAsync, AnyAsync, ... - // AddAsync(T entity, ct), UpdateAsync(T entity, ct), DeleteAsync(T entity, ct) - - /// - /// Returns a single-query paged result by embedding the total count as a scalar sub-query, - /// eliminating the need for a separate COUNT query. - /// The specification must contain filter, sort, and projection but no Skip/Take. - /// - Task> GetPagedAsync( - ISpecification spec, - int pageNumber, - int pageSize, - CancellationToken ct = default - ); - - // Ardalis only has DeleteAsync(T entity), we also need DeleteAsync(Guid id) - /// - /// Deletes the entity with the given ; throws when no entity is found. - /// - Task DeleteAsync(Guid id, CancellationToken ct = default, string? errorCode = null); -} diff --git a/src/SharedKernel/SharedKernel.Domain/Interfaces/IUnitOfWork.cs b/src/SharedKernel/SharedKernel.Domain/Interfaces/IUnitOfWork.cs deleted file mode 100644 index 6a7db5e9..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Interfaces/IUnitOfWork.cs +++ /dev/null @@ -1,70 +0,0 @@ -using SharedKernel.Domain.Options; - -namespace SharedKernel.Domain.Interfaces; - -/// -/// Contract for the relational unit-of-work boundary used by application services. -/// Repositories stage entity changes, while this contract defines how those staged changes are flushed -/// and how explicit transaction boundaries are created. -/// -/// -/// -/// is the simple write path for already-orchestrated service operations and -/// translates to one persistence flush for the current scope. -/// -/// -/// -/// is the explicit transaction path. The outermost call resolves the effective transaction policy by merging -/// configured defaults with per-call overrides, applies the effective timeout/retry policy, opens the database -/// transaction, and commits once after the delegate succeeds. -/// -/// -/// Nested ExecuteInTransactionAsync(...) calls do not create another top-level transaction. They execute -/// inside the active outer transaction by using a savepoint and inherit the active outer policy. Conflicting nested -/// options fail fast to avoid silently changing isolation level, timeout, or retry behavior mid-transaction. -/// -/// -public interface IUnitOfWork -{ - /// - /// Persists all staged relational changes for the current service operation. - /// Use this for single-write flows after repository calls. - /// This method must not be called inside - /// or . - /// - Task CommitAsync(CancellationToken ct = default); - - /// - /// Runs a multi-step relational write flow in one explicit transaction. - /// The outermost call owns the database transaction and retry strategy; nested calls use savepoints inside the active transaction. - /// The delegate should stage repository changes only; do not call inside it. - /// Calling from inside the delegate throws . - /// When is provided, its non-null values override the configured transaction defaults for the outermost call. - /// Nested calls inherit the active outer transaction policy and must not pass conflicting overrides. - /// Example: - /// await _unitOfWork.ExecuteInTransactionAsync(async () => - /// { - /// await _productRepository.UpdateAsync(product, ct); - /// await _reviewRepository.AddAsync(review, ct); - /// }, ct); - /// - Task ExecuteInTransactionAsync( - Func action, - CancellationToken ct = default, - TransactionOptions? options = null - ); - - /// - /// Runs a multi-step relational write flow in one explicit transaction and returns a value. - /// The outermost call owns the database transaction and retry strategy; nested calls use savepoints inside the active transaction. - /// The delegate should stage repository changes only; do not call inside it. - /// Calling from inside the delegate throws . - /// When is provided, its non-null values override the configured transaction defaults for the outermost call. - /// Nested calls inherit the active outer transaction policy and must not pass conflicting overrides. - /// - Task ExecuteInTransactionAsync( - Func> action, - CancellationToken ct = default, - TransactionOptions? options = null - ); -} diff --git a/src/SharedKernel/SharedKernel.Domain/Options/TransactionOptions.cs b/src/SharedKernel/SharedKernel.Domain/Options/TransactionOptions.cs deleted file mode 100644 index 1c364975..00000000 --- a/src/SharedKernel/SharedKernel.Domain/Options/TransactionOptions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Data; - -namespace SharedKernel.Domain.Options; - -/// -/// Per-call overrides for the transaction policy applied by . -/// Any null property means "inherit the configured default"; non-null values override that default for the outermost transaction only. -/// -public sealed record TransactionOptions -{ - public IsolationLevel? IsolationLevel { get; init; } - public int? TimeoutSeconds { get; init; } - public bool? RetryEnabled { get; init; } - public int? RetryCount { get; init; } - public int? RetryDelaySeconds { get; init; } - - /// - /// Returns true when all properties are null, meaning the record carries no overrides - /// and the configured defaults apply entirely. - /// - public bool IsEmpty() => - IsolationLevel is null - && TimeoutSeconds is null - && RetryEnabled is null - && RetryCount is null - && RetryDelaySeconds is null; -} diff --git a/src/SharedKernel/SharedKernel.Domain/SharedKernel.Domain.csproj b/src/SharedKernel/SharedKernel.Domain/SharedKernel.Domain.csproj deleted file mode 100644 index a35a939e..00000000 --- a/src/SharedKernel/SharedKernel.Domain/SharedKernel.Domain.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs b/src/SharedKernel/SharedKernel.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs deleted file mode 100644 index 6944877c..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using SharedKernel.Application.Contracts; -using StackExchange.Redis; - -namespace SharedKernel.Infrastructure.Idempotency; - -/// -/// Redis/Dragonfly-backed implementation of that stores -/// idempotency cache entries and distributed locks using atomic Lua scripts. -/// Suitable for multi-instance deployments where in-process state would cause duplicate processing. -/// -public sealed class DistributedCacheIdempotencyStore : IIdempotencyStore -{ - private const string KeyPrefix = "idempotency:"; - - private static readonly LuaScript ReleaseLockScript = LuaScript.Prepare( - "if redis.call('get', @key) == @value then return redis.call('del', @key) else return 0 end" - ); - - private readonly IDatabase _database; - private readonly ConcurrentDictionary _lockOwners = new(); - - public DistributedCacheIdempotencyStore(IConnectionMultiplexer connectionMultiplexer) - { - _database = connectionMultiplexer.GetDatabase(); - } - - /// Returns the cached entry for if it exists in Redis, or if absent or expired. - public async Task TryGetAsync( - string key, - CancellationToken ct = default - ) - { - RedisValue json = await _database.StringGetAsync(KeyPrefix + key); - return json.IsNullOrEmpty - ? null - : JsonSerializer.Deserialize(json.ToString()); - } - - /// - /// Attempts to set a lock key in Redis using SET NX with the given . - /// Returns if the lock was acquired; the lock value is stored locally for later release. - /// - public async Task TryAcquireAsync( - string key, - TimeSpan ttl, - CancellationToken ct = default - ) - { - string lockKey = KeyPrefix + key + IdempotencyStoreConstants.LockSuffix; - string lockValue = Guid.NewGuid().ToString("N"); - - bool acquired = await _database.StringSetAsync( - lockKey, - lockValue, - ttl, - when: When.NotExists - ); - - if (acquired) - _lockOwners[key] = lockValue; - - return acquired; - } - - /// Serialises and stores it under in Redis with the specified . - public async Task SetAsync( - string key, - IdempotencyCacheEntry entry, - TimeSpan ttl, - CancellationToken ct = default - ) - { - string json = JsonSerializer.Serialize(entry); - await _database.StringSetAsync(KeyPrefix + key, json, ttl); - } - - /// Releases the lock for using an atomic Lua compare-and-delete script to prevent releasing a lock owned by another instance. - public async Task ReleaseAsync(string key, CancellationToken ct = default) - { - if (!_lockOwners.TryRemove(key, out string? lockValue)) - return; - - string lockKey = KeyPrefix + key + IdempotencyStoreConstants.LockSuffix; - await _database.ScriptEvaluateAsync( - ReleaseLockScript, - new { key = (RedisKey)lockKey, value = (RedisValue)lockValue } - ); - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Idempotency/IdempotencyStoreConstants.cs b/src/SharedKernel/SharedKernel.Infrastructure/Idempotency/IdempotencyStoreConstants.cs deleted file mode 100644 index b8c0a723..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Idempotency/IdempotencyStoreConstants.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace SharedKernel.Infrastructure.Idempotency; - -/// Shared key-naming constants used by idempotency store implementations. -internal static class IdempotencyStoreConstants -{ - /// Suffix appended to an idempotency key to form the corresponding distributed-lock key. - public const string LockSuffix = ":lock"; -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs b/src/SharedKernel/SharedKernel.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs deleted file mode 100644 index 0790f7cb..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using SharedKernel.Application.Contracts; - -namespace SharedKernel.Infrastructure.Idempotency; - -/// -/// Single-process, in-memory implementation of backed by -/// . Suitable for development and single-instance -/// deployments; TTL enforcement is done lazily on access via EvictExpired. -/// -public sealed class InMemoryIdempotencyStore : IIdempotencyStore -{ - private readonly ConcurrentDictionary _store = - new(); - private readonly ConcurrentDictionary _lockOwners = new(); - private readonly TimeProvider _timeProvider; - - public InMemoryIdempotencyStore(TimeProvider timeProvider) - { - _timeProvider = timeProvider; - } - - /// Returns the cached entry for if it exists and has not expired; triggers lazy eviction otherwise. - public Task TryGetAsync(string key, CancellationToken ct = default) - { - if ( - _store.TryGetValue(key, out (string Value, DateTimeOffset Expiry) entry) - && entry.Expiry > _timeProvider.GetUtcNow() - ) - { - IdempotencyCacheEntry? result = JsonSerializer.Deserialize( - entry.Value - ); - return Task.FromResult(result); - } - - EvictExpired(); - return Task.FromResult(null); - } - - /// Attempts to insert a lock entry using TryAdd; returns if the lock was acquired by this call. - public Task TryAcquireAsync(string key, TimeSpan ttl, CancellationToken ct = default) - { - EvictExpired(); - - string lockKey = key + IdempotencyStoreConstants.LockSuffix; - string lockValue = Guid.NewGuid().ToString("N"); - DateTimeOffset expiry = _timeProvider.GetUtcNow().Add(ttl); - bool acquired = _store.TryAdd(lockKey, (lockValue, expiry)); - - if (acquired) - _lockOwners[key] = lockValue; - - return Task.FromResult(acquired); - } - - /// Serialises and inserts or replaces it in the store with the specified . - public Task SetAsync( - string key, - IdempotencyCacheEntry entry, - TimeSpan ttl, - CancellationToken ct = default - ) - { - string json = JsonSerializer.Serialize(entry); - DateTimeOffset expiry = _timeProvider.GetUtcNow().Add(ttl); - _store[key] = (json, expiry); - return Task.CompletedTask; - } - - /// Removes the lock entry for only if it is still owned by this store instance, preventing accidental release of expired locks. - public Task ReleaseAsync(string key, CancellationToken ct = default) - { - if (!_lockOwners.TryRemove(key, out string? lockValue)) - return Task.CompletedTask; - - string lockKey = key + IdempotencyStoreConstants.LockSuffix; - _store.TryRemove( - new KeyValuePair( - lockKey, - _store.GetValueOrDefault(lockKey) - ) - ); - return Task.CompletedTask; - } - - /// Lazily removes all entries whose expiry has passed, keeping memory usage bounded without a dedicated timer. - private void EvictExpired() - { - DateTimeOffset now = _timeProvider.GetUtcNow(); - foreach (KeyValuePair kvp in _store) - { - if (kvp.Value.Expiry <= now) - _store.TryRemove(kvp); - } - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs deleted file mode 100644 index 5d08fb76..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace SharedKernel.Infrastructure.Persistence.Auditing; - -/// -/// Infrastructure implementation of that stamps -/// audit fields on EF Core entity entries in response to Add, Modify, and soft-delete state transitions. -/// -public sealed class AuditableEntityStateManager : IAuditableEntityStateManager -{ - /// - /// Stamps creation audit fields, assigns the tenant ID when one is active, resets soft-delete - /// flags, and ensures the entity entry state is . - /// - public void StampAdded( - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor, - bool hasTenant, - Guid currentTenantId - ) - { - if (entity.TenantId == Guid.Empty && hasTenant) - entity.TenantId = currentTenantId; - - entity.Audit.CreatedAtUtc = now; - entity.Audit.CreatedBy = actor; - StampModified(entity, now, actor); - ResetSoftDelete(entity); - entry.State = EntityState.Added; - } - - /// Updates the UpdatedAtUtc and UpdatedBy audit fields. - public void StampModified(IAuditableTenantEntity entity, DateTime now, Guid actor) - { - entity.Audit.UpdatedAtUtc = now; - entity.Audit.UpdatedBy = actor; - } - - /// - /// Converts a hard-delete entry to a soft-delete by switching the entry state to Modified - /// and setting IsDeleted, DeletedAtUtc, and DeletedBy. - /// Also ensures the owned entry is marked Modified. - /// - public void MarkSoftDeleted( - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor - ) - { - entry.State = EntityState.Modified; - entity.IsDeleted = true; - entity.DeletedAtUtc = now; - entity.DeletedBy = actor; - StampModified(entity, now, actor); - EnsureAuditOwnedEntryState(entry, now, actor); - } - - private static void ResetSoftDelete(IAuditableTenantEntity entity) - { - entity.IsDeleted = false; - entity.DeletedAtUtc = null; - entity.DeletedBy = null; - } - - private static void EnsureAuditOwnedEntryState(EntityEntry ownerEntry, DateTime now, Guid actor) - { - EntityEntry? auditEntry = ownerEntry - .Reference(nameof(IAuditableTenantEntity.Audit)) - .TargetEntry; - if (auditEntry is null) - return; - - if ( - auditEntry.State is EntityState.Deleted or EntityState.Detached or EntityState.Unchanged - ) - auditEntry.State = EntityState.Modified; - - auditEntry.Property(nameof(AuditInfo.UpdatedAtUtc)).CurrentValue = now; - auditEntry.Property(nameof(AuditInfo.UpdatedBy)).CurrentValue = actor; - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs deleted file mode 100644 index 33267da6..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.EntityFrameworkCore.ChangeTracking; -using SharedKernel.Domain.Entities.Contracts; - -namespace SharedKernel.Infrastructure.Persistence.Auditing; - -/// -/// Abstracts audit-field stamping for instances tracked by EF Core, -/// covering the add, modify, and soft-delete state transitions. -/// -public interface IAuditableEntityStateManager -{ - /// Stamps creation audit fields and assigns tenant context when an entity is first added. - void StampAdded( - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor, - bool hasTenant, - Guid currentTenantId - ); - - /// Updates the last-modified audit fields when an entity changes. - void StampModified(IAuditableTenantEntity entity, DateTime now, Guid actor); - - /// Converts a pending hard-delete into a soft-delete and stamps the deletion audit fields. - void MarkSoftDeleted( - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor - ); -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs deleted file mode 100644 index 2ceb5564..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SharedKernel.Domain.Entities; -using SharedKernel.Domain.Entities.Contracts; - -namespace SharedKernel.Infrastructure.Persistence.Configurations; - -/// -/// Extension methods that apply the standard tenant, audit, soft-delete, and optimistic-concurrency -/// column configuration to any entity implementing . -/// -public static class TenantAuditableEntityConfigurationExtensions -{ - /// - /// Configures tenant ID, owned audit info columns, soft-delete fields, PostgreSQL xmin - /// optimistic-concurrency token, standard indexes, and a soft-delete consistency check constraint. - /// - public static void ConfigureTenantAuditable(this EntityTypeBuilder builder) - where TEntity : class, IAuditableTenantEntity - { - builder.Property(e => e.TenantId).IsRequired(); - - builder.OwnsOne( - e => e.Audit, - audit => - { - audit - .Property(a => a.CreatedAtUtc) - .HasColumnName("CreatedAtUtc") - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - audit - .Property(a => a.CreatedBy) - .HasColumnName("CreatedBy") - .IsRequired() - .HasDefaultValue(AuditDefaults.SystemActorId); - - audit - .Property(a => a.UpdatedAtUtc) - .HasColumnName("UpdatedAtUtc") - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - audit - .Property(a => a.UpdatedBy) - .HasColumnName("UpdatedBy") - .IsRequired() - .HasDefaultValue(AuditDefaults.SystemActorId); - } - ); - - builder.Property(e => e.IsDeleted).IsRequired().HasDefaultValue(false); - - builder.Property(e => e.DeletedAtUtc).HasColumnType("timestamp with time zone"); - - builder.Property(e => e.DeletedBy); - - // PostgreSQL native optimistic concurrency using the built-in xmin system column (transaction ID) - builder - .Property("xmin") - .HasColumnType("xid") - .ValueGeneratedOnAddOrUpdate() - .IsConcurrencyToken(); - - builder.HasIndex(e => e.TenantId); - builder.HasIndex(e => new { e.TenantId, e.IsDeleted }); - - builder.ToTable(t => - t.HasCheckConstraint( - $"CK_{builder.Metadata.GetTableName()}_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ) - ); - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs deleted file mode 100644 index b8aeb355..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SharedKernel.Domain.Entities.Contracts; - -namespace SharedKernel.Infrastructure.Persistence.EntityNormalization; - -/// -/// Defines normalization behavior applied to instances -/// before they are persisted, such as lowercasing lookup fields. -/// -public interface IEntityNormalizationService -{ - /// Normalizes the relevant fields of in place before persistence. - void Normalize(IAuditableTenantEntity entity); -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs deleted file mode 100644 index 62c3815c..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SharedKernel.Domain.Entities.Contracts; - -namespace SharedKernel.Infrastructure.Persistence.SoftDelete; - -/// -/// Defines explicit soft-delete cascade behavior for one aggregate/entity type. -/// Implementations decide: -/// -/// which entity types they can handle -/// which dependents should be soft-deleted together with the root entity -/// -/// -public interface ISoftDeleteCascadeRule -{ - /// - /// Returns true when this rule can provide dependents for the given entity instance. - /// - bool CanHandle(IAuditableTenantEntity entity); - - /// - /// Returns dependents that should be soft-deleted when the root entity is deleted. - /// Returned entities must be tracked/auditable entities. - /// - Task> GetDependentsAsync( - DbContext dbContext, - IAuditableTenantEntity entity, - CancellationToken cancellationToken = default - ); -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs deleted file mode 100644 index a8e6bff2..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using SharedKernel.Domain.Entities.Contracts; - -namespace SharedKernel.Infrastructure.Persistence.SoftDelete; - -/// -/// Orchestrates the soft-delete of an entity entry, including recursive cascade to dependents -/// discovered through registered implementations. -/// -public interface ISoftDeleteProcessor -{ - /// - /// Converts the EF Core delete for into a soft-delete update, - /// then recursively soft-deletes all dependents returned by applicable cascade rules. - /// - Task ProcessAsync( - DbContext dbContext, - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor, - IReadOnlyCollection softDeleteCascadeRules, - CancellationToken cancellationToken - ); -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteExtensions.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteExtensions.cs deleted file mode 100644 index 45b7b560..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SharedKernel.Domain.Entities.Contracts; - -namespace SharedKernel.Infrastructure.Persistence.SoftDelete; - -public static class SoftDeleteExtensions -{ - public static Task BulkSoftDeleteAsync( - this IQueryable query, - Guid? actorId, - DateTime deletedAtUtc, - CancellationToken ct = default - ) - where TEntity : class, ISoftDeletable - { - return query.ExecuteUpdateAsync( - s => - s.SetProperty(e => e.IsDeleted, true) - .SetProperty(e => e.DeletedAtUtc, deletedAtUtc) - .SetProperty(e => e.DeletedBy, actorId), - ct - ); - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs deleted file mode 100644 index 9749c9b9..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Infrastructure.Persistence.Auditing; - -namespace SharedKernel.Infrastructure.Persistence.SoftDelete; - -/// -/// Default implementation of that recursively soft-deletes -/// an entity and all dependents surfaced by cascade rules, guarding against cycles via a visited set. -/// -public sealed class SoftDeleteProcessor : ISoftDeleteProcessor -{ - private readonly IAuditableEntityStateManager _stateManager; - - public SoftDeleteProcessor(IAuditableEntityStateManager stateManager) - { - _stateManager = stateManager; - } - - /// - public Task ProcessAsync( - DbContext dbContext, - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor, - IReadOnlyCollection softDeleteCascadeRules, - CancellationToken cancellationToken - ) - { - HashSet visited = new(ReferenceEqualityComparer.Instance); - return SoftDeleteWithRulesAsync( - dbContext, - entry, - entity, - now, - actor, - softDeleteCascadeRules, - visited, - cancellationToken - ); - } - - private async Task SoftDeleteWithRulesAsync( - DbContext dbContext, - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor, - IReadOnlyCollection softDeleteCascadeRules, - HashSet visited, - CancellationToken cancellationToken - ) - { - if (!visited.Add(entity)) - return; - - _stateManager.MarkSoftDeleted(entry, entity, now, actor); - - foreach ( - ISoftDeleteCascadeRule rule in softDeleteCascadeRules.Where(r => r.CanHandle(entity)) - ) - { - IReadOnlyCollection dependents = await rule.GetDependentsAsync( - dbContext, - entity, - cancellationToken - ); - foreach (IAuditableTenantEntity dependent in dependents) - { - if (dependent.IsDeleted || dependent.TenantId != entity.TenantId) - continue; - - EntityEntry dependentEntry = dbContext.Entry(dependent); - await SoftDeleteWithRulesAsync( - dbContext, - dependentEntry, - dependent, - now, - actor, - softDeleteCascadeRules, - visited, - cancellationToken - ); - } - } - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs deleted file mode 100644 index 8f751a48..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using SharedKernel.Application.Context; -using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Infrastructure.Persistence.Auditing; -using SharedKernel.Infrastructure.Persistence.EntityNormalization; -using SharedKernel.Infrastructure.Persistence.SoftDelete; - -namespace SharedKernel.Infrastructure.Persistence; - -/// -/// Abstract base DbContext that enforces multi-tenancy, audit stamping, soft delete, -/// and optimistic concurrency for all tenant-aware microservice contexts. -/// -public abstract class TenantAuditableDbContext : DbContext -{ - private readonly ITenantProvider _tenantProvider; - private readonly IActorProvider _actorProvider; - private readonly TimeProvider _timeProvider; - private readonly IReadOnlyCollection _softDeleteCascadeRules; - private readonly IAuditableEntityStateManager _entityStateManager; - private readonly ISoftDeleteProcessor _softDeleteProcessor; - private readonly IEntityNormalizationService? _entityNormalizationService; - - protected Guid CurrentTenantId => _tenantProvider.TenantId; - protected bool HasTenant => _tenantProvider.HasTenant; - - protected TenantAuditableDbContext( - DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor, - IEntityNormalizationService? entityNormalizationService = null - ) - : base(options) - { - _tenantProvider = tenantProvider; - _actorProvider = actorProvider; - _timeProvider = timeProvider; - _softDeleteCascadeRules = softDeleteCascadeRules.ToList(); - _entityStateManager = entityStateManager; - _softDeleteProcessor = softDeleteProcessor; - _entityNormalizationService = entityNormalizationService; - } - - public override int SaveChanges(bool acceptAllChangesOnSuccess) - { - throw new NotSupportedException( - "Use SaveChangesAsync to avoid deadlocks. All paths should go through IUnitOfWork.CommitAsync()." - ); - } - - public override async Task SaveChangesAsync( - bool acceptAllChangesOnSuccess, - CancellationToken cancellationToken = default - ) - { - await ApplyEntityAuditingAsync(cancellationToken); - return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); - } - - private async Task ApplyEntityAuditingAsync(CancellationToken cancellationToken) - { - DateTime now = _timeProvider.GetUtcNow().UtcDateTime; - Guid actor = _actorProvider.ActorId; - - foreach ( - EntityEntry entry in ChangeTracker - .Entries() - .Where(e => e.Entity is IAuditableTenantEntity) - .ToList() - ) - { - IAuditableTenantEntity entity = (IAuditableTenantEntity)entry.Entity; - switch (entry.State) - { - case EntityState.Added: - _entityNormalizationService?.Normalize(entity); - _entityStateManager.StampAdded( - entry, - entity, - now, - actor, - HasTenant, - CurrentTenantId - ); - break; - case EntityState.Modified: - _entityNormalizationService?.Normalize(entity); - _entityStateManager.StampModified(entity, now, actor); - break; - case EntityState.Deleted: - await _softDeleteProcessor.ProcessAsync( - this, - entry, - entity, - now, - actor, - _softDeleteCascadeRules, - cancellationToken - ); - break; - } - } - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs deleted file mode 100644 index 04b80dc6..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace SharedKernel.Infrastructure.Persistence.UnitOfWork; - -/// -/// Temporarily overrides the EF Core command timeout for the duration of a scope, -/// restoring the previous timeout value when disposed. -/// -internal sealed class DbContextCommandTimeoutScope(DbContext dbContext) -{ - /// - /// Applies as the current command timeout and returns a disposable - /// that restores the previous timeout on disposal. Providers that do not support command timeouts - /// are silently ignored. - /// - public IDisposable Apply(int? timeoutSeconds) - { - int? previousTimeout = GetCommandTimeoutIfSupported(); - SetCommandTimeoutIfSupported(timeoutSeconds); - return new Releaser(this, previousTimeout); - } - - private int? GetCommandTimeoutIfSupported() - { - try - { - return dbContext.Database.GetCommandTimeout(); - } - catch (Exception ex) when (IsCommandTimeoutNotSupported(ex)) - { - return null; - } - } - - private void SetCommandTimeoutIfSupported(int? timeoutSeconds) - { - try - { - dbContext.Database.SetCommandTimeout(timeoutSeconds); - } - catch (Exception ex) when (IsCommandTimeoutNotSupported(ex)) { } - } - - private static bool IsCommandTimeoutNotSupported(Exception ex) => - ex is InvalidOperationException or NotSupportedException; - - private sealed class Releaser(DbContextCommandTimeoutScope scope, int? previousTimeout) - : IDisposable - { - private DbContextCommandTimeoutScope? _scope = scope; - - public void Dispose() - { - DbContextCommandTimeoutScope? scope = Interlocked.Exchange(ref _scope, null); - if (scope is null) - return; - - scope.SetCommandTimeoutIfSupported(previousTimeout); - } - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs deleted file mode 100644 index be01bfc5..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; - -namespace SharedKernel.Infrastructure.Persistence.UnitOfWork; - -/// -/// Captures and restores a snapshot of all non-detached EF Core change tracker entries, -/// enabling transactional rollback of in-memory state after a savepoint rollback. -/// -internal sealed class DbContextTrackedStateManager(DbContext dbContext) -{ - /// - /// Captures the current state, current values, and original values of all tracked entities - /// and returns a snapshot keyed by object reference identity. - /// - public IReadOnlyDictionary Capture() - { - return dbContext - .ChangeTracker.Entries() - .Where(entry => entry.State != EntityState.Detached) - .ToDictionary( - entry => entry.Entity, - entry => new TrackedEntitySnapshot( - entry.State, - entry.CurrentValues.Clone(), - entry.OriginalValues.Clone() - ), - ReferenceEqualityComparer.Instance - ); - } - - /// - /// Restores the change tracker to the given snapshot, detaching entities not present in - /// the snapshot and reverting current/original values for those that are. - /// - public void Restore(IReadOnlyDictionary snapshot) - { - foreach (EntityEntry entry in dbContext.ChangeTracker.Entries().ToList()) - { - if (!snapshot.TryGetValue(entry.Entity, out TrackedEntitySnapshot? entitySnapshot)) - { - entry.State = EntityState.Detached; - continue; - } - - entry.CurrentValues.SetValues(entitySnapshot.CurrentValues); - entry.OriginalValues.SetValues(entitySnapshot.OriginalValues); - entry.State = entitySnapshot.State; - } - } - - internal sealed record TrackedEntitySnapshot( - EntityState State, - PropertyValues CurrentValues, - PropertyValues OriginalValues - ); -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs deleted file mode 100644 index 602af39c..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; -using SharedKernel.Domain.Options; - -namespace SharedKernel.Infrastructure.Persistence.UnitOfWork; - -/// -/// EF Core implementation of that delegates transaction -/// management and execution strategy creation to the underlying . -/// -public sealed class EfCoreTransactionProvider : IDbTransactionProvider -{ - private readonly DbContext _dbContext; - - public EfCoreTransactionProvider(DbContext dbContext) => _dbContext = dbContext; - - public IDbContextTransaction? CurrentTransaction => _dbContext.Database.CurrentTransaction; - - public Task BeginTransactionAsync( - IsolationLevel isolationLevel, - CancellationToken ct - ) => _dbContext.Database.BeginTransactionAsync(isolationLevel, ct); - - public IExecutionStrategy CreateExecutionStrategy(TransactionOptions options) => - UnitOfWorkExecutionStrategyFactory.Create(_dbContext, options); -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs deleted file mode 100644 index 1e405fe3..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Data; -using Microsoft.EntityFrameworkCore.Storage; -using SharedKernel.Domain.Options; - -namespace SharedKernel.Infrastructure.Persistence.UnitOfWork; - -/// -/// Abstracts low-level database transaction management and execution strategy creation -/// used by to operate independently of the specific EF Core provider. -/// -public interface IDbTransactionProvider -{ - /// Returns the currently active database transaction, or null when none is open. - IDbContextTransaction? CurrentTransaction { get; } - - /// Opens a new database transaction with the specified isolation level. - Task BeginTransactionAsync( - IsolationLevel isolationLevel, - CancellationToken ct - ); - - /// Creates an execution strategy appropriate for the current provider and the given transaction options. - IExecutionStrategy CreateExecutionStrategy(TransactionOptions options); -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs deleted file mode 100644 index 54a4da84..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace SharedKernel.Infrastructure.Persistence.UnitOfWork; - -/// -/// Tracks the nesting depth of managed transaction scopes opened by , -/// exposing to prevent CommitAsync calls inside an outermost transaction. -/// -internal sealed class ManagedTransactionScope -{ - private int _depth; - - public bool IsActive => Volatile.Read(ref _depth) > 0; - - /// Increments the nesting depth and returns a disposable that decrements it on disposal. - public IDisposable Enter() - { - Interlocked.Increment(ref _depth); - return new Releaser(this); - } - - private void Exit() => Interlocked.Decrement(ref _depth); - - private sealed class Releaser(ManagedTransactionScope scope) : IDisposable - { - private ManagedTransactionScope? _scope = scope; - - public void Dispose() - { - ManagedTransactionScope? scope = Interlocked.Exchange(ref _scope, null); - scope?.Exit(); - } - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs deleted file mode 100644 index d442fc11..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SharedKernel.Application.Options; -using SharedKernel.Domain.Interfaces; -using SharedKernel.Domain.Options; - -namespace SharedKernel.Infrastructure.Persistence.UnitOfWork; - -/// -/// EF Core implementation of backed by . -/// -public sealed class UnitOfWork : IUnitOfWork -{ - private const string CommitWithinTransactionMessage = - "CommitAsync cannot be called inside ExecuteInTransactionAsync. The outermost transaction saves and commits automatically."; - - private readonly DbContext _dbContext; - private readonly TransactionDefaultsOptions _transactionDefaults; - private readonly ILogger _logger; - private readonly IDbTransactionProvider _transactionProvider; - private readonly ManagedTransactionScope _managedTransactionScope = new(); - private readonly DbContextTrackedStateManager _trackedStateManager; - private readonly DbContextCommandTimeoutScope _commandTimeoutScope; - private int _savepointCounter; - private TransactionOptions? _activeTransactionOptions; - - /// - /// Creates a that uses configured transaction defaults for explicit transactions. - /// - /// EF Core context that tracks staged relational changes for the current scope. - /// - /// Configured defaults used to resolve the effective isolation level, timeout, and retry policy - /// for outermost calls. - /// - /// Logger used for transaction orchestration diagnostics. - /// Provides transaction management operations for the underlying database. - public UnitOfWork( - DbContext dbContext, - IOptions transactionDefaults, - ILogger logger, - IDbTransactionProvider transactionProvider - ) - { - _dbContext = dbContext; - _transactionDefaults = transactionDefaults.Value; - _logger = logger; - _transactionProvider = transactionProvider; - _trackedStateManager = new DbContextTrackedStateManager(dbContext); - _commandTimeoutScope = new DbContextCommandTimeoutScope(dbContext); - } - - /// - /// Persists all currently staged relational changes without opening an explicit transaction boundary. - /// Use this for simple service flows that already know when the write should be flushed. - /// Retries are managed by this unit of work using the configured default transaction policy. - /// - /// Cancellation token for the underlying SaveChangesAsync call. - /// A task that completes when all staged changes have been flushed to the database. - /// - /// Thrown when called inside because the outermost managed transaction - /// owns the save and commit lifecycle. - /// - public Task CommitAsync(CancellationToken ct = default) - { - if (_managedTransactionScope.IsActive) - { - _logger.CommitRejectedInsideManagedTransaction(); - throw new InvalidOperationException(CommitWithinTransactionMessage); - } - - TransactionOptions effectiveOptions = _transactionDefaults.Resolve(null); - _logger.CommitStarted( - effectiveOptions.RetryEnabled ?? true, - effectiveOptions.TimeoutSeconds - ); - IExecutionStrategy strategy = _transactionProvider.CreateExecutionStrategy( - effectiveOptions - ); - return strategy.ExecuteAsync( - async cancellationToken => - { - await _dbContext.SaveChangesAsync(cancellationToken); - _logger.CommitCompleted(); - }, - ct - ); - } - - /// - /// Executes a write delegate inside an explicit relational transaction. - /// The outermost call owns transaction creation, retry strategy, timeout application, save, and commit. - /// - /// - /// Delegate that stages repository/entity changes inside the transaction boundary. - /// The delegate should not call . - /// - /// Cancellation token propagated to transaction, savepoint, and save operations. - /// - /// Optional per-call transaction overrides. Non-null values override configured defaults only for the outermost call. - /// Nested calls inherit the already active outer transaction policy. - /// - /// A task that completes when the transactional delegate has been saved and committed. - public async Task ExecuteInTransactionAsync( - Func action, - CancellationToken ct = default, - TransactionOptions? options = null - ) => - await ExecuteInTransactionAsync( - async () => - { - await action(); - return true; - }, - ct, - options - ); - - /// - /// Executes a write delegate inside an explicit relational transaction and returns a value created by that flow. - /// Per-call override configured defaults only for the outermost transaction boundary. - /// - /// Type returned by the transactional delegate. - /// - /// Delegate that stages repository/entity changes and returns a value computed inside the transaction boundary. - /// - /// Cancellation token propagated to transaction, savepoint, and save operations. - /// - /// Optional per-call transaction overrides. Non-null values override configured defaults only for the outermost call. - /// Nested calls inherit the already active outer transaction policy. - /// - /// The value returned by after the transaction has been saved and committed. - public async Task ExecuteInTransactionAsync( - Func> action, - CancellationToken ct = default, - TransactionOptions? options = null - ) - { - IDbContextTransaction? currentTransaction = _transactionProvider.CurrentTransaction; - if (currentTransaction is not null) - return await ExecuteWithinSavepointAsync(currentTransaction, action, options, ct); - - TransactionOptions effectiveOptions = _transactionDefaults.Resolve(options); - return await ExecuteAsOutermostTransactionAsync(action, effectiveOptions, ct); - } - - /// - /// Executes a nested transaction scope by using a savepoint inside the active outer transaction. - /// - private async Task ExecuteWithinSavepointAsync( - IDbContextTransaction transaction, - Func> action, - TransactionOptions? options, - CancellationToken ct - ) - { - ValidateNestedTransactionOptions(options); - string savepointName = $"uow_sp_{Interlocked.Increment(ref _savepointCounter)}"; - IReadOnlyDictionary snapshot = - _trackedStateManager.Capture(); - - _logger.SavepointCreating(savepointName); - await transaction.CreateSavepointAsync(savepointName, ct); - try - { - using IDisposable scope = _managedTransactionScope.Enter(); - T result = await action(); - await ReleaseSavepointIfSupportedAsync(transaction, savepointName, ct); - _logger.SavepointReleased(savepointName); - return result; - } - catch - { - await transaction.RollbackToSavepointAsync(savepointName, ct); - _trackedStateManager.Restore(snapshot); - _logger.SavepointRolledBack(savepointName); - throw; - } - } - - /// - /// Executes the outermost transaction boundary through EF Core's execution strategy so the whole unit - /// of work can be replayed on transient relational failures. - /// - private async Task ExecuteAsOutermostTransactionAsync( - Func> action, - TransactionOptions effectiveOptions, - CancellationToken ct - ) - { - IExecutionStrategy strategy = _transactionProvider.CreateExecutionStrategy( - effectiveOptions - ); - TransactionOptions? previousActiveOptions = _activeTransactionOptions; - - return await strategy.ExecuteAsync( - state: action, - operation: async (_, transactionalAction, cancellationToken) => - { - _activeTransactionOptions = effectiveOptions; - using IDisposable timeoutScope = _commandTimeoutScope.Apply( - effectiveOptions.TimeoutSeconds - ); - _logger.OutermostTransactionStarted( - effectiveOptions.IsolationLevel!.Value, - effectiveOptions.TimeoutSeconds, - effectiveOptions.RetryEnabled ?? true - ); - - IDbContextTransaction? transaction = null; - try - { - transaction = await _transactionProvider.BeginTransactionAsync( - effectiveOptions.IsolationLevel!.Value, - cancellationToken - ); - _logger.DatabaseTransactionOpened(); - } - catch (Exception ex) when (IsTransactionNotSupported(ex)) - { - _logger.DatabaseTransactionUnsupported(ex); - } - - IReadOnlyDictionary< - object, - DbContextTrackedStateManager.TrackedEntitySnapshot - > snapshot = _trackedStateManager.Capture(); - - try - { - using IDisposable scope = _managedTransactionScope.Enter(); - T result = await transactionalAction(); - await _dbContext.SaveChangesAsync(cancellationToken); - - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - _logger.DatabaseTransactionCommitted(); - } - - _logger.OutermostTransactionCompleted(); - return result; - } - catch (Exception ex) - { - if (transaction is not null) - { - await transaction.RollbackAsync(cancellationToken); - _logger.DatabaseTransactionRolledBack(ex); - } - - _trackedStateManager.Restore(snapshot); - throw; - } - finally - { - if (transaction is not null) - await transaction.DisposeAsync(); - - _activeTransactionOptions = previousActiveOptions; - } - }, - verifySucceeded: null, - ct - ); - } - - /// - /// Ensures nested transaction scopes inherit the effective outer transaction policy. - /// - private void ValidateNestedTransactionOptions(TransactionOptions? options) - { - if (_activeTransactionOptions is null) - throw new InvalidOperationException( - "Nested transaction execution requires an active outer transaction policy." - ); - - if (options is null || options.IsEmpty()) - return; - - TransactionOptions effectiveOptions = _transactionDefaults.Resolve(options); - if (effectiveOptions != _activeTransactionOptions) - { - throw new InvalidOperationException( - "Nested transactions inherit the active outer transaction options. " - + "Pass null/default options inside nested ExecuteInTransactionAsync calls." - ); - } - } - - /// - /// Releases the current savepoint when the provider supports explicit savepoint release. - /// - private async Task ReleaseSavepointIfSupportedAsync( - IDbContextTransaction transaction, - string savepointName, - CancellationToken ct - ) - { - try - { - await transaction.ReleaseSavepointAsync(savepointName, ct); - } - catch (NotSupportedException) { } - } - - private static bool IsTransactionNotSupported(Exception ex) => - ex is InvalidOperationException or NotSupportedException; -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs deleted file mode 100644 index 2024ed7e..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; -using Npgsql.EntityFrameworkCore.PostgreSQL; -using SharedKernel.Domain.Options; - -namespace SharedKernel.Infrastructure.Persistence.UnitOfWork; - -/// -/// Factory that selects the appropriate EF Core execution strategy based on the provider type -/// and the retry configuration specified in . -/// -internal static class UnitOfWorkExecutionStrategyFactory -{ - /// - /// Returns a when retries are disabled, - /// a for Npgsql providers, or the - /// provider's default strategy otherwise. - /// - public static IExecutionStrategy Create( - DbContext dbContext, - TransactionOptions effectiveOptions - ) - { - if (effectiveOptions.RetryEnabled == false) - return new NonRetryingExecutionStrategy(dbContext); - - if (!dbContext.Database.IsNpgsql()) - return dbContext.Database.CreateExecutionStrategy(); - - return new NpgsqlRetryingExecutionStrategy( - dbContext, - effectiveOptions.RetryCount ?? 3, - TimeSpan.FromSeconds(effectiveOptions.RetryDelaySeconds ?? 5), - errorCodesToAdd: null - ); - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs deleted file mode 100644 index 8eec5779..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Data; -using Microsoft.Extensions.Logging; - -namespace SharedKernel.Infrastructure.Persistence.UnitOfWork; - -/// -/// Compile-time source-generated logger extension methods for diagnostics. -/// -internal static partial class UnitOfWorkLogs -{ - [LoggerMessage( - EventId = 5001, - Level = LogLevel.Warning, - Message = "CommitAsync was called inside ExecuteInTransactionAsync and was rejected." - )] - public static partial void CommitRejectedInsideManagedTransaction(this ILogger logger); - - [LoggerMessage( - EventId = 5002, - Level = LogLevel.Debug, - Message = "CommitAsync started. RetryEnabled={RetryEnabled}, TimeoutSeconds={TimeoutSeconds}" - )] - public static partial void CommitStarted( - this ILogger logger, - bool retryEnabled, - int? timeoutSeconds - ); - - [LoggerMessage( - EventId = 5003, - Level = LogLevel.Debug, - Message = "CommitAsync completed successfully." - )] - public static partial void CommitCompleted(this ILogger logger); - - [LoggerMessage( - EventId = 5004, - Level = LogLevel.Debug, - Message = "Outermost transaction started. IsolationLevel={IsolationLevel}, TimeoutSeconds={TimeoutSeconds}, RetryEnabled={RetryEnabled}" - )] - public static partial void OutermostTransactionStarted( - this ILogger logger, - IsolationLevel isolationLevel, - int? timeoutSeconds, - bool retryEnabled - ); - - [LoggerMessage( - EventId = 5005, - Level = LogLevel.Debug, - Message = "Explicit database transaction opened." - )] - public static partial void DatabaseTransactionOpened(this ILogger logger); - - [LoggerMessage( - EventId = 5006, - Level = LogLevel.Warning, - Message = "Provider does not support explicit database transactions. Continuing without an explicit transaction." - )] - public static partial void DatabaseTransactionUnsupported( - this ILogger logger, - Exception exception - ); - - [LoggerMessage( - EventId = 5007, - Level = LogLevel.Debug, - Message = "Explicit database transaction committed." - )] - public static partial void DatabaseTransactionCommitted(this ILogger logger); - - [LoggerMessage( - EventId = 5008, - Level = LogLevel.Warning, - Message = "Explicit database transaction rolled back due to an exception." - )] - public static partial void DatabaseTransactionRolledBack( - this ILogger logger, - Exception exception - ); - - [LoggerMessage( - EventId = 5009, - Level = LogLevel.Debug, - Message = "Outermost transaction completed successfully." - )] - public static partial void OutermostTransactionCompleted(this ILogger logger); - - [LoggerMessage( - EventId = 5010, - Level = LogLevel.Debug, - Message = "Creating savepoint {SavepointName}." - )] - public static partial void SavepointCreating(this ILogger logger, string savepointName); - - [LoggerMessage( - EventId = 5011, - Level = LogLevel.Debug, - Message = "Released savepoint {SavepointName}." - )] - public static partial void SavepointReleased(this ILogger logger, string savepointName); - - [LoggerMessage( - EventId = 5012, - Level = LogLevel.Debug, - Message = "Rolled back to savepoint {SavepointName}." - )] - public static partial void SavepointRolledBack(this ILogger logger, string savepointName); -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Queue/BoundedChannelQueue.cs b/src/SharedKernel/SharedKernel.Infrastructure/Queue/BoundedChannelQueue.cs deleted file mode 100644 index 3b2c1d4b..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Queue/BoundedChannelQueue.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Threading.Channels; -using SharedKernel.Application.Queue; - -namespace SharedKernel.Infrastructure.Queue; - -/// -/// A generic bounded channel-based queue. Subclass or instantiate directly for -/// specific queue types (jobs, webhooks, emails, etc.). -/// -public class BoundedChannelQueue : IQueue, IQueueReader -{ - private readonly Channel _channel; - - /// Creates a bounded channel with the specified , waiting on enqueue when full and using a single reader. - public BoundedChannelQueue(int capacity) - { - _channel = Channel.CreateBounded( - new BoundedChannelOptions(capacity) - { - FullMode = BoundedChannelFullMode.Wait, - SingleReader = true, - } - ); - } - - /// Returns an async stream that yields items as they are enqueued, completing when the channel is closed. - public IAsyncEnumerable ReadAllAsync(CancellationToken ct = default) => - _channel.Reader.ReadAllAsync(ct); - - /// Writes to the channel, waiting asynchronously if the channel is at capacity. - public ValueTask EnqueueAsync(T item, CancellationToken ct = default) => - _channel.Writer.WriteAsync(item, ct); -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Queue/QueueConsumerBackgroundService.cs b/src/SharedKernel/SharedKernel.Infrastructure/Queue/QueueConsumerBackgroundService.cs deleted file mode 100644 index 2beba879..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Queue/QueueConsumerBackgroundService.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.Extensions.Hosting; -using SharedKernel.Application.Queue; - -namespace SharedKernel.Infrastructure.Queue; - -/// -/// Base that drains an in a -/// continuous async loop, dispatching each item to and routing -/// non-cancellation exceptions to . -/// -public abstract class QueueConsumerBackgroundService : BackgroundService -{ - private readonly IQueueReader _queue; - - protected QueueConsumerBackgroundService(IQueueReader queue) => _queue = queue; - - protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await foreach (T item in _queue.ReadAllAsync(stoppingToken)) - { - try - { - await ProcessItemAsync(item, stoppingToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - await HandleErrorAsync(item, ex, stoppingToken); - } - } - } - - /// Processes a single dequeued item; implement the core business logic here. - protected abstract Task ProcessItemAsync(T item, CancellationToken ct); - - /// Called when throws a non-cancellation exception; default implementation is a no-op. - protected virtual Task HandleErrorAsync(T item, Exception ex, CancellationToken ct) => - Task.CompletedTask; -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs b/src/SharedKernel/SharedKernel.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs deleted file mode 100644 index 21b8e980..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Linq.Expressions; - -namespace SharedKernel.Infrastructure.Repositories.Pagination; - -/// -/// Composes an existing projection expression with a scalar COUNT sub-query -/// so that EF Core can retrieve both the projected items and the total count -/// in a single SQL round-trip. -/// -internal static class PagedProjectionBuilder -{ - /// - /// Builds entity => new PagedRow<TResult>(selector(entity), countSource.Count()) - /// as an expression tree that EF Core translates into a scalar sub-query for the count. - /// - internal static Expression>> BuildPaged( - this Expression> selector, - IQueryable countSource - ) - { - // Build an expression node that represents Queryable.Count(countSource). - MethodCallExpression countCall = Expression.Call( - typeof(Queryable), - nameof(Queryable.Count), - [typeof(T)], - countSource.Expression - ); - - // Get the PagedRow(TResult item, int totalCount) constructor via reflection. - System.Reflection.ConstructorInfo ctor = - typeof(PagedRow).GetConstructor([typeof(TResult), typeof(int)]) - ?? throw new InvalidOperationException( - $"No suitable constructor found for {typeof(PagedRow)} with parameters (TResult, int)." - ); - - // Combine: new PagedRow(selector.Body, countCall) - NewExpression newExpr = Expression.New(ctor, selector.Body, countCall); - - // Reuse the lambda parameter from the original selector. - ParameterExpression entityParam = selector.Parameters[0]; - - return Expression.Lambda>>(newExpr, entityParam); - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/Pagination/PagedRow.cs b/src/SharedKernel/SharedKernel.Infrastructure/Repositories/Pagination/PagedRow.cs deleted file mode 100644 index 761ac20b..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/Pagination/PagedRow.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SharedKernel.Infrastructure.Repositories.Pagination; - -/// -/// Internal wrapper that carries a projected item together with the total count -/// so that both can be retrieved in a single SQL query via a scalar sub-query. -/// -internal sealed record PagedRow(TResult Item, int TotalCount); diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs b/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs deleted file mode 100644 index ed3c49e2..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Linq.Expressions; -using Ardalis.Specification; -using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Errors; -using SharedKernel.Domain.Common; -using SharedKernel.Domain.Exceptions; -using SharedKernel.Domain.Interfaces; -using SharedKernel.Infrastructure.Repositories.Pagination; - -namespace SharedKernel.Infrastructure.Repositories; - -/// -/// Base repository that wraps the Ardalis Specification EF Core repository, overriding write methods -/// to stage changes without flushing — persistence is deferred to . -/// -public abstract class RepositoryBase - : Ardalis.Specification.EntityFrameworkCore.RepositoryBase, - IRepository - where T : class -{ - protected RepositoryBase(DbContext dbContext) - : base(dbContext) { } - - /// - /// Returns a paged result where the total count is embedded as a scalar sub-query alongside - /// the projected items. When the requested page is empty and > 1, - /// a second COUNT query is issued to determine whether the page is out of range. - /// The must contain filter, sort, and projection but no Skip/Take. - /// - public virtual async Task> GetPagedAsync( - ISpecification spec, - int pageNumber, - int pageSize, - CancellationToken ct = default - ) - { - // Get filtered + sorted entity query via virtual ApplySpecification - // so derived repositories can customise the source queryable. - IQueryable baseQuery = ApplySpecification((ISpecification)spec); - IQueryable countSource = ApplySpecification( - (ISpecification)spec, - evaluateCriteriaOnly: true - ); - - // Build combined projection: entity => new PagedRow(projection(entity), baseQuery.Count()) - if (spec.Selector is null) - throw new InvalidOperationException( - $"Specification {spec.GetType().Name} must define a Select projection to use GetPagedAsync." - ); - - Expression>> combinedSelector = spec.Selector.BuildPaged( - countSource - ); - - // Apply skip/take + combined select -> single SQL query - int skip = (pageNumber - 1) * pageSize; - List> results = await baseQuery - .Skip(skip) - .Take(pageSize) - .Select(combinedSelector) - .ToListAsync(ct); - - // Unwrap - if (results.Count > 0) - return new PagedResponse( - results.Select(r => r.Item), - results[0].TotalCount, - pageNumber, - pageSize - ); - - // Empty page — if pageNumber > 1, verify whether data actually exists - if (pageNumber > 1) - { - int totalCount = await baseQuery.CountAsync(ct); - if (totalCount > 0) - { - int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - throw new ValidationException( - $"PageNumber {pageNumber} exceeds total pages ({totalPages}).", - ErrorCatalog.General.PageOutOfRange - ); - } - } - - return new PagedResponse([], 0, pageNumber, pageSize); - } - - // Override write methods — do NOT call SaveChangesAsync, that is UoW responsibility. - // Return 0 (no rows persisted yet — UoW will commit later). - /// Tracks for insertion without flushing to the database. - public override Task AddAsync(T entity, CancellationToken ct = default) - { - DbContext.Set().Add(entity); - return Task.FromResult(entity); - } - - /// Tracks multiple entities for insertion without flushing to the database. - public override Task> AddRangeAsync( - IEnumerable entities, - CancellationToken ct = default - ) - { - DbContext.Set().AddRange(entities); - return Task.FromResult(entities); - } - - /// Marks as modified without flushing to the database. - public override Task UpdateAsync(T entity, CancellationToken ct = default) - { - DbContext.Set().Update(entity); - return Task.FromResult(0); - } - - /// Marks multiple entities as modified without flushing to the database. - public override Task UpdateRangeAsync( - IEnumerable entities, - CancellationToken ct = default - ) - { - DbContext.Set().UpdateRange(entities); - return Task.FromResult(0); - } - - /// Marks for deletion without flushing to the database. - public override Task DeleteAsync(T entity, CancellationToken ct = default) - { - DbContext.Set().Remove(entity); - return Task.FromResult(0); - } - - /// Marks multiple entities for deletion without flushing to the database. - public override Task DeleteRangeAsync( - IEnumerable entities, - CancellationToken ct = default - ) - { - DbContext.Set().RemoveRange(entities); - return Task.FromResult(0); - } - - // Guid-based delete (our contract, not in IRepositoryBase) - /// - /// Looks up the entity by and marks it for deletion. - /// Throws when the entity does not exist. - /// - [Obsolete("Use GetByIdAsync + DeleteAsync(entity) with ErrorOr pattern instead.")] - public async Task DeleteAsync(Guid id, CancellationToken ct = default, string? errorCode = null) - { - T entity = - await GetByIdAsync(id, ct) - ?? throw new NotFoundException( - typeof(T).Name, - id, - errorCode ?? ErrorCatalog.General.NotFound - ); - DbContext.Set().Remove(entity); - } -} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj deleted file mode 100644 index 6f7f1198..00000000 --- a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - net10.0 - enable - enable - - - - - - - - - - - diff --git a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs b/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs deleted file mode 100644 index 9f6e86a6..00000000 --- a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Wolverine; -using Wolverine.RabbitMQ; - -namespace SharedKernel.Messaging.Conventions; - -/// -/// Standardised RabbitMQ transport registration for all microservices. -/// Reads the connection from ConnectionStrings:RabbitMQ (full URI) with a fallback -/// to the legacy RabbitMQ:HostName setting. -/// -public static class RabbitMqConventionExtensions -{ - private const string DefaultRabbitMqUri = "amqp://guest:guest@localhost:5672"; - - /// - /// Configures the Wolverine RabbitMQ transport using a shared connection strategy. - /// - public static WolverineOptions UseSharedRabbitMq( - this WolverineOptions opts, - IConfiguration configuration - ) - { - string connectionString = - configuration.GetConnectionString("RabbitMQ") ?? BuildFromHostName(configuration); - - opts.UseRabbitMq(new Uri(connectionString)).AutoProvision().EnableWolverineControlQueues(); - - return opts; - } - - private static string BuildFromHostName(IConfiguration configuration) - { - string? hostName = configuration["RabbitMQ:HostName"]; - return hostName is not null ? $"amqp://{hostName}" : DefaultRabbitMqUri; - } -} diff --git a/src/SharedKernel/SharedKernel.Messaging/Conventions/WolverineConventionExtensions.cs b/src/SharedKernel/SharedKernel.Messaging/Conventions/WolverineConventionExtensions.cs deleted file mode 100644 index 6c73e35e..00000000 --- a/src/SharedKernel/SharedKernel.Messaging/Conventions/WolverineConventionExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Wolverine; - -namespace SharedKernel.Messaging.Conventions; - -/// -/// Extension methods that apply shared Wolverine conventions for durable messaging -/// across all microservices in the system. -/// -public static class WolverineConventionExtensions -{ - /// - /// Applies shared durability and outbox/inbox conventions to Wolverine messaging options. - /// - public static WolverineOptions ApplySharedConventions(this WolverineOptions opts) - { - opts.Durability.Mode = DurabilityMode.Balanced; - opts.Policies.UseDurableInboxOnAllListeners(); - opts.Policies.UseDurableOutboxOnAllSendingEndpoints(); - return opts; - } -} diff --git a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj b/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj deleted file mode 100644 index 539302c8..00000000 --- a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - net10.0 - enable - enable - - - - - - - - - diff --git a/src/SharedKernel/SharedKernel.Messaging/Topology/RabbitMqTopology.cs b/src/SharedKernel/SharedKernel.Messaging/Topology/RabbitMqTopology.cs deleted file mode 100644 index 3d484a8a..00000000 --- a/src/SharedKernel/SharedKernel.Messaging/Topology/RabbitMqTopology.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace SharedKernel.Messaging.Topology; - -public static class RabbitMqTopology -{ - public static class Exchanges - { - public const string Identity = "identity.events"; - public const string ProductCatalog = "product-catalog.events"; - public const string Reviews = "reviews.events"; - } - - public static class Queues - { - public static class Reviews - { - public const string ProductCreated = "reviews.product-created"; - public const string ProductDeleted = "reviews.product-deleted"; - public const string TenantDeactivated = "reviews.tenant-deactivated"; - } - - public static class Webhooks - { - public const string ProductCreated = "webhooks.product-created"; - public const string ProductDeleted = "webhooks.product-deleted"; - public const string ReviewCreated = "webhooks.review-created"; - public const string CategoryDeleted = "webhooks.category-deleted"; - } - - public static class BackgroundJobs - { - public const string TenantDeactivated = "background-jobs.tenant-deactivated"; - } - - public static class Notifications - { - public const string UserRegistered = "notifications.user-registered"; - public const string UserRoleChanged = "notifications.user-role-changed"; - public const string InvitationCreated = "notifications.invitation-created"; - } - - public static class FileStorage - { - public const string ProductDeleted = "file-storage.product-deleted"; - } - - public static class Identity - { - public const string TenantDeactivated = "identity.tenant-deactivated"; - public const string UsersCascadeCompleted = "identity.users-cascade-completed"; - public const string ProductsCascadeCompleted = "identity.products-cascade-completed"; - public const string CategoriesCascadeCompleted = - "identity.categories-cascade-completed"; - } - - public static class ProductCatalog - { - public const string TenantDeactivated = "product-catalog.tenant-deactivated"; - public const string ReviewsCascadeCompleted = - "product-catalog.reviews-cascade-completed"; - public const string FilesCascadeCompleted = "product-catalog.files-cascade-completed"; - public const string StartProductDeletionSaga = - "product-catalog.start-product-deletion-saga"; - } - } -} diff --git a/src/SharedKernel/SharedKernel.Messaging/Topology/RetryPolicies.cs b/src/SharedKernel/SharedKernel.Messaging/Topology/RetryPolicies.cs deleted file mode 100644 index 82cb2e04..00000000 --- a/src/SharedKernel/SharedKernel.Messaging/Topology/RetryPolicies.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Wolverine; -using Wolverine.ErrorHandling; - -namespace SharedKernel.Messaging.Topology; - -/// -/// Shared retry and error handling policies for Wolverine message processing, -/// applied consistently across all microservices. -/// -public static class RetryPolicies -{ - /// - /// Applies shared retry policies for common transient and concurrency failures. - /// - public static WolverineOptions ApplySharedRetryPolicies(this WolverineOptions opts) - { - opts.OnException() - .RetryWithCooldown( - TimeSpan.FromMilliseconds(50), - TimeSpan.FromMilliseconds(100), - TimeSpan.FromMilliseconds(250) - ) - .Then.MoveToErrorQueue(); - - opts.OnException() - .RetryWithCooldown(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) - .Then.MoveToErrorQueue(); - - opts.OnException() - .ScheduleRetry( - TimeSpan.FromSeconds(5), - TimeSpan.FromSeconds(30), - TimeSpan.FromMinutes(2) - ) - .Then.MoveToErrorQueue(); - - return opts; - } -} diff --git a/src/SharedKernel/SharedKernel.Messaging/Topology/TenantAwareEnvelopeMapper.cs b/src/SharedKernel/SharedKernel.Messaging/Topology/TenantAwareEnvelopeMapper.cs deleted file mode 100644 index a7600057..00000000 --- a/src/SharedKernel/SharedKernel.Messaging/Topology/TenantAwareEnvelopeMapper.cs +++ /dev/null @@ -1,45 +0,0 @@ -using RabbitMQ.Client; -using Wolverine; -using Wolverine.RabbitMQ.Internal; - -namespace SharedKernel.Messaging.Topology; - -/// -/// Custom envelope mapper that propagates TenantId via RabbitMQ message headers, -/// enabling tenant-aware message routing across microservices. -/// -public sealed class TenantAwareEnvelopeMapper : IRabbitMqEnvelopeMapper -{ - private const string TenantIdHeader = "x-tenant-id"; - - /// - /// Maps the Wolverine envelope's TenantId to an outgoing RabbitMQ message header. - /// - public void MapEnvelopeToOutgoing(Envelope envelope, IBasicProperties outgoing) - { - if (envelope.TenantId is not null) - { - outgoing.Headers ??= new Dictionary(); - outgoing.Headers[TenantIdHeader] = envelope.TenantId; - } - } - - /// - /// Maps the incoming RabbitMQ message's tenant header to the Wolverine envelope's TenantId. - /// - public void MapIncomingToEnvelope(Envelope envelope, IReadOnlyBasicProperties incoming) - { - if ( - incoming.Headers is not null - && incoming.Headers.TryGetValue(TenantIdHeader, out object? tenantId) - && tenantId is not null - ) - { - envelope.TenantId = tenantId switch - { - byte[] bytes => System.Text.Encoding.UTF8.GetString(bytes), - _ => tenantId.ToString(), - }; - } - } -} diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/APITemplate.Tests.csproj b/tests/APITemplate.Tests/APITemplate.Tests.csproj similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/APITemplate.Tests.csproj rename to tests/APITemplate.Tests/APITemplate.Tests.csproj diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/AssemblyFixtures.cs b/tests/APITemplate.Tests/AssemblyFixtures.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/AssemblyFixtures.cs rename to tests/APITemplate.Tests/AssemblyFixtures.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/GlobalUsings.ApplicationFeatures.cs b/tests/APITemplate.Tests/GlobalUsings.ApplicationFeatures.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/GlobalUsings.ApplicationFeatures.cs rename to tests/APITemplate.Tests/GlobalUsings.ApplicationFeatures.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Helpers/TestNoOpStartupTaskCoordinator.cs b/tests/APITemplate.Tests/Helpers/TestNoOpStartupTaskCoordinator.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Helpers/TestNoOpStartupTaskCoordinator.cs rename to tests/APITemplate.Tests/Helpers/TestNoOpStartupTaskCoordinator.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Auth/AuthEdgeCasesTests.cs b/tests/APITemplate.Tests/Integration/Auth/AuthEdgeCasesTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Auth/AuthEdgeCasesTests.cs rename to tests/APITemplate.Tests/Integration/Auth/AuthEdgeCasesTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Auth/AuthenticatedCrudTests.cs b/tests/APITemplate.Tests/Integration/Auth/AuthenticatedCrudTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Auth/AuthenticatedCrudTests.cs rename to tests/APITemplate.Tests/Integration/Auth/AuthenticatedCrudTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Auth/BffSecurityTests.cs b/tests/APITemplate.Tests/Integration/Auth/BffSecurityTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Auth/BffSecurityTests.cs rename to tests/APITemplate.Tests/Integration/Auth/BffSecurityTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Auth/UnauthorizedAccessTests.cs b/tests/APITemplate.Tests/Integration/Auth/UnauthorizedAccessTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Auth/UnauthorizedAccessTests.cs rename to tests/APITemplate.Tests/Integration/Auth/UnauthorizedAccessTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs b/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs rename to tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/BatchControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/BatchControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/BatchControllerTests.cs rename to tests/APITemplate.Tests/Integration/Features/BatchControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/FilesControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/FilesControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/FilesControllerTests.cs rename to tests/APITemplate.Tests/Integration/Features/FilesControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/IdempotentControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/IdempotentControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/IdempotentControllerTests.cs rename to tests/APITemplate.Tests/Integration/Features/IdempotentControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/JobCallbackUrlTests.cs b/tests/APITemplate.Tests/Integration/Features/JobCallbackUrlTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/JobCallbackUrlTests.cs rename to tests/APITemplate.Tests/Integration/Features/JobCallbackUrlTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/JobsControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/JobsControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/JobsControllerTests.cs rename to tests/APITemplate.Tests/Integration/Features/JobsControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/PatchControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/PatchControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/PatchControllerTests.cs rename to tests/APITemplate.Tests/Integration/Features/PatchControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/SseControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/SseControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/SseControllerTests.cs rename to tests/APITemplate.Tests/Integration/Features/SseControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/TenantsControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/TenantsControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/TenantsControllerTests.cs rename to tests/APITemplate.Tests/Integration/Features/TenantsControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/UsersControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/UsersControllerTests.cs similarity index 84% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/UsersControllerTests.cs rename to tests/APITemplate.Tests/Integration/Features/UsersControllerTests.cs index 5c379704..4975a465 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/UsersControllerTests.cs +++ b/tests/APITemplate.Tests/Integration/Features/UsersControllerTests.cs @@ -26,22 +26,12 @@ public async Task GetMe_WithAuthenticatedNonAdminUser_ReturnsCurrentUser() _factory.Services, username: "regular-user", email: "regular-user@example.com", - ct: ct - ); + ct: ct); - IntegrationAuthHelper.Authenticate( - _client, - user.Id, - tenant.Id, - user.Username, - UserRole.User - ); + IntegrationAuthHelper.Authenticate(_client, user.Id, tenant.Id, user.Username, UserRole.User); var response = await _client.GetAsync("/api/v1/users/me", ct); - var payload = await response.Content.ReadFromJsonAsync( - TestJsonOptions.CaseInsensitive, - ct - ); + var payload = await response.Content.ReadFromJsonAsync(TestJsonOptions.CaseInsensitive, ct); response.StatusCode.ShouldBe(HttpStatusCode.OK); payload.ShouldNotBeNull(); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs rename to tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/GraphQLJsonOptions.cs b/tests/APITemplate.Tests/Integration/GraphQL/GraphQLJsonOptions.cs similarity index 77% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/GraphQLJsonOptions.cs rename to tests/APITemplate.Tests/Integration/GraphQL/GraphQLJsonOptions.cs index 1d493d9b..4916164a 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/GraphQLJsonOptions.cs +++ b/tests/APITemplate.Tests/Integration/GraphQL/GraphQLJsonOptions.cs @@ -6,6 +6,6 @@ internal static class GraphQLJsonOptions { internal static readonly JsonSerializerOptions Default = new() { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/GraphQLProductReviewTests.cs b/tests/APITemplate.Tests/Integration/GraphQL/GraphQLProductReviewTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/GraphQLProductReviewTests.cs rename to tests/APITemplate.Tests/Integration/GraphQL/GraphQLProductReviewTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/GraphQLResponse.cs b/tests/APITemplate.Tests/Integration/GraphQL/GraphQLResponse.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/GraphQLResponse.cs rename to tests/APITemplate.Tests/Integration/GraphQL/GraphQLResponse.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/GraphQLTests.cs b/tests/APITemplate.Tests/Integration/GraphQL/GraphQLTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/GraphQLTests.cs rename to tests/APITemplate.Tests/Integration/GraphQL/GraphQLTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/ProductGraphQLModels.cs b/tests/APITemplate.Tests/Integration/GraphQL/ProductGraphQLModels.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/ProductGraphQLModels.cs rename to tests/APITemplate.Tests/Integration/GraphQL/ProductGraphQLModels.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/ProductReviewGraphQLModels.cs b/tests/APITemplate.Tests/Integration/GraphQL/ProductReviewGraphQLModels.cs similarity index 78% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/ProductReviewGraphQLModels.cs rename to tests/APITemplate.Tests/Integration/GraphQL/ProductReviewGraphQLModels.cs index 753fbaf4..c9675ce6 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/GraphQL/ProductReviewGraphQLModels.cs +++ b/tests/APITemplate.Tests/Integration/GraphQL/ProductReviewGraphQLModels.cs @@ -2,12 +2,7 @@ namespace APITemplate.Tests.Integration.GraphQL; public sealed record ProductReviewItem(Guid Id, Guid UserId, int Rating, Guid ProductId); -public sealed record ProductReviewResultsPage( - List Items, - int TotalCount, - int PageNumber, - int PageSize -); +public sealed record ProductReviewResultsPage(List Items, int TotalCount, int PageNumber, int PageSize); public sealed record ProductReviewPage(ProductReviewResultsPage Page); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/GraphQLTestHelper.cs b/tests/APITemplate.Tests/Integration/Helpers/GraphQLTestHelper.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/GraphQLTestHelper.cs rename to tests/APITemplate.Tests/Integration/Helpers/GraphQLTestHelper.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/InMemoryProductRepository.cs b/tests/APITemplate.Tests/Integration/Helpers/InMemoryProductRepository.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/InMemoryProductRepository.cs rename to tests/APITemplate.Tests/Integration/Helpers/InMemoryProductRepository.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/TestConfigurationHelper.cs b/tests/APITemplate.Tests/Integration/Helpers/TestConfigurationHelper.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/TestConfigurationHelper.cs rename to tests/APITemplate.Tests/Integration/Helpers/TestConfigurationHelper.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/TestJsonOptions.cs b/tests/APITemplate.Tests/Integration/Helpers/TestJsonOptions.cs similarity index 82% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/TestJsonOptions.cs rename to tests/APITemplate.Tests/Integration/Helpers/TestJsonOptions.cs index 75bb4db7..75c05884 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/TestJsonOptions.cs +++ b/tests/APITemplate.Tests/Integration/Helpers/TestJsonOptions.cs @@ -6,6 +6,6 @@ internal static class TestJsonOptions { internal static readonly JsonSerializerOptions CaseInsensitive = new() { - PropertyNameCaseInsensitive = true, + PropertyNameCaseInsensitive = true }; } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/TestOutputCacheStore.cs b/tests/APITemplate.Tests/Integration/Helpers/TestOutputCacheStore.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/TestOutputCacheStore.cs rename to tests/APITemplate.Tests/Integration/Helpers/TestOutputCacheStore.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs b/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs rename to tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs b/tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs rename to tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/CorsTests.cs b/tests/APITemplate.Tests/Integration/Infrastructure/CorsTests.cs similarity index 75% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/CorsTests.cs rename to tests/APITemplate.Tests/Integration/Infrastructure/CorsTests.cs index 53c26589..c0fdc301 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/CorsTests.cs +++ b/tests/APITemplate.Tests/Integration/Infrastructure/CorsTests.cs @@ -23,14 +23,10 @@ public async Task Preflight_FromAllowedOrigin_ReturnsCorsHeaders() var response = await _client.SendAsync(request, ct); response.IsSuccessStatusCode.ShouldBeTrue(); - response - .Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigins) - .ShouldBeTrue(); + response.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigins).ShouldBeTrue(); allowedOrigins!.Single().ShouldBe("http://localhost:3000"); - response - .Headers.TryGetValues("Access-Control-Allow-Credentials", out var credValues) - .ShouldBeTrue(); + response.Headers.TryGetValues("Access-Control-Allow-Credentials", out var credValues).ShouldBeTrue(); credValues!.Single().ShouldBe("true"); } @@ -57,13 +53,9 @@ public async Task Get_AnonymousEndpoint_FromAllowedOrigin_ReturnsCorsHeaders() var response = await _client.SendAsync(request, ct); response.IsSuccessStatusCode.ShouldBeTrue(); - response - .Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigins) - .ShouldBeTrue(); + response.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigins).ShouldBeTrue(); allowedOrigins!.Single().ShouldBe("http://localhost:3000"); - response - .Headers.TryGetValues("Access-Control-Allow-Credentials", out var credValues) - .ShouldBeTrue(); + response.Headers.TryGetValues("Access-Control-Allow-Credentials", out var credValues).ShouldBeTrue(); credValues!.Single().ShouldBe("true"); } @@ -76,13 +68,9 @@ public async Task Get_ProtectedEndpoint_FromAllowedOrigin_ReturnsCorsHeadersEven var response = await _client.SendAsync(request, ct); - response - .Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigins) - .ShouldBeTrue(); + response.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigins).ShouldBeTrue(); allowedOrigins!.Single().ShouldBe("http://localhost:3000"); - response - .Headers.TryGetValues("Access-Control-Allow-Credentials", out var credValues) - .ShouldBeTrue(); + response.Headers.TryGetValues("Access-Control-Allow-Credentials", out var credValues).ShouldBeTrue(); credValues!.Single().ShouldBe("true"); } } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/RateLimitingTests.cs b/tests/APITemplate.Tests/Integration/Infrastructure/RateLimitingTests.cs similarity index 95% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/RateLimitingTests.cs rename to tests/APITemplate.Tests/Integration/Infrastructure/RateLimitingTests.cs index a94a79aa..0716650b 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/RateLimitingTests.cs +++ b/tests/APITemplate.Tests/Integration/Infrastructure/RateLimitingTests.cs @@ -19,8 +19,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { o.PermitLimit = PermitLimit; o.WindowMinutes = 1; - }) - ); + })); } } @@ -73,11 +72,11 @@ public async Task DifferentUsers_HaveIndependentBuckets() for (var i = 0; i < PermitLimit; i++) await clientA.GetAsync("/api/v1/products", ct); - (await clientA.GetAsync("/api/v1/products", ct)).StatusCode.ShouldBe( - HttpStatusCode.TooManyRequests - ); + (await clientA.GetAsync("/api/v1/products", ct)).StatusCode + .ShouldBe(HttpStatusCode.TooManyRequests); // user-b has their own independent bucket — not affected - (await clientB.GetAsync("/api/v1/products", ct)).StatusCode.ShouldBe(HttpStatusCode.OK); + (await clientB.GetAsync("/api/v1/products", ct)).StatusCode + .ShouldBe(HttpStatusCode.OK); } } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/ScalarAndOpenApiTests.cs b/tests/APITemplate.Tests/Integration/Infrastructure/ScalarAndOpenApiTests.cs similarity index 88% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/ScalarAndOpenApiTests.cs rename to tests/APITemplate.Tests/Integration/Infrastructure/ScalarAndOpenApiTests.cs index a18d1073..4d9de344 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/ScalarAndOpenApiTests.cs +++ b/tests/APITemplate.Tests/Integration/Infrastructure/ScalarAndOpenApiTests.cs @@ -42,11 +42,8 @@ public async Task OpenApi_ContainsGlobalErrorResponsesForRestEndpoints() using var doc = JsonDocument.Parse(content); var paths = doc.RootElement.GetProperty("paths"); - var productReviewsPath = paths - .EnumerateObject() - .FirstOrDefault(p => - p.Name.Contains("productreviews", StringComparison.OrdinalIgnoreCase) - ) + var productReviewsPath = paths.EnumerateObject() + .FirstOrDefault(p => p.Name.Contains("productreviews", StringComparison.OrdinalIgnoreCase)) .Value; productReviewsPath.ValueKind.ShouldBe(JsonValueKind.Object); @@ -55,14 +52,10 @@ public async Task OpenApi_ContainsGlobalErrorResponsesForRestEndpoints() var responses = productReviewsPost.GetProperty("responses"); responses.TryGetProperty(StatusCodes.Status400BadRequest.ToString(), out _).ShouldBeTrue(); - responses - .TryGetProperty(StatusCodes.Status401Unauthorized.ToString(), out _) - .ShouldBeTrue(); + responses.TryGetProperty(StatusCodes.Status401Unauthorized.ToString(), out _).ShouldBeTrue(); responses.TryGetProperty(StatusCodes.Status403Forbidden.ToString(), out _).ShouldBeTrue(); responses.TryGetProperty(StatusCodes.Status404NotFound.ToString(), out _).ShouldBeTrue(); - responses - .TryGetProperty(StatusCodes.Status500InternalServerError.ToString(), out _) - .ShouldBeTrue(); + responses.TryGetProperty(StatusCodes.Status500InternalServerError.ToString(), out _).ShouldBeTrue(); } [Fact] @@ -107,8 +100,7 @@ public async Task GraphQL_Endpoint_IsAccessible() var response = await _client.PostAsJsonAsync( "/graphql", new { query = "{ __typename }" }, - ct - ); + ct); response.StatusCode.ShouldBe(HttpStatusCode.OK); } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/WolverineNotificationTests.cs b/tests/APITemplate.Tests/Integration/Infrastructure/WolverineNotificationTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Infrastructure/WolverineNotificationTests.cs rename to tests/APITemplate.Tests/Integration/Infrastructure/WolverineNotificationTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs b/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs rename to tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/IntegrationResponseModels.cs b/tests/APITemplate.Tests/Integration/IntegrationResponseModels.cs similarity index 87% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/IntegrationResponseModels.cs rename to tests/APITemplate.Tests/Integration/IntegrationResponseModels.cs index bfb8d809..cfa1c260 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/IntegrationResponseModels.cs +++ b/tests/APITemplate.Tests/Integration/IntegrationResponseModels.cs @@ -6,8 +6,7 @@ public sealed record ApiErrorResponse( int Status, string Detail, string ErrorCode, - string TraceId -); + string TraceId); public sealed record ProductDataContractResponse( Guid Id, @@ -16,5 +15,4 @@ public sealed record ProductDataContractResponse( string? Description, DateTime CreatedAt, string? Format, - long? FileSizeBytes -); + long? FileSizeBytes); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresAdvisoryLockStartupTaskCoordinatorTests.cs b/tests/APITemplate.Tests/Integration/Postgres/PostgresAdvisoryLockStartupTaskCoordinatorTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresAdvisoryLockStartupTaskCoordinatorTests.cs rename to tests/APITemplate.Tests/Integration/Postgres/PostgresAdvisoryLockStartupTaskCoordinatorTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresSearchTests.cs b/tests/APITemplate.Tests/Integration/Postgres/PostgresSearchTests.cs similarity index 82% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresSearchTests.cs rename to tests/APITemplate.Tests/Integration/Postgres/PostgresSearchTests.cs index 80b0b37b..5e52c85f 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresSearchTests.cs +++ b/tests/APITemplate.Tests/Integration/Postgres/PostgresSearchTests.cs @@ -10,8 +10,7 @@ namespace APITemplate.Tests.Integration.Postgres; -public sealed class PostgresSearchTests(SharedPostgresContainer postgres) - : PostgresTestBase(postgres) +public sealed class PostgresSearchTests(SharedPostgresContainer postgres) : PostgresTestBase(postgres) { [Fact] public async Task ProductSearch_FullTextAndFacets_ReturnTenantScopedResults() @@ -22,14 +21,13 @@ public async Task ProductSearch_FullTextAndFacets_ReturnTenantScopedResults() _factory.Services, username, $"{username}@example.com", - ct: ct - ); + ct: ct); var otherTenant = new Tenant { Id = Guid.NewGuid(), Code = $"search-other-{Guid.NewGuid():N}", - Name = "Other Search Tenant", + Name = "Other Search Tenant" }; await using (var scope = _factory.Services.CreateAsyncScope()) @@ -40,21 +38,21 @@ public async Task ProductSearch_FullTextAndFacets_ReturnTenantScopedResults() { Id = Guid.NewGuid(), TenantId = tenant.Id, - Name = "Electronics", + Name = "Electronics" }; var books = new Category { Id = Guid.NewGuid(), TenantId = tenant.Id, - Name = "Books", + Name = "Books" }; var otherCategory = new Category { Id = Guid.NewGuid(), TenantId = otherTenant.Id, - Name = "Electronics", + Name = "Electronics" }; db.Tenants.Add(otherTenant); @@ -67,7 +65,7 @@ public async Task ProductSearch_FullTextAndFacets_ReturnTenantScopedResults() Name = "Wireless Mouse", Description = "Silent office mouse", Price = 30m, - CategoryId = electronics.Id, + CategoryId = electronics.Id }, new Product { @@ -76,7 +74,7 @@ public async Task ProductSearch_FullTextAndFacets_ReturnTenantScopedResults() Name = "Wireless Keyboard", Description = "Mechanical office keyboard", Price = 80m, - CategoryId = electronics.Id, + CategoryId = electronics.Id }, new Product { @@ -85,7 +83,7 @@ public async Task ProductSearch_FullTextAndFacets_ReturnTenantScopedResults() Name = "Fantasy Novel", Description = "Epic dragon story", Price = 15m, - CategoryId = books.Id, + CategoryId = books.Id }, new Product { @@ -94,33 +92,22 @@ public async Task ProductSearch_FullTextAndFacets_ReturnTenantScopedResults() Name = "Wireless Speaker", Description = "Other tenant item", Price = 120m, - CategoryId = otherCategory.Id, - } - ); + CategoryId = otherCategory.Id + }); await db.SaveChangesAsync(ct); } - IntegrationAuthHelper.Authenticate( - _client, - tenantId: tenant.Id, - username: username, - role: Domain.Enums.UserRole.User - ); + IntegrationAuthHelper.Authenticate(_client, tenantId: tenant.Id, username: username, role: Domain.Enums.UserRole.User); var response = await _client.GetAsync("/api/v1/products?query=wireless", ct); response.StatusCode.ShouldBe(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync( - TestJsonOptions.CaseInsensitive, - ct - ); + var payload = await response.Content.ReadFromJsonAsync(TestJsonOptions.CaseInsensitive, ct); payload.ShouldNotBeNull(); payload!.Page.Items.Count().ShouldBe(2); - payload - .Page.Items.Select(item => item.Name) - .ShouldBe(["Wireless Mouse", "Wireless Keyboard"], ignoreOrder: true); + payload.Page.Items.Select(item => item.Name).ShouldBe(["Wireless Mouse", "Wireless Keyboard"], ignoreOrder: true); payload.Facets.Categories.Count.ShouldBe(1); payload.Facets.Categories.Single().CategoryName.ShouldBe("Electronics"); payload.Facets.Categories.Single().Count.ShouldBe(2); @@ -138,8 +125,7 @@ public async Task GraphQL_SearchQueries_UsePostgresFullText() _factory.Services, username, $"{username}@example.com", - ct: ct - ); + ct: ct); await using (var scope = _factory.Services.CreateAsyncScope()) { @@ -151,16 +137,15 @@ public async Task GraphQL_SearchQueries_UsePostgresFullText() Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Office Supplies", - Description = "Desk organization", + Description = "Desk organization" }, new Category { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Kitchen Goods", - Description = "Cookware", - } - ); + Description = "Cookware" + }); db.Products.AddRange( new Product @@ -169,7 +154,7 @@ public async Task GraphQL_SearchQueries_UsePostgresFullText() TenantId = tenant.Id, Name = "Wireless Charger", Description = "Fast charging pad", - Price = 40m, + Price = 40m }, new Product { @@ -177,19 +162,13 @@ public async Task GraphQL_SearchQueries_UsePostgresFullText() TenantId = tenant.Id, Name = "Paper Notebook", Description = "Meeting notes", - Price = 12m, - } - ); + Price = 12m + }); await db.SaveChangesAsync(ct); } - IntegrationAuthHelper.Authenticate( - _client, - tenantId: tenant.Id, - username: username, - role: Domain.Enums.UserRole.User - ); + IntegrationAuthHelper.Authenticate(_client, tenantId: tenant.Id, username: username, role: Domain.Enums.UserRole.User); var productsQuery = new { @@ -209,16 +188,15 @@ public async Task GraphQL_SearchQueries_UsePostgresFullText() { query = "wireless", pageNumber = 1, - pageSize = 10, - }, - }, + pageSize = 10 + } + } }; var products = await graphql.ReadRequiredGraphQLFieldAsync( await graphql.PostAsync(productsQuery), data => data.Products, - "products" - ); + "products"); products.Page.Items.Count.ShouldBe(1); products.Page.Items[0].Name.ShouldBe("Wireless Charger"); @@ -240,16 +218,15 @@ await graphql.PostAsync(productsQuery), { query = "office", pageNumber = 1, - pageSize = 10, - }, - }, + pageSize = 10 + } + } }; var categories = await graphql.ReadRequiredGraphQLFieldAsync( await graphql.PostAsync(categoriesQuery), data => data.Categories, - "categories" - ); + "categories"); categories.Page.Items.Count.ShouldBe(1); categories.Page.Items[0].Name.ShouldBe("Office Supplies"); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresSoftDeleteTests.cs b/tests/APITemplate.Tests/Integration/Postgres/PostgresSoftDeleteTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresSoftDeleteTests.cs rename to tests/APITemplate.Tests/Integration/Postgres/PostgresSoftDeleteTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresTenantIsolationTests.cs b/tests/APITemplate.Tests/Integration/Postgres/PostgresTenantIsolationTests.cs similarity index 66% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresTenantIsolationTests.cs rename to tests/APITemplate.Tests/Integration/Postgres/PostgresTenantIsolationTests.cs index 4a566e84..850a2b69 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresTenantIsolationTests.cs +++ b/tests/APITemplate.Tests/Integration/Postgres/PostgresTenantIsolationTests.cs @@ -12,88 +12,25 @@ namespace APITemplate.Tests.Integration.Postgres; -public sealed class PostgresTenantIsolationTests(SharedPostgresContainer postgres) - : PostgresTestBase(postgres) +public sealed class PostgresTenantIsolationTests(SharedPostgresContainer postgres) : PostgresTestBase(postgres) { [Fact] public async Task GlobalQueryFilters_IsolateProductsAndReviewsAcrossTenants() { var ct = TestContext.Current.CancellationToken; var actorId = Guid.NewGuid(); - var tenantA = new Tenant - { - Id = Guid.NewGuid(), - Code = $"tenant-a-{Guid.NewGuid():N}", - Name = "Tenant A", - }; - var tenantB = new Tenant - { - Id = Guid.NewGuid(), - Code = $"tenant-b-{Guid.NewGuid():N}", - Name = "Tenant B", - }; - var categoryA = new Category - { - Id = Guid.NewGuid(), - TenantId = tenantA.Id, - Name = $"Category-A-{Guid.NewGuid():N}", - }; - var categoryB = new Category - { - Id = Guid.NewGuid(), - TenantId = tenantB.Id, - Name = $"Category-B-{Guid.NewGuid():N}", - }; - var userA = new AppUser - { - Id = Guid.NewGuid(), - TenantId = tenantA.Id, - Username = $"usera-{Guid.NewGuid():N}", - Email = $"a-{Guid.NewGuid():N}@example.com", - }; - var userB = new AppUser - { - Id = Guid.NewGuid(), - TenantId = tenantB.Id, - Username = $"userb-{Guid.NewGuid():N}", - Email = $"b-{Guid.NewGuid():N}@example.com", - }; - var productA = new Product - { - Id = Guid.NewGuid(), - TenantId = tenantA.Id, - Name = $"Product-A-{Guid.NewGuid():N}", - Price = 10m, - CategoryId = categoryA.Id, - }; - var productB = new Product - { - Id = Guid.NewGuid(), - TenantId = tenantB.Id, - Name = $"Product-B-{Guid.NewGuid():N}", - Price = 20m, - CategoryId = categoryB.Id, - }; - var reviewA = new ProductReview - { - Id = Guid.NewGuid(), - TenantId = tenantA.Id, - ProductId = productA.Id, - UserId = userA.Id, - Rating = 5, - }; - var reviewB = new ProductReview - { - Id = Guid.NewGuid(), - TenantId = tenantB.Id, - ProductId = productB.Id, - UserId = userB.Id, - Rating = 4, - }; - - await using ( - var seedContext = await CreateDbContextAsync(hasTenant: false, Guid.Empty, actorId, ct) - ) + var tenantA = new Tenant { Id = Guid.NewGuid(), Code = $"tenant-a-{Guid.NewGuid():N}", Name = "Tenant A" }; + var tenantB = new Tenant { Id = Guid.NewGuid(), Code = $"tenant-b-{Guid.NewGuid():N}", Name = "Tenant B" }; + var categoryA = new Category { Id = Guid.NewGuid(), TenantId = tenantA.Id, Name = $"Category-A-{Guid.NewGuid():N}" }; + var categoryB = new Category { Id = Guid.NewGuid(), TenantId = tenantB.Id, Name = $"Category-B-{Guid.NewGuid():N}" }; + var userA = new AppUser { Id = Guid.NewGuid(), TenantId = tenantA.Id, Username = $"usera-{Guid.NewGuid():N}", Email = $"a-{Guid.NewGuid():N}@example.com" }; + var userB = new AppUser { Id = Guid.NewGuid(), TenantId = tenantB.Id, Username = $"userb-{Guid.NewGuid():N}", Email = $"b-{Guid.NewGuid():N}@example.com" }; + var productA = new Product { Id = Guid.NewGuid(), TenantId = tenantA.Id, Name = $"Product-A-{Guid.NewGuid():N}", Price = 10m, CategoryId = categoryA.Id }; + var productB = new Product { Id = Guid.NewGuid(), TenantId = tenantB.Id, Name = $"Product-B-{Guid.NewGuid():N}", Price = 20m, CategoryId = categoryB.Id }; + var reviewA = new ProductReview { Id = Guid.NewGuid(), TenantId = tenantA.Id, ProductId = productA.Id, UserId = userA.Id, Rating = 5 }; + var reviewB = new ProductReview { Id = Guid.NewGuid(), TenantId = tenantB.Id, ProductId = productB.Id, UserId = userB.Id, Rating = 4 }; + + await using (var seedContext = await CreateDbContextAsync(hasTenant: false, Guid.Empty, actorId, ct)) { seedContext.Tenants.AddRange(tenantA, tenantB); seedContext.Users.AddRange(userA, userB); @@ -105,24 +42,19 @@ public async Task GlobalQueryFilters_IsolateProductsAndReviewsAcrossTenants() await using var tenantAContext = await CreateDbContextAsync(true, tenantA.Id, actorId, ct); await using var tenantBContext = await CreateDbContextAsync(true, tenantB.Id, actorId, ct); - await using var unrestrictedContext = await CreateDbContextAsync( - false, - Guid.Empty, - actorId, - ct - ); + await using var unrestrictedContext = await CreateDbContextAsync(false, Guid.Empty, actorId, ct); var tenantAProducts = await tenantAContext.Products.OrderBy(p => p.Id).ToListAsync(ct); var tenantAReviews = await tenantAContext.ProductReviews.OrderBy(r => r.Id).ToListAsync(ct); var tenantBProducts = await tenantBContext.Products.OrderBy(p => p.Id).ToListAsync(ct); var tenantBReviews = await tenantBContext.ProductReviews.OrderBy(r => r.Id).ToListAsync(ct); - var allProducts = await unrestrictedContext - .Products.IgnoreQueryFilters() + var allProducts = await unrestrictedContext.Products + .IgnoreQueryFilters() .Where(p => p.Id == productA.Id || p.Id == productB.Id) .OrderBy(p => p.Id) .ToListAsync(ct); - var allReviews = await unrestrictedContext - .ProductReviews.IgnoreQueryFilters() + var allReviews = await unrestrictedContext.ProductReviews + .IgnoreQueryFilters() .Where(r => r.Id == reviewA.Id || r.Id == reviewB.Id) .OrderBy(r => r.Id) .ToListAsync(ct); @@ -146,15 +78,13 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() _factory.Services, usernameA, $"{usernameA}@example.com", - ct: ct - ); + ct: ct); var (tenantB, userB) = await IntegrationAuthHelper.SeedTenantUserAsync( _factory.Services, usernameB, $"{usernameB}@example.com", - ct: ct - ); + ct: ct); Guid categoryAId; Guid categoryBId; @@ -169,14 +99,14 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() { Id = Guid.NewGuid(), TenantId = tenantA.Id, - Name = $"Category-A-{Guid.NewGuid():N}", + Name = $"Category-A-{Guid.NewGuid():N}" }; var categoryB = new Category { Id = Guid.NewGuid(), TenantId = tenantB.Id, - Name = $"Category-B-{Guid.NewGuid():N}", + Name = $"Category-B-{Guid.NewGuid():N}" }; var productA1 = new Product @@ -185,7 +115,7 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() TenantId = tenantA.Id, Name = $"Product-A1-{Guid.NewGuid():N}", Price = 100m, - CategoryId = categoryA.Id, + CategoryId = categoryA.Id }; var productA2 = new Product @@ -194,7 +124,7 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() TenantId = tenantA.Id, Name = $"Product-A2-{Guid.NewGuid():N}", Price = 300m, - CategoryId = categoryA.Id, + CategoryId = categoryA.Id }; var productB1 = new Product @@ -203,7 +133,7 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() TenantId = tenantB.Id, Name = $"Product-B1-{Guid.NewGuid():N}", Price = 999m, - CategoryId = categoryB.Id, + CategoryId = categoryB.Id }; var reviewA1 = new ProductReview @@ -212,7 +142,7 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() TenantId = tenantA.Id, ProductId = productA1.Id, UserId = userA.Id, - Rating = 5, + Rating = 5 }; var reviewA2 = new ProductReview @@ -221,7 +151,7 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() TenantId = tenantA.Id, ProductId = productA1.Id, UserId = userA.Id, - Rating = 4, + Rating = 4 }; var reviewB1 = new ProductReview @@ -230,7 +160,7 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() TenantId = tenantB.Id, ProductId = productB1.Id, UserId = userB.Id, - Rating = 3, + Rating = 3 }; db.Categories.AddRange(categoryA, categoryB); @@ -254,20 +184,12 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() reviewToSoftDeleteId = reviewA2.Id; } - IntegrationAuthHelper.Authenticate( - _client, - tenantId: tenantA.Id, - username: usernameA, - role: Domain.Enums.UserRole.User - ); + IntegrationAuthHelper.Authenticate(_client, tenantId: tenantA.Id, username: usernameA, role: Domain.Enums.UserRole.User); var statsResponse = await _client.GetAsync($"/api/v1/categories/{categoryAId}/stats", ct); statsResponse.StatusCode.ShouldBe(HttpStatusCode.OK); - var payload = await statsResponse.Content.ReadFromJsonAsync( - TestJsonOptions.CaseInsensitive, - ct - ); + var payload = await statsResponse.Content.ReadFromJsonAsync(TestJsonOptions.CaseInsensitive, ct); payload.ShouldNotBeNull(); payload!.CategoryId.ShouldBe(categoryAId); payload.ProductCount.ShouldBe(1); @@ -275,28 +197,16 @@ public async Task CategoryStats_ReturnsTenantScopedAndSoftDeleteScopedValues() payload.TotalReviews.ShouldBe(1); // Tenant A token must not access stats of tenant B category. - var forbiddenByIsolation = await _client.GetAsync( - $"/api/v1/categories/{categoryBId}/stats", - ct - ); + var forbiddenByIsolation = await _client.GetAsync($"/api/v1/categories/{categoryBId}/stats", ct); forbiddenByIsolation.StatusCode.ShouldBe(HttpStatusCode.NotFound); // Ensure soft-deleted review is hidden from tenant-scoped query path. - var reviewById = await _client.GetAsync( - $"/api/v1/productreviews/{reviewToSoftDeleteId}", - ct - ); + var reviewById = await _client.GetAsync($"/api/v1/productreviews/{reviewToSoftDeleteId}", ct); reviewById.StatusCode.ShouldBe(HttpStatusCode.NotFound); - var reviewsByProduct = await _client.GetAsync( - $"/api/v1/productreviews/by-product/{productAId}", - ct - ); + var reviewsByProduct = await _client.GetAsync($"/api/v1/productreviews/by-product/{productAId}", ct); reviewsByProduct.StatusCode.ShouldBe(HttpStatusCode.OK); - var reviews = await reviewsByProduct.Content.ReadFromJsonAsync( - TestJsonOptions.CaseInsensitive, - ct - ); + var reviews = await reviewsByProduct.Content.ReadFromJsonAsync(TestJsonOptions.CaseInsensitive, ct); reviews.ShouldNotBeNull(); reviews!.Length.ShouldBe(1); } @@ -310,8 +220,7 @@ public async Task CategoryStats_FunctionCallable_ReturnsZeroValuesForEmptyCatego _factory.Services, username, $"{username}@example.com", - ct: ct - ); + ct: ct); Guid categoryId; @@ -322,7 +231,7 @@ public async Task CategoryStats_FunctionCallable_ReturnsZeroValuesForEmptyCatego { Id = Guid.NewGuid(), TenantId = tenant.Id, - Name = $"Category-Stats-Smoke-{Guid.NewGuid():N}", + Name = $"Category-Stats-Smoke-{Guid.NewGuid():N}" }; db.Categories.Add(category); @@ -330,20 +239,12 @@ public async Task CategoryStats_FunctionCallable_ReturnsZeroValuesForEmptyCatego categoryId = category.Id; } - IntegrationAuthHelper.Authenticate( - _client, - tenantId: tenant.Id, - username: username, - role: Domain.Enums.UserRole.User - ); + IntegrationAuthHelper.Authenticate(_client, tenantId: tenant.Id, username: username, role: Domain.Enums.UserRole.User); var response = await _client.GetAsync($"/api/v1/categories/{categoryId}/stats", ct); response.StatusCode.ShouldBe(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync( - TestJsonOptions.CaseInsensitive, - ct - ); + var payload = await response.Content.ReadFromJsonAsync(TestJsonOptions.CaseInsensitive, ct); payload.ShouldNotBeNull(); payload!.CategoryId.ShouldBe(categoryId); payload.ProductCount.ShouldBe(0); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresTenantSoftDeleteCascadeTests.cs b/tests/APITemplate.Tests/Integration/Postgres/PostgresTenantSoftDeleteCascadeTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresTenantSoftDeleteCascadeTests.cs rename to tests/APITemplate.Tests/Integration/Postgres/PostgresTenantSoftDeleteCascadeTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresTestBase.cs b/tests/APITemplate.Tests/Integration/Postgres/PostgresTestBase.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresTestBase.cs rename to tests/APITemplate.Tests/Integration/Postgres/PostgresTestBase.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresTransactionTests.cs b/tests/APITemplate.Tests/Integration/Postgres/PostgresTransactionTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresTransactionTests.cs rename to tests/APITemplate.Tests/Integration/Postgres/PostgresTransactionTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresWebApplicationFactory.cs b/tests/APITemplate.Tests/Integration/Postgres/PostgresWebApplicationFactory.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/PostgresWebApplicationFactory.cs rename to tests/APITemplate.Tests/Integration/Postgres/PostgresWebApplicationFactory.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/SharedPostgresContainer.cs b/tests/APITemplate.Tests/Integration/Postgres/SharedPostgresContainer.cs similarity index 68% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/SharedPostgresContainer.cs rename to tests/APITemplate.Tests/Integration/Postgres/SharedPostgresContainer.cs index b34c8fc8..6fe18c27 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Postgres/SharedPostgresContainer.cs +++ b/tests/APITemplate.Tests/Integration/Postgres/SharedPostgresContainer.cs @@ -6,12 +6,11 @@ namespace APITemplate.Tests.Integration.Postgres; public sealed class SharedPostgresContainer : IAsyncLifetime { - public PostgreSqlContainer Container { get; } = - new PostgreSqlBuilder("postgres:16-alpine") - .WithUsername("postgres") - .WithPassword("postgres") - .WithCleanUp(true) - .Build(); + public PostgreSqlContainer Container { get; } = new PostgreSqlBuilder("postgres:16-alpine") + .WithUsername("postgres") + .WithPassword("postgres") + .WithCleanUp(true) + .Build(); public string ServerConnectionString { @@ -19,7 +18,7 @@ public string ServerConnectionString { var builder = new NpgsqlConnectionStringBuilder(Container.GetConnectionString()) { - Database = "postgres", + Database = "postgres" }; return builder.ConnectionString; } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Products/CategoriesControllerTests.cs b/tests/APITemplate.Tests/Integration/Products/CategoriesControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Products/CategoriesControllerTests.cs rename to tests/APITemplate.Tests/Integration/Products/CategoriesControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Products/ProductDataControllerTests.cs b/tests/APITemplate.Tests/Integration/Products/ProductDataControllerTests.cs similarity index 63% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Products/ProductDataControllerTests.cs rename to tests/APITemplate.Tests/Integration/Products/ProductDataControllerTests.cs index a22eb27d..1f810e11 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Products/ProductDataControllerTests.cs +++ b/tests/APITemplate.Tests/Integration/Products/ProductDataControllerTests.cs @@ -1,8 +1,8 @@ using System.Net; using System.Net.Http.Json; using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; using APITemplate.Tests.Integration.Helpers; +using APITemplate.Domain.Interfaces; using Microsoft.Extensions.DependencyInjection; using Moq; using Shouldly; @@ -45,24 +45,12 @@ public async Task GetAll_WithTypeFilter_PassesTypeToRepository() _repositoryMock .Setup(r => r.GetAllAsync("image", It.IsAny())) - .ReturnsAsync([ - new ImageProductData - { - Title = "Photo", - Width = 100, - Height = 100, - Format = "png", - FileSizeBytes = 1000, - }, - ]); + .ReturnsAsync([new ImageProductData { Title = "Photo", Width = 100, Height = 100, Format = "png", FileSizeBytes = 1000 }]); var response = await _client.GetAsync("/api/v1/product-data?type=image", ct); response.StatusCode.ShouldBe(HttpStatusCode.OK); - var items = await response.Content.ReadFromJsonAsync( - TestJsonOptions.CaseInsensitive, - ct - ); + var items = await response.Content.ReadFromJsonAsync(TestJsonOptions.CaseInsensitive, ct); items.ShouldNotBeNull(); items!.Length.ShouldBe(1); items[0].Type.ShouldBe("image"); @@ -74,15 +62,7 @@ public async Task GetById_WhenExists_ReturnsOk() var ct = TestContext.Current.CancellationToken; IntegrationAuthHelper.Authenticate(_client); - var image = new ImageProductData - { - TenantId = Guid.Parse("00000000-0000-0000-0000-000000000001"), - Title = "Banner", - Width = 800, - Height = 600, - Format = "jpg", - FileSizeBytes = 200000, - }; + var image = new ImageProductData { TenantId = Guid.Parse("00000000-0000-0000-0000-000000000001"), Title = "Banner", Width = 800, Height = 600, Format = "jpg", FileSizeBytes = 200000 }; _repositoryMock .Setup(r => r.GetByIdAsync(image.Id, It.IsAny())) @@ -91,10 +71,7 @@ public async Task GetById_WhenExists_ReturnsOk() var response = await _client.GetAsync($"/api/v1/product-data/{image.Id}", ct); response.StatusCode.ShouldBe(HttpStatusCode.OK); - var data = await response.Content.ReadFromJsonAsync( - TestJsonOptions.CaseInsensitive, - ct - ); + var data = await response.Content.ReadFromJsonAsync(TestJsonOptions.CaseInsensitive, ct); data.ShouldNotBeNull(); data!.Title.ShouldBe("Banner"); data.Type.ShouldBe("image"); @@ -126,48 +103,25 @@ public async Task Create_ValidRequest_ReturnsCreated(string type) if (type == "image") { _repositoryMock - .Setup(r => - r.CreateAsync(It.IsAny(), It.IsAny()) - ) + .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((ProductData d, CancellationToken _) => d); } else { _repositoryMock - .Setup(r => - r.CreateAsync(It.IsAny(), It.IsAny()) - ) + .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((ProductData d, CancellationToken _) => d); } - object payload = - type == "image" - ? new - { - Title = "Hero Banner", - Description = "Main page hero", - Width = 1920, - Height = 1080, - Format = "jpg", - FileSizeBytes = 500000, - } - : new - { - Title = "Product Demo", - DurationSeconds = 120, - Resolution = "1080p", - Format = "mp4", - FileSizeBytes = 10000000, - }; + object payload = type == "image" + ? new { Title = "Hero Banner", Description = "Main page hero", Width = 1920, Height = 1080, Format = "jpg", FileSizeBytes = 500000 } + : new { Title = "Product Demo", DurationSeconds = 120, Resolution = "1080p", Format = "mp4", FileSizeBytes = 10000000 }; var response = await _client.PostAsJsonAsync($"/api/v1/product-data/{type}", payload, ct); var body = await response.Content.ReadAsStringAsync(ct); response.StatusCode.ShouldBe(HttpStatusCode.Created, body); - var data = await response.Content.ReadFromJsonAsync( - TestJsonOptions.CaseInsensitive, - ct - ); + var data = await response.Content.ReadFromJsonAsync(TestJsonOptions.CaseInsensitive, ct); data.ShouldNotBeNull(); data!.Type.ShouldBe(type); } @@ -180,24 +134,9 @@ public async Task Create_InvalidRequest_ReturnsBadRequest(string type) var ct = TestContext.Current.CancellationToken; IntegrationAuthHelper.Authenticate(_client); - object payload = - type == "image" - ? new - { - Title = "", - Width = -1, - Height = 0, - Format = "bmp", - FileSizeBytes = -100, - } - : new - { - Title = "", - DurationSeconds = 0, - Resolution = "480p", - Format = "wmv", - FileSizeBytes = -1, - }; + object payload = type == "image" + ? new { Title = "", Width = -1, Height = 0, Format = "bmp", FileSizeBytes = -100 } + : new { Title = "", DurationSeconds = 0, Resolution = "480p", Format = "wmv", FileSizeBytes = -1 }; var response = await _client.PostAsJsonAsync($"/api/v1/product-data/{type}", payload, ct); @@ -214,27 +153,13 @@ public async Task Delete_WithToken_ReturnsNoContent() _repositoryMock .Setup(r => r.GetByIdAsync(id, It.IsAny())) - .ReturnsAsync( - new ImageProductData - { - Id = id, - TenantId = Guid.Parse("00000000-0000-0000-0000-000000000001"), - Title = "Image", - } - ); + .ReturnsAsync(new ImageProductData { Id = id, TenantId = Guid.Parse("00000000-0000-0000-0000-000000000001"), Title = "Image" }); var response = await _client.DeleteAsync($"/api/v1/product-data/{id}", ct); response.StatusCode.ShouldBe(HttpStatusCode.NoContent); _repositoryMock.Verify( - r => - r.SoftDeleteAsync( - id, - It.IsAny(), - It.IsAny(), - It.IsAny() - ), - Times.Once - ); + r => r.SoftDeleteAsync(id, It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); } } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Products/ProductReviewsControllerTests.cs b/tests/APITemplate.Tests/Integration/Products/ProductReviewsControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Products/ProductReviewsControllerTests.cs rename to tests/APITemplate.Tests/Integration/Products/ProductReviewsControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Products/ProductsControllerTests.cs b/tests/APITemplate.Tests/Integration/Products/ProductsControllerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Products/ProductsControllerTests.cs rename to tests/APITemplate.Tests/Integration/Products/ProductsControllerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Security/PermissionAuthorizationIntegrationTests.cs b/tests/APITemplate.Tests/Integration/Security/PermissionAuthorizationIntegrationTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Integration/Security/PermissionAuthorizationIntegrationTests.cs rename to tests/APITemplate.Tests/Integration/Security/PermissionAuthorizationIntegrationTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsServiceCollectionExtensionsTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsServiceCollectionExtensionsTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsServiceCollectionExtensionsTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsServiceCollectionExtensionsTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/CleanupRecurringJobTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/CleanupRecurringJobTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/CleanupRecurringJobTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/CleanupRecurringJobTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/DragonflyDistributedJobCoordinatorTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/DragonflyDistributedJobCoordinatorTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/DragonflyDistributedJobCoordinatorTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/DragonflyDistributedJobCoordinatorTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/EmailRetryRecurringJobTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/EmailRetryRecurringJobTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/EmailRetryRecurringJobTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/EmailRetryRecurringJobTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/EmailRetryServiceTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/EmailRetryServiceTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/EmailRetryServiceTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/EmailRetryServiceTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/ExternalSyncRecurringJobTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/ExternalSyncRecurringJobTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/ExternalSyncRecurringJobTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/ExternalSyncRecurringJobTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/ReindexRecurringJobTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/ReindexRecurringJobTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/ReindexRecurringJobTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/ReindexRecurringJobTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/TickerQRecurringJobRegistrarTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/TickerQRecurringJobRegistrarTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/TickerQRecurringJobRegistrarTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/TickerQRecurringJobRegistrarTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/TickerQSchedulerDbContextTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/TickerQSchedulerDbContextTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/BackgroundJobs/TickerQSchedulerDbContextTests.cs rename to tests/APITemplate.Tests/Unit/BackgroundJobs/TickerQSchedulerDbContextTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs b/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs similarity index 69% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs rename to tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs index fdbb6567..c4a5fc9e 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs +++ b/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs @@ -4,10 +4,7 @@ namespace APITemplate.Tests.Unit.Build; internal static class PackageReferencePolicy { - public static PolicyEvaluationResult Evaluate( - string projectXml, - string? centralPackagesXml = null - ) + public static PolicyEvaluationResult Evaluate(string projectXml, string? centralPackagesXml = null) { var projectReferences = ParseProjectReferences(projectXml); var centralVersions = ParseCentralVersions(centralPackagesXml); @@ -22,33 +19,28 @@ public static PolicyEvaluationResult Evaluate( private static IReadOnlyList ParseProjectReferences(string projectXml) { - return XDocument - .Parse(projectXml) + return XDocument.Parse(projectXml) .Descendants() .Where(node => node.Name.LocalName == "PackageReference") .Select(node => new PackageReference( (string?)node.Attribute("Include") ?? string.Empty, - (string?)node.Attribute("Version") ?? string.Empty - )) + (string?)node.Attribute("Version") ?? string.Empty)) .Where(reference => !string.IsNullOrWhiteSpace(reference.Include)) .ToList(); } - private static IReadOnlyDictionary ParseCentralVersions( - string? centralPackagesXml - ) + private static IReadOnlyDictionary ParseCentralVersions(string? centralPackagesXml) { if (string.IsNullOrWhiteSpace(centralPackagesXml)) return new Dictionary(StringComparer.Ordinal); - return XDocument - .Parse(centralPackagesXml) + return XDocument.Parse(centralPackagesXml) .Descendants() .Where(node => node.Name.LocalName == "PackageVersion") .Select(node => new { Include = (string?)node.Attribute("Include") ?? string.Empty, - Version = (string?)node.Attribute("Version") ?? string.Empty, + Version = (string?)node.Attribute("Version") ?? string.Empty }) .Where(item => !string.IsNullOrWhiteSpace(item.Include)) .ToDictionary(item => item.Include, item => item.Version, StringComparer.Ordinal); @@ -56,20 +48,15 @@ private static IReadOnlyDictionary ParseCentralVersions( private static IReadOnlyList ResolveVersions( IReadOnlyList projectReferences, - IReadOnlyDictionary centralVersions - ) + IReadOnlyDictionary centralVersions) { return projectReferences - .Select(reference => - reference with - { - Version = - string.IsNullOrWhiteSpace(reference.Version) - && centralVersions.TryGetValue(reference.Include, out var resolvedVersion) - ? resolvedVersion - : reference.Version, - } - ) + .Select(reference => reference with + { + Version = string.IsNullOrWhiteSpace(reference.Version) && centralVersions.TryGetValue(reference.Include, out var resolvedVersion) + ? resolvedVersion + : reference.Version + }) .ToList(); } } @@ -79,34 +66,35 @@ internal static class PackagePolicies public static readonly PrefixVersionRule HealthChecks = new( Name: "HealthChecks", Prefix: "AspNetCore.HealthChecks.", - VersionSelector: version => version.Major.ToString() - ); + VersionSelector: version => version.Major.ToString()); public static readonly PrefixVersionRule HotChocolate = new( Name: "HotChocolate", Prefix: "HotChocolate.", - VersionSelector: version => version.ToString() - ); + VersionSelector: version => version.ToString()); public static readonly PrefixVersionRule Keycloak = new( Name: "Keycloak.AuthServices", Prefix: "Keycloak.AuthServices.", - VersionSelector: version => version.ToString() - ); + VersionSelector: version => version.ToString()); public static readonly ExactPairVersionRule Ardalis = new( Name: "Ardalis.Specification", FirstPackageId: "Ardalis.Specification", - SecondPackageId: "Ardalis.Specification.EntityFrameworkCore" - ); + SecondPackageId: "Ardalis.Specification.EntityFrameworkCore"); public static readonly RequiredPinnedVersionRule Scalar = new( Name: "Scalar.AspNetCore", - PackageId: "Scalar.AspNetCore" - ); + PackageId: "Scalar.AspNetCore"); public static IReadOnlyList All { get; } = - [HealthChecks, HotChocolate, Keycloak, Ardalis, Scalar]; + [ + HealthChecks, + HotChocolate, + Keycloak, + Ardalis, + Scalar + ]; } internal interface IPackagePolicyRule @@ -117,8 +105,7 @@ internal interface IPackagePolicyRule internal sealed record PrefixVersionRule( string Name, string Prefix, - Func VersionSelector -) : IPackagePolicyRule + Func VersionSelector) : IPackagePolicyRule { public void Validate(IReadOnlyList references, List errors) { @@ -139,31 +126,24 @@ public void Validate(IReadOnlyList references, List er .ToList(); if (distinctVersions.Count > 1) - errors.Add( - $"{Name} packages must share the same version policy. Found: {string.Join(", ", family.Select(reference => $"{reference.Include}={reference.Version}"))}." - ); + errors.Add($"{Name} packages must share the same version policy. Found: {string.Join(", ", family.Select(reference => $"{reference.Include}={reference.Version}"))}."); } } internal sealed record ExactPairVersionRule( string Name, string FirstPackageId, - string SecondPackageId -) : IPackagePolicyRule + string SecondPackageId) : IPackagePolicyRule { public void Validate(IReadOnlyList references, List errors) { var pair = references - .Where(reference => - reference.Include == FirstPackageId || reference.Include == SecondPackageId - ) + .Where(reference => reference.Include == FirstPackageId || reference.Include == SecondPackageId) .ToList(); if (pair.Count != 2) { - errors.Add( - $"{Name} package pair must include both {FirstPackageId} and {SecondPackageId}." - ); + errors.Add($"{Name} package pair must include both {FirstPackageId} and {SecondPackageId}."); return; } @@ -172,13 +152,13 @@ public void Validate(IReadOnlyList references, List er return; if (parsed[0].Version != parsed[1].Version) - errors.Add( - $"{Name} packages must share the exact same version. Found: {parsed[0].Reference.Include}={parsed[0].Version}, {parsed[1].Reference.Include}={parsed[1].Version}." - ); + errors.Add($"{Name} packages must share the exact same version. Found: {parsed[0].Reference.Include}={parsed[0].Version}, {parsed[1].Reference.Include}={parsed[1].Version}."); } } -internal sealed record RequiredPinnedVersionRule(string Name, string PackageId) : IPackagePolicyRule +internal sealed record RequiredPinnedVersionRule( + string Name, + string PackageId) : IPackagePolicyRule { public void Validate(IReadOnlyList references, List errors) { @@ -199,17 +179,14 @@ internal static class PackageVersionParsing public static List<(PackageReference Reference, Version Version)> Parse( string familyName, IReadOnlyCollection references, - List errors - ) + List errors) { var parsed = new List<(PackageReference, Version)>(); foreach (var reference in references) { if (!Version.TryParse(reference.Version, out var version)) { - errors.Add( - $"{familyName} package {reference.Include} has an invalid version '{reference.Version}'." - ); + errors.Add($"{familyName} package {reference.Include} has an invalid version '{reference.Version}'."); continue; } @@ -252,47 +229,35 @@ internal static class PackagePolicyTestFiles """; - public static string GetRepoRoot() => - Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + public static string GetRepoRoot() + => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); public static string ReadProjectXml(string repoRoot) { var projectPaths = new[] { Path.Combine(repoRoot, "src", "APITemplate.Api", "APITemplate.Api.csproj"), - Path.Combine( - repoRoot, - "src", - "APITemplate.Application", - "APITemplate.Application.csproj" - ), - Path.Combine( - repoRoot, - "src", - "APITemplate.Infrastructure", - "APITemplate.Infrastructure.csproj" - ), + Path.Combine(repoRoot, "src", "APITemplate.Application", "APITemplate.Application.csproj"), + Path.Combine(repoRoot, "src", "APITemplate.Infrastructure", "APITemplate.Infrastructure.csproj") }; var packageReferences = projectPaths .Select(path => XDocument.Parse(File.ReadAllText(path))) - .SelectMany(document => - document - .Descendants() - .Where(node => node.Name.LocalName == "PackageReference") - .Select(node => new XElement(node)) - ) + .SelectMany(document => document + .Descendants() + .Where(node => node.Name.LocalName == "PackageReference") + .Select(node => new XElement(node))) .ToList(); var aggregateDocument = new XDocument( - new XElement("Project", new XElement("ItemGroup", packageReferences)) - ); + new XElement("Project", + new XElement("ItemGroup", packageReferences))); return aggregateDocument.ToString(); } - public static string ReadCentralPackageXml(string repoRoot) => - File.ReadAllText(Path.Combine(repoRoot, "Directory.Packages.props")); + public static string ReadCentralPackageXml(string repoRoot) + => File.ReadAllText(Path.Combine(repoRoot, "Directory.Packages.props")); } internal sealed record PackageReference(string Include, string Version); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs b/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs similarity index 97% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs rename to tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs index e63b9b25..773b8f39 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs +++ b/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs @@ -11,8 +11,7 @@ public void APITemplateCsproj_CompliesWithPackageFamilyVersionPolicy() var repoRoot = PackagePolicyTestFiles.GetRepoRoot(); var result = PackageReferencePolicy.Evaluate( PackagePolicyTestFiles.ReadProjectXml(repoRoot), - PackagePolicyTestFiles.ReadCentralPackageXml(repoRoot) - ); + PackagePolicyTestFiles.ReadCentralPackageXml(repoRoot)); result.Errors.ShouldBeEmpty(); } @@ -22,8 +21,7 @@ public void Evaluate_FailsWhenPackageFamiliesDriftFromPolicy() { var result = PackageReferencePolicy.Evaluate( PackagePolicyTestFiles.ProjectXmlWithoutInlineVersions, - PackagePolicyTestFiles.CentralPackageXmlWithVersionDrift - ); + PackagePolicyTestFiles.CentralPackageXmlWithVersionDrift); result.Errors.ShouldContain(error => error.Contains(PackagePolicies.HealthChecks.Name)); result.Errors.ShouldContain(error => error.Contains(PackagePolicies.HotChocolate.Name)); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs b/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs rename to tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Common/ConfigurationExtensionsTests.cs b/tests/APITemplate.Tests/Unit/Common/ConfigurationExtensionsTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Common/ConfigurationExtensionsTests.cs rename to tests/APITemplate.Tests/Unit/Common/ConfigurationExtensionsTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Common/PaginationDefaultsTests.cs b/tests/APITemplate.Tests/Unit/Common/PaginationDefaultsTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Common/PaginationDefaultsTests.cs rename to tests/APITemplate.Tests/Unit/Common/PaginationDefaultsTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Email/FailedEmailStoreTests.cs b/tests/APITemplate.Tests/Unit/Email/FailedEmailStoreTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Email/FailedEmailStoreTests.cs rename to tests/APITemplate.Tests/Unit/Email/FailedEmailStoreTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/ErrorOrMapping/ErrorOrExtensionsTests.cs b/tests/APITemplate.Tests/Unit/ErrorOrMapping/ErrorOrExtensionsTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/ErrorOrMapping/ErrorOrExtensionsTests.cs rename to tests/APITemplate.Tests/Unit/ErrorOrMapping/ErrorOrExtensionsTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs b/tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs similarity index 75% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs rename to tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs index 1e064c9f..452f15e4 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs +++ b/tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs @@ -4,9 +4,9 @@ using APITemplate.Domain.Exceptions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Moq; using Shouldly; using Xunit; @@ -27,34 +27,27 @@ public ApiExceptionHandlerTests() options.CustomizeProblemDetails = context => { var extensions = context.ProblemDetails.Extensions; - var errorCode = - extensions.TryGetValue("errorCode", out var code) && code is string existingCode - ? existingCode - : ErrorCatalog.General.Unknown; + var errorCode = extensions.TryGetValue("errorCode", out var code) && code is string existingCode + ? existingCode + : ErrorCatalog.General.Unknown; extensions["traceId"] = context.HttpContext.TraceIdentifier; extensions["errorCode"] = errorCode; context.ProblemDetails.Type ??= $"https://api-template.local/errors/{errorCode}"; }; }); - _problemDetailsService = services - .BuildServiceProvider() - .GetRequiredService(); + _problemDetailsService = services.BuildServiceProvider().GetRequiredService(); } public static IEnumerable ExceptionMappingCases() { yield return [ - new NotFoundException( - "Product", - Guid.Empty, - ErrorCatalog.Reviews.ProductNotFoundForReview - ), + new NotFoundException("Product", Guid.Empty, ErrorCatalog.Reviews.ProductNotFoundForReview), HttpStatusCode.NotFound, "Not Found", $"Product with id '{Guid.Empty}' not found.", - ErrorCatalog.Reviews.ProductNotFoundForReview, + ErrorCatalog.Reviews.ProductNotFoundForReview ]; yield return [ @@ -62,7 +55,7 @@ public static IEnumerable ExceptionMappingCases() HttpStatusCode.BadRequest, "Bad Request", "validation failed", - ErrorCatalog.General.ValidationFailed, + ErrorCatalog.General.ValidationFailed ]; yield return [ @@ -70,7 +63,7 @@ public static IEnumerable ExceptionMappingCases() HttpStatusCode.InternalServerError, "Internal Server Error", "An unexpected error occurred.", - ErrorCatalog.General.Unknown, + ErrorCatalog.General.Unknown ]; yield return [ @@ -78,7 +71,7 @@ public static IEnumerable ExceptionMappingCases() HttpStatusCode.Conflict, "Conflict", "The resource was modified by another request. Please retrieve the latest version and retry.", - ErrorCatalog.General.ConcurrencyConflict, + ErrorCatalog.General.ConcurrencyConflict ]; } @@ -89,17 +82,12 @@ public async Task TryHandleAsync_MapsExceptionToProblemDetails( HttpStatusCode expectedStatus, string expectedTitle, string expectedDetail, - string expectedErrorCode - ) + string expectedErrorCode) { var context = CreateHttpContext(); var handler = new ApiExceptionHandler(_loggerMock.Object, _problemDetailsService); - var handled = await handler.TryHandleAsync( - context, - exception, - TestContext.Current.CancellationToken - ); + var handled = await handler.TryHandleAsync(context, exception, TestContext.Current.CancellationToken); handled.ShouldBeTrue(); context.Response.StatusCode.ShouldBe((int)expectedStatus); @@ -110,9 +98,7 @@ string expectedErrorCode body.GetProperty("title").GetString().ShouldBe(expectedTitle); body.GetProperty("detail").GetString().ShouldBe(expectedDetail); body.GetProperty("errorCode").GetString().ShouldBe(expectedErrorCode); - body.GetProperty("type") - .GetString() - .ShouldBe($"https://api-template.local/errors/{expectedErrorCode}"); + body.GetProperty("type").GetString().ShouldBe($"https://api-template.local/errors/{expectedErrorCode}"); body.GetProperty("traceId").GetString().ShouldNotBeNullOrWhiteSpace(); } @@ -123,11 +109,7 @@ public async Task TryHandleAsync_WhenGraphQlPath_ReturnsFalse() context.Request.Path = "/graphql"; var handler = new ApiExceptionHandler(_loggerMock.Object, _problemDetailsService); - var handled = await handler.TryHandleAsync( - context, - new InvalidOperationException("boom"), - TestContext.Current.CancellationToken - ); + var handled = await handler.TryHandleAsync(context, new InvalidOperationException("boom"), TestContext.Current.CancellationToken); handled.ShouldBeFalse(); } @@ -141,11 +123,7 @@ public async Task TryHandleAsync_WhenRequestIsAborted_ReturnsTrueWithoutProblemD cts.Cancel(); var handler = new ApiExceptionHandler(_loggerMock.Object, _problemDetailsService); - var handled = await handler.TryHandleAsync( - context, - new OperationCanceledException(cts.Token), - cts.Token - ); + var handled = await handler.TryHandleAsync(context, new OperationCanceledException(cts.Token), cts.Token); handled.ShouldBeTrue(); context.Response.StatusCode.ShouldBe(499); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Extensions/WolverineTypeExtensionsTests.cs b/tests/APITemplate.Tests/Unit/Extensions/WolverineTypeExtensionsTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Extensions/WolverineTypeExtensionsTests.cs rename to tests/APITemplate.Tests/Unit/Extensions/WolverineTypeExtensionsTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Filters/FluentValidationActionFilterTests.cs b/tests/APITemplate.Tests/Unit/Filters/FluentValidationActionFilterTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Filters/FluentValidationActionFilterTests.cs rename to tests/APITemplate.Tests/Unit/Filters/FluentValidationActionFilterTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/CategoryRequestHandlersTests.cs b/tests/APITemplate.Tests/Unit/Handlers/CategoryRequestHandlersTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/CategoryRequestHandlersTests.cs rename to tests/APITemplate.Tests/Unit/Handlers/CategoryRequestHandlersTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/ProductDataCascadeDeleteHandlerTests.cs b/tests/APITemplate.Tests/Unit/Handlers/ProductDataCascadeDeleteHandlerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/ProductDataCascadeDeleteHandlerTests.cs rename to tests/APITemplate.Tests/Unit/Handlers/ProductDataCascadeDeleteHandlerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/ProductDataRequestHandlersTests.cs b/tests/APITemplate.Tests/Unit/Handlers/ProductDataRequestHandlersTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/ProductDataRequestHandlersTests.cs rename to tests/APITemplate.Tests/Unit/Handlers/ProductDataRequestHandlersTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/ProductRequestHandlersTests.cs b/tests/APITemplate.Tests/Unit/Handlers/ProductRequestHandlersTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/ProductRequestHandlersTests.cs rename to tests/APITemplate.Tests/Unit/Handlers/ProductRequestHandlersTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/ProductReviewRequestHandlersTests.cs b/tests/APITemplate.Tests/Unit/Handlers/ProductReviewRequestHandlersTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/ProductReviewRequestHandlersTests.cs rename to tests/APITemplate.Tests/Unit/Handlers/ProductReviewRequestHandlersTests.cs diff --git a/tests/APITemplate.Tests/Unit/Handlers/UnitOfWorkMockExtensions.cs b/tests/APITemplate.Tests/Unit/Handlers/UnitOfWorkMockExtensions.cs new file mode 100644 index 00000000..5aec096b --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Handlers/UnitOfWorkMockExtensions.cs @@ -0,0 +1,22 @@ +using APITemplate.Domain.Interfaces; +using APITemplate.Domain.Options; +using Moq; + +namespace APITemplate.Tests.Unit.Handlers; + +internal static class UnitOfWorkMockExtensions +{ + public static void SetupImmediateTransactionExecution(this Mock unitOfWorkMock) + { + unitOfWorkMock + .Setup(u => u.ExecuteInTransactionAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns((Func action, CancellationToken _, TransactionOptions? _) => action()); + } + + public static void SetupImmediateTransactionExecution(this Mock unitOfWorkMock) + { + unitOfWorkMock + .Setup(u => u.ExecuteInTransactionAsync(It.IsAny>>(), It.IsAny(), It.IsAny())) + .Returns((Func> action, CancellationToken _, TransactionOptions? _) => action()); + } +} diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/UserRequestHandlersTests.cs b/tests/APITemplate.Tests/Unit/Handlers/UserRequestHandlersTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Handlers/UserRequestHandlersTests.cs rename to tests/APITemplate.Tests/Unit/Handlers/UserRequestHandlersTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Integration/TestConfigurationHelperTests.cs b/tests/APITemplate.Tests/Unit/Integration/TestConfigurationHelperTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Integration/TestConfigurationHelperTests.cs rename to tests/APITemplate.Tests/Unit/Integration/TestConfigurationHelperTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Integration/TestServiceHelperTests.cs b/tests/APITemplate.Tests/Unit/Integration/TestServiceHelperTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Integration/TestServiceHelperTests.cs rename to tests/APITemplate.Tests/Unit/Integration/TestServiceHelperTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs b/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs rename to tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Middleware/ErrorOrValidationMiddlewareTests.cs b/tests/APITemplate.Tests/Unit/Middleware/ErrorOrValidationMiddlewareTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Middleware/ErrorOrValidationMiddlewareTests.cs rename to tests/APITemplate.Tests/Unit/Middleware/ErrorOrValidationMiddlewareTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Middleware/RequestContextMiddlewareTests.cs b/tests/APITemplate.Tests/Unit/Middleware/RequestContextMiddlewareTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Middleware/RequestContextMiddlewareTests.cs rename to tests/APITemplate.Tests/Unit/Middleware/RequestContextMiddlewareTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Observability/HttpRouteResolverTests.cs b/tests/APITemplate.Tests/Unit/Observability/HttpRouteResolverTests.cs similarity index 89% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Observability/HttpRouteResolverTests.cs rename to tests/APITemplate.Tests/Unit/Observability/HttpRouteResolverTests.cs index dfaff172..7e8f6c4e 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Observability/HttpRouteResolverTests.cs +++ b/tests/APITemplate.Tests/Unit/Observability/HttpRouteResolverTests.cs @@ -13,8 +13,10 @@ public void ReplaceVersionToken_WhenRouteContainsApiVersionConstraint_ReplacesWi { var resolvedRoute = HttpRouteResolver.ReplaceVersionToken( "api/v{version:apiVersion}/Products", - new RouteValueDictionary { ["version"] = "1" } - ); + new RouteValueDictionary + { + ["version"] = "1" + }); resolvedRoute.ShouldBe("api/v1/Products"); } @@ -24,8 +26,7 @@ public void ReplaceVersionToken_WhenVersionMissing_LeavesTemplateUnchanged() { var resolvedRoute = HttpRouteResolver.ReplaceVersionToken( "api/v{version:apiVersion}/Products", - new RouteValueDictionary() - ); + new RouteValueDictionary()); resolvedRoute.ShouldBe("api/v{version:apiVersion}/Products"); } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Observability/ObservabilityServiceCollectionExtensionsTests.cs b/tests/APITemplate.Tests/Unit/Observability/ObservabilityServiceCollectionExtensionsTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Observability/ObservabilityServiceCollectionExtensionsTests.cs rename to tests/APITemplate.Tests/Unit/Observability/ObservabilityServiceCollectionExtensionsTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/AuthBootstrapSeederTests.cs b/tests/APITemplate.Tests/Unit/Persistence/AuthBootstrapSeederTests.cs similarity index 87% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/AuthBootstrapSeederTests.cs rename to tests/APITemplate.Tests/Unit/Persistence/AuthBootstrapSeederTests.cs index b54e54ba..01f1e2e6 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/AuthBootstrapSeederTests.cs +++ b/tests/APITemplate.Tests/Unit/Persistence/AuthBootstrapSeederTests.cs @@ -28,7 +28,7 @@ public async Task SeedAsync_WhenTenantExistsButInactiveOrDeleted_RestoresTenant( IsActive = false, IsDeleted = true, DeletedAtUtc = DateTime.UtcNow, - DeletedBy = Guid.NewGuid(), + DeletedBy = Guid.NewGuid() }; dbContext.Tenants.Add(tenant); @@ -37,8 +37,8 @@ public async Task SeedAsync_WhenTenantExistsButInactiveOrDeleted_RestoresTenant( var sut = CreateSeeder(dbContext); await sut.SeedAsync(ct); - var restoredTenant = await dbContext - .Tenants.IgnoreQueryFilters() + var restoredTenant = await dbContext.Tenants + .IgnoreQueryFilters() .SingleAsync(t => t.Code == "default", ct); restoredTenant.IsActive.ShouldBeTrue(); @@ -56,8 +56,8 @@ public async Task SeedAsync_WhenNoTenantExists_CreatesTenant() var sut = CreateSeeder(dbContext); await sut.SeedAsync(ct); - var tenant = await dbContext - .Tenants.IgnoreQueryFilters() + var tenant = await dbContext.Tenants + .IgnoreQueryFilters() .SingleAsync(t => t.Code == "default", ct); tenant.ShouldNotBeNull(); @@ -81,15 +81,16 @@ private static AppDbContext CreateDbContext() [], new AppUserEntityNormalizationService(), stateManager, - new SoftDeleteProcessor(stateManager) - ); + new SoftDeleteProcessor(stateManager)); } private static AuthBootstrapSeeder CreateSeeder(AppDbContext dbContext) { - var tenantOptions = Options.Create( - new BootstrapTenantOptions { Code = "default", Name = "Default Tenant" } - ); + var tenantOptions = Options.Create(new BootstrapTenantOptions + { + Code = "default", + Name = "Default Tenant" + }); return new AuthBootstrapSeeder(dbContext, tenantOptions); } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/PostgresRetryConfigurationTests.cs b/tests/APITemplate.Tests/Unit/Persistence/PostgresRetryConfigurationTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/PostgresRetryConfigurationTests.cs rename to tests/APITemplate.Tests/Unit/Persistence/PostgresRetryConfigurationTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/StartupTaskCoordinatorTests.cs b/tests/APITemplate.Tests/Unit/Persistence/StartupTaskCoordinatorTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/StartupTaskCoordinatorTests.cs rename to tests/APITemplate.Tests/Unit/Persistence/StartupTaskCoordinatorTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/UnitOfWorkTests.cs b/tests/APITemplate.Tests/Unit/Persistence/UnitOfWorkTests.cs similarity index 68% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/UnitOfWorkTests.cs rename to tests/APITemplate.Tests/Unit/Persistence/UnitOfWorkTests.cs index 5d9baaed..f7ab16c9 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Persistence/UnitOfWorkTests.cs +++ b/tests/APITemplate.Tests/Unit/Persistence/UnitOfWorkTests.cs @@ -31,26 +31,28 @@ public async Task CommitAsync_UsesExecutionStrategy_AndPersistsChanges() TimeoutSeconds = 30, RetryEnabled = true, RetryCount = 6, - RetryDelaySeconds = 8, + RetryDelaySeconds = 8 }; var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, defaults, provider); - dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Committed" }); + dbContext.Categories.Add(new Category + { + Id = Guid.NewGuid(), + Name = "Committed" + }); await sut.CommitAsync(TestContext.Current.CancellationToken); executionStrategy.ExecuteAsyncCallCount.ShouldBe(1); - provider.CapturedOptions.ShouldBe( - new TransactionOptions - { - IsolationLevel = IsolationLevel.ReadCommitted, - TimeoutSeconds = 30, - RetryEnabled = true, - RetryCount = 6, - RetryDelaySeconds = 8, - } - ); + provider.CapturedOptions.ShouldBe(new TransactionOptions + { + IsolationLevel = IsolationLevel.ReadCommitted, + TimeoutSeconds = 30, + RetryEnabled = true, + RetryCount = 6, + RetryDelaySeconds = 8 + }); (await dbContext.Categories.CountAsync(TestContext.Current.CancellationToken)).ShouldBe(1); } @@ -62,15 +64,16 @@ public async Task ExecuteInTransactionAsync_WithLegacyCallShape_UsesExecutionStr var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, new TransactionDefaultsOptions(), provider); - await sut.ExecuteInTransactionAsync( - async () => + await sut.ExecuteInTransactionAsync(async () => + { + dbContext.Categories.Add(new Category { - dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Books" }); + Id = Guid.NewGuid(), + Name = "Books" + }); - await Task.CompletedTask; - }, - TestContext.Current.CancellationToken - ); + await Task.CompletedTask; + }, TestContext.Current.CancellationToken); executionStrategy.ExecuteAsyncCallCount.ShouldBe(1); provider.BegunIsolationLevel.ShouldBe(IsolationLevel.ReadCommitted); @@ -90,7 +93,7 @@ public async Task ExecuteInTransactionAsync_WithPerCallOptions_MergesOverridesWi TimeoutSeconds = 30, RetryEnabled = true, RetryCount = 3, - RetryDelaySeconds = 5, + RetryDelaySeconds = 5 }; var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, defaults, provider); @@ -98,7 +101,11 @@ public async Task ExecuteInTransactionAsync_WithPerCallOptions_MergesOverridesWi var result = await sut.ExecuteInTransactionAsync( async () => { - var category = new Category { Id = Guid.NewGuid(), Name = "Games" }; + var category = new Category + { + Id = Guid.NewGuid(), + Name = "Games" + }; dbContext.Categories.Add(category); await Task.CompletedTask; @@ -109,28 +116,20 @@ public async Task ExecuteInTransactionAsync_WithPerCallOptions_MergesOverridesWi { IsolationLevel = IsolationLevel.Serializable, TimeoutSeconds = 12, - RetryEnabled = false, - } - ); + RetryEnabled = false + }); executionStrategy.ExecuteAsyncCallCount.ShouldBe(1); provider.BegunIsolationLevel.ShouldBe(IsolationLevel.Serializable); - provider.CapturedOptions.ShouldBe( - new TransactionOptions - { - IsolationLevel = IsolationLevel.Serializable, - TimeoutSeconds = 12, - RetryEnabled = false, - RetryCount = 3, - RetryDelaySeconds = 5, - } - ); - ( - await dbContext.Categories.SingleAsync( - c => c.Id == result, - TestContext.Current.CancellationToken - ) - ).Name.ShouldBe("Games"); + provider.CapturedOptions.ShouldBe(new TransactionOptions + { + IsolationLevel = IsolationLevel.Serializable, + TimeoutSeconds = 12, + RetryEnabled = false, + RetryCount = 3, + RetryDelaySeconds = 5 + }); + (await dbContext.Categories.SingleAsync(c => c.Id == result, TestContext.Current.CancellationToken)).Name.ShouldBe("Games"); } [Fact] @@ -141,17 +140,17 @@ public async Task ExecuteInTransactionAsync_WhenActionThrows_RollsBackAndPropaga var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, new TransactionDefaultsOptions(), provider); - var act = () => - sut.ExecuteInTransactionAsync( - async () => - { - dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Music" }); + var act = () => sut.ExecuteInTransactionAsync(async () => + { + dbContext.Categories.Add(new Category + { + Id = Guid.NewGuid(), + Name = "Music" + }); - await Task.CompletedTask; - throw new InvalidOperationException("boom"); - }, - TestContext.Current.CancellationToken - ); + await Task.CompletedTask; + throw new InvalidOperationException("boom"); + }, TestContext.Current.CancellationToken); await Should.ThrowAsync(act); executionStrategy.ExecuteAsyncCallCount.ShouldBe(1); @@ -167,31 +166,25 @@ public async Task ExecuteInTransactionAsync_WhenNestedFailureIsCaught_RollsBackT var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, new TransactionDefaultsOptions(), provider); - await sut.ExecuteInTransactionAsync( - async () => - { - dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer-A" }); + await sut.ExecuteInTransactionAsync(async () => + { + dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer-A" }); - try + try + { + await sut.ExecuteInTransactionAsync(async () => { - await sut.ExecuteInTransactionAsync( - async () => - { - dbContext.Categories.Add( - new Category { Id = Guid.NewGuid(), Name = "Inner" } - ); - await Task.CompletedTask; - throw new InvalidOperationException("inner failure"); - }, - TestContext.Current.CancellationToken - ); - } - catch (InvalidOperationException) { } - - dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer-B" }); - }, - TestContext.Current.CancellationToken - ); + dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Inner" }); + await Task.CompletedTask; + throw new InvalidOperationException("inner failure"); + }, TestContext.Current.CancellationToken); + } + catch (InvalidOperationException) + { + } + + dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer-B" }); + }, TestContext.Current.CancellationToken); var transaction = provider.LastCreatedTransaction!; executionStrategy.ExecuteAsyncCallCount.ShouldBe(1); @@ -200,8 +193,8 @@ await sut.ExecuteInTransactionAsync( transaction.RollbackToSavepointCount.ShouldBe(1); transaction.CommitCount.ShouldBe(1); - var categoryNames = await dbContext - .Categories.OrderBy(c => c.Name) + var categoryNames = await dbContext.Categories + .OrderBy(c => c.Name) .Select(c => c.Name) .ToListAsync(TestContext.Current.CancellationToken); @@ -216,22 +209,16 @@ public async Task ExecuteInTransactionAsync_WhenNestedGenericSucceeds_UsesSavepo var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, new TransactionDefaultsOptions(), provider); - var nestedResult = await sut.ExecuteInTransactionAsync( - async () => + var nestedResult = await sut.ExecuteInTransactionAsync(async () => + { + return await sut.ExecuteInTransactionAsync(async () => { - return await sut.ExecuteInTransactionAsync( - async () => - { - var category = new Category { Id = Guid.NewGuid(), Name = "Nested" }; - dbContext.Categories.Add(category); - await Task.CompletedTask; - return category.Name; - }, - TestContext.Current.CancellationToken - ); - }, - TestContext.Current.CancellationToken - ); + var category = new Category { Id = Guid.NewGuid(), Name = "Nested" }; + dbContext.Categories.Add(category); + await Task.CompletedTask; + return category.Name; + }, TestContext.Current.CancellationToken); + }, TestContext.Current.CancellationToken); var transaction = provider.LastCreatedTransaction!; nestedResult.ShouldBe("Nested"); @@ -248,24 +235,19 @@ public async Task ExecuteInTransactionAsync_WhenNestedOptionsConflict_Throws() var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, new TransactionDefaultsOptions(), provider); - var act = () => - sut.ExecuteInTransactionAsync( + var act = () => sut.ExecuteInTransactionAsync(async () => + { + await sut.ExecuteInTransactionAsync( async () => { - await sut.ExecuteInTransactionAsync( - async () => - { - await Task.CompletedTask; - return true; - }, - TestContext.Current.CancellationToken, - new TransactionOptions { IsolationLevel = IsolationLevel.Serializable } - ); - + await Task.CompletedTask; return true; }, - TestContext.Current.CancellationToken - ); + TestContext.Current.CancellationToken, + new TransactionOptions { IsolationLevel = IsolationLevel.Serializable }); + + return true; + }, TestContext.Current.CancellationToken); var ex = await Should.ThrowAsync(act); ex.Message.ShouldContain("Nested transactions inherit"); @@ -279,26 +261,17 @@ public async Task ExecuteInTransactionAsync_WhenNestedFailureBubbles_RollsBackOu var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, new TransactionDefaultsOptions(), provider); - var act = () => - sut.ExecuteInTransactionAsync( - async () => - { - dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer" }); - - await sut.ExecuteInTransactionAsync( - async () => - { - dbContext.Categories.Add( - new Category { Id = Guid.NewGuid(), Name = "Inner" } - ); - await Task.CompletedTask; - throw new InvalidOperationException("nested bubble"); - }, - TestContext.Current.CancellationToken - ); - }, - TestContext.Current.CancellationToken - ); + var act = () => sut.ExecuteInTransactionAsync(async () => + { + dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer" }); + + await sut.ExecuteInTransactionAsync(async () => + { + dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Inner" }); + await Task.CompletedTask; + throw new InvalidOperationException("nested bubble"); + }, TestContext.Current.CancellationToken); + }, TestContext.Current.CancellationToken); await Should.ThrowAsync(act); var transaction = provider.LastCreatedTransaction!; @@ -317,15 +290,11 @@ public async Task ExecuteInTransactionAsync_WhenCommitIsCalledInsideOuterScope_T var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, new TransactionDefaultsOptions(), provider); - var act = () => - sut.ExecuteInTransactionAsync( - async () => - { - dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer" }); - await sut.CommitAsync(TestContext.Current.CancellationToken); - }, - TestContext.Current.CancellationToken - ); + var act = () => sut.ExecuteInTransactionAsync(async () => + { + dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer" }); + await sut.CommitAsync(TestContext.Current.CancellationToken); + }, TestContext.Current.CancellationToken); var ex = await Should.ThrowAsync(act); @@ -342,25 +311,16 @@ public async Task ExecuteInTransactionAsync_WhenCommitIsCalledInsideNestedScope_ var provider = new RecordingTransactionProvider(executionStrategy); var sut = CreateUnitOfWork(dbContext, new TransactionDefaultsOptions(), provider); - var act = () => - sut.ExecuteInTransactionAsync( - async () => - { - dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer" }); - - await sut.ExecuteInTransactionAsync( - async () => - { - dbContext.Categories.Add( - new Category { Id = Guid.NewGuid(), Name = "Inner" } - ); - await sut.CommitAsync(TestContext.Current.CancellationToken); - }, - TestContext.Current.CancellationToken - ); - }, - TestContext.Current.CancellationToken - ); + var act = () => sut.ExecuteInTransactionAsync(async () => + { + dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Outer" }); + + await sut.ExecuteInTransactionAsync(async () => + { + dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Inner" }); + await sut.CommitAsync(TestContext.Current.CancellationToken); + }, TestContext.Current.CancellationToken); + }, TestContext.Current.CancellationToken); var ex = await Should.ThrowAsync(act); @@ -380,19 +340,22 @@ public async Task ExecuteInTransactionAsync_WhenProviderDoesNotSupportTransactio var provider = new RecordingTransactionProvider(executionStrategy) { BeginTransactionOverride = (_, _) => - throw new NotSupportedException("Transactions are not supported by this provider."), + throw new NotSupportedException("Transactions are not supported by this provider.") }; var sut = CreateUnitOfWork(dbContext, new TransactionDefaultsOptions(), provider); await sut.ExecuteInTransactionAsync( async () => { - dbContext.Categories.Add(new Category { Id = Guid.NewGuid(), Name = "NoTx" }); + dbContext.Categories.Add(new Category + { + Id = Guid.NewGuid(), + Name = "NoTx" + }); await Task.CompletedTask; }, - TestContext.Current.CancellationToken - ); + TestContext.Current.CancellationToken); executionStrategy.ExecuteAsyncCallCount.ShouldBe(1); (await dbContext.Categories.CountAsync(TestContext.Current.CancellationToken)).ShouldBe(1); @@ -401,14 +364,12 @@ await sut.ExecuteInTransactionAsync( private static UnitOfWork CreateUnitOfWork( AppDbContext dbContext, TransactionDefaultsOptions defaults, - IDbTransactionProvider transactionProvider - ) => - new( + IDbTransactionProvider transactionProvider) + => new( dbContext, Options.Create(defaults), NullLogger.Instance, - transactionProvider - ); + transactionProvider); private static AppDbContext CreateDbContext() { @@ -427,8 +388,7 @@ private static AppDbContext CreateDbContext() [], new AppUserEntityNormalizationService(), stateManager, - new SoftDeleteProcessor(stateManager) - ); + new SoftDeleteProcessor(stateManager)); } private sealed class TestTenantProvider : ITenantProvider @@ -455,11 +415,7 @@ public RecordingTransactionProvider(RecordingExecutionStrategy executionStrategy public TransactionOptions? CapturedOptions { get; private set; } public IsolationLevel? BegunIsolationLevel { get; private set; } public RecordingTransaction? LastCreatedTransaction { get; private set; } - public Func< - IsolationLevel, - CancellationToken, - Task - >? BeginTransactionOverride { get; set; } + public Func>? BeginTransactionOverride { get; set; } public IExecutionStrategy CreateExecutionStrategy(TransactionOptions options) { @@ -467,10 +423,7 @@ public IExecutionStrategy CreateExecutionStrategy(TransactionOptions options) return _executionStrategy; } - public Task BeginTransactionAsync( - IsolationLevel isolationLevel, - CancellationToken ct - ) + public Task BeginTransactionAsync(IsolationLevel isolationLevel, CancellationToken ct) { if (BeginTransactionOverride is not null) return BeginTransactionOverride(isolationLevel, ct); @@ -488,21 +441,21 @@ private sealed class RecordingExecutionStrategy : IExecutionStrategy public int ExecuteAsyncCallCount { get; private set; } public bool RetriesOnFailure => true; - public void Execute(Action operation) => throw new NotSupportedException(); + public void Execute(Action operation) + => throw new NotSupportedException(); - public TResult Execute(Func operation) => - throw new NotSupportedException(); + public TResult Execute(Func operation) + => throw new NotSupportedException(); public TResult Execute( TState state, Func operation, - Func>? verifySucceeded - ) => throw new NotSupportedException(); + Func>? verifySucceeded) + => throw new NotSupportedException(); public Task ExecuteAsync( Func operation, - CancellationToken cancellationToken = default - ) + CancellationToken cancellationToken = default) { ExecuteAsyncCallCount++; return operation(cancellationToken); @@ -510,8 +463,7 @@ public Task ExecuteAsync( public Task ExecuteAsync( Func> operation, - CancellationToken cancellationToken = default - ) + CancellationToken cancellationToken = default) { ExecuteAsyncCallCount++; return operation(cancellationToken); @@ -520,14 +472,8 @@ public Task ExecuteAsync( public Task ExecuteAsync( TState state, Func> operation, - Func< - DbContext, - TState, - CancellationToken, - Task> - >? verifySucceeded, - CancellationToken cancellationToken = default - ) + Func>>? verifySucceeded, + CancellationToken cancellationToken = default) { ExecuteAsyncCallCount++; return operation(null!, state, cancellationToken); @@ -570,10 +516,7 @@ public Task CreateSavepointAsync(string name, CancellationToken cancellationToke public void RollbackToSavepoint(string name) => RollbackToSavepointCount++; - public Task RollbackToSavepointAsync( - string name, - CancellationToken cancellationToken = default - ) + public Task RollbackToSavepointAsync(string name, CancellationToken cancellationToken = default) { RollbackToSavepointCount++; return Task.CompletedTask; @@ -581,10 +524,7 @@ public Task RollbackToSavepointAsync( public void ReleaseSavepoint(string name) => ReleaseSavepointCount++; - public Task ReleaseSavepointAsync( - string name, - CancellationToken cancellationToken = default - ) + public Task ReleaseSavepointAsync(string name, CancellationToken cancellationToken = default) { ReleaseSavepointCount++; return Task.CompletedTask; @@ -592,6 +532,8 @@ public Task ReleaseSavepointAsync( public ValueTask DisposeAsync() => ValueTask.CompletedTask; - public void Dispose() { } + public void Dispose() + { + } } } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Repositories/CategoryRepositoryTests.cs b/tests/APITemplate.Tests/Unit/Repositories/CategoryRepositoryTests.cs similarity index 86% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Repositories/CategoryRepositoryTests.cs rename to tests/APITemplate.Tests/Unit/Repositories/CategoryRepositoryTests.cs index 26f82039..7d508e6c 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Repositories/CategoryRepositoryTests.cs +++ b/tests/APITemplate.Tests/Unit/Repositories/CategoryRepositoryTests.cs @@ -1,7 +1,7 @@ -using APITemplate.Application.Common.Context; using APITemplate.Domain.Entities; using APITemplate.Domain.Exceptions; using APITemplate.Domain.Interfaces; +using APITemplate.Application.Common.Context; using APITemplate.Infrastructure.Persistence; using APITemplate.Infrastructure.Persistence.Auditing; using APITemplate.Infrastructure.Persistence.EntityNormalization; @@ -133,16 +133,13 @@ public async Task GetStatsByIdAsync_WhenStatsExist_ReturnsStats() CategoryName = "Electronics", ProductCount = 3, AveragePrice = 150m, - TotalReviews = 10, + TotalReviews = 10 }; _spExecutorMock - .Setup(e => - e.QueryFirstAsync( - It.IsAny(), - It.IsAny() - ) - ) + .Setup(e => e.QueryFirstAsync( + It.IsAny(), + It.IsAny())) .ReturnsAsync(expected); var result = await _sut.GetStatsByIdAsync(categoryId, ct); @@ -154,14 +151,9 @@ public async Task GetStatsByIdAsync_WhenStatsExist_ReturnsStats() result.AveragePrice.ShouldBe(150m); result.TotalReviews.ShouldBe(10); - _spExecutorMock.Verify( - e => - e.QueryFirstAsync( - It.IsAny(), - It.IsAny() - ), - Times.Once - ); + _spExecutorMock.Verify(e => e.QueryFirstAsync( + It.IsAny(), + It.IsAny()), Times.Once); } [Fact] @@ -169,12 +161,9 @@ public async Task GetStatsByIdAsync_WhenCategoryNotFound_ReturnsNull() { var ct = TestContext.Current.CancellationToken; _spExecutorMock - .Setup(e => - e.QueryFirstAsync( - It.IsAny(), - It.IsAny() - ) - ) + .Setup(e => e.QueryFirstAsync( + It.IsAny(), + It.IsAny())) .ReturnsAsync((ProductCategoryStats?)null); var result = await _sut.GetStatsByIdAsync(Guid.NewGuid(), ct); @@ -190,14 +179,11 @@ private static Category CreateCategory(string name, string? description = null) TenantId = TestTenantId, Name = name, Description = description, - Audit = new() { CreatedAtUtc = DateTime.UtcNow }, + Audit = new() { CreatedAtUtc = DateTime.UtcNow } }; } - private static AppDbContext CreateDbContext( - DbContextOptions options, - ITenantProvider tenantProvider - ) + private static AppDbContext CreateDbContext(DbContextOptions options, ITenantProvider tenantProvider) { var stateManager = new AuditableEntityStateManager(); @@ -209,8 +195,7 @@ ITenantProvider tenantProvider [], new AppUserEntityNormalizationService(), stateManager, - new SoftDeleteProcessor(stateManager) - ); + new SoftDeleteProcessor(stateManager)); } private sealed class TestTenantProvider : ITenantProvider diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Repositories/ProductRepositoryTests.cs b/tests/APITemplate.Tests/Unit/Repositories/ProductRepositoryTests.cs similarity index 97% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Repositories/ProductRepositoryTests.cs rename to tests/APITemplate.Tests/Unit/Repositories/ProductRepositoryTests.cs index 761e65f6..a3118954 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Repositories/ProductRepositoryTests.cs +++ b/tests/APITemplate.Tests/Unit/Repositories/ProductRepositoryTests.cs @@ -1,6 +1,6 @@ -using APITemplate.Application.Common.Context; using APITemplate.Domain.Entities; using APITemplate.Domain.Exceptions; +using APITemplate.Application.Common.Context; using APITemplate.Infrastructure.Persistence; using APITemplate.Infrastructure.Persistence.Auditing; using APITemplate.Infrastructure.Persistence.EntityNormalization; @@ -121,7 +121,7 @@ private static Product CreateProduct(string name, decimal price) TenantId = TestTenantId, Name = name, Price = price, - Audit = new() { CreatedAtUtc = DateTime.UtcNow }, + Audit = new() { CreatedAtUtc = DateTime.UtcNow } }; } @@ -137,8 +137,7 @@ private static AppDbContext CreateDbContext(DbContextOptions optio [], new AppUserEntityNormalizationService(), stateManager, - new SoftDeleteProcessor(stateManager) - ); + new SoftDeleteProcessor(stateManager)); } private sealed class TestTenantProvider : ITenantProvider diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Repositories/UserRepositoryTests.cs b/tests/APITemplate.Tests/Unit/Repositories/UserRepositoryTests.cs similarity index 85% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Repositories/UserRepositoryTests.cs rename to tests/APITemplate.Tests/Unit/Repositories/UserRepositoryTests.cs index 5636c82a..b74f8578 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Repositories/UserRepositoryTests.cs +++ b/tests/APITemplate.Tests/Unit/Repositories/UserRepositoryTests.cs @@ -79,10 +79,7 @@ public async Task ExistsByEmailAsync_WhenEmailDiffersOnlyByCase_ReturnsTrue() [Fact] public async Task ExistsByEmailAsync_WhenNotExists_ReturnsFalse() { - var result = await _sut.ExistsByEmailAsync( - "nonexistent@test.com", - TestContext.Current.CancellationToken - ); + var result = await _sut.ExistsByEmailAsync("nonexistent@test.com", TestContext.Current.CancellationToken); result.ShouldBeFalse(); } @@ -95,10 +92,7 @@ public async Task ExistsByUsernameAsync_WhenExists_ReturnsTrue() _dbContext.Users.Add(user); await _dbContext.SaveChangesAsync(ct); - var result = await _sut.ExistsByUsernameAsync( - AppUser.NormalizeUsername("existinguser"), - ct - ); + var result = await _sut.ExistsByUsernameAsync(AppUser.NormalizeUsername("existinguser"), ct); result.ShouldBeTrue(); } @@ -106,10 +100,7 @@ public async Task ExistsByUsernameAsync_WhenExists_ReturnsTrue() [Fact] public async Task ExistsByUsernameAsync_WhenNotExists_ReturnsFalse() { - var result = await _sut.ExistsByUsernameAsync( - "NONEXISTENT", - TestContext.Current.CancellationToken - ); + var result = await _sut.ExistsByUsernameAsync("NONEXISTENT", TestContext.Current.CancellationToken); result.ShouldBeFalse(); } @@ -142,14 +133,10 @@ public async Task ListAsync_WithUsernameFilter_UsesNormalizedUsernameContains() var ct = TestContext.Current.CancellationToken; _dbContext.Users.AddRange( CreateUser("AlphaAdmin", "alpha@example.com"), - CreateUser("betauser", "beta@example.com") - ); + CreateUser("betauser", "beta@example.com")); await _dbContext.SaveChangesAsync(ct); - var result = await _sut.ListAsync( - new UserFilterSpecification(new UserFilter(Username: "alpha")), - ct - ); + var result = await _sut.ListAsync(new UserFilterSpecification(new UserFilter(Username: "alpha")), ct); result.Select(user => user.Username).ShouldBe(["AlphaAdmin"]); } @@ -160,14 +147,10 @@ public async Task ListAsync_WithEmailFilter_RequiresExactCaseInsensitiveMatch() var ct = TestContext.Current.CancellationToken; _dbContext.Users.AddRange( CreateUser("exact", "Exact.Match@Test.com"), - CreateUser("partial", "other@test.com") - ); + CreateUser("partial", "other@test.com")); await _dbContext.SaveChangesAsync(ct); - var result = await _sut.ListAsync( - new UserFilterSpecification(new UserFilter(Email: "exact.match@test.com")), - ct - ); + var result = await _sut.ListAsync(new UserFilterSpecification(new UserFilter(Email: "exact.match@test.com")), ct); result.Select(user => user.Username).ShouldBe(["exact"]); } @@ -183,7 +166,7 @@ private static AppUser CreateUser(string username, string email) IsActive = true, Role = UserRole.User, TenantId = TestTenantId, - Audit = new() { CreatedAtUtc = DateTime.UtcNow }, + Audit = new() { CreatedAtUtc = DateTime.UtcNow } }; } @@ -199,8 +182,7 @@ private static AppDbContext CreateDbContext(DbContextOptions optio [], new AppUserEntityNormalizationService(), stateManager, - new SoftDeleteProcessor(stateManager) - ); + new SoftDeleteProcessor(stateManager)); } private sealed class TestTenantProvider : ITenantProvider diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs b/tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs similarity index 98% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs rename to tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs index b9ba7d75..1f368b19 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs +++ b/tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs @@ -95,7 +95,7 @@ private static ClaimsPrincipal CreatePrincipal(UserRole role) var claims = new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()), - new Claim(ClaimTypes.Role, role.ToString()), + new Claim(ClaimTypes.Role, role.ToString()) }; return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); } diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Security/StaticRolePermissionMapTests.cs b/tests/APITemplate.Tests/Unit/Security/StaticRolePermissionMapTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Security/StaticRolePermissionMapTests.cs rename to tests/APITemplate.Tests/Unit/Security/StaticRolePermissionMapTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs b/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs similarity index 94% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs rename to tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs index 0e0382cc..6ec944e2 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs +++ b/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs @@ -20,8 +20,7 @@ public async Task ExecuteAsync_WhenProviderDoesNotSupportRawSql_Throws() var sut = new StoredProcedureExecutor(dbContext); await Should.ThrowAsync(() => - sut.ExecuteAsync($"select 1", TestContext.Current.CancellationToken) - ); + sut.ExecuteAsync($"select 1", TestContext.Current.CancellationToken)); } private static AppDbContext CreateDbContext() @@ -40,8 +39,7 @@ private static AppDbContext CreateDbContext() [], new AppUserEntityNormalizationService(), stateManager, - new SoftDeleteProcessor(stateManager) - ); + new SoftDeleteProcessor(stateManager)); } private sealed class TestTenantProvider : ITenantProvider diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validation/UserValidationTests.cs b/tests/APITemplate.Tests/Unit/Validation/UserValidationTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validation/UserValidationTests.cs rename to tests/APITemplate.Tests/Unit/Validation/UserValidationTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs b/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs similarity index 98% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs rename to tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs index e08c7c58..1df3180d 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs @@ -59,8 +59,7 @@ public void FluentValidation_DescriptionRule_BasedOnPrice( decimal price, string? description, bool expectedIsValid, - string? expectedErrorProperty - ) + string? expectedErrorProperty) { var result = _sut.Validate(new CreateProductRequest("Any name", description, price)); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validators/CreateProductReviewRequestValidatorTests.cs b/tests/APITemplate.Tests/Unit/Validators/CreateProductReviewRequestValidatorTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validators/CreateProductReviewRequestValidatorTests.cs rename to tests/APITemplate.Tests/Unit/Validators/CreateProductReviewRequestValidatorTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validators/UpdateProductRequestValidatorTests.cs b/tests/APITemplate.Tests/Unit/Validators/UpdateProductRequestValidatorTests.cs similarity index 98% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validators/UpdateProductRequestValidatorTests.cs rename to tests/APITemplate.Tests/Unit/Validators/UpdateProductRequestValidatorTests.cs index d55f1cfe..fe497152 100644 --- a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Validators/UpdateProductRequestValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/Validators/UpdateProductRequestValidatorTests.cs @@ -59,8 +59,7 @@ public void FluentValidation_DescriptionRule_BasedOnPrice( decimal price, string? description, bool expectedIsValid, - string? expectedErrorProperty - ) + string? expectedErrorProperty) { var result = _sut.Validate(new UpdateProductRequest("Any name", description, price)); diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs rename to tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs rename to tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs diff --git a/monolith/API-Template-mono/tests/APITemplate.Tests/xunit.runner.json b/tests/APITemplate.Tests/xunit.runner.json similarity index 100% rename from monolith/API-Template-mono/tests/APITemplate.Tests/xunit.runner.json rename to tests/APITemplate.Tests/xunit.runner.json diff --git a/tests/BackgroundJobs.Tests/BackgroundJobs.Tests.csproj b/tests/BackgroundJobs.Tests/BackgroundJobs.Tests.csproj deleted file mode 100644 index b2edcae6..00000000 --- a/tests/BackgroundJobs.Tests/BackgroundJobs.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - diff --git a/tests/BackgroundJobs.Tests/Domain/Entities/JobExecutionTests.cs b/tests/BackgroundJobs.Tests/Domain/Entities/JobExecutionTests.cs deleted file mode 100644 index cecd1c4e..00000000 --- a/tests/BackgroundJobs.Tests/Domain/Entities/JobExecutionTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using BackgroundJobs.Domain.Entities; -using BackgroundJobs.Domain.Enums; -using Shouldly; -using Xunit; - -namespace BackgroundJobs.Tests.Domain.Entities; - -public sealed class JobExecutionTests -{ - [Fact] - public void MarkProcessing_SetsStatusAndStartTime() - { - JobExecution job = CreatePendingJob(); - - job.MarkProcessing(TimeProvider.System); - - job.Status.ShouldBe(JobStatus.Processing); - job.StartedAtUtc.ShouldNotBeNull(); - } - - [Fact] - public void MarkCompleted_SetsStatusProgressAndPayload() - { - JobExecution job = CreatePendingJob(); - job.MarkProcessing(TimeProvider.System); - - job.MarkCompleted("{\"rows\":42}", TimeProvider.System); - - job.Status.ShouldBe(JobStatus.Completed); - job.ProgressPercent.ShouldBe(100); - job.ResultPayload.ShouldBe("{\"rows\":42}"); - job.CompletedAtUtc.ShouldNotBeNull(); - } - - [Fact] - public void MarkCompleted_WithNullPayload_SetsStatusAndProgress() - { - JobExecution job = CreatePendingJob(); - - job.MarkCompleted(null, TimeProvider.System); - - job.Status.ShouldBe(JobStatus.Completed); - job.ProgressPercent.ShouldBe(100); - job.ResultPayload.ShouldBeNull(); - } - - [Fact] - public void MarkFailed_SetsStatusAndErrorMessage() - { - JobExecution job = CreatePendingJob(); - job.MarkProcessing(TimeProvider.System); - - job.MarkFailed("Something went wrong", TimeProvider.System); - - job.Status.ShouldBe(JobStatus.Failed); - job.ErrorMessage.ShouldBe("Something went wrong"); - job.CompletedAtUtc.ShouldNotBeNull(); - } - - [Fact] - public void UpdateProgress_ClampsToValidRange() - { - JobExecution job = CreatePendingJob(); - - job.UpdateProgress(50); - job.ProgressPercent.ShouldBe(50); - - job.UpdateProgress(-10); - job.ProgressPercent.ShouldBe(0); - - job.UpdateProgress(200); - job.ProgressPercent.ShouldBe(100); - } - - [Fact] - public void NewJob_HasPendingStatusAndZeroProgress() - { - JobExecution job = CreatePendingJob(); - - job.Status.ShouldBe(JobStatus.Pending); - job.ProgressPercent.ShouldBe(0); - job.StartedAtUtc.ShouldBeNull(); - job.CompletedAtUtc.ShouldBeNull(); - job.ResultPayload.ShouldBeNull(); - job.ErrorMessage.ShouldBeNull(); - } - - private static JobExecution CreatePendingJob() => - new() - { - Id = Guid.NewGuid(), - JobType = "test-job", - SubmittedAtUtc = DateTime.UtcNow, - }; -} diff --git a/tests/BackgroundJobs.Tests/Features/Jobs/Commands/SubmitJobCommandHandlerTests.cs b/tests/BackgroundJobs.Tests/Features/Jobs/Commands/SubmitJobCommandHandlerTests.cs deleted file mode 100644 index c314e25a..00000000 --- a/tests/BackgroundJobs.Tests/Features/Jobs/Commands/SubmitJobCommandHandlerTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using BackgroundJobs.Application.Common; -using BackgroundJobs.Application.Features.Jobs.Commands; -using BackgroundJobs.Application.Features.Jobs.DTOs; -using BackgroundJobs.Domain.Entities; -using BackgroundJobs.Domain.Enums; -using BackgroundJobs.Domain.Interfaces; -using ErrorOr; -using Moq; -using SharedKernel.Application.Context; -using SharedKernel.Domain.Interfaces; -using Shouldly; -using Xunit; - -namespace BackgroundJobs.Tests.Features.Jobs.Commands; - -public sealed class SubmitJobCommandHandlerTests -{ - private readonly Mock _repositoryMock = new(); - private readonly Mock _jobQueueMock = new(); - private readonly Mock _unitOfWorkMock = new(); - private readonly Mock _tenantProviderMock = new(); - - public SubmitJobCommandHandlerTests() - { - _unitOfWorkMock - .Setup(u => - u.ExecuteInTransactionAsync( - It.IsAny>(), - It.IsAny(), - null - ) - ) - .Returns, CancellationToken, object?>( - (action, _, _) => - { - action(); - return Task.CompletedTask; - } - ); - - _tenantProviderMock.SetupGet(x => x.TenantId).Returns(Guid.NewGuid()); - } - - [Fact] - public async Task HandleAsync_CreatesJobAndEnqueues() - { - SubmitJobRequest request = new("data-export", "{\"format\":\"csv\"}", null); - SubmitJobCommand command = new(request); - - ErrorOr result = await SubmitJobCommandHandler.HandleAsync( - command, - _repositoryMock.Object, - _jobQueueMock.Object, - _unitOfWorkMock.Object, - _tenantProviderMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - result.IsError.ShouldBeFalse(); - result.Value.JobType.ShouldBe("data-export"); - result.Value.Status.ShouldBe(JobStatus.Pending); - result.Value.Parameters.ShouldBe("{\"format\":\"csv\"}"); - } - - [Fact] - public async Task HandleAsync_AddsEntityToRepository() - { - SubmitJobRequest request = new("report-gen", null, null); - SubmitJobCommand command = new(request); - - await SubmitJobCommandHandler.HandleAsync( - command, - _repositoryMock.Object, - _jobQueueMock.Object, - _unitOfWorkMock.Object, - _tenantProviderMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - _repositoryMock.Verify( - r => - r.AddAsync( - It.Is(j => j.JobType == "report-gen"), - It.IsAny() - ), - Times.Once - ); - } - - [Fact] - public async Task HandleAsync_EnqueuesJobIdAfterPersistence() - { - SubmitJobRequest request = new("cleanup", null, "https://callback.example.com/hook"); - SubmitJobCommand command = new(request); - - ErrorOr result = await SubmitJobCommandHandler.HandleAsync( - command, - _repositoryMock.Object, - _jobQueueMock.Object, - _unitOfWorkMock.Object, - _tenantProviderMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - _jobQueueMock.Verify( - q => q.EnqueueAsync(result.Value.Id, It.IsAny()), - Times.Once - ); - } -} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props deleted file mode 100644 index a1f22633..00000000 --- a/tests/Directory.Build.props +++ /dev/null @@ -1,8 +0,0 @@ - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - diff --git a/tests/FileStorage.Tests/Features/Files/EventHandlers/ProductDeletedEventHandlerTests.cs b/tests/FileStorage.Tests/Features/Files/EventHandlers/ProductDeletedEventHandlerTests.cs deleted file mode 100644 index fb80a79e..00000000 --- a/tests/FileStorage.Tests/Features/Files/EventHandlers/ProductDeletedEventHandlerTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using Contracts.IntegrationEvents.Sagas; -using FileStorage.Application.Features.Files.EventHandlers; -using Microsoft.Extensions.Logging; -using Moq; -using Shouldly; -using Wolverine; -using Xunit; - -namespace FileStorage.Tests.Features.Files.EventHandlers; - -public sealed class ProductDeletedEventHandlerTests -{ - private readonly Mock _busMock = new(); - private readonly Mock> _loggerMock = new(); - - [Fact] - public async Task HandleAsync_PublishesFilesCascadeCompleted() - { - Guid correlationId = Guid.NewGuid(); - IReadOnlyList productIds = new[] { Guid.NewGuid(), Guid.NewGuid() }; - ProductDeletedIntegrationEvent @event = new( - correlationId, - productIds, - Guid.NewGuid(), - DateTime.UtcNow - ); - FilesCascadeCompleted? capturedMessage = null; - _busMock - .Setup(b => b.PublishAsync(It.IsAny(), null)) - .Callback( - (msg, _) => capturedMessage = msg as FilesCascadeCompleted - ) - .Returns(ValueTask.CompletedTask); - - await ProductDeletedEventHandler.HandleAsync( - @event, - _busMock.Object, - _loggerMock.Object, - CancellationToken.None - ); - - capturedMessage.ShouldNotBeNull(); - capturedMessage.CorrelationId.ShouldBe(correlationId); - capturedMessage.DeletedCount.ShouldBe(0); - } - - [Fact] - public async Task HandleAsync_PublishesExactlyOneMessage() - { - ProductDeletedIntegrationEvent @event = new( - Guid.NewGuid(), - new[] { Guid.NewGuid() }, - Guid.NewGuid(), - DateTime.UtcNow - ); - - await ProductDeletedEventHandler.HandleAsync( - @event, - _busMock.Object, - _loggerMock.Object, - CancellationToken.None - ); - - _busMock.Verify(b => b.PublishAsync(It.IsAny(), null), Times.Once); - } -} diff --git a/tests/FileStorage.Tests/FileStorage.Tests.csproj b/tests/FileStorage.Tests/FileStorage.Tests.csproj deleted file mode 100644 index ad395341..00000000 --- a/tests/FileStorage.Tests/FileStorage.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - diff --git a/tests/Identity.Tests/EventHandlers/TenantDeactivatedEventHandlerTests.cs b/tests/Identity.Tests/EventHandlers/TenantDeactivatedEventHandlerTests.cs deleted file mode 100644 index db31a370..00000000 --- a/tests/Identity.Tests/EventHandlers/TenantDeactivatedEventHandlerTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Contracts.IntegrationEvents.Sagas; -using Identity.Application.EventHandlers; -using Identity.Domain.Entities; -using Identity.Domain.Enums; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using Shouldly; -using Wolverine; -using Xunit; - -namespace Identity.Tests.EventHandlers; - -public sealed class TenantDeactivatedEventHandlerTests : IDisposable -{ - private readonly SqliteConnection _connection; - private readonly TestDbContext _dbContext; - private readonly Mock _busMock = new(); - private readonly Mock> _loggerMock = new(); - - public TenantDeactivatedEventHandlerTests() - { - _connection = new SqliteConnection("DataSource=:memory:"); - _connection.Open(); - - DbContextOptions options = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - _dbContext = new TestDbContext(options); - _dbContext.Database.EnsureCreated(); - } - - public void Dispose() - { - _dbContext.Dispose(); - _connection.Dispose(); - } - - [Fact] - public async Task HandleAsync_SoftDeletesUsersForTenant() - { - Guid tenantId = Guid.NewGuid(); - Guid actorId = Guid.NewGuid(); - AppUser user1 = CreateUser(tenantId); - AppUser user2 = CreateUser(tenantId); - AppUser userOtherTenant = CreateUser(Guid.NewGuid()); - _dbContext.Users.AddRange(user1, user2, userOtherTenant); - await _dbContext.SaveChangesAsync(); - - TenantDeactivatedIntegrationEvent @event = new( - Guid.NewGuid(), - tenantId, - actorId, - DateTime.UtcNow - ); - - await TenantDeactivatedEventHandler.HandleAsync( - @event, - _dbContext, - _busMock.Object, - TimeProvider.System, - _loggerMock.Object, - CancellationToken.None - ); - - AppUser refreshed1 = await _dbContext - .Users.IgnoreQueryFilters() - .SingleAsync(u => u.Id == user1.Id); - AppUser refreshed2 = await _dbContext - .Users.IgnoreQueryFilters() - .SingleAsync(u => u.Id == user2.Id); - AppUser other = await _dbContext - .Users.IgnoreQueryFilters() - .SingleAsync(u => u.Id == userOtherTenant.Id); - - refreshed1.IsDeleted.ShouldBeTrue(); - refreshed1.DeletedBy.ShouldBe(actorId); - refreshed2.IsDeleted.ShouldBeTrue(); - other.IsDeleted.ShouldBeFalse(); - } - - [Fact] - public async Task HandleAsync_PublishesUsersCascadeCompletedWithCorrectCorrelationId() - { - Guid tenantId = Guid.NewGuid(); - Guid correlationId = Guid.NewGuid(); - _dbContext.Users.Add(CreateUser(tenantId)); - await _dbContext.SaveChangesAsync(); - - TenantDeactivatedIntegrationEvent @event = new( - correlationId, - tenantId, - Guid.NewGuid(), - DateTime.UtcNow - ); - - await TenantDeactivatedEventHandler.HandleAsync( - @event, - _dbContext, - _busMock.Object, - TimeProvider.System, - _loggerMock.Object, - CancellationToken.None - ); - - _busMock.Verify( - b => - b.PublishAsync( - It.Is(m => - m.CorrelationId == correlationId - && m.TenantId == tenantId - && m.DeactivatedCount == 1 - ), - It.IsAny() - ), - Times.Once - ); - } - - [Fact] - public async Task HandleAsync_SkipsAlreadyDeletedUsers() - { - Guid tenantId = Guid.NewGuid(); - AppUser alreadyDeleted = CreateUser(tenantId); - alreadyDeleted.IsDeleted = true; - _dbContext.Users.Add(alreadyDeleted); - await _dbContext.SaveChangesAsync(); - - TenantDeactivatedIntegrationEvent @event = new( - Guid.NewGuid(), - tenantId, - Guid.NewGuid(), - DateTime.UtcNow - ); - - await TenantDeactivatedEventHandler.HandleAsync( - @event, - _dbContext, - _busMock.Object, - TimeProvider.System, - _loggerMock.Object, - CancellationToken.None - ); - - _busMock.Verify( - b => - b.PublishAsync( - It.Is(m => m.DeactivatedCount == 0), - It.IsAny() - ), - Times.Once - ); - } - - private static AppUser CreateUser(Guid tenantId) => - new() - { - Id = Guid.NewGuid(), - Username = "user-" + Guid.NewGuid().ToString("N")[..8], - Email = $"user-{Guid.NewGuid():N}@test.com", - TenantId = tenantId, - Role = UserRole.User, - }; -} diff --git a/tests/Identity.Tests/Features/Tenant/Commands/CreateTenantCommandHandlerTests.cs b/tests/Identity.Tests/Features/Tenant/Commands/CreateTenantCommandHandlerTests.cs deleted file mode 100644 index 43b65045..00000000 --- a/tests/Identity.Tests/Features/Tenant/Commands/CreateTenantCommandHandlerTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Features.Tenant.Commands; -using Identity.Application.Features.Tenant.DTOs; -using Identity.Domain.Interfaces; -using Moq; -using SharedKernel.Domain.Interfaces; -using Shouldly; -using Xunit; -using TenantEntity = Identity.Domain.Entities.Tenant; - -namespace Identity.Tests.Features.Tenant.Commands; - -public sealed class CreateTenantCommandHandlerTests -{ - private readonly Mock _repositoryMock = new(); - private readonly Mock _unitOfWorkMock = new(); - - public CreateTenantCommandHandlerTests() - { - _unitOfWorkMock - .Setup(u => - u.ExecuteInTransactionAsync( - It.IsAny>>(), - It.IsAny(), - null - ) - ) - .Returns>, CancellationToken, object?>( - (action, _, _) => action() - ); - } - - [Fact] - public async Task HandleAsync_WhenCodeAlreadyExists_ReturnsConflictError() - { - CreateTenantRequest request = new("EXISTING", "Existing Tenant"); - CreateTenantCommand command = new(request); - _repositoryMock - .Setup(r => r.CodeExistsAsync("EXISTING", It.IsAny())) - .ReturnsAsync(true); - - var (result, _) = await CreateTenantCommandHandler.HandleAsync( - command, - _repositoryMock.Object, - _unitOfWorkMock.Object, - CancellationToken.None - ); - - result.IsError.ShouldBeTrue(); - result.FirstError.Code.ShouldBe(IdentityErrorCatalog.Tenants.CodeAlreadyExists); - } - - [Fact] - public async Task HandleAsync_WhenCodeIsUnique_CreatesTenantAndReturnsResponse() - { - CreateTenantRequest request = new("NEW-CODE", "New Tenant"); - CreateTenantCommand command = new(request); - _repositoryMock - .Setup(r => r.CodeExistsAsync("NEW-CODE", It.IsAny())) - .ReturnsAsync(false); - - var (result, _) = await CreateTenantCommandHandler.HandleAsync( - command, - _repositoryMock.Object, - _unitOfWorkMock.Object, - CancellationToken.None - ); - - result.IsError.ShouldBeFalse(); - result.Value.Code.ShouldBe("NEW-CODE"); - result.Value.Name.ShouldBe("New Tenant"); - _repositoryMock.Verify( - r => r.AddAsync(It.IsAny(), It.IsAny()), - Times.Once - ); - } -} diff --git a/tests/Identity.Tests/Features/TenantInvitation/Commands/AcceptTenantInvitationCommandHandlerTests.cs b/tests/Identity.Tests/Features/TenantInvitation/Commands/AcceptTenantInvitationCommandHandlerTests.cs deleted file mode 100644 index c3557297..00000000 --- a/tests/Identity.Tests/Features/TenantInvitation/Commands/AcceptTenantInvitationCommandHandlerTests.cs +++ /dev/null @@ -1,151 +0,0 @@ -using ErrorOr; -using Identity.Application.Errors; -using Identity.Application.Features.TenantInvitation.Commands; -using Identity.Application.Security; -using Identity.Domain.Enums; -using Identity.Domain.Interfaces; -using Moq; -using SharedKernel.Domain.Interfaces; -using Shouldly; -using Xunit; -using TenantInvitationEntity = Identity.Domain.Entities.TenantInvitation; - -namespace Identity.Tests.Features.TenantInvitation.Commands; - -public sealed class AcceptTenantInvitationCommandHandlerTests -{ - private readonly Mock _invitationRepoMock = new(); - private readonly Mock _unitOfWorkMock = new(); - private readonly Mock _tokenGeneratorMock = new(); - - public AcceptTenantInvitationCommandHandlerTests() - { - _tokenGeneratorMock - .Setup(t => t.HashToken(It.IsAny())) - .Returns(token => $"hashed_{token}"); - } - - [Fact] - public async Task HandleAsync_WhenInvitationNotFound_ReturnsNotFoundError() - { - AcceptTenantInvitationCommand command = new("some-token"); - _invitationRepoMock - .Setup(r => - r.GetValidByTokenHashAsync("hashed_some-token", It.IsAny()) - ) - .ReturnsAsync((TenantInvitationEntity?)null); - - ErrorOr result = await AcceptTenantInvitationCommandHandler.HandleAsync( - command, - _invitationRepoMock.Object, - _unitOfWorkMock.Object, - _tokenGeneratorMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - result.IsError.ShouldBeTrue(); - result.FirstError.Code.ShouldBe(IdentityErrorCatalog.Invitations.NotFound); - } - - [Fact] - public async Task HandleAsync_WhenInvitationExpired_ReturnsExpiredError() - { - AcceptTenantInvitationCommand command = new("expired-token"); - TenantInvitationEntity invitation = CreateInvitation( - expiresAtUtc: DateTime.UtcNow.AddHours(-1), - status: InvitationStatus.Pending - ); - _invitationRepoMock - .Setup(r => - r.GetValidByTokenHashAsync("hashed_expired-token", It.IsAny()) - ) - .ReturnsAsync(invitation); - - ErrorOr result = await AcceptTenantInvitationCommandHandler.HandleAsync( - command, - _invitationRepoMock.Object, - _unitOfWorkMock.Object, - _tokenGeneratorMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - result.IsError.ShouldBeTrue(); - result.FirstError.Code.ShouldBe(IdentityErrorCatalog.Invitations.Expired); - } - - [Fact] - public async Task HandleAsync_WhenAlreadyAccepted_ReturnsAlreadyAcceptedError() - { - AcceptTenantInvitationCommand command = new("accepted-token"); - TenantInvitationEntity invitation = CreateInvitation( - expiresAtUtc: DateTime.UtcNow.AddHours(24), - status: InvitationStatus.Accepted - ); - _invitationRepoMock - .Setup(r => - r.GetValidByTokenHashAsync("hashed_accepted-token", It.IsAny()) - ) - .ReturnsAsync(invitation); - - ErrorOr result = await AcceptTenantInvitationCommandHandler.HandleAsync( - command, - _invitationRepoMock.Object, - _unitOfWorkMock.Object, - _tokenGeneratorMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - result.IsError.ShouldBeTrue(); - result.FirstError.Code.ShouldBe(IdentityErrorCatalog.Invitations.AlreadyAccepted); - } - - [Fact] - public async Task HandleAsync_WhenValid_AcceptsInvitationAndCommits() - { - AcceptTenantInvitationCommand command = new("valid-token"); - TenantInvitationEntity invitation = CreateInvitation( - expiresAtUtc: DateTime.UtcNow.AddHours(24), - status: InvitationStatus.Pending - ); - _invitationRepoMock - .Setup(r => - r.GetValidByTokenHashAsync("hashed_valid-token", It.IsAny()) - ) - .ReturnsAsync(invitation); - - ErrorOr result = await AcceptTenantInvitationCommandHandler.HandleAsync( - command, - _invitationRepoMock.Object, - _unitOfWorkMock.Object, - _tokenGeneratorMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - result.IsError.ShouldBeFalse(); - invitation.Status.ShouldBe(InvitationStatus.Accepted); - _invitationRepoMock.Verify( - r => r.UpdateAsync(invitation, It.IsAny()), - Times.Once - ); - _unitOfWorkMock.Verify(u => u.CommitAsync(It.IsAny()), Times.Once); - } - - private static TenantInvitationEntity CreateInvitation( - DateTime expiresAtUtc, - InvitationStatus status - ) => - new() - { - Id = Guid.NewGuid(), - Email = "test@example.com", - NormalizedEmail = "TEST@EXAMPLE.COM", - TokenHash = "hashed_token", - ExpiresAtUtc = expiresAtUtc, - Status = status, - TenantId = Guid.NewGuid(), - }; -} diff --git a/tests/Identity.Tests/Identity.Tests.csproj b/tests/Identity.Tests/Identity.Tests.csproj deleted file mode 100644 index da8497d4..00000000 --- a/tests/Identity.Tests/Identity.Tests.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - diff --git a/tests/Identity.Tests/Middleware/CsrfValidationMiddlewareTests.cs b/tests/Identity.Tests/Middleware/CsrfValidationMiddlewareTests.cs deleted file mode 100644 index f08fb770..00000000 --- a/tests/Identity.Tests/Middleware/CsrfValidationMiddlewareTests.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System.Security.Claims; -using Identity.Api.Middleware; -using Identity.Application.Security; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Shouldly; -using Xunit; - -namespace Identity.Tests.Middleware; - -public sealed class CsrfValidationMiddlewareTests -{ - // Builds a DefaultHttpContext wired with a mock IAuthenticationService. - // Returns both the context and the auth mock so individual tests can verify - // whether AuthenticateAsync was called (e.g. to assert the short-circuit path). - // cookieAuthInUser and cookieAuthResult are mutually exclusive: when cookieAuthInUser - // is true the middleware short-circuits before calling AuthenticateAsync. - // csrfHeader: pass AuthConstants.Csrf.HeaderValue for a valid header, a custom string - // for an invalid one, or null to omit the header entirely. - private static (DefaultHttpContext context, Mock authMock) BuildContext( - string method = "POST", - bool withBearerToken = false, - bool cookieAuthInUser = false, - AuthenticateResult? cookieAuthResult = null, - string? csrfHeader = null - ) - { - DefaultHttpContext context = new(); - context.Request.Method = method; - context.Response.Body = new MemoryStream(); - - if (withBearerToken) - context.Request.Headers.Authorization = "Bearer test-token"; - - if (cookieAuthInUser) - { - ClaimsIdentity identity = new(authenticationType: AuthConstants.BffSchemes.Cookie); - context.User = new ClaimsPrincipal(identity); - } - - if (csrfHeader != null) - context.Request.Headers[AuthConstants.Csrf.HeaderName] = csrfHeader; - - Mock authServiceMock = new(); - authServiceMock - .Setup(s => s.AuthenticateAsync(context, AuthConstants.BffSchemes.Cookie)) - .ReturnsAsync(cookieAuthResult ?? AuthenticateResult.NoResult()); - - ServiceCollection services = new(); - services.AddSingleton(authServiceMock.Object); - context.RequestServices = services.BuildServiceProvider(); - - return (context, authServiceMock); - } - - private static readonly RequestDelegate NextThatSets200 = ctx => - { - ctx.Response.StatusCode = 200; - return Task.CompletedTask; - }; - - private static AuthenticateResult BuildSuccessfulCookieAuth() - { - ClaimsIdentity identity = new(authenticationType: AuthConstants.BffSchemes.Cookie); - return AuthenticateResult.Success( - new AuthenticationTicket(new ClaimsPrincipal(identity), AuthConstants.BffSchemes.Cookie) - ); - } - - private static ( - CsrfValidationMiddleware middleware, - Mock mock - ) BuildMiddleware() - { - Mock problemDetailsMock = new(); - problemDetailsMock - .Setup(p => p.TryWriteAsync(It.IsAny())) - .ReturnsAsync(true); - - CsrfValidationMiddleware middleware = new(NextThatSets200, problemDetailsMock.Object); - return (middleware, problemDetailsMock); - } - - [Theory] - [InlineData("GET")] - [InlineData("HEAD")] - [InlineData("OPTIONS")] - public async Task SafeMethods_AlwaysPassThrough(string method) - { - (DefaultHttpContext context, _) = BuildContext(method: method); - (CsrfValidationMiddleware middleware, _) = BuildMiddleware(); - - await middleware.InvokeAsync(context); - - context.Response.StatusCode.ShouldBe(200); - } - - [Fact] - public async Task Post_WithBearerToken_PassesThrough_WithoutCsrfHeader() - { - (DefaultHttpContext context, _) = BuildContext(withBearerToken: true); - (CsrfValidationMiddleware middleware, _) = BuildMiddleware(); - - await middleware.InvokeAsync(context); - - context.Response.StatusCode.ShouldBe(200); - } - - [Fact] - public async Task Post_WithNoAuth_PassesThrough() - { - (DefaultHttpContext context, _) = BuildContext( - cookieAuthResult: AuthenticateResult.NoResult() - ); - (CsrfValidationMiddleware middleware, _) = BuildMiddleware(); - - await middleware.InvokeAsync(context); - - context.Response.StatusCode.ShouldBe(200); - } - - [Fact] - public async Task Post_WithFailedCookieAuth_PassesThrough() - { - (DefaultHttpContext context, _) = BuildContext( - cookieAuthResult: AuthenticateResult.Fail("Cookie expired") - ); - (CsrfValidationMiddleware middleware, _) = BuildMiddleware(); - - await middleware.InvokeAsync(context); - - context.Response.StatusCode.ShouldBe(200); - } - - [Fact] - public async Task Post_CookieInUser_WithCsrfHeader_PassesThrough_WithoutCallingAuthenticateAsync() - { - (DefaultHttpContext context, Mock authMock) = BuildContext( - cookieAuthInUser: true, - csrfHeader: AuthConstants.Csrf.HeaderValue - ); - (CsrfValidationMiddleware middleware, _) = BuildMiddleware(); - - await middleware.InvokeAsync(context); - - context.Response.StatusCode.ShouldBe(200); - authMock.Verify( - s => s.AuthenticateAsync(context, AuthConstants.BffSchemes.Cookie), - Times.Never - ); - } - - [Fact] - public async Task Post_CookieViaAuthenticateAsync_WithCsrfHeader_PassesThrough() - { - (DefaultHttpContext context, _) = BuildContext( - cookieAuthResult: BuildSuccessfulCookieAuth(), - csrfHeader: AuthConstants.Csrf.HeaderValue - ); - (CsrfValidationMiddleware middleware, _) = BuildMiddleware(); - - await middleware.InvokeAsync(context); - - context.Response.StatusCode.ShouldBe(200); - } - - [Fact] - public async Task Post_CookieInUser_WithoutCsrfHeader_Returns403_WithoutCallingAuthenticateAsync() - { - (DefaultHttpContext context, Mock authMock) = BuildContext( - cookieAuthInUser: true - ); - (CsrfValidationMiddleware middleware, Mock problemMock) = - BuildMiddleware(); - - await middleware.InvokeAsync(context); - - context.Response.StatusCode.ShouldBe(403); - problemMock.Verify(p => p.TryWriteAsync(It.IsAny()), Times.Once); - authMock.Verify( - s => s.AuthenticateAsync(context, AuthConstants.BffSchemes.Cookie), - Times.Never - ); - } - - [Fact] - public async Task Post_CookieViaAuthenticateAsync_WithoutCsrfHeader_Returns403() - { - (DefaultHttpContext context, _) = BuildContext( - cookieAuthResult: BuildSuccessfulCookieAuth() - ); - (CsrfValidationMiddleware middleware, Mock mock) = - BuildMiddleware(); - - await middleware.InvokeAsync(context); - - context.Response.StatusCode.ShouldBe(403); - mock.Verify(p => p.TryWriteAsync(It.IsAny()), Times.Once); - } - - [Fact] - public async Task Post_CookieInUser_WrongCsrfHeaderValue_Returns403() - { - (DefaultHttpContext context, _) = BuildContext( - cookieAuthInUser: true, - csrfHeader: "wrong-value" - ); - (CsrfValidationMiddleware middleware, Mock mock) = - BuildMiddleware(); - - await middleware.InvokeAsync(context); - - context.Response.StatusCode.ShouldBe(403); - mock.Verify(p => p.TryWriteAsync(It.IsAny()), Times.Once); - } -} diff --git a/tests/Identity.Tests/Sagas/TenantDeactivationSagaTests.cs b/tests/Identity.Tests/Sagas/TenantDeactivationSagaTests.cs deleted file mode 100644 index 8205ab84..00000000 --- a/tests/Identity.Tests/Sagas/TenantDeactivationSagaTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Contracts.IntegrationEvents.Sagas; -using Identity.Application.Sagas; -using Shouldly; -using Xunit; - -namespace Identity.Tests.Sagas; - -public sealed class TenantDeactivationSagaTests -{ - [Fact] - public void Start_CreatesSagaWithCorrectProperties() - { - Guid correlationId = Guid.NewGuid(); - Guid tenantId = Guid.NewGuid(); - Guid actorId = Guid.NewGuid(); - StartTenantDeactivationSaga command = new(correlationId, tenantId, actorId); - - ( - TenantDeactivationSaga saga, - TenantDeactivatedIntegrationEvent @event, - TenantDeactivationSagaTimeout timeout - ) = TenantDeactivationSaga.Start(command, TimeProvider.System); - - saga.Id.ShouldBe(correlationId); - saga.TenantId.ShouldBe(tenantId); - saga.UsersCascaded.ShouldBeFalse(); - saga.ProductsCascaded.ShouldBeFalse(); - saga.CategoriesCascaded.ShouldBeFalse(); - timeout.CorrelationId.ShouldBe(correlationId); - } - - [Fact] - public void Start_PublishesIntegrationEventWithCorrectFields() - { - Guid correlationId = Guid.NewGuid(); - Guid tenantId = Guid.NewGuid(); - Guid actorId = Guid.NewGuid(); - StartTenantDeactivationSaga command = new(correlationId, tenantId, actorId); - - ( - TenantDeactivationSaga _, - TenantDeactivatedIntegrationEvent @event, - TenantDeactivationSagaTimeout timeout - ) = TenantDeactivationSaga.Start(command, TimeProvider.System); - - @event.CorrelationId.ShouldBe(correlationId); - @event.TenantId.ShouldBe(tenantId); - @event.ActorId.ShouldBe(actorId); - timeout.CorrelationId.ShouldBe(correlationId); - } - - [Fact] - public void Handle_UsersCascadeCompleted_SetsFlag() - { - TenantDeactivationSaga saga = CreateSaga(); - - saga.Handle(new UsersCascadeCompleted(Guid.NewGuid(), Guid.NewGuid(), 5)); - - saga.UsersCascaded.ShouldBeTrue(); - saga.ProductsCascaded.ShouldBeFalse(); - saga.CategoriesCascaded.ShouldBeFalse(); - } - - [Fact] - public void Handle_ProductsCascadeCompleted_SetsFlag() - { - TenantDeactivationSaga saga = CreateSaga(); - - saga.Handle(new ProductsCascadeCompleted(Guid.NewGuid(), Guid.NewGuid(), 3)); - - saga.ProductsCascaded.ShouldBeTrue(); - } - - [Fact] - public void Handle_CategoriesCascadeCompleted_SetsFlag() - { - TenantDeactivationSaga saga = CreateSaga(); - - saga.Handle(new CategoriesCascadeCompleted(Guid.NewGuid(), Guid.NewGuid(), 2)); - - saga.CategoriesCascaded.ShouldBeTrue(); - } - - [Fact] - public void TryComplete_DoesNotComplete_WhenNotAllCascaded() - { - TenantDeactivationSaga saga = CreateSaga(); - - saga.Handle(new UsersCascadeCompleted(Guid.NewGuid(), Guid.NewGuid(), 5)); - saga.Handle(new ProductsCascadeCompleted(Guid.NewGuid(), Guid.NewGuid(), 3)); - - saga.IsCompleted().ShouldBeFalse(); - } - - [Fact] - public void TryComplete_CompletesWhenAllCascaded() - { - TenantDeactivationSaga saga = CreateSaga(); - - saga.Handle(new UsersCascadeCompleted(Guid.NewGuid(), Guid.NewGuid(), 5)); - saga.Handle(new ProductsCascadeCompleted(Guid.NewGuid(), Guid.NewGuid(), 3)); - saga.Handle(new CategoriesCascadeCompleted(Guid.NewGuid(), Guid.NewGuid(), 2)); - - saga.IsCompleted().ShouldBeTrue(); - } - - [Fact] - public void Handle_Timeout_CompletesWhenCascadeIsIncomplete() - { - TenantDeactivationSaga saga = CreateSaga(); - - saga.Handle( - new TenantDeactivationSagaTimeout(saga.Id), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance - ); - - saga.IsCompleted().ShouldBeTrue(); - } - - private static TenantDeactivationSaga CreateSaga() => - new() { Id = Guid.NewGuid(), TenantId = Guid.NewGuid() }; -} diff --git a/tests/Identity.Tests/TestDbContext.cs b/tests/Identity.Tests/TestDbContext.cs deleted file mode 100644 index a58b75e2..00000000 --- a/tests/Identity.Tests/TestDbContext.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Identity.Domain.Entities; -using Microsoft.EntityFrameworkCore; - -namespace Identity.Tests; - -/// -/// Minimal DbContext for handler tests. Uses SQLite in-memory so ExecuteUpdateAsync works. -/// Ignores navigation properties and complex configurations not needed for handler tests. -/// -internal sealed class TestDbContext : DbContext -{ - public TestDbContext(DbContextOptions options) - : base(options) { } - - public DbSet Users => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(u => u.Id); - entity.OwnsOne(u => u.Audit); - entity.Ignore(u => u.Tenant); - }); - } -} diff --git a/tests/Integration.Tests/CrossServiceTestBase.cs b/tests/Integration.Tests/CrossServiceTestBase.cs deleted file mode 100644 index 42ab224d..00000000 --- a/tests/Integration.Tests/CrossServiceTestBase.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Integration.Tests.Fixtures; -using Xunit; - -namespace Integration.Tests; - -public static class TestConstants -{ - public const string CollectionName = "CrossService"; - public const string CategoryName = "Integration.CrossService"; - public const string StartupSmokeCategoryName = "Integration.SmokeStartup"; - public static readonly TimeSpan TrackedSessionTimeout = TimeSpan.FromSeconds(30); -} - -[CollectionDefinition(TestConstants.CollectionName)] -public sealed class CrossServiceCollection : ICollectionFixture; diff --git a/tests/Integration.Tests/Events/ProductCreatedEventFlowTests.cs b/tests/Integration.Tests/Events/ProductCreatedEventFlowTests.cs deleted file mode 100644 index 154a8db1..00000000 --- a/tests/Integration.Tests/Events/ProductCreatedEventFlowTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using Integration.Tests.Factories; -using Integration.Tests.Fixtures; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Reviews.Domain.Entities; -using Reviews.Infrastructure.Persistence; -using Shouldly; -using TestCommon; -using Wolverine; -using Wolverine.Tracking; -using Xunit; - -namespace Integration.Tests.Events; - -[Trait("Category", TestConstants.CategoryName)] -[Collection(TestConstants.CollectionName)] -public sealed class ProductCreatedEventFlowTests : IAsyncLifetime -{ - private readonly SharedContainers _containers; - private ProductCatalogServiceFactory _productCatalogFactory = null!; - private ReviewsServiceFactory _reviewsFactory = null!; - - public ProductCreatedEventFlowTests(SharedContainers containers) - { - _containers = containers; - } - - public async ValueTask InitializeAsync() - { - _productCatalogFactory = new ProductCatalogServiceFactory(_containers); - _reviewsFactory = new ReviewsServiceFactory(_containers); - - await Task.WhenAll( - _productCatalogFactory.InitializeAsync().AsTask(), - _reviewsFactory.InitializeAsync().AsTask() - ); - } - - public async ValueTask DisposeAsync() - { - await Task.WhenAll( - _reviewsFactory.DisposeAsync().AsTask(), - _productCatalogFactory.DisposeAsync().AsTask() - ); - } - - [Fact] - public async Task ProductCreatedEvent_CreatesProjectionInReviewsWithCorrectTenantId() - { - // Arrange - Guid productId = Guid.NewGuid(); - Guid tenantId = Guid.NewGuid(); - string productName = "Integration Test Product"; - - ProductCreatedIntegrationEvent integrationEvent = new( - ProductId: productId, - TenantId: tenantId, - Name: productName, - OccurredAtUtc: DateTime.UtcNow - ); - - IHost productCatalogHost = _productCatalogFactory.Services.GetRequiredService(); - IHost reviewsHost = _reviewsFactory.Services.GetRequiredService(); - - // Act - CancellationToken ct = TestContext.Current.CancellationToken; - - ITrackedSession session = await productCatalogHost - .TrackActivity() - .Timeout(TestConstants.TrackedSessionTimeout) - .IncludeExternalTransports() - .AlsoTrack(reviewsHost) - .WaitForMessageToBeReceivedAt(reviewsHost) - .PublishMessageAndWaitAsync(integrationEvent); - - // Assert - session.Received.MessagesOf().ShouldNotBeEmpty(); - - ProductProjection projection = await AsyncPoll.UntilNotNullAsync( - async () => - { - await using AsyncServiceScope scope = _reviewsFactory.Services.CreateAsyncScope(); - ReviewsDbContext db = scope.ServiceProvider.GetRequiredService(); - return await db - .ProductProjections.IgnoreQueryFilters() - .FirstOrDefaultAsync(p => p.ProductId == productId, ct); - }, - TestConstants.TrackedSessionTimeout, - cancellationToken: ct - ); - projection.Name.ShouldBe(productName); - // Verifies TenantAwareEnvelopeMapper propagated x-tenant-id through RabbitMQ - projection.TenantId.ShouldBe(tenantId); - projection.IsActive.ShouldBeTrue(); - } -} diff --git a/tests/Integration.Tests/Events/TenantDeactivatedEventFlowTests.cs b/tests/Integration.Tests/Events/TenantDeactivatedEventFlowTests.cs deleted file mode 100644 index 213196eb..00000000 --- a/tests/Integration.Tests/Events/TenantDeactivatedEventFlowTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Integration.Tests.Factories; -using Integration.Tests.Fixtures; -using Integration.Tests.Helpers; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using ProductCatalog.Infrastructure.Persistence; -using Shouldly; -using TestCommon; -using Wolverine; -using Wolverine.Tracking; -using Xunit; - -namespace Integration.Tests.Events; - -[Trait("Category", TestConstants.CategoryName)] -[Collection(TestConstants.CollectionName)] -public sealed class TenantDeactivatedEventFlowTests : IAsyncLifetime -{ - private readonly SharedContainers _containers; - private IdentityServiceFactory _identityFactory = null!; - private ProductCatalogServiceFactory _productCatalogFactory = null!; - - public TenantDeactivatedEventFlowTests(SharedContainers containers) - { - _containers = containers; - } - - public async ValueTask InitializeAsync() - { - _identityFactory = new IdentityServiceFactory(_containers); - _productCatalogFactory = new ProductCatalogServiceFactory(_containers); - - await Task.WhenAll( - _identityFactory.InitializeAsync().AsTask(), - _productCatalogFactory.InitializeAsync().AsTask() - ); - } - - public async ValueTask DisposeAsync() - { - await Task.WhenAll( - _productCatalogFactory.DisposeAsync().AsTask(), - _identityFactory.DisposeAsync().AsTask() - ); - } - - [Fact] - public async Task TenantDeactivatedEvent_CascadeDeletesProductsAndCategories() - { - // Arrange - Guid tenantId = Guid.NewGuid(); - Guid actorId = Guid.NewGuid(); - - await using (AsyncServiceScope scope = _productCatalogFactory.Services.CreateAsyncScope()) - { - ProductCatalogDbContext db = - scope.ServiceProvider.GetRequiredService(); - db.Categories.Add( - new ProductCatalog.Domain.Entities.Category - { - Id = Guid.NewGuid(), - Name = "Deactivation Test Category", - TenantId = tenantId, - Audit = TestDataHelper.CreateAudit(actorId), - } - ); - db.Products.Add( - new ProductCatalog.Domain.Entities.Product - { - Id = Guid.NewGuid(), - Name = "Deactivation Test Product", - Price = 50.00m, - TenantId = tenantId, - Audit = TestDataHelper.CreateAudit(actorId), - } - ); - await db.SaveChangesAsync(); - } - - TenantDeactivatedIntegrationEvent integrationEvent = new( - CorrelationId: Guid.NewGuid(), - TenantId: tenantId, - ActorId: actorId, - OccurredAtUtc: DateTime.UtcNow - ); - - IHost identityHost = _identityFactory.Services.GetRequiredService(); - IHost productCatalogHost = _productCatalogFactory.Services.GetRequiredService(); - - CancellationToken ct = TestContext.Current.CancellationToken; - - // Act - ITrackedSession session = await identityHost - .TrackActivity() - .Timeout(TestConstants.TrackedSessionTimeout) - .IncludeExternalTransports() - .AlsoTrack(productCatalogHost) - .WaitForMessageToBeReceivedAt(productCatalogHost) - .PublishMessageAndWaitAsync(integrationEvent); - - // Assert - session.Received.MessagesOf().ShouldNotBeEmpty(); - - await AsyncPoll.UntilTrueAsync( - async () => - { - await using AsyncServiceScope scope2 = - _productCatalogFactory.Services.CreateAsyncScope(); - ProductCatalogDbContext db2 = - scope2.ServiceProvider.GetRequiredService(); - - int deletedProductCount = await db2 - .Products.IgnoreQueryFilters() - .CountAsync(p => p.TenantId == tenantId && p.IsDeleted, ct); - int deletedCategoryCount = await db2 - .Categories.IgnoreQueryFilters() - .CountAsync(c => c.TenantId == tenantId && c.IsDeleted, ct); - return deletedProductCount > 0 && deletedCategoryCount > 0; - }, - TestConstants.TrackedSessionTimeout, - cancellationToken: ct - ); - } -} diff --git a/tests/Integration.Tests/Factories/BackgroundJobsServiceFactory.cs b/tests/Integration.Tests/Factories/BackgroundJobsServiceFactory.cs deleted file mode 100644 index 2da755cc..00000000 --- a/tests/Integration.Tests/Factories/BackgroundJobsServiceFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -extern alias BackgroundJobsApi; - -using Integration.Tests.Fixtures; - -namespace Integration.Tests.Factories; - -public sealed class BackgroundJobsServiceFactory : ServiceFactoryBase -{ - public BackgroundJobsServiceFactory(SharedContainers containers) - : base(containers) { } - - protected override string ServiceName => "BackgroundJobs"; - protected override string ConnectionStringKey => "DefaultConnection"; -} diff --git a/tests/Integration.Tests/Factories/FileStorageServiceFactory.cs b/tests/Integration.Tests/Factories/FileStorageServiceFactory.cs deleted file mode 100644 index c619f5e8..00000000 --- a/tests/Integration.Tests/Factories/FileStorageServiceFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -extern alias FileStorageApi; - -using Integration.Tests.Fixtures; - -namespace Integration.Tests.Factories; - -public sealed class FileStorageServiceFactory : ServiceFactoryBase -{ - public FileStorageServiceFactory(SharedContainers containers) - : base(containers) { } - - protected override string ServiceName => "FileStorage"; - protected override string ConnectionStringKey => "FileStorageDb"; -} diff --git a/tests/Integration.Tests/Factories/GatewayServiceFactory.cs b/tests/Integration.Tests/Factories/GatewayServiceFactory.cs deleted file mode 100644 index 352fd47a..00000000 --- a/tests/Integration.Tests/Factories/GatewayServiceFactory.cs +++ /dev/null @@ -1,38 +0,0 @@ -extern alias GatewayApi; - -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; - -namespace Integration.Tests.Factories; - -public sealed class GatewayServiceFactory : WebApplicationFactory -{ - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - Dictionary config = new() - { - ["Keycloak:realm"] = "api-template", - ["Keycloak:auth-server-url"] = "http://localhost:8180", - ["ReverseProxy:Clusters:identity:Destinations:destination1:Address"] = - "http://localhost:5991", - ["ReverseProxy:Clusters:product-catalog:Destinations:destination1:Address"] = - "http://localhost:5992", - ["ReverseProxy:Clusters:reviews:Destinations:destination1:Address"] = - "http://localhost:5993", - ["ReverseProxy:Clusters:notifications:Destinations:destination1:Address"] = - "http://localhost:5994", - ["ReverseProxy:Clusters:file-storage:Destinations:destination1:Address"] = - "http://localhost:5995", - ["ReverseProxy:Clusters:background-jobs:Destinations:destination1:Address"] = - "http://localhost:5996", - ["ReverseProxy:Clusters:webhooks:Destinations:destination1:Address"] = - "http://localhost:5997", - }; - - builder.ConfigureAppConfiguration( - (_, configurationBuilder) => configurationBuilder.AddInMemoryCollection(config) - ); - builder.UseEnvironment("Development"); - } -} diff --git a/tests/Integration.Tests/Factories/IdentityServiceFactory.cs b/tests/Integration.Tests/Factories/IdentityServiceFactory.cs deleted file mode 100644 index 733fe5a5..00000000 --- a/tests/Integration.Tests/Factories/IdentityServiceFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -extern alias IdentityApi; - -using Identity.Application.Security; -using Integration.Tests.Fixtures; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Moq; - -namespace Integration.Tests.Factories; - -public sealed class IdentityServiceFactory : ServiceFactoryBase -{ - public IdentityServiceFactory(SharedContainers containers) - : base(containers) { } - - protected override string ServiceName => "Identity"; - protected override string ConnectionStringKey => "IdentityDb"; - - protected override void ConfigureServiceSpecificMocks(IServiceCollection services) - { - services.RemoveAll(); - services.AddSingleton(new Mock().Object); - } -} diff --git a/tests/Integration.Tests/Factories/NotificationsServiceFactory.cs b/tests/Integration.Tests/Factories/NotificationsServiceFactory.cs deleted file mode 100644 index 5d22398b..00000000 --- a/tests/Integration.Tests/Factories/NotificationsServiceFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -extern alias NotificationsApi; - -using Integration.Tests.Fixtures; - -namespace Integration.Tests.Factories; - -public sealed class NotificationsServiceFactory : ServiceFactoryBase -{ - public NotificationsServiceFactory(SharedContainers containers) - : base(containers) { } - - protected override string ServiceName => "Notifications"; - protected override string ConnectionStringKey => "DefaultConnection"; -} diff --git a/tests/Integration.Tests/Factories/ProductCatalogServiceFactory.cs b/tests/Integration.Tests/Factories/ProductCatalogServiceFactory.cs deleted file mode 100644 index 9b22b870..00000000 --- a/tests/Integration.Tests/Factories/ProductCatalogServiceFactory.cs +++ /dev/null @@ -1,32 +0,0 @@ -extern alias ProductCatalogApi; - -using Integration.Tests.Fixtures; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Moq; -using ProductCatalog.Domain.Interfaces; -using ProductCatalog.Infrastructure.Persistence; - -namespace Integration.Tests.Factories; - -public sealed class ProductCatalogServiceFactory : ServiceFactoryBase -{ - public ProductCatalogServiceFactory(SharedContainers containers) - : base(containers) { } - - protected override string ServiceName => "ProductCatalog"; - protected override string ConnectionStringKey => "ProductCatalogDb"; - - protected override void ConfigureAdditionalConfiguration(Dictionary config) - { - config["MongoDB:ConnectionString"] = "mongodb://localhost:27017"; - config["MongoDB:DatabaseName"] = "test_product_catalog"; - } - - protected override void ConfigureServiceSpecificMocks(IServiceCollection services) - { - services.RemoveAll(typeof(MongoDbContext)); - services.RemoveAll(typeof(IProductDataRepository)); - services.AddSingleton(new Mock().Object); - } -} diff --git a/tests/Integration.Tests/Factories/ReviewsServiceFactory.cs b/tests/Integration.Tests/Factories/ReviewsServiceFactory.cs deleted file mode 100644 index cd2a2fe8..00000000 --- a/tests/Integration.Tests/Factories/ReviewsServiceFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -extern alias ReviewsApi; - -using Integration.Tests.Fixtures; - -namespace Integration.Tests.Factories; - -public sealed class ReviewsServiceFactory : ServiceFactoryBase -{ - public ReviewsServiceFactory(SharedContainers containers) - : base(containers) { } - - protected override string ServiceName => "Reviews"; - protected override string ConnectionStringKey => "ReviewsDb"; -} diff --git a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs b/tests/Integration.Tests/Factories/ServiceFactoryBase.cs deleted file mode 100644 index 353387c7..00000000 --- a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs +++ /dev/null @@ -1,130 +0,0 @@ -using Integration.Tests.Fixtures; -using Integration.Tests.Helpers; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using TestCommon; -using Xunit; - -namespace Integration.Tests.Factories; - -public abstract class ServiceFactoryBase : WebApplicationFactory, IAsyncLifetime - where TProgram : class -{ - private readonly SharedContainers _containers; - private readonly string _databaseName = $"test_{Guid.NewGuid():N}"; - - protected ServiceFactoryBase(SharedContainers containers) - { - _containers = containers; - } - - protected abstract string ServiceName { get; } - protected abstract string ConnectionStringKey { get; } - - public string ConnectionString => - TestDatabaseLifecycle.BuildConnectionString( - _containers.PostgresServerConnectionString, - _databaseName - ); - - public async ValueTask InitializeAsync() - { - await TestDatabaseLifecycle.CreateDatabaseAsync( - _containers.PostgresServerConnectionString, - _databaseName - ); - } - - public new async ValueTask DisposeAsync() - { - try - { - await base.DisposeAsync(); - } - catch (OperationCanceledException) { } - catch (AggregateException ex) - when (ex.InnerExceptions.All(e => - e is OperationCanceledException or TaskCanceledException - ) - ) { } - - await TestDatabaseLifecycle.DropDatabaseAsync( - _containers.PostgresServerConnectionString, - _databaseName - ); - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - Dictionary config = CrossServiceConfigHelper.GetBaseConfiguration( - ServiceName, - ConnectionStringKey, - ConnectionString, - _containers.RabbitMqConnectionString - ); - - // Shared infrastructure requires this options section in every service host. - config["TransactionDefaults:IsolationLevel"] = "ReadCommitted"; - config["TransactionDefaults:TimeoutSeconds"] = "30"; - config["TransactionDefaults:RetryEnabled"] = "true"; - config["TransactionDefaults:RetryCount"] = "3"; - config["TransactionDefaults:RetryDelaySeconds"] = "5"; - config["ConnectionStrings:Dragonfly"] = string.Empty; - config["ConnectionStrings__Dragonfly"] = string.Empty; - - ConfigureAdditionalConfiguration(config); - - // Ensure values are available early for Program-time configuration reads. - foreach ((string key, string? value) in config) - { - if (!string.IsNullOrWhiteSpace(value)) - { - builder.UseSetting(key, value); - } - } - - builder.ConfigureAppConfiguration( - (_, configBuilder) => configBuilder.AddInMemoryCollection(config) - ); - - builder.ConfigureTestServices(services => - { - TestAuthSetup.ConfigureTestJwtBearer(services); - RemoveExternalHealthChecks(services); - ConfigureServiceSpecificMocks(services); - }); - - builder.UseEnvironment("Development"); - } - - protected virtual void ConfigureAdditionalConfiguration(Dictionary config) { } - - protected virtual void ConfigureServiceSpecificMocks(IServiceCollection services) { } - - private static void RemoveExternalHealthChecks(IServiceCollection services) - { - services.Configure( - options => - { - List toRemove = - options - .Registrations.Where(r => - r.Name.Contains("mongodb", StringComparison.OrdinalIgnoreCase) - || r.Name.Contains("keycloak", StringComparison.OrdinalIgnoreCase) - || r.Name.Contains("dragonfly", StringComparison.OrdinalIgnoreCase) - ) - .ToList(); - - foreach ( - Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckRegistration r in toRemove - ) - { - options.Registrations.Remove(r); - } - } - ); - } -} diff --git a/tests/Integration.Tests/Factories/WebhooksServiceFactory.cs b/tests/Integration.Tests/Factories/WebhooksServiceFactory.cs deleted file mode 100644 index 8c70bfba..00000000 --- a/tests/Integration.Tests/Factories/WebhooksServiceFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -extern alias WebhooksApi; - -using Integration.Tests.Fixtures; - -namespace Integration.Tests.Factories; - -public sealed class WebhooksServiceFactory : ServiceFactoryBase -{ - public WebhooksServiceFactory(SharedContainers containers) - : base(containers) { } - - protected override string ServiceName => "Webhooks"; - protected override string ConnectionStringKey => "DefaultConnection"; -} diff --git a/tests/Integration.Tests/Fixtures/SharedContainers.cs b/tests/Integration.Tests/Fixtures/SharedContainers.cs deleted file mode 100644 index 49da7bbc..00000000 --- a/tests/Integration.Tests/Fixtures/SharedContainers.cs +++ /dev/null @@ -1,91 +0,0 @@ -using DotNet.Testcontainers.Builders; -using Npgsql; -using RabbitMQ.Client; -using Testcontainers.PostgreSql; -using Testcontainers.RabbitMq; -using Xunit; - -namespace Integration.Tests.Fixtures; - -public sealed class SharedContainers : IAsyncLifetime -{ - public PostgreSqlContainer Postgres { get; } = - new PostgreSqlBuilder("postgres:18.3") - .WithUsername("postgres") - .WithPassword("postgres") - .WithCleanUp(true) - .Build(); - - public RabbitMqContainer RabbitMq { get; } = - new RabbitMqBuilder("rabbitmq:4.2.5-management") - .WithUsername("guest") - .WithPassword("guest") - .WithWaitStrategy( - Wait.ForUnixContainer() - .UntilInternalTcpPortIsAvailable(5672) - .UntilMessageIsLogged( - "Server startup complete", - o => o.WithTimeout(TimeSpan.FromMinutes(2)) - ) - ) - .WithCleanUp(true) - .Build(); - - public string PostgresServerConnectionString - { - get - { - NpgsqlConnectionStringBuilder builder = new(Postgres.GetConnectionString()) - { - Database = "postgres", - }; - return builder.ConnectionString; - } - } - - public string RabbitMqConnectionString => RabbitMq.GetConnectionString(); - - public async ValueTask InitializeAsync() - { - await Task.WhenAll(Postgres.StartAsync(), RabbitMq.StartAsync()); - - // Force process-level override so every in-process test host resolves the same RabbitMQ endpoint. - Environment.SetEnvironmentVariable("ConnectionStrings__RabbitMQ", RabbitMqConnectionString); - - // Guard against startup races: ensure AMQP handshake succeeds before tests boot service hosts. - ConnectionFactory factory = new() { Uri = new Uri(RabbitMqConnectionString) }; - Exception? lastError = null; - TimeSpan delay = TimeSpan.FromMilliseconds(100); - for (int attempt = 1; attempt <= 30; attempt++) - { - try - { - await using IConnection connection = await factory.CreateConnectionAsync(); - if (connection.IsOpen) - { - return; - } - } - catch (Exception ex) - { - lastError = ex; - } - - await Task.Delay(delay); - delay = TimeSpan.FromTicks( - Math.Min(delay.Ticks * 3 / 2, TimeSpan.FromSeconds(2).Ticks) - ); - } - - throw new InvalidOperationException( - $"RabbitMQ container was not reachable at '{RabbitMqConnectionString}' after warm-up.", - lastError - ); - } - - public async ValueTask DisposeAsync() - { - Environment.SetEnvironmentVariable("ConnectionStrings__RabbitMQ", null); - await Task.WhenAll(Postgres.DisposeAsync().AsTask(), RabbitMq.DisposeAsync().AsTask()); - } -} diff --git a/tests/Integration.Tests/Helpers/CrossServiceConfigHelper.cs b/tests/Integration.Tests/Helpers/CrossServiceConfigHelper.cs deleted file mode 100644 index 2ab10b81..00000000 --- a/tests/Integration.Tests/Helpers/CrossServiceConfigHelper.cs +++ /dev/null @@ -1,32 +0,0 @@ -using TestCommon; - -namespace Integration.Tests.Helpers; - -internal static class CrossServiceConfigHelper -{ - internal static Dictionary GetBaseConfiguration( - string serviceName, - string connectionStringKey, - string dbConnectionString, - string rabbitMqConnectionString - ) - { - Dictionary config = TestBaseConfiguration.GetSharedConfiguration( - $"Integration.Tests.{serviceName}" - ); - - config[$"ConnectionStrings:{connectionStringKey}"] = dbConnectionString; - config["ConnectionStrings:RabbitMQ"] = rabbitMqConnectionString; - config["ConnectionStrings__RabbitMQ"] = rabbitMqConnectionString; - Uri rabbitUri = new(rabbitMqConnectionString); - config["RabbitMQ:HostName"] = $"{rabbitUri.Host}:{rabbitUri.Port}"; - config[$"Observability:ServiceName"] = $"Integration.Tests.{serviceName}"; - config["Invitation:BaseUrl"] = "http://localhost:3000"; - config["Invitation:ExpirationHours"] = "72"; - config["Bff:CookieDomain"] = "localhost"; - config["Bff:CookieName"] = ".test"; - config["FileStorage:BasePath"] = Path.GetTempPath(); - - return config; - } -} diff --git a/tests/Integration.Tests/Helpers/IntegrationAuthHelper.cs b/tests/Integration.Tests/Helpers/IntegrationAuthHelper.cs deleted file mode 100644 index f8233ec8..00000000 --- a/tests/Integration.Tests/Helpers/IntegrationAuthHelper.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Net.Http.Headers; -using System.Security.Claims; -using Microsoft.IdentityModel.Tokens; -using SharedKernel.Application.Security; -using TestCommon; - -namespace Integration.Tests.Helpers; - -internal static class IntegrationAuthHelper -{ - private static readonly SigningCredentials SigningCredentials = new( - TestAuthSetup.SecurityKey, - SecurityAlgorithms.RsaSha256 - ); - - public static void AuthenticateAsPlatformAdmin( - HttpClient client, - Guid tenantId, - Guid? userId = null - ) => Authenticate(client, SharedAuthConstants.Roles.PlatformAdmin, tenantId, userId); - - public static void AuthenticateAsTenantAdmin( - HttpClient client, - Guid tenantId, - Guid? userId = null - ) => Authenticate(client, SharedAuthConstants.Roles.TenantAdmin, tenantId, userId); - - private static void Authenticate(HttpClient client, string role, Guid tenantId, Guid? userId) - { - var id = userId ?? Guid.NewGuid(); - var token = new JwtSecurityToken( - issuer: TestAuthSetup.Issuer, - audience: TestAuthSetup.Audience, - claims: - [ - new Claim(SharedAuthConstants.Claims.Subject, id.ToString()), - new Claim(SharedAuthConstants.Claims.TenantId, tenantId.ToString()), - new Claim(ClaimTypes.Role, role), - new Claim(ClaimTypes.Email, $"{id:N}@example.com"), - ], - expires: DateTime.UtcNow.AddHours(1), - signingCredentials: SigningCredentials - ); - - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - new JwtSecurityTokenHandler().WriteToken(token) - ); - } -} diff --git a/tests/Integration.Tests/Helpers/TestDataHelper.cs b/tests/Integration.Tests/Helpers/TestDataHelper.cs deleted file mode 100644 index 4d93a0ef..00000000 --- a/tests/Integration.Tests/Helpers/TestDataHelper.cs +++ /dev/null @@ -1,18 +0,0 @@ -using SharedKernel.Domain.Entities; - -namespace Integration.Tests.Helpers; - -internal static class TestDataHelper -{ - internal static AuditInfo CreateAudit(Guid actorId) - { - DateTime now = DateTime.UtcNow; - return new AuditInfo - { - CreatedAtUtc = now, - CreatedBy = actorId, - UpdatedAtUtc = now, - UpdatedBy = actorId, - }; - } -} diff --git a/tests/Integration.Tests/Infrastructure/GatewayScalarAndOpenApiTests.cs b/tests/Integration.Tests/Infrastructure/GatewayScalarAndOpenApiTests.cs deleted file mode 100644 index 9b528b36..00000000 --- a/tests/Integration.Tests/Infrastructure/GatewayScalarAndOpenApiTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Net; -using Integration.Tests.Factories; -using Shouldly; -using TestCommon; -using Xunit; - -namespace Integration.Tests.Infrastructure; - -public sealed class GatewayScalarAndOpenApiTests : IClassFixture -{ - private readonly HttpClient _client; - - public GatewayScalarAndOpenApiTests(GatewayServiceFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact] - public async Task Scalar_Endpoint_ContainsAllServiceDocumentsAndOAuthConfig() - { - var ct = TestContext.Current.CancellationToken; - HttpResponseMessage response = await _client.GetAsync("/scalar/v1", ct); - - string content = await response.ShouldHaveStatusAsync(HttpStatusCode.OK, ct); - content.ShouldContain("api-template-scalar"); - content.ShouldContain("authorizationUrl"); - content.ShouldContain("tokenUrl"); - } - - [Theory] - [InlineData("/openapi/identity.json")] - [InlineData("/openapi/product-catalog.json")] - [InlineData("/openapi/reviews.json")] - [InlineData("/openapi/file-storage.json")] - [InlineData("/openapi/notifications.json")] - [InlineData("/openapi/background-jobs.json")] - [InlineData("/openapi/webhooks.json")] - public async Task OpenApi_GatewayRoutes_AreRegistered(string openApiPath) - { - var ct = TestContext.Current.CancellationToken; - HttpResponseMessage response = await _client.GetAsync(openApiPath, ct); - - // Upstream service is intentionally unavailable in this test host, so - // YARP should produce a proxy failure instead of a missing route. - ( - response.StatusCode == HttpStatusCode.BadGateway - || response.StatusCode == HttpStatusCode.ServiceUnavailable - ).ShouldBeTrue(); - } -} diff --git a/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs b/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs deleted file mode 100644 index 7167dbec..00000000 --- a/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs +++ /dev/null @@ -1,430 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using FileStorage.Domain.Entities; -using FileStorage.Infrastructure.Persistence; -using Identity.Infrastructure.Persistence; -using Integration.Tests.Factories; -using Integration.Tests.Fixtures; -using Integration.Tests.Helpers; -using Microsoft.Extensions.DependencyInjection; -using Reviews.Domain.Entities; -using Reviews.Infrastructure.Persistence; -using Shouldly; -using TestCommon; -using Xunit; - -namespace Integration.Tests.Infrastructure; - -[Trait("Category", TestConstants.CategoryName)] -[Collection(TestConstants.CollectionName)] -public sealed class OutputCacheBehaviorTests : IAsyncLifetime -{ - private readonly SharedContainers _containers; - private ProductCatalogServiceFactory _productCatalogFactory = null!; - private IdentityServiceFactory _identityFactory = null!; - private ReviewsServiceFactory _reviewsFactory = null!; - private FileStorageServiceFactory _fileStorageFactory = null!; - - public OutputCacheBehaviorTests(SharedContainers containers) - { - _containers = containers; - } - - public async ValueTask InitializeAsync() - { - _productCatalogFactory = new ProductCatalogServiceFactory(_containers); - _identityFactory = new IdentityServiceFactory(_containers); - _reviewsFactory = new ReviewsServiceFactory(_containers); - _fileStorageFactory = new FileStorageServiceFactory(_containers); - - await Task.WhenAll( - _productCatalogFactory.InitializeAsync().AsTask(), - _identityFactory.InitializeAsync().AsTask(), - _reviewsFactory.InitializeAsync().AsTask(), - _fileStorageFactory.InitializeAsync().AsTask() - ); - } - - public async ValueTask DisposeAsync() - { - await Task.WhenAll( - _fileStorageFactory.DisposeAsync().AsTask(), - _reviewsFactory.DisposeAsync().AsTask(), - _identityFactory.DisposeAsync().AsTask(), - _productCatalogFactory.DisposeAsync().AsTask() - ); - } - - [Fact] - public async Task ProductCatalog_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() - { - var ct = TestContext.Current.CancellationToken; - var tenantId = Guid.NewGuid(); - HttpClient client = _productCatalogFactory.CreateClient(); - IntegrationAuthHelper.AuthenticateAsTenantAdmin(client, tenantId); - - HttpResponseMessage first = await client.GetAsync("/api/v1/products", ct); - string firstBody = await first.Content.ReadAsStringAsync(ct); - first.StatusCode.ShouldBe(HttpStatusCode.OK, firstBody); - - HttpResponseMessage second = await client.GetAsync("/api/v1/products", ct); - string secondBody = await second.Content.ReadAsStringAsync(ct); - second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); - second.Headers.Age.ShouldNotBeNull(); - } - - [Fact] - public async Task ProductCatalog_WarmReadThenWrite_ReadShowsFreshDataAfterInvalidation() - { - var ct = TestContext.Current.CancellationToken; - var tenantId = Guid.NewGuid(); - HttpClient client = _productCatalogFactory.CreateClient(); - IntegrationAuthHelper.AuthenticateAsTenantAdmin(client, tenantId); - - HttpResponseMessage warm = await client.GetAsync("/api/v1/products", ct); - warm.StatusCode.ShouldBe(HttpStatusCode.OK); - - string productName = $"cache-product-{Guid.NewGuid():N}"; - HttpResponseMessage create = await client.PostAsJsonAsync( - "/api/v1/products", - new - { - Items = new[] - { - new - { - Name = productName, - Description = "created", - Price = 11m, - }, - }, - }, - ct - ); - create.StatusCode.ShouldBe(HttpStatusCode.OK, await create.Content.ReadAsStringAsync(ct)); - - // Cache invalidation is async (Wolverine OutgoingMessages) — poll until the eviction propagates. - await AsyncPoll.UntilTrueAsync( - async () => - { - HttpResponseMessage r = await client.GetAsync("/api/v1/products", ct); - string b = await r.Content.ReadAsStringAsync(ct); - return b.Contains(productName, StringComparison.OrdinalIgnoreCase); - }, - timeout: TimeSpan.FromSeconds(2), - interval: TimeSpan.FromMilliseconds(50), - cancellationToken: ct - ); - } - - [Fact] - public async Task ProductCatalog_CacheIsIsolatedByTenant() - { - var ct = TestContext.Current.CancellationToken; - var tenantA = Guid.NewGuid(); - var tenantB = Guid.NewGuid(); - - HttpClient clientA = _productCatalogFactory.CreateClient(); - HttpClient clientB = _productCatalogFactory.CreateClient(); - IntegrationAuthHelper.AuthenticateAsTenantAdmin(clientA, tenantA); - IntegrationAuthHelper.AuthenticateAsTenantAdmin(clientB, tenantB); - - HttpResponseMessage warmA = await clientA.GetAsync("/api/v1/products", ct); - warmA.StatusCode.ShouldBe(HttpStatusCode.OK); - - string productName = $"tenant-b-product-{Guid.NewGuid():N}"; - HttpResponseMessage createB = await clientB.PostAsJsonAsync( - "/api/v1/products", - new - { - Items = new[] - { - new - { - Name = productName, - Description = "tenant-b", - Price = 22m, - }, - }, - }, - ct - ); - createB.StatusCode.ShouldBe(HttpStatusCode.OK, await createB.Content.ReadAsStringAsync(ct)); - - string bodyB = await ( - await clientB.GetAsync("/api/v1/products", ct) - ).Content.ReadAsStringAsync(ct); - bodyB.ShouldContain(productName); - - string bodyA = await ( - await clientA.GetAsync("/api/v1/products", ct) - ).Content.ReadAsStringAsync(ct); - bodyA.ShouldNotContain(productName); - } - - [Fact] - public async Task Identity_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() - { - var ct = TestContext.Current.CancellationToken; - var tenantId = Guid.NewGuid(); - HttpClient client = _identityFactory.CreateClient(); - IntegrationAuthHelper.AuthenticateAsPlatformAdmin(client, tenantId); - - HttpResponseMessage first = await client.GetAsync("/api/v1/tenants", ct); - first.StatusCode.ShouldBe(HttpStatusCode.OK, await first.Content.ReadAsStringAsync(ct)); - - HttpResponseMessage second = await client.GetAsync("/api/v1/tenants", ct); - second.StatusCode.ShouldBe(HttpStatusCode.OK, await second.Content.ReadAsStringAsync(ct)); - second.Headers.Age.ShouldNotBeNull(); - } - - [Fact] - public async Task Identity_WarmReadThenCreateUser_ReadShowsFreshDataAfterInvalidation() - { - var ct = TestContext.Current.CancellationToken; - var tenantId = Guid.NewGuid(); - HttpClient client = _identityFactory.CreateClient(); - IntegrationAuthHelper.AuthenticateAsPlatformAdmin(client, tenantId); - - await using (AsyncServiceScope scope = _identityFactory.Services.CreateAsyncScope()) - { - IdentityDbContext db = scope.ServiceProvider.GetRequiredService(); - db.Tenants.Add( - new Identity.Domain.Entities.Tenant - { - Id = tenantId, - TenantId = tenantId, - Code = $"seed-{Guid.NewGuid():N}".Substring(0, 10), - Name = "Seed Tenant", - IsActive = true, - } - ); - await db.SaveChangesAsync(ct); - } - - HttpResponseMessage warm = await client.GetAsync("/api/v1/users", ct); - warm.StatusCode.ShouldBe(HttpStatusCode.OK); - - string username = $"cache-user-{Guid.NewGuid():N}".Substring(0, 16); - string email = $"{username}@example.com"; - HttpResponseMessage create = await client.PostAsJsonAsync( - "/api/v1/users", - new { Username = username, Email = email }, - ct - ); - create.StatusCode.ShouldBe( - HttpStatusCode.Created, - await create.Content.ReadAsStringAsync(ct) - ); - - string createBody = await create.Content.ReadAsStringAsync(ct); - Guid createdId = JsonDocument.Parse(createBody).RootElement.GetProperty("id").GetGuid(); - - string body = await ( - await client.GetAsync($"/api/v1/users/{createdId}", ct) - ).Content.ReadAsStringAsync(ct); - body.ShouldContain(username); - } - - [Fact] - public async Task Reviews_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() - { - var ct = TestContext.Current.CancellationToken; - var tenantId = Guid.NewGuid(); - HttpClient client = _reviewsFactory.CreateClient(); - IntegrationAuthHelper.AuthenticateAsTenantAdmin(client, tenantId, Guid.NewGuid()); - - Guid productId = Guid.NewGuid(); - await using (AsyncServiceScope scope = _reviewsFactory.Services.CreateAsyncScope()) - { - ReviewsDbContext db = scope.ServiceProvider.GetRequiredService(); - db.ProductProjections.Add( - new ProductProjection - { - ProductId = productId, - TenantId = tenantId, - Name = "Cache Age Product", - IsActive = true, - } - ); - await db.SaveChangesAsync(ct); - } - - HttpResponseMessage first = await client.GetAsync( - $"/api/v1/productreviews/by-product/{productId}", - ct - ); - first.StatusCode.ShouldBe(HttpStatusCode.OK, await first.Content.ReadAsStringAsync(ct)); - - HttpResponseMessage second = await client.GetAsync( - $"/api/v1/productreviews/by-product/{productId}", - ct - ); - second.StatusCode.ShouldBe(HttpStatusCode.OK, await second.Content.ReadAsStringAsync(ct)); - second.Headers.Age.ShouldNotBeNull(); - } - - [Fact] - public async Task Reviews_WarmReadThenCreateReview_ReadShowsFreshDataAfterInvalidation() - { - var ct = TestContext.Current.CancellationToken; - var tenantId = Guid.NewGuid(); - HttpClient client = _reviewsFactory.CreateClient(); - Guid userId = Guid.NewGuid(); - IntegrationAuthHelper.AuthenticateAsTenantAdmin(client, tenantId, userId); - - Guid productId = Guid.NewGuid(); - await using (AsyncServiceScope scope = _reviewsFactory.Services.CreateAsyncScope()) - { - ReviewsDbContext db = scope.ServiceProvider.GetRequiredService(); - db.ProductProjections.Add( - new ProductProjection - { - ProductId = productId, - TenantId = tenantId, - Name = "Cache Test Product", - IsActive = true, - } - ); - await db.SaveChangesAsync(ct); - } - - HttpResponseMessage warm = await client.GetAsync( - $"/api/v1/productreviews/by-product/{productId}", - ct - ); - warm.StatusCode.ShouldBe(HttpStatusCode.OK); - - HttpResponseMessage create = await client.PostAsJsonAsync( - "/api/v1/productreviews", - new - { - ProductId = productId, - Comment = "cache-review", - Rating = 5, - }, - ct - ); - create.StatusCode.ShouldBe( - HttpStatusCode.Created, - await create.Content.ReadAsStringAsync(ct) - ); - - string body = await ( - await client.GetAsync($"/api/v1/productreviews/by-product/{productId}", ct) - ).Content.ReadAsStringAsync(ct); - body.ShouldContain("cache-review"); - } - - [Fact] - public async Task Reviews_CacheIsIsolatedByTenant() - { - var ct = TestContext.Current.CancellationToken; - var tenantA = Guid.NewGuid(); - var tenantB = Guid.NewGuid(); - var userA = Guid.NewGuid(); - var userB = Guid.NewGuid(); - var tenantBProductId = Guid.NewGuid(); - - HttpClient clientA = _reviewsFactory.CreateClient(); - HttpClient clientB = _reviewsFactory.CreateClient(); - IntegrationAuthHelper.AuthenticateAsTenantAdmin(clientA, tenantA, userA); - IntegrationAuthHelper.AuthenticateAsTenantAdmin(clientB, tenantB, userB); - - await using (AsyncServiceScope scope = _reviewsFactory.Services.CreateAsyncScope()) - { - ReviewsDbContext db = scope.ServiceProvider.GetRequiredService(); - db.ProductProjections.Add( - new ProductProjection - { - ProductId = tenantBProductId, - TenantId = tenantB, - Name = "Tenant B Product", - IsActive = true, - } - ); - await db.SaveChangesAsync(ct); - } - - HttpResponseMessage warmA = await clientA.GetAsync("/api/v1/productreviews", ct); - warmA.StatusCode.ShouldBe(HttpStatusCode.OK); - - HttpResponseMessage createB = await clientB.PostAsJsonAsync( - "/api/v1/productreviews", - new - { - ProductId = tenantBProductId, - Comment = "tenant-b-review", - Rating = 4, - }, - ct - ); - createB.StatusCode.ShouldBe( - HttpStatusCode.Created, - await createB.Content.ReadAsStringAsync(ct) - ); - - string bodyB = await ( - await clientB.GetAsync("/api/v1/productreviews", ct) - ).Content.ReadAsStringAsync(ct); - bodyB.ShouldContain("tenant-b-review"); - - string bodyA = await ( - await clientA.GetAsync("/api/v1/productreviews", ct) - ).Content.ReadAsStringAsync(ct); - bodyA.ShouldNotContain("tenant-b-review"); - } - - [Fact] - public async Task FileStorage_DownloadEndpoint_ReturnsAgeHeaderOnSecondRead() - { - var ct = TestContext.Current.CancellationToken; - var tenantId = Guid.NewGuid(); - var fileId = Guid.NewGuid(); - HttpClient client = _fileStorageFactory.CreateClient(); - IntegrationAuthHelper.AuthenticateAsTenantAdmin(client, tenantId); - - string tenantDir = Path.Combine(Path.GetTempPath(), tenantId.ToString()); - Directory.CreateDirectory(tenantDir); - string storagePath = Path.Combine(tenantDir, $"{Guid.NewGuid():N}.txt"); - await File.WriteAllTextAsync(storagePath, "cached-file-content", ct); - - await using (AsyncServiceScope scope = _fileStorageFactory.Services.CreateAsyncScope()) - { - FileStorageDbContext db = - scope.ServiceProvider.GetRequiredService(); - db.StoredFiles.Add( - new StoredFile - { - Id = fileId, - OriginalFileName = "cache.txt", - StoragePath = storagePath, - ContentType = "text/plain", - SizeBytes = 19, - Description = "cache-file", - TenantId = tenantId, - } - ); - await db.SaveChangesAsync(ct); - } - - HttpResponseMessage first = await client.GetAsync($"/api/v1/files/{fileId}/download", ct); - string firstBody = await first.Content.ReadAsStringAsync(ct); - first.StatusCode.ShouldBe(HttpStatusCode.OK, firstBody); - - HttpResponseMessage second = await client.GetAsync($"/api/v1/files/{fileId}/download", ct); - string secondBody = await second.Content.ReadAsStringAsync(ct); - second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); - second.Headers.Age.ShouldNotBeNull(); - - try - { - Directory.Delete(tenantDir, recursive: true); - } - catch - { - // Best-effort cleanup; temp files are harmless if locked. - } - } -} diff --git a/tests/Integration.Tests/Infrastructure/ServiceOpenApiParityTests.cs b/tests/Integration.Tests/Infrastructure/ServiceOpenApiParityTests.cs deleted file mode 100644 index a2bfcdc7..00000000 --- a/tests/Integration.Tests/Infrastructure/ServiceOpenApiParityTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Integration.Tests.Factories; -using Integration.Tests.Fixtures; -using Shouldly; -using Xunit; - -namespace Integration.Tests.Infrastructure; - -[Trait("Category", TestConstants.CategoryName)] -[Collection(TestConstants.CollectionName)] -public sealed class ServiceOpenApiParityTests : IAsyncLifetime -{ - private readonly SharedContainers _containers; - private IdentityServiceFactory _identityFactory = null!; - private ProductCatalogServiceFactory _productCatalogFactory = null!; - private ReviewsServiceFactory _reviewsFactory = null!; - - public ServiceOpenApiParityTests(SharedContainers containers) - { - _containers = containers; - } - - public async ValueTask InitializeAsync() - { - _identityFactory = new IdentityServiceFactory(_containers); - _productCatalogFactory = new ProductCatalogServiceFactory(_containers); - _reviewsFactory = new ReviewsServiceFactory(_containers); - - await Task.WhenAll( - _identityFactory.InitializeAsync().AsTask(), - _productCatalogFactory.InitializeAsync().AsTask(), - _reviewsFactory.InitializeAsync().AsTask() - ); - } - - public async ValueTask DisposeAsync() - { - await Task.WhenAll( - _reviewsFactory.DisposeAsync().AsTask(), - _productCatalogFactory.DisposeAsync().AsTask(), - _identityFactory.DisposeAsync().AsTask() - ); - } - - [Theory] - [InlineData("identity")] - [InlineData("product-catalog")] - [InlineData("reviews")] - public async Task Service_OpenApi_IncludesOAuthAndProblemDetails(string serviceName) - { - HttpClient client = serviceName switch - { - "identity" => _identityFactory.CreateClient(), - "product-catalog" => _productCatalogFactory.CreateClient(), - "reviews" => _reviewsFactory.CreateClient(), - _ => throw new ArgumentOutOfRangeException(nameof(serviceName), serviceName, null), - }; - - HttpResponseMessage response = await client.GetAsync("/openapi/v1.json"); - response.IsSuccessStatusCode.ShouldBeTrue(); - - string content = await response.Content.ReadAsStringAsync(); - content.ShouldContain("OAuth2"); - content.ShouldContain("oauth2"); - content.ShouldContain("ApiProblemDetails"); - content.ShouldContain("application/problem+json"); - } -} diff --git a/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs b/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs deleted file mode 100644 index 4b165e97..00000000 --- a/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Integration.Tests.Factories; -using Integration.Tests.Fixtures; -using Shouldly; -using Xunit; - -namespace Integration.Tests.Infrastructure; - -[Trait("Category", TestConstants.StartupSmokeCategoryName)] -[Collection(TestConstants.CollectionName)] -public sealed class ServiceStartupSmokeTests -{ - private readonly SharedContainers _containers; - - public ServiceStartupSmokeTests(SharedContainers containers) - { - _containers = containers; - } - - [Fact] - public async Task AllServices_Start_And_AreHealthy() - { - await using GatewayServiceFactory gatewayFactory = new(); - await AssertHealthyAsync(gatewayFactory.CreateClient()); - - await AssertServiceHealthyAsync(new ProductCatalogServiceFactory(_containers)); - await AssertServiceHealthyAsync(new ReviewsServiceFactory(_containers)); - await AssertServiceHealthyAsync(new IdentityServiceFactory(_containers)); - await AssertServiceHealthyAsync(new NotificationsServiceFactory(_containers)); - await AssertServiceHealthyAsync(new FileStorageServiceFactory(_containers)); - await AssertServiceHealthyAsync(new BackgroundJobsServiceFactory(_containers)); - await AssertServiceHealthyAsync(new WebhooksServiceFactory(_containers)); - } - - private static async Task AssertHealthyAsync(HttpClient client) - { - HttpResponseMessage response = await client.GetAsync("/health"); - response.IsSuccessStatusCode.ShouldBeTrue(); - } - - private static async Task AssertServiceHealthyAsync( - ServiceFactoryBase factory - ) - where TProgram : class - { - await factory.InitializeAsync(); - try - { - await AssertHealthyAsync(factory.CreateClient()); - } - finally - { - await factory.DisposeAsync(); - } - } -} diff --git a/tests/Integration.Tests/Integration.Tests.csproj b/tests/Integration.Tests/Integration.Tests.csproj deleted file mode 100644 index 1130bb86..00000000 --- a/tests/Integration.Tests/Integration.Tests.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/Integration.Tests/Sagas/ProductDeletionSagaIntegrationTests.cs b/tests/Integration.Tests/Sagas/ProductDeletionSagaIntegrationTests.cs deleted file mode 100644 index 2ef436f7..00000000 --- a/tests/Integration.Tests/Sagas/ProductDeletionSagaIntegrationTests.cs +++ /dev/null @@ -1,163 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using Contracts.IntegrationEvents.Sagas; -using Integration.Tests.Factories; -using Integration.Tests.Fixtures; -using Integration.Tests.Helpers; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using ProductCatalog.Infrastructure.Persistence; -using Reviews.Domain.Entities; -using Reviews.Infrastructure.Persistence; -using Shouldly; -using TestCommon; -using Wolverine; -using Wolverine.Tracking; -using Xunit; - -namespace Integration.Tests.Sagas; - -[Trait("Category", TestConstants.CategoryName)] -[Collection(TestConstants.CollectionName)] -public sealed class ProductDeletionSagaIntegrationTests : IAsyncLifetime -{ - private readonly SharedContainers _containers; - private ProductCatalogServiceFactory _productCatalogFactory = null!; - private ReviewsServiceFactory _reviewsFactory = null!; - private FileStorageServiceFactory _fileStorageFactory = null!; - - public ProductDeletionSagaIntegrationTests(SharedContainers containers) - { - _containers = containers; - } - - public async ValueTask InitializeAsync() - { - _productCatalogFactory = new ProductCatalogServiceFactory(_containers); - _reviewsFactory = new ReviewsServiceFactory(_containers); - _fileStorageFactory = new FileStorageServiceFactory(_containers); - - await Task.WhenAll( - _productCatalogFactory.InitializeAsync().AsTask(), - _reviewsFactory.InitializeAsync().AsTask(), - _fileStorageFactory.InitializeAsync().AsTask() - ); - } - - public async ValueTask DisposeAsync() - { - await Task.WhenAll( - _fileStorageFactory.DisposeAsync().AsTask(), - _reviewsFactory.DisposeAsync().AsTask(), - _productCatalogFactory.DisposeAsync().AsTask() - ); - } - - [Fact] - public async Task ProductDeletionSaga_CompletesWhenAllCascadesFinish() - { - // Arrange - Guid tenantId = Guid.NewGuid(); - Guid actorId = Guid.NewGuid(); - Guid productId = Guid.NewGuid(); - Guid correlationId = Guid.NewGuid(); - - await using (AsyncServiceScope scope = _productCatalogFactory.Services.CreateAsyncScope()) - { - ProductCatalogDbContext db = - scope.ServiceProvider.GetRequiredService(); - db.Products.Add( - new ProductCatalog.Domain.Entities.Product - { - Id = productId, - Name = "Test Product", - Price = 10.00m, - TenantId = tenantId, - Audit = TestDataHelper.CreateAudit(actorId), - } - ); - await db.SaveChangesAsync(); - } - - await using (AsyncServiceScope scope = _reviewsFactory.Services.CreateAsyncScope()) - { - ReviewsDbContext db = scope.ServiceProvider.GetRequiredService(); - db.ProductProjections.Add( - new ProductProjection - { - ProductId = productId, - TenantId = tenantId, - Name = "Test Product", - IsActive = true, - } - ); - db.ProductReviews.Add( - new ProductReview - { - Id = Guid.NewGuid(), - ProductId = productId, - UserId = actorId, - Rating = 5, - Comment = "Great product", - TenantId = tenantId, - Audit = TestDataHelper.CreateAudit(actorId), - } - ); - await db.SaveChangesAsync(); - } - - // Act - IHost productCatalogHost = _productCatalogFactory.Services.GetRequiredService(); - IHost reviewsHost = _reviewsFactory.Services.GetRequiredService(); - IHost fileStorageHost = _fileStorageFactory.Services.GetRequiredService(); - - CancellationToken ct = TestContext.Current.CancellationToken; - - ITrackedSession session = await productCatalogHost - .TrackActivity() - .Timeout(TestConstants.TrackedSessionTimeout) - .IncludeExternalTransports() - .AlsoTrack(reviewsHost) - .AlsoTrack(fileStorageHost) - .WaitForMessageToBeReceivedAt(productCatalogHost) - .WaitForMessageToBeReceivedAt(productCatalogHost) - .InvokeMessageAndWaitAsync( - new StartProductDeletionSaga(correlationId, [productId], tenantId, actorId) - ); - - // Assert - session.Sent.MessagesOf().ShouldNotBeEmpty(); - session.Received.MessagesOf().ShouldNotBeEmpty(); - session.Received.MessagesOf().ShouldNotBeEmpty(); - - ProductProjection projection = await AsyncPoll.UntilNotNullAsync( - async () => - { - await using AsyncServiceScope scope = _reviewsFactory.Services.CreateAsyncScope(); - ReviewsDbContext db = scope.ServiceProvider.GetRequiredService(); - ProductProjection? p = await db.ProductProjections.FirstOrDefaultAsync( - x => x.ProductId == productId, - ct - ); - return p is { IsActive: false } ? p : null; - }, - TestConstants.TrackedSessionTimeout, - cancellationToken: ct - ); - projection.IsActive.ShouldBeFalse(); - - await AsyncPoll.UntilTrueAsync( - async () => - { - await using AsyncServiceScope scope = _reviewsFactory.Services.CreateAsyncScope(); - ReviewsDbContext db = scope.ServiceProvider.GetRequiredService(); - int deletedReviewCount = await db - .ProductReviews.IgnoreQueryFilters() - .CountAsync(r => r.ProductId == productId && r.IsDeleted, ct); - return deletedReviewCount > 0; - }, - TestConstants.TrackedSessionTimeout, - cancellationToken: ct - ); - } -} diff --git a/tests/Integration.Tests/Sagas/TenantDeactivationSagaIntegrationTests.cs b/tests/Integration.Tests/Sagas/TenantDeactivationSagaIntegrationTests.cs deleted file mode 100644 index a8e34113..00000000 --- a/tests/Integration.Tests/Sagas/TenantDeactivationSagaIntegrationTests.cs +++ /dev/null @@ -1,174 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Contracts.IntegrationEvents.Sagas; -using Identity.Domain.Entities; -using Identity.Domain.Enums; -using Identity.Infrastructure.Persistence; -using Integration.Tests.Factories; -using Integration.Tests.Fixtures; -using Integration.Tests.Helpers; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using ProductCatalog.Infrastructure.Persistence; -using Shouldly; -using TestCommon; -using Wolverine; -using Wolverine.Tracking; -using Xunit; - -namespace Integration.Tests.Sagas; - -[Trait("Category", TestConstants.CategoryName)] -[Collection(TestConstants.CollectionName)] -public sealed class TenantDeactivationSagaIntegrationTests : IAsyncLifetime -{ - private readonly SharedContainers _containers; - private IdentityServiceFactory _identityFactory = null!; - private ProductCatalogServiceFactory _productCatalogFactory = null!; - - public TenantDeactivationSagaIntegrationTests(SharedContainers containers) - { - _containers = containers; - } - - public async ValueTask InitializeAsync() - { - _identityFactory = new IdentityServiceFactory(_containers); - _productCatalogFactory = new ProductCatalogServiceFactory(_containers); - - await Task.WhenAll( - _identityFactory.InitializeAsync().AsTask(), - _productCatalogFactory.InitializeAsync().AsTask() - ); - } - - public async ValueTask DisposeAsync() - { - await Task.WhenAll( - _productCatalogFactory.DisposeAsync().AsTask(), - _identityFactory.DisposeAsync().AsTask() - ); - } - - [Fact] - public async Task TenantDeactivationSaga_CompletesWhenAllCascadesFinish() - { - // Arrange - Guid tenantId = Guid.NewGuid(); - Guid actorId = Guid.NewGuid(); - Guid correlationId = Guid.NewGuid(); - - await using (AsyncServiceScope scope = _identityFactory.Services.CreateAsyncScope()) - { - IdentityDbContext db = scope.ServiceProvider.GetRequiredService(); - db.Tenants.Add( - new Tenant - { - Id = tenantId, - TenantId = tenantId, - Code = $"test-{tenantId:N}", - Name = "Test Tenant", - IsActive = true, - Audit = TestDataHelper.CreateAudit(actorId), - } - ); - db.Users.Add( - new AppUser - { - Id = Guid.NewGuid(), - TenantId = tenantId, - Username = "testuser", - Email = "testuser@example.com", - IsActive = true, - Role = UserRole.User, - Audit = TestDataHelper.CreateAudit(actorId), - } - ); - await db.SaveChangesAsync(); - } - - await using (AsyncServiceScope scope = _productCatalogFactory.Services.CreateAsyncScope()) - { - ProductCatalogDbContext db = - scope.ServiceProvider.GetRequiredService(); - db.Categories.Add( - new ProductCatalog.Domain.Entities.Category - { - Id = Guid.NewGuid(), - Name = "Test Category", - TenantId = tenantId, - Audit = TestDataHelper.CreateAudit(actorId), - } - ); - db.Products.Add( - new ProductCatalog.Domain.Entities.Product - { - Id = Guid.NewGuid(), - Name = "Test Product", - Price = 25.00m, - TenantId = tenantId, - Audit = TestDataHelper.CreateAudit(actorId), - } - ); - await db.SaveChangesAsync(); - } - - // Act - IHost identityHost = _identityFactory.Services.GetRequiredService(); - IHost productCatalogHost = _productCatalogFactory.Services.GetRequiredService(); - - CancellationToken ct = TestContext.Current.CancellationToken; - - ITrackedSession session = await identityHost - .TrackActivity() - .Timeout(TestConstants.TrackedSessionTimeout) - .IncludeExternalTransports() - .AlsoTrack(productCatalogHost) - .WaitForMessageToBeReceivedAt(identityHost) - .WaitForMessageToBeReceivedAt(identityHost) - .WaitForMessageToBeReceivedAt(identityHost) - .InvokeMessageAndWaitAsync( - new StartTenantDeactivationSaga(correlationId, tenantId, actorId) - ); - - // Assert - session.Sent.MessagesOf().ShouldNotBeEmpty(); - session.Received.MessagesOf().ShouldNotBeEmpty(); - session.Received.MessagesOf().ShouldNotBeEmpty(); - session.Received.MessagesOf().ShouldNotBeEmpty(); - - await AsyncPoll.UntilTrueAsync( - async () => - { - await using AsyncServiceScope scope = _identityFactory.Services.CreateAsyncScope(); - IdentityDbContext db = - scope.ServiceProvider.GetRequiredService(); - int deletedUserCount = await db - .Users.IgnoreQueryFilters() - .CountAsync(u => u.TenantId == tenantId && u.IsDeleted, ct); - return deletedUserCount > 0; - }, - TestConstants.TrackedSessionTimeout, - cancellationToken: ct - ); - - await AsyncPoll.UntilTrueAsync( - async () => - { - await using AsyncServiceScope scope = - _productCatalogFactory.Services.CreateAsyncScope(); - ProductCatalogDbContext db = - scope.ServiceProvider.GetRequiredService(); - int deletedProductCount = await db - .Products.IgnoreQueryFilters() - .CountAsync(p => p.TenantId == tenantId && p.IsDeleted, ct); - int deletedCategoryCount = await db - .Categories.IgnoreQueryFilters() - .CountAsync(c => c.TenantId == tenantId && c.IsDeleted, ct); - return deletedProductCount > 0 && deletedCategoryCount > 0; - }, - TestConstants.TrackedSessionTimeout, - cancellationToken: ct - ); - } -} diff --git a/tests/Integration.Tests/xunit.runner.json b/tests/Integration.Tests/xunit.runner.json deleted file mode 100644 index dd80f43a..00000000 --- a/tests/Integration.Tests/xunit.runner.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelizeAssembly": false, - "parallelizeTestCollections": false -} diff --git a/tests/Notifications.Tests/Features/Emails/EventHandlers/TenantInvitationNotificationHandlerTests.cs b/tests/Notifications.Tests/Features/Emails/EventHandlers/TenantInvitationNotificationHandlerTests.cs deleted file mode 100644 index 00c746a6..00000000 --- a/tests/Notifications.Tests/Features/Emails/EventHandlers/TenantInvitationNotificationHandlerTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Microsoft.Extensions.Options; -using Moq; -using Notifications.Application.Features.Emails.EventHandlers; -using Notifications.Application.Options; -using Notifications.Domain.Constants; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; -using Shouldly; -using Xunit; - -namespace Notifications.Tests.Features.Emails.EventHandlers; - -public sealed class TenantInvitationNotificationHandlerTests -{ - private readonly Mock _rendererMock = new(); - private readonly Mock _queueMock = new(); - private readonly IOptions _options = Options.Create( - new EmailOptions { BaseUrl = "https://app.example.com", InvitationTokenExpiryHours = 48 } - ); - - [Fact] - public async Task HandleAsync_RendersTemplateWithInvitationUrl() - { - TenantInvitationCreatedIntegrationEvent @event = new( - Guid.NewGuid(), - "invite@example.com", - "Acme Corp", - "abc123token", - DateTime.UtcNow - ); - _rendererMock - .Setup(r => - r.RenderAsync( - EmailTemplateNames.TenantInvitation, - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync("invitation"); - - await TenantInvitationNotificationHandler.HandleAsync( - @event, - _rendererMock.Object, - _queueMock.Object, - _options, - CancellationToken.None - ); - - _rendererMock.Verify( - r => - r.RenderAsync( - EmailTemplateNames.TenantInvitation, - It.IsAny(), - It.IsAny() - ), - Times.Once - ); - } - - [Fact] - public async Task HandleAsync_EnqueuesEmailWithCorrectSubjectContainingTenantName() - { - TenantInvitationCreatedIntegrationEvent @event = new( - Guid.NewGuid(), - "invite@example.com", - "Acme Corp", - "abc123token", - DateTime.UtcNow - ); - _rendererMock - .Setup(r => - r.RenderAsync(It.IsAny(), It.IsAny(), It.IsAny()) - ) - .ReturnsAsync("invitation"); - EmailMessage? capturedMessage = null; - _queueMock - .Setup(q => q.EnqueueAsync(It.IsAny(), It.IsAny())) - .Callback((msg, _) => capturedMessage = msg) - .Returns(ValueTask.CompletedTask); - - await TenantInvitationNotificationHandler.HandleAsync( - @event, - _rendererMock.Object, - _queueMock.Object, - _options, - CancellationToken.None - ); - - capturedMessage.ShouldNotBeNull(); - capturedMessage.To.ShouldBe("invite@example.com"); - capturedMessage.Subject.ShouldContain("Acme Corp"); - capturedMessage.TemplateName.ShouldBe(EmailTemplateNames.TenantInvitation); - } -} diff --git a/tests/Notifications.Tests/Features/Emails/EventHandlers/UserRegisteredNotificationHandlerTests.cs b/tests/Notifications.Tests/Features/Emails/EventHandlers/UserRegisteredNotificationHandlerTests.cs deleted file mode 100644 index c9ea71ad..00000000 --- a/tests/Notifications.Tests/Features/Emails/EventHandlers/UserRegisteredNotificationHandlerTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Microsoft.Extensions.Options; -using Moq; -using Notifications.Application.Features.Emails.EventHandlers; -using Notifications.Application.Options; -using Notifications.Domain.Constants; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; -using Shouldly; -using Xunit; - -namespace Notifications.Tests.Features.Emails.EventHandlers; - -public sealed class UserRegisteredNotificationHandlerTests -{ - private readonly Mock _rendererMock = new(); - private readonly Mock _queueMock = new(); - private readonly IOptions _options = Options.Create( - new EmailOptions { BaseUrl = "https://app.example.com" } - ); - - [Fact] - public async Task HandleAsync_RendersTemplateWithCorrectModel() - { - UserRegisteredIntegrationEvent @event = new( - Guid.NewGuid(), - Guid.NewGuid(), - "user@example.com", - "testuser", - DateTime.UtcNow - ); - _rendererMock - .Setup(r => - r.RenderAsync( - EmailTemplateNames.UserRegistration, - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync("welcome"); - - await UserRegisteredNotificationHandler.HandleAsync( - @event, - _rendererMock.Object, - _queueMock.Object, - _options, - CancellationToken.None - ); - - _rendererMock.Verify( - r => - r.RenderAsync( - EmailTemplateNames.UserRegistration, - It.IsAny(), - It.IsAny() - ), - Times.Once - ); - } - - [Fact] - public async Task HandleAsync_EnqueuesEmailWithCorrectRecipientAndSubject() - { - UserRegisteredIntegrationEvent @event = new( - Guid.NewGuid(), - Guid.NewGuid(), - "user@example.com", - "testuser", - DateTime.UtcNow - ); - _rendererMock - .Setup(r => - r.RenderAsync(It.IsAny(), It.IsAny(), It.IsAny()) - ) - .ReturnsAsync("welcome"); - EmailMessage? capturedMessage = null; - _queueMock - .Setup(q => q.EnqueueAsync(It.IsAny(), It.IsAny())) - .Callback((msg, _) => capturedMessage = msg) - .Returns(ValueTask.CompletedTask); - - await UserRegisteredNotificationHandler.HandleAsync( - @event, - _rendererMock.Object, - _queueMock.Object, - _options, - CancellationToken.None - ); - - capturedMessage.ShouldNotBeNull(); - capturedMessage.To.ShouldBe("user@example.com"); - capturedMessage.Subject.ShouldBe(EmailSubjects.UserRegistration); - capturedMessage.HtmlBody.ShouldBe("welcome"); - capturedMessage.TemplateName.ShouldBe(EmailTemplateNames.UserRegistration); - } -} diff --git a/tests/Notifications.Tests/Features/Emails/EventHandlers/UserRoleChangedNotificationHandlerTests.cs b/tests/Notifications.Tests/Features/Emails/EventHandlers/UserRoleChangedNotificationHandlerTests.cs deleted file mode 100644 index 7c8dcc27..00000000 --- a/tests/Notifications.Tests/Features/Emails/EventHandlers/UserRoleChangedNotificationHandlerTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Moq; -using Notifications.Application.Features.Emails.EventHandlers; -using Notifications.Domain.Constants; -using Notifications.Domain.Interfaces; -using Notifications.Domain.ValueObjects; -using Shouldly; -using Xunit; - -namespace Notifications.Tests.Features.Emails.EventHandlers; - -public sealed class UserRoleChangedNotificationHandlerTests -{ - private readonly Mock _rendererMock = new(); - private readonly Mock _queueMock = new(); - - [Fact] - public async Task HandleAsync_RendersTemplateWithRoleInformation() - { - UserRoleChangedIntegrationEvent @event = new( - Guid.NewGuid(), - Guid.NewGuid(), - "user@example.com", - "testuser", - "Viewer", - "Admin", - DateTime.UtcNow - ); - _rendererMock - .Setup(r => - r.RenderAsync( - EmailTemplateNames.UserRoleChanged, - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync("role changed"); - - await UserRoleChangedNotificationHandler.HandleAsync( - @event, - _rendererMock.Object, - _queueMock.Object, - CancellationToken.None - ); - - _rendererMock.Verify( - r => - r.RenderAsync( - EmailTemplateNames.UserRoleChanged, - It.IsAny(), - It.IsAny() - ), - Times.Once - ); - } - - [Fact] - public async Task HandleAsync_EnqueuesEmailWithCorrectRecipientAndSubject() - { - UserRoleChangedIntegrationEvent @event = new( - Guid.NewGuid(), - Guid.NewGuid(), - "user@example.com", - "testuser", - "Viewer", - "Admin", - DateTime.UtcNow - ); - _rendererMock - .Setup(r => - r.RenderAsync(It.IsAny(), It.IsAny(), It.IsAny()) - ) - .ReturnsAsync("role changed"); - EmailMessage? capturedMessage = null; - _queueMock - .Setup(q => q.EnqueueAsync(It.IsAny(), It.IsAny())) - .Callback((msg, _) => capturedMessage = msg) - .Returns(ValueTask.CompletedTask); - - await UserRoleChangedNotificationHandler.HandleAsync( - @event, - _rendererMock.Object, - _queueMock.Object, - CancellationToken.None - ); - - capturedMessage.ShouldNotBeNull(); - capturedMessage.To.ShouldBe("user@example.com"); - capturedMessage.Subject.ShouldBe(EmailSubjects.UserRoleChanged); - capturedMessage.HtmlBody.ShouldBe("role changed"); - capturedMessage.TemplateName.ShouldBe(EmailTemplateNames.UserRoleChanged); - } -} diff --git a/tests/Notifications.Tests/Notifications.Tests.csproj b/tests/Notifications.Tests/Notifications.Tests.csproj deleted file mode 100644 index b4a75a2a..00000000 --- a/tests/Notifications.Tests/Notifications.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - diff --git a/tests/ProductCatalog.Tests/Domain/Entities/ProductTests.cs b/tests/ProductCatalog.Tests/Domain/Entities/ProductTests.cs deleted file mode 100644 index 0c27bb3d..00000000 --- a/tests/ProductCatalog.Tests/Domain/Entities/ProductTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using ProductCatalog.Domain.Entities; -using Shouldly; -using Xunit; - -namespace ProductCatalog.Tests.Domain.Entities; - -public sealed class ProductTests -{ - [Fact] - public void SoftDeleteProductDataLinks_RemovesAllLinks() - { - Product product = CreateProductWithLinks(3); - product.ProductDataLinks.Count.ShouldBe(3); - - product.SoftDeleteProductDataLinks(); - - product.ProductDataLinks.ShouldBeEmpty(); - } - - [Fact] - public void SoftDeleteProductDataLinks_WithNoLinks_DoesNothing() - { - Product product = new() { Id = Guid.NewGuid(), Name = "Test" }; - - product.SoftDeleteProductDataLinks(); - - product.ProductDataLinks.ShouldBeEmpty(); - } - - [Fact] - public void SyncProductDataLinks_RemovesLinksNotInTargetSet() - { - Guid keepId = Guid.NewGuid(); - Guid removeId = Guid.NewGuid(); - Product product = new() { Id = Guid.NewGuid(), Name = "Test" }; - product.ProductDataLinks.Add(ProductDataLink.Create(product.Id, keepId)); - product.ProductDataLinks.Add(ProductDataLink.Create(product.Id, removeId)); - - HashSet targetIds = new() { keepId }; - Dictionary existingById = product.ProductDataLinks.ToDictionary(l => - l.ProductDataId - ); - - product.SyncProductDataLinks(targetIds, existingById); - - product.ProductDataLinks.Count.ShouldBe(1); - product.ProductDataLinks.First().ProductDataId.ShouldBe(keepId); - } - - [Fact] - public void SyncProductDataLinks_AddsNewLinksNotInExisting() - { - Guid existingId = Guid.NewGuid(); - Guid newId = Guid.NewGuid(); - Product product = new() { Id = Guid.NewGuid(), Name = "Test" }; - product.ProductDataLinks.Add(ProductDataLink.Create(product.Id, existingId)); - - HashSet targetIds = new() { existingId, newId }; - Dictionary existingById = product.ProductDataLinks.ToDictionary(l => - l.ProductDataId - ); - - product.SyncProductDataLinks(targetIds, existingById); - - product.ProductDataLinks.Count.ShouldBe(2); - product.ProductDataLinks.ShouldContain(l => l.ProductDataId == newId); - } - - [Fact] - public void Name_ThrowsOnEmpty() - { - Should.Throw(() => new Product { Id = Guid.NewGuid(), Name = "" }); - } - - [Fact] - public void Price_ThrowsOnNegative() - { - Should.Throw(() => - { - Product product = new() { Id = Guid.NewGuid(), Name = "Test" }; - product.Price = -1m; - }); - } - - private static Product CreateProductWithLinks(int count) - { - Product product = new() { Id = Guid.NewGuid(), Name = "Test Product" }; - for (int i = 0; i < count; i++) - product.ProductDataLinks.Add(ProductDataLink.Create(product.Id, Guid.NewGuid())); - return product; - } -} diff --git a/tests/ProductCatalog.Tests/EventHandlers/TenantDeactivatedEventHandlerTests.cs b/tests/ProductCatalog.Tests/EventHandlers/TenantDeactivatedEventHandlerTests.cs deleted file mode 100644 index ce6abcc5..00000000 --- a/tests/ProductCatalog.Tests/EventHandlers/TenantDeactivatedEventHandlerTests.cs +++ /dev/null @@ -1,214 +0,0 @@ -using Contracts.IntegrationEvents.Identity; -using Contracts.IntegrationEvents.Sagas; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using ProductCatalog.Application.EventHandlers; -using ProductCatalog.Domain.Entities; -using Shouldly; -using Wolverine; -using Xunit; - -namespace ProductCatalog.Tests.EventHandlers; - -public sealed class TenantDeactivatedEventHandlerTests : IDisposable -{ - private readonly SqliteConnection _connection; - private readonly TestDbContext _dbContext; - private readonly Mock _busMock = new(); - private readonly Mock> _loggerMock = new(); - - public TenantDeactivatedEventHandlerTests() - { - _connection = new SqliteConnection("DataSource=:memory:"); - _connection.Open(); - - DbContextOptions options = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - _dbContext = new TestDbContext(options); - _dbContext.Database.EnsureCreated(); - } - - public void Dispose() - { - _dbContext.Dispose(); - _connection.Dispose(); - } - - [Fact] - public async Task HandleAsync_SoftDeletesProductsForTenant() - { - Guid tenantId = Guid.NewGuid(); - Guid actorId = Guid.NewGuid(); - Product p1 = CreateProduct(tenantId); - Product p2 = CreateProduct(tenantId); - Product pOther = CreateProduct(Guid.NewGuid()); - _dbContext.Products.AddRange(p1, p2, pOther); - await _dbContext.SaveChangesAsync(); - - TenantDeactivatedIntegrationEvent @event = new( - Guid.NewGuid(), - tenantId, - actorId, - DateTime.UtcNow - ); - - await TenantDeactivatedEventHandler.HandleAsync( - @event, - _dbContext, - _busMock.Object, - TimeProvider.System, - _loggerMock.Object, - CancellationToken.None - ); - - Product rp1 = await _dbContext - .Products.IgnoreQueryFilters() - .SingleAsync(p => p.Id == p1.Id); - Product rp2 = await _dbContext - .Products.IgnoreQueryFilters() - .SingleAsync(p => p.Id == p2.Id); - Product other = await _dbContext - .Products.IgnoreQueryFilters() - .SingleAsync(p => p.Id == pOther.Id); - - rp1.IsDeleted.ShouldBeTrue(); - rp1.DeletedBy.ShouldBe(actorId); - rp2.IsDeleted.ShouldBeTrue(); - other.IsDeleted.ShouldBeFalse(); - } - - [Fact] - public async Task HandleAsync_SoftDeletesCategoriesForTenant() - { - Guid tenantId = Guid.NewGuid(); - Guid actorId = Guid.NewGuid(); - Category c1 = CreateCategory(tenantId); - Category cOther = CreateCategory(Guid.NewGuid()); - _dbContext.Categories.AddRange(c1, cOther); - await _dbContext.SaveChangesAsync(); - - TenantDeactivatedIntegrationEvent @event = new( - Guid.NewGuid(), - tenantId, - actorId, - DateTime.UtcNow - ); - - await TenantDeactivatedEventHandler.HandleAsync( - @event, - _dbContext, - _busMock.Object, - TimeProvider.System, - _loggerMock.Object, - CancellationToken.None - ); - - Category rc1 = await _dbContext - .Categories.IgnoreQueryFilters() - .SingleAsync(c => c.Id == c1.Id); - Category other = await _dbContext - .Categories.IgnoreQueryFilters() - .SingleAsync(c => c.Id == cOther.Id); - - rc1.IsDeleted.ShouldBeTrue(); - rc1.DeletedBy.ShouldBe(actorId); - other.IsDeleted.ShouldBeFalse(); - } - - [Fact] - public async Task HandleAsync_PublishesProductsCascadeCompletedWithCorrectCorrelationId() - { - Guid tenantId = Guid.NewGuid(); - Guid correlationId = Guid.NewGuid(); - _dbContext.Products.Add(CreateProduct(tenantId)); - await _dbContext.SaveChangesAsync(); - - TenantDeactivatedIntegrationEvent @event = new( - correlationId, - tenantId, - Guid.NewGuid(), - DateTime.UtcNow - ); - - await TenantDeactivatedEventHandler.HandleAsync( - @event, - _dbContext, - _busMock.Object, - TimeProvider.System, - _loggerMock.Object, - CancellationToken.None - ); - - _busMock.Verify( - b => - b.PublishAsync( - It.Is(m => - m.CorrelationId == correlationId - && m.TenantId == tenantId - && m.DeletedCount == 1 - ), - It.IsAny() - ), - Times.Once - ); - } - - [Fact] - public async Task HandleAsync_PublishesCategoriesCascadeCompletedWithCorrectCorrelationId() - { - Guid tenantId = Guid.NewGuid(); - Guid correlationId = Guid.NewGuid(); - _dbContext.Categories.Add(CreateCategory(tenantId)); - await _dbContext.SaveChangesAsync(); - - TenantDeactivatedIntegrationEvent @event = new( - correlationId, - tenantId, - Guid.NewGuid(), - DateTime.UtcNow - ); - - await TenantDeactivatedEventHandler.HandleAsync( - @event, - _dbContext, - _busMock.Object, - TimeProvider.System, - _loggerMock.Object, - CancellationToken.None - ); - - _busMock.Verify( - b => - b.PublishAsync( - It.Is(m => - m.CorrelationId == correlationId - && m.TenantId == tenantId - && m.DeletedCount == 1 - ), - It.IsAny() - ), - Times.Once - ); - } - - private static Product CreateProduct(Guid tenantId) => - new() - { - Id = Guid.NewGuid(), - Name = "Product-" + Guid.NewGuid().ToString("N")[..8], - Price = 9.99m, - TenantId = tenantId, - }; - - private static Category CreateCategory(Guid tenantId) => - new() - { - Id = Guid.NewGuid(), - Name = "Category-" + Guid.NewGuid().ToString("N")[..8], - TenantId = tenantId, - }; -} diff --git a/tests/ProductCatalog.Tests/ProductCatalog.Tests.csproj b/tests/ProductCatalog.Tests/ProductCatalog.Tests.csproj deleted file mode 100644 index 0b1d6ed8..00000000 --- a/tests/ProductCatalog.Tests/ProductCatalog.Tests.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - diff --git a/tests/ProductCatalog.Tests/Sagas/ProductDeletionSagaTests.cs b/tests/ProductCatalog.Tests/Sagas/ProductDeletionSagaTests.cs deleted file mode 100644 index 9cc0b491..00000000 --- a/tests/ProductCatalog.Tests/Sagas/ProductDeletionSagaTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using Contracts.IntegrationEvents.Sagas; -using ProductCatalog.Application.Sagas; -using Shouldly; -using Xunit; - -namespace ProductCatalog.Tests.Sagas; - -public sealed class ProductDeletionSagaTests -{ - [Fact] - public void Start_CreatesSagaWithCorrectProperties() - { - Guid correlationId = Guid.NewGuid(); - IReadOnlyList productIds = new[] { Guid.NewGuid(), Guid.NewGuid() }; - Guid tenantId = Guid.NewGuid(); - Guid actorId = Guid.NewGuid(); - StartProductDeletionSaga command = new(correlationId, productIds, tenantId, actorId); - - ( - ProductDeletionSaga saga, - ProductDeletedIntegrationEvent @event, - ProductDeletionSagaTimeout timeout - ) = ProductDeletionSaga.Start(command, TimeProvider.System); - - saga.Id.ShouldBe(correlationId); - saga.ProductIds.ShouldBe(productIds); - saga.TenantId.ShouldBe(tenantId); - saga.ReviewsCascaded.ShouldBeFalse(); - saga.FilesCascaded.ShouldBeFalse(); - timeout.CorrelationId.ShouldBe(correlationId); - } - - [Fact] - public void Start_PublishesIntegrationEventWithCorrectFields() - { - IReadOnlyList productIds = new[] { Guid.NewGuid() }; - Guid tenantId = Guid.NewGuid(); - StartProductDeletionSaga command = new( - Guid.NewGuid(), - productIds, - tenantId, - Guid.NewGuid() - ); - - ( - ProductDeletionSaga _, - ProductDeletedIntegrationEvent @event, - ProductDeletionSagaTimeout timeout - ) = ProductDeletionSaga.Start(command, TimeProvider.System); - - @event.ProductIds.ShouldBe(productIds); - @event.TenantId.ShouldBe(tenantId); - @event.CorrelationId.ShouldBe(command.CorrelationId); - timeout.CorrelationId.ShouldBe(command.CorrelationId); - } - - [Fact] - public void Handle_ReviewsCascadeCompleted_SetsFlag() - { - ProductDeletionSaga saga = CreateSaga(); - - saga.Handle(new ReviewsCascadeCompleted(Guid.NewGuid(), 10)); - - saga.ReviewsCascaded.ShouldBeTrue(); - saga.FilesCascaded.ShouldBeFalse(); - } - - [Fact] - public void Handle_FilesCascadeCompleted_SetsFlag() - { - ProductDeletionSaga saga = CreateSaga(); - - saga.Handle(new FilesCascadeCompleted(Guid.NewGuid(), 5)); - - saga.FilesCascaded.ShouldBeTrue(); - saga.ReviewsCascaded.ShouldBeFalse(); - } - - [Fact] - public void TryComplete_DoesNotComplete_WhenOnlyReviewsCascaded() - { - ProductDeletionSaga saga = CreateSaga(); - - saga.Handle(new ReviewsCascadeCompleted(Guid.NewGuid(), 10)); - - saga.IsCompleted().ShouldBeFalse(); - } - - [Fact] - public void TryComplete_CompletesWhenBothCascaded() - { - ProductDeletionSaga saga = CreateSaga(); - - saga.Handle(new ReviewsCascadeCompleted(Guid.NewGuid(), 10)); - saga.Handle(new FilesCascadeCompleted(Guid.NewGuid(), 5)); - - saga.IsCompleted().ShouldBeTrue(); - } - - [Fact] - public void Handle_Timeout_CompletesWhenCascadeIsIncomplete() - { - ProductDeletionSaga saga = CreateSaga(); - - saga.Handle( - new ProductDeletionSagaTimeout(saga.Id), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance - ); - - saga.IsCompleted().ShouldBeTrue(); - } - - private static ProductDeletionSaga CreateSaga() => - new() - { - Id = Guid.NewGuid(), - ProductIds = new[] { Guid.NewGuid() }, - TenantId = Guid.NewGuid(), - }; -} diff --git a/tests/ProductCatalog.Tests/TestDbContext.cs b/tests/ProductCatalog.Tests/TestDbContext.cs deleted file mode 100644 index 2962399c..00000000 --- a/tests/ProductCatalog.Tests/TestDbContext.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using ProductCatalog.Domain.Entities; - -namespace ProductCatalog.Tests; - -/// -/// Minimal DbContext for handler tests. Uses SQLite in-memory so ExecuteUpdateAsync works. -/// Ignores navigation properties and complex configurations not needed for handler tests. -/// -internal sealed class TestDbContext : DbContext -{ - public TestDbContext(DbContextOptions options) - : base(options) { } - - public DbSet Products => Set(); - public DbSet Categories => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(p => p.Id); - entity.OwnsOne(p => p.Audit); - entity.Ignore(p => p.Category); - entity.Ignore(p => p.ProductDataLinks); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(c => c.Id); - entity.OwnsOne(c => c.Audit); - entity.Ignore(c => c.Products); - }); - } -} diff --git a/tests/Reviews.Tests/EventHandlers/ProductCreatedEventHandlerTests.cs b/tests/Reviews.Tests/EventHandlers/ProductCreatedEventHandlerTests.cs deleted file mode 100644 index 19d68bb0..00000000 --- a/tests/Reviews.Tests/EventHandlers/ProductCreatedEventHandlerTests.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Contracts.IntegrationEvents.ProductCatalog; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using Reviews.Application.Features.ProductEvents; -using Reviews.Domain.Entities; -using Shouldly; -using Xunit; - -namespace Reviews.Tests.EventHandlers; - -public sealed class ProductCreatedEventHandlerTests -{ - private readonly Mock> _loggerMock = new(); - - [Fact] - public async Task HandleAsync_WhenNewProduct_CreatesProjection() - { - using TestDbContext dbContext = CreateDbContext(); - Guid productId = Guid.NewGuid(); - Guid tenantId = Guid.NewGuid(); - ProductCreatedIntegrationEvent @event = new(productId, tenantId, "Widget", DateTime.UtcNow); - - await ProductCreatedEventHandler.HandleAsync( - @event, - dbContext, - _loggerMock.Object, - CancellationToken.None - ); - - ProductProjection? projection = await dbContext.ProductProjections.FindAsync(productId); - projection.ShouldNotBeNull(); - projection.Name.ShouldBe("Widget"); - projection.TenantId.ShouldBe(tenantId); - projection.IsActive.ShouldBeTrue(); - } - - [Fact] - public async Task HandleAsync_WhenExistingProduct_ReactivatesAndUpdatesName() - { - using TestDbContext dbContext = CreateDbContext(); - Guid productId = Guid.NewGuid(); - Guid newTenantId = Guid.NewGuid(); - dbContext.ProductProjections.Add( - new ProductProjection - { - ProductId = productId, - TenantId = Guid.NewGuid(), - Name = "Old Name", - IsActive = false, - } - ); - await dbContext.SaveChangesAsync(); - - ProductCreatedIntegrationEvent @event = new( - productId, - newTenantId, - "New Name", - DateTime.UtcNow - ); - - await ProductCreatedEventHandler.HandleAsync( - @event, - dbContext, - _loggerMock.Object, - CancellationToken.None - ); - - ProductProjection? projection = await dbContext.ProductProjections.FindAsync(productId); - projection.ShouldNotBeNull(); - projection.Name.ShouldBe("New Name"); - projection.TenantId.ShouldBe(newTenantId); - projection.IsActive.ShouldBeTrue(); - } - - private static TestDbContext CreateDbContext() - { - DbContextOptions options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - return new TestDbContext(options); - } -} diff --git a/tests/Reviews.Tests/Features/ProductReview/Commands/CreateProductReviewCommandHandlerTests.cs b/tests/Reviews.Tests/Features/ProductReview/Commands/CreateProductReviewCommandHandlerTests.cs deleted file mode 100644 index fafb4ff5..00000000 --- a/tests/Reviews.Tests/Features/ProductReview/Commands/CreateProductReviewCommandHandlerTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Moq; -using Reviews.Application.Common.Errors; -using Reviews.Application.Common.Responses; -using Reviews.Application.Features.CreateReview; -using Reviews.Domain.Entities; -using Reviews.Domain.Interfaces; -using SharedKernel.Application.Context; -using SharedKernel.Domain.Interfaces; -using Shouldly; -using Wolverine; -using Xunit; -using ProductReviewEntity = Reviews.Domain.Entities.ProductReview; - -namespace Reviews.Tests.Features.ProductReview.Commands; - -public sealed class CreateProductReviewCommandHandlerTests -{ - private readonly Mock _reviewRepoMock = new(); - private readonly Mock _unitOfWorkMock = new(); - private readonly Mock _actorProviderMock = new(); - private readonly Mock _busMock = new(); - private readonly Guid _userId = Guid.NewGuid(); - - public CreateProductReviewCommandHandlerTests() - { - _actorProviderMock.Setup(a => a.ActorId).Returns(_userId); - _unitOfWorkMock - .Setup(u => - u.ExecuteInTransactionAsync( - It.IsAny>>(), - It.IsAny(), - null - ) - ) - .Returns>, CancellationToken, object?>( - (action, _, _) => action() - ); - _busMock - .Setup(b => b.PublishAsync(It.IsAny(), null)) - .Returns(ValueTask.CompletedTask); - } - - [Fact] - public async Task HandleAsync_WhenProductDoesNotExist_ReturnsNotFoundError() - { - using TestDbContext dbContext = CreateDbContext(); - Guid productId = Guid.NewGuid(); - CreateProductReviewRequest request = new(productId, "Great!", 5); - CreateProductReviewCommand command = new(request); - - var (result, _) = await CreateProductReviewCommandHandler.HandleAsync( - command, - _reviewRepoMock.Object, - dbContext, - _unitOfWorkMock.Object, - _actorProviderMock.Object, - _busMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - result.IsError.ShouldBeTrue(); - result.FirstError.Code.ShouldBe(ErrorCatalog.Reviews.ProductNotFoundForReview); - } - - [Fact] - public async Task HandleAsync_WhenProductExistsButInactive_ReturnsNotFoundError() - { - using TestDbContext dbContext = CreateDbContext(); - Guid productId = Guid.NewGuid(); - dbContext - .Set() - .Add( - new ProductProjection - { - ProductId = productId, - TenantId = Guid.NewGuid(), - Name = "Inactive Product", - IsActive = false, - } - ); - await dbContext.SaveChangesAsync(); - - CreateProductReviewRequest request = new(productId, "Should fail", 3); - CreateProductReviewCommand command = new(request); - - var (result, _) = await CreateProductReviewCommandHandler.HandleAsync( - command, - _reviewRepoMock.Object, - dbContext, - _unitOfWorkMock.Object, - _actorProviderMock.Object, - _busMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - result.IsError.ShouldBeTrue(); - result.FirstError.Code.ShouldBe(ErrorCatalog.Reviews.ProductNotFoundForReview); - } - - [Fact] - public async Task HandleAsync_WhenProductExistsAndActive_CreatesReview() - { - using TestDbContext dbContext = CreateDbContext(); - Guid productId = Guid.NewGuid(); - dbContext - .Set() - .Add( - new ProductProjection - { - ProductId = productId, - TenantId = Guid.NewGuid(), - Name = "Active Product", - IsActive = true, - } - ); - await dbContext.SaveChangesAsync(); - - CreateProductReviewRequest request = new(productId, "Excellent!", 5); - CreateProductReviewCommand command = new(request); - - var (result, _) = await CreateProductReviewCommandHandler.HandleAsync( - command, - _reviewRepoMock.Object, - dbContext, - _unitOfWorkMock.Object, - _actorProviderMock.Object, - _busMock.Object, - TimeProvider.System, - CancellationToken.None - ); - - result.IsError.ShouldBeFalse(); - result.Value.ProductId.ShouldBe(productId); - result.Value.Rating.ShouldBe(5); - result.Value.UserId.ShouldBe(_userId); - } - - private static TestDbContext CreateDbContext() - { - DbContextOptions options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - return new TestDbContext(options); - } -} diff --git a/tests/Reviews.Tests/Reviews.Tests.csproj b/tests/Reviews.Tests/Reviews.Tests.csproj deleted file mode 100644 index 28c6be1e..00000000 --- a/tests/Reviews.Tests/Reviews.Tests.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - diff --git a/tests/Reviews.Tests/TestDbContext.cs b/tests/Reviews.Tests/TestDbContext.cs deleted file mode 100644 index 06edd688..00000000 --- a/tests/Reviews.Tests/TestDbContext.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Reviews.Domain.Entities; - -namespace Reviews.Tests; - -/// -/// Minimal DbContext for in-memory testing of handlers that use DbContext.Set<T>() directly. -/// -internal sealed class TestDbContext : DbContext -{ - public TestDbContext(DbContextOptions options) - : base(options) { } - - public DbSet ProductProjections => Set(); - public DbSet ProductReviews => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity().HasKey(p => p.ProductId); - - modelBuilder.Entity(entity => - { - entity.HasKey(r => r.Id); - entity.OwnsOne(r => r.Audit); - }); - } -} diff --git a/tests/SharedKernel.Tests/Batch/BatchFailureContextTests.cs b/tests/SharedKernel.Tests/Batch/BatchFailureContextTests.cs deleted file mode 100644 index a4a1f6e5..00000000 --- a/tests/SharedKernel.Tests/Batch/BatchFailureContextTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using SharedKernel.Application.Batch; -using SharedKernel.Application.DTOs; -using Shouldly; -using Xunit; - -namespace SharedKernel.Tests.Batch; - -public sealed class BatchFailureContextTests -{ - [Fact] - public void Constructor_SetsItems() - { - IReadOnlyList items = new[] { "a", "b", "c" }; - - BatchFailureContext context = new(items); - - context.Items.ShouldBe(items); - context.HasFailures.ShouldBeFalse(); - context.FailedIndices.ShouldBeEmpty(); - } - - [Fact] - public void AddFailure_WithErrorList_TracksFailure() - { - BatchFailureContext context = new(new[] { "item" }); - Guid id = Guid.NewGuid(); - IReadOnlyList errors = new[] { "Error 1", "Error 2" }; - - context.AddFailure(0, id, errors); - - context.HasFailures.ShouldBeTrue(); - context.FailedIndices.ShouldContain(0); - context.IsFailed(0).ShouldBeTrue(); - } - - [Fact] - public void AddFailure_WithSingleError_TracksFailure() - { - BatchFailureContext context = new(new[] { "item" }); - Guid id = Guid.NewGuid(); - - context.AddFailure(0, id, "single error"); - - context.HasFailures.ShouldBeTrue(); - context.IsFailed(0).ShouldBeTrue(); - } - - [Fact] - public void AddFailures_MergesMultipleFailures() - { - BatchFailureContext context = new(new[] { "a", "b", "c" }); - List failures = new() - { - new BatchResultItem(0, Guid.NewGuid(), new[] { "err0" }), - new BatchResultItem(2, Guid.NewGuid(), new[] { "err2" }), - }; - - context.AddFailures(failures); - - context.HasFailures.ShouldBeTrue(); - context.IsFailed(0).ShouldBeTrue(); - context.IsFailed(1).ShouldBeFalse(); - context.IsFailed(2).ShouldBeTrue(); - context.FailedIndices.Count.ShouldBe(2); - } - - [Fact] - public void ToFailureResponse_ReturnsResponseWithAllFailures() - { - BatchFailureContext context = new(new[] { "a", "b" }); - Guid id1 = Guid.NewGuid(); - Guid id2 = Guid.NewGuid(); - context.AddFailure(0, id1, "err1"); - context.AddFailure(1, id2, "err2"); - - BatchResponse response = context.ToFailureResponse(); - - response.Failures.Count.ShouldBe(2); - response.SuccessCount.ShouldBe(0); - response.FailureCount.ShouldBe(2); - } - - [Fact] - public async Task ApplyRulesAsync_ExecutesAllRules() - { - BatchFailureContext context = new(new[] { "a", "b" }); - int ruleCallCount = 0; - TestBatchRule rule1 = new(() => ruleCallCount++); - TestBatchRule rule2 = new(() => ruleCallCount++); - - await context.ApplyRulesAsync(CancellationToken.None, rule1, rule2); - - ruleCallCount.ShouldBe(2); - } - - [Fact] - public void IsFailed_ReturnsFalse_ForNonFailedIndex() - { - BatchFailureContext context = new(new[] { "a", "b" }); - context.AddFailure(0, null, "err"); - - context.IsFailed(1).ShouldBeFalse(); - } - - private sealed class TestBatchRule : IBatchRule - { - private readonly Action _onApply; - - public TestBatchRule(Action onApply) => _onApply = onApply; - - public Task ApplyAsync(BatchFailureContext context, CancellationToken ct) - { - _onApply(); - return Task.CompletedTask; - } - } -} diff --git a/tests/SharedKernel.Tests/Extensions/RepositoryExtensionsTests.cs b/tests/SharedKernel.Tests/Extensions/RepositoryExtensionsTests.cs deleted file mode 100644 index 0d7d7de2..00000000 --- a/tests/SharedKernel.Tests/Extensions/RepositoryExtensionsTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Ardalis.Specification; -using ErrorOr; -using Moq; -using SharedKernel.Application.Extensions; -using Shouldly; -using Xunit; - -namespace SharedKernel.Tests.Extensions; - -public sealed class RepositoryExtensionsTests -{ - private readonly Mock> _repositoryMock = new(); - - [Fact] - public async Task GetByIdOrError_WhenEntityExists_ReturnsEntity() - { - Guid id = Guid.NewGuid(); - TestEntity entity = new() { Id = id }; - _repositoryMock - .Setup(r => r.GetByIdAsync(id, It.IsAny())) - .ReturnsAsync(entity); - Error notFoundError = Error.NotFound("TEST-404", "Not found"); - - ErrorOr result = await _repositoryMock.Object.GetByIdOrError(id, notFoundError); - - result.IsError.ShouldBeFalse(); - result.Value.ShouldBe(entity); - } - - [Fact] - public async Task GetByIdOrError_WhenEntityDoesNotExist_ReturnsNotFoundError() - { - Guid id = Guid.NewGuid(); - _repositoryMock - .Setup(r => r.GetByIdAsync(id, It.IsAny())) - .ReturnsAsync((TestEntity?)null); - Error notFoundError = Error.NotFound("TEST-404", "Entity not found"); - - ErrorOr result = await _repositoryMock.Object.GetByIdOrError(id, notFoundError); - - result.IsError.ShouldBeTrue(); - result.FirstError.ShouldBe(notFoundError); - } - - public sealed class TestEntity - { - public Guid Id { get; set; } - } -} diff --git a/tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs b/tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs deleted file mode 100644 index 00271158..00000000 --- a/tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using ErrorOr; -using FluentValidation; -using FluentValidation.Results; -using SharedKernel.Application.Errors; -using SharedKernel.Application.Middleware; -using Shouldly; -using Wolverine; -using Xunit; - -namespace SharedKernel.Tests.Middleware; - -public sealed class ErrorOrValidationMiddlewareTests -{ - [Fact] - public async Task BeforeAsync_WhenNoValidator_ReturnsContinue() - { - TestCommand message = new("valid"); - - (HandlerContinuation continuation, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message); - - continuation.ShouldBe(HandlerContinuation.Continue); - } - - [Fact] - public async Task BeforeAsync_WhenValidationPasses_ReturnsContinue() - { - TestCommand message = new("valid"); - PassingValidator validator = new(); - - (HandlerContinuation continuation, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - continuation.ShouldBe(HandlerContinuation.Continue); - } - - [Fact] - public async Task BeforeAsync_WhenValidationFails_ReturnsStopWithErrors() - { - TestCommand message = new(""); - FailingValidator validator = new(); - - (HandlerContinuation continuation, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - continuation.ShouldBe(HandlerContinuation.Stop); - response.IsError.ShouldBeTrue(); - response.Errors.Count.ShouldBe(1); - response.FirstError.Code.ShouldBe(ErrorCatalog.General.ValidationFailed); - } - - [Fact] - public async Task BeforeAsync_WhenValidationFails_ErrorContainsPropertyNameMetadata() - { - TestCommand message = new(""); - FailingValidator validator = new(); - - (HandlerContinuation _, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - response.FirstError.Metadata.ShouldContainKey("propertyName"); - response.FirstError.Metadata["propertyName"].ShouldBe("Value"); - } - - [Fact] - public async Task BeforeAsync_WhenValidationFails_ErrorContainsAttemptedValueMetadata() - { - TestCommand message = new(""); - FailingValidator validator = new(); - - (HandlerContinuation _, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - response.FirstError.Metadata.ShouldContainKey("attemptedValue"); - response.FirstError.Metadata["attemptedValue"].ShouldBe(""); - } - - public sealed record TestCommand(string Value); - - private sealed class PassingValidator : AbstractValidator - { - public PassingValidator() - { - // No rules - always passes - } - } - - private sealed class FailingValidator : AbstractValidator - { - public FailingValidator() - { - RuleFor(x => x.Value).NotEmpty().WithMessage("Value is required."); - } - } -} diff --git a/tests/SharedKernel.Tests/OutputCaching/CacheInvalidationHandlerTests.cs b/tests/SharedKernel.Tests/OutputCaching/CacheInvalidationHandlerTests.cs deleted file mode 100644 index b79fc37c..00000000 --- a/tests/SharedKernel.Tests/OutputCaching/CacheInvalidationHandlerTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Moq; -using SharedKernel.Api.OutputCaching; -using SharedKernel.Application.Common.Events; -using Xunit; - -namespace SharedKernel.Tests.OutputCaching; - -public sealed class CacheInvalidationHandlerTests -{ - [Fact] - public async Task HandleAsync_EvictsProvidedTag() - { - var serviceMock = new Mock(); - var message = new CacheInvalidationNotification("Products"); - - await CacheInvalidationHandler.HandleAsync( - message, - serviceMock.Object, - TestContext.Current.CancellationToken - ); - - serviceMock.Verify( - x => x.EvictAsync("Products", It.IsAny()), - Times.Once - ); - } -} diff --git a/tests/SharedKernel.Tests/OutputCaching/OutputCacheInvalidationServiceTests.cs b/tests/SharedKernel.Tests/OutputCaching/OutputCacheInvalidationServiceTests.cs deleted file mode 100644 index 161a3fc6..00000000 --- a/tests/SharedKernel.Tests/OutputCaching/OutputCacheInvalidationServiceTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Microsoft.AspNetCore.OutputCaching; -using Microsoft.Extensions.Logging; -using Moq; -using SharedKernel.Api.OutputCaching; -using Shouldly; -using Xunit; - -namespace SharedKernel.Tests.OutputCaching; - -public sealed class OutputCacheInvalidationServiceTests -{ - private readonly Mock _storeMock = new(); - private readonly Mock> _loggerMock = new(); - - [Fact] - public async Task EvictAsync_SingleTag_EvictsStoreOnce() - { - var sut = new OutputCacheInvalidationService(_storeMock.Object, _loggerMock.Object); - - await sut.EvictAsync("Products", TestContext.Current.CancellationToken); - - _storeMock.Verify( - x => x.EvictByTagAsync("Products", It.IsAny()), - Times.Once - ); - } - - [Fact] - public async Task EvictAsync_DuplicateTags_EvictsDistinctTagsOnly() - { - var sut = new OutputCacheInvalidationService(_storeMock.Object, _loggerMock.Object); - - await sut.EvictAsync(["Products", "Products", "Categories"]); - - _storeMock.Verify( - x => x.EvictByTagAsync("Products", It.IsAny()), - Times.Once - ); - _storeMock.Verify( - x => x.EvictByTagAsync("Categories", It.IsAny()), - Times.Once - ); - } - - [Fact] - public async Task EvictAsync_WhenOneTagFails_ContinuesWithRemainingTags() - { - _storeMock - .Setup(x => x.EvictByTagAsync("Products", It.IsAny())) - .ThrowsAsync(new InvalidOperationException("boom")); - - var sut = new OutputCacheInvalidationService(_storeMock.Object, _loggerMock.Object); - - var ex = await Record.ExceptionAsync(() => - sut.EvictAsync(["Products", "Categories"], TestContext.Current.CancellationToken) - ); - - ex.ShouldBeNull(); - _storeMock.Verify( - x => x.EvictByTagAsync("Products", It.IsAny()), - Times.Once - ); - _storeMock.Verify( - x => x.EvictByTagAsync("Categories", It.IsAny()), - Times.Once - ); - } -} diff --git a/tests/SharedKernel.Tests/OutputCaching/TenantAwareOutputCachePolicyTests.cs b/tests/SharedKernel.Tests/OutputCaching/TenantAwareOutputCachePolicyTests.cs deleted file mode 100644 index bf4f4994..00000000 --- a/tests/SharedKernel.Tests/OutputCaching/TenantAwareOutputCachePolicyTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OutputCaching; -using SharedKernel.Api.OutputCaching; -using SharedKernel.Application.Security; -using Shouldly; -using Xunit; - -namespace SharedKernel.Tests.OutputCaching; - -public sealed class TenantAwareOutputCachePolicyTests -{ - [Fact] - public async Task CacheRequestAsync_GetRequest_EnablesCacheAndVariesByTenant() - { - var sut = new TenantAwareOutputCachePolicy(); - var context = CreateContext("GET", Guid.NewGuid()); - - await sut.CacheRequestAsync(context, TestContext.Current.CancellationToken); - - context.EnableOutputCaching.ShouldBeTrue(); - context.AllowCacheLookup.ShouldBeTrue(); - context.AllowCacheStorage.ShouldBeTrue(); - context - .CacheVaryByRules.VaryByValues.ContainsKey(SharedAuthConstants.Claims.TenantId) - .ShouldBeTrue(); - } - - [Fact] - public async Task CacheRequestAsync_HeadRequest_EnablesCache() - { - var sut = new TenantAwareOutputCachePolicy(); - var context = CreateContext("HEAD", Guid.NewGuid()); - - await sut.CacheRequestAsync(context, TestContext.Current.CancellationToken); - - context.EnableOutputCaching.ShouldBeTrue(); - } - - [Theory] - [InlineData("POST")] - [InlineData("PUT")] - [InlineData("DELETE")] - [InlineData("PATCH")] - public async Task CacheRequestAsync_NonReadRequest_DoesNotEnableCache(string method) - { - var sut = new TenantAwareOutputCachePolicy(); - var context = CreateContext(method, Guid.NewGuid()); - - await sut.CacheRequestAsync(context, TestContext.Current.CancellationToken); - - context.EnableOutputCaching.ShouldBeFalse(); - context.AllowCacheLookup.ShouldBeFalse(); - context.AllowCacheStorage.ShouldBeFalse(); - } - - private static OutputCacheContext CreateContext(string method, Guid tenantId) - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = method; - httpContext.User = new ClaimsPrincipal( - new ClaimsIdentity( - [new Claim(SharedAuthConstants.Claims.TenantId, tenantId.ToString())], - "Test" - ) - ); - - return new OutputCacheContext { HttpContext = httpContext }; - } -} diff --git a/tests/SharedKernel.Tests/SharedKernel.Tests.csproj b/tests/SharedKernel.Tests/SharedKernel.Tests.csproj deleted file mode 100644 index 34057dba..00000000 --- a/tests/SharedKernel.Tests/SharedKernel.Tests.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - - diff --git a/tests/Tests.Common/AsyncPoll.cs b/tests/Tests.Common/AsyncPoll.cs deleted file mode 100644 index 3d7c6683..00000000 --- a/tests/Tests.Common/AsyncPoll.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Diagnostics; - -namespace TestCommon; - -/// -/// Bounded polling for async integration tests (e.g. DB visibility after Wolverine + RabbitMQ). -/// Avoids fixed in tests while still enforcing a hard timeout. -/// -public static class AsyncPoll -{ - private static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(100); - - /// - /// Repeatedly calls until it returns non-null or elapses. - /// - public static async Task UntilNotNullAsync( - Func> probe, - TimeSpan timeout, - TimeSpan? interval = null, - CancellationToken cancellationToken = default - ) - where T : class - { - TimeSpan step = interval ?? DefaultInterval; - Stopwatch sw = Stopwatch.StartNew(); - - while (sw.Elapsed < timeout) - { - cancellationToken.ThrowIfCancellationRequested(); - T? value = await probe().ConfigureAwait(false); - if (value is not null) - return value; - - TimeSpan remaining = timeout - sw.Elapsed; - if (remaining <= TimeSpan.Zero) - break; - - TimeSpan delay = step < remaining ? step : remaining; - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - } - - throw new TimeoutException( - $"Expected non-null result within {timeout.TotalSeconds} seconds (type {typeof(T).Name})." - ); - } - - /// - /// Repeatedly calls until it returns true or elapses. - /// - public static async Task UntilTrueAsync( - Func> condition, - TimeSpan timeout, - TimeSpan? interval = null, - CancellationToken cancellationToken = default - ) - { - TimeSpan step = interval ?? DefaultInterval; - Stopwatch sw = Stopwatch.StartNew(); - - while (sw.Elapsed < timeout) - { - cancellationToken.ThrowIfCancellationRequested(); - if (await condition().ConfigureAwait(false)) - return; - - TimeSpan remaining = timeout - sw.Elapsed; - if (remaining <= TimeSpan.Zero) - break; - - TimeSpan delay = step < remaining ? step : remaining; - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - } - - throw new TimeoutException( - $"Expected condition to become true within {timeout.TotalSeconds} seconds." - ); - } -} diff --git a/tests/Tests.Common/HttpResponseAssertionExtensions.cs b/tests/Tests.Common/HttpResponseAssertionExtensions.cs deleted file mode 100644 index d1e28e4f..00000000 --- a/tests/Tests.Common/HttpResponseAssertionExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Net; -using Shouldly; - -namespace TestCommon; - -public static class HttpResponseAssertionExtensions -{ - /// - /// Asserts the response status, reads the body once, and returns it for further assertions. - /// - public static async Task ShouldHaveStatusAsync( - this HttpResponseMessage response, - HttpStatusCode expected, - CancellationToken cancellationToken = default - ) - { - string body = await response.Content.ReadAsStringAsync(cancellationToken); - response.StatusCode.ShouldBe(expected, body); - return body; - } -} diff --git a/tests/Tests.Common/TestAuthSetup.cs b/tests/Tests.Common/TestAuthSetup.cs deleted file mode 100644 index 08a47ccb..00000000 --- a/tests/Tests.Common/TestAuthSetup.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Security.Cryptography; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; - -namespace TestCommon; - -public static class TestAuthSetup -{ - private static readonly RSA RsaKey = RSA.Create(2048); - - public static readonly RsaSecurityKey SecurityKey = new(RsaKey); - - public const string Issuer = "http://localhost:8180/realms/api-template"; - public const string Audience = "api-template"; - - public static void ConfigureTestJwtBearer(IServiceCollection services) - { - services.PostConfigure( - JwtBearerDefaults.AuthenticationScheme, - options => - { - options.Authority = null; - options.RequireHttpsMetadata = false; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = Issuer, - ValidateAudience = true, - ValidAudience = Audience, - ValidateLifetime = true, - IssuerSigningKey = SecurityKey, - ValidateIssuerSigningKey = true, - }; - } - ); - } -} diff --git a/tests/Tests.Common/TestBaseConfiguration.cs b/tests/Tests.Common/TestBaseConfiguration.cs deleted file mode 100644 index 4ab22d89..00000000 --- a/tests/Tests.Common/TestBaseConfiguration.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace TestCommon; - -public static class TestBaseConfiguration -{ - public static Dictionary GetSharedConfiguration(string hmacKeySeed) - { - string testRedactionHmacKey = Convert.ToBase64String( - SHA256.HashData(Encoding.UTF8.GetBytes(hmacKeySeed)) - ); - - return new Dictionary - { - ["Keycloak:realm"] = "api-template", - ["Keycloak:auth-server-url"] = "http://localhost:8180/", - ["Keycloak:resource"] = "api-template", - ["Keycloak:credentials:secret"] = "test-secret", - ["Keycloak:SkipReadinessCheck"] = "true", - ["SystemIdentity:DefaultActorId"] = "00000000-0000-0000-0000-000000000000", - ["Bootstrap:Tenant:Code"] = "default", - ["Bootstrap:Tenant:Name"] = "Default Tenant", - // New shared infrastructure options consumed by AddSharedInfrastructure() - ["TransactionDefaults:IsolationLevel"] = "ReadCommitted", - ["TransactionDefaults:TimeoutSeconds"] = "30", - ["TransactionDefaults:RetryEnabled"] = "true", - ["TransactionDefaults:RetryCount"] = "3", - ["TransactionDefaults:RetryDelaySeconds"] = "5", - // Backward-compatible legacy section used by older components/tests - ["Persistence:Transactions:IsolationLevel"] = "ReadCommitted", - ["Persistence:Transactions:TimeoutSeconds"] = "30", - ["Persistence:Transactions:RetryEnabled"] = "true", - ["Persistence:Transactions:RetryCount"] = "3", - ["Persistence:Transactions:RetryDelaySeconds"] = "5", - ["Redaction:HmacKeyEnvironmentVariable"] = "APITEMPLATE_REDACTION_HMAC_KEY", - ["Redaction:HmacKey"] = testRedactionHmacKey, - ["Redaction:KeyId"] = "1001", - ["Observability:Exporters:Aspire:Enabled"] = "false", - ["Observability:Exporters:Otlp:Enabled"] = "false", - ["Observability:Exporters:Console:Enabled"] = "false", - }; - } -} diff --git a/tests/Tests.Common/TestDatabaseLifecycle.cs b/tests/Tests.Common/TestDatabaseLifecycle.cs deleted file mode 100644 index 508f0a97..00000000 --- a/tests/Tests.Common/TestDatabaseLifecycle.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Npgsql; - -namespace TestCommon; - -public static class TestDatabaseLifecycle -{ - public static string BuildConnectionString(string serverConnectionString, string databaseName) - { - NpgsqlConnectionStringBuilder builder = new(serverConnectionString) - { - Database = databaseName, - }; - return builder.ConnectionString; - } - - public static async Task CreateDatabaseAsync(string serverConnectionString, string databaseName) - { - await using NpgsqlConnection conn = new(serverConnectionString); - await conn.OpenAsync(); - await using NpgsqlCommand cmd = conn.CreateCommand(); - cmd.CommandText = $"CREATE DATABASE \"{databaseName}\""; - await cmd.ExecuteNonQueryAsync(); - } - - public static async Task DropDatabaseAsync(string serverConnectionString, string databaseName) - { - try - { - await using NpgsqlConnection conn = new(serverConnectionString); - await conn.OpenAsync(); - await using NpgsqlCommand cmd = conn.CreateCommand(); - // databaseName is always a GUID — no SQL injection risk - cmd.CommandText = $""" - SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{databaseName}' AND pid <> pg_backend_pid(); - DROP DATABASE IF EXISTS "{databaseName}"; - """; - await cmd.ExecuteNonQueryAsync(); - } - catch - { - // Best-effort cleanup — container disposal handles the rest. - } - } -} diff --git a/tests/Tests.Common/TestJsonOptions.cs b/tests/Tests.Common/TestJsonOptions.cs deleted file mode 100644 index 3c757d22..00000000 --- a/tests/Tests.Common/TestJsonOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json; - -namespace TestCommon; - -public static class TestJsonOptions -{ - public static JsonSerializerOptions CaseInsensitive { get; } = - new() { PropertyNameCaseInsensitive = true }; -} diff --git a/tests/Tests.Common/Tests.Common.csproj b/tests/Tests.Common/Tests.Common.csproj deleted file mode 100644 index a74a78eb..00000000 --- a/tests/Tests.Common/Tests.Common.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - enable - enable - false - TestCommon - - - - - - - - - - diff --git a/tests/Webhooks.Tests/Infrastructure/Hmac/HmacHelperTests.cs b/tests/Webhooks.Tests/Infrastructure/Hmac/HmacHelperTests.cs deleted file mode 100644 index fb8f7568..00000000 --- a/tests/Webhooks.Tests/Infrastructure/Hmac/HmacHelperTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Shouldly; -using Webhooks.Infrastructure.Hmac; -using Xunit; - -namespace Webhooks.Tests.Infrastructure.Hmac; - -public sealed class HmacHelperTests -{ - [Fact] - public void ComputeHash_ReturnsConsistentResult() - { - byte[] key = Encoding.UTF8.GetBytes("test-secret-key"); - string timestamp = "1234567890"; - string payload = "{\"event\":\"test\"}"; - - byte[] hash1 = HmacHelper.ComputeHash(key, timestamp, payload); - byte[] hash2 = HmacHelper.ComputeHash(key, timestamp, payload); - - hash1.ShouldBe(hash2); - } - - [Fact] - public void ComputeHash_DifferentKeys_ProduceDifferentHashes() - { - byte[] key1 = Encoding.UTF8.GetBytes("key-one"); - byte[] key2 = Encoding.UTF8.GetBytes("key-two"); - string timestamp = "1234567890"; - string payload = "{\"event\":\"test\"}"; - - byte[] hash1 = HmacHelper.ComputeHash(key1, timestamp, payload); - byte[] hash2 = HmacHelper.ComputeHash(key2, timestamp, payload); - - hash1.ShouldNotBe(hash2); - } - - [Fact] - public void ComputeHash_DifferentTimestamps_ProduceDifferentHashes() - { - byte[] key = Encoding.UTF8.GetBytes("test-secret-key"); - string payload = "{\"event\":\"test\"}"; - - byte[] hash1 = HmacHelper.ComputeHash(key, "1111111111", payload); - byte[] hash2 = HmacHelper.ComputeHash(key, "2222222222", payload); - - hash1.ShouldNotBe(hash2); - } - - [Fact] - public void ComputeHash_MatchesExpectedHmacSha256() - { - byte[] key = Encoding.UTF8.GetBytes("secret"); - string timestamp = "ts"; - string payload = "body"; - - byte[] result = HmacHelper.ComputeHash(key, timestamp, payload); - - // Verify against direct HMACSHA256 computation - string signedContent = $"{timestamp}.{payload}"; - byte[] expectedHash = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(signedContent)); - - result.ShouldBe(expectedHash); - } - - [Fact] - public void ComputeHash_ProducesNonEmptyHash() - { - byte[] key = Encoding.UTF8.GetBytes("key"); - byte[] result = HmacHelper.ComputeHash(key, "ts", "payload"); - - result.ShouldNotBeEmpty(); - result.Length.ShouldBe(32); // SHA-256 produces 32 bytes - } -} diff --git a/tests/Webhooks.Tests/Webhooks.Tests.csproj b/tests/Webhooks.Tests/Webhooks.Tests.csproj deleted file mode 100644 index 04ce4777..00000000 --- a/tests/Webhooks.Tests/Webhooks.Tests.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - -